Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,26 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MusallahBoard</title>
<link rel="icon" type="image/svg+xml" href="/images/musallahboard-icon.svg" />
<style>
/* The board fades in from black. The agent fades the update screen to
black before it navigates back here (agent/docs/architecture.md,
section 8), so together the switch is a dip to black, not a cut. */
#mb-veil {
position: fixed;
inset: 0;
z-index: 2147483647;
background: #000;
pointer-events: none;
animation: mb-veil-out 700ms ease-out 150ms both;
}
@keyframes mb-veil-out {
from { opacity: 1; }
to { opacity: 0; visibility: hidden; }
}
</style>
</head>
<body>
<div id="mb-veil" aria-hidden="true"></div>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "musallah-board",
"version": "2.0.0",
"version": "2.1.0",
"type": "module",
"scripts": {
"dev": "vite --host",
Expand Down
80 changes: 73 additions & 7 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ import { buildSlideshow } from './frames/registry.js';
import { resolveTheme, DEFAULT_THEME } from './themes/registry.js';
import './frames/builders.jsx'; // registers default builders
import { applyCursorPreference } from './utils/cookies.js';
import { updateNotice } from './utils/updates.js';
import { APP_VERSION } from './version.js';
import SetupModal from './components/SetupModal.jsx';
import DebugMenu from './components/DebugMenu.jsx';
import TopBar from './components/TopBar.jsx';
import PrayerRail from './components/PrayerRail.jsx';
import Ticker from './components/Ticker.jsx';
import NoticeBanner from './components/NoticeBanner.jsx';

const PAYLOAD_REFRESH_MS = 10 * 60 * 1000;
const LOCAL_STATUS_REFRESH_MS = 60 * 1000;
Expand Down Expand Up @@ -78,9 +80,16 @@ const SERVICE_PORT_URL = 'http://10.77.0.1/';
*/
function WaitingForContent({ status }) {
const sync = status?.sync;
const offline =
`Plug in a USB stick with a MusallahBoard update, or connect a laptop or phone ` +
`to the board's ethernet port and open ${SERVICE_PORT_URL}`;
// Only the routes this board accepts: the service port is off by default,
// and a board that does not read USB sticks should not ask for one.
const routes = [];
if (status?.usbImport) routes.push('plug in a USB stick with this board\'s offline bundle');
if (status?.servicePort) {
routes.push(`connect a laptop or phone to the board's ethernet port and open ${SERVICE_PORT_URL}`);
}
const offline = routes.length
? `Without internet: ${routes.join(', or ')}.`
: 'Connect the board to the internet so it can download its content.';
if (sync?.enabled && !sync.lastError) {
return (
<Status
Expand Down Expand Up @@ -118,6 +127,9 @@ function WaitingForContent({ status }) {
* content. Dated by when the package was made, in the content's zone.
*/
function StaleNote({ status }) {
if (status?.staleDays === 0 && status.daysRemaining != null && status.daysRemaining <= CONTENT_LOW_DAYS) {
return <ContentEndsNote status={status} />;
}
if (!(status?.staleDays > 0)) return null;
const b = status?.content;
let when = b?.lastDay ?? '';
Expand All @@ -134,6 +146,42 @@ function StaleNote({ status }) {
return <div className="stale-note">Content last updated {when}</div>;
}

/** Content ending this many days after today, or sooner, gets a note. */
const CONTENT_LOW_DAYS = 2;

/**
* "Content ends Wednesday, October 1": the last days before an offline board's
* content runs out, so whoever maintains it has warning to send more before
* the stale note appears. An online board never gets here: it syncs a week
* ahead every half hour, unless its sync is failing, which is worth knowing.
*/
function ContentEndsNote({ status }) {
const lastDay = status?.content?.lastDay;
let when = lastDay ?? '';
const at = lastDay ? new Date(`${lastDay}T12:00:00Z`) : null;
if (at && !Number.isNaN(at.getTime())) {
when = new Intl.DateTimeFormat('en-US', {
weekday: 'long', month: 'long', day: 'numeric', timeZone: 'UTC',
}).format(at);
}
return <div className="stale-note">{status.daysRemaining === 0 ? 'Content ends today' : `Content ends ${when}`}</div>;
}

/**
* The agent could not confirm the board's clock since it booted: no NTP, no
* hardware clock, no laptop's time (agent/docs/architecture.md, section 10).
* After a power cut an offline Pi without an RTC comes up at whatever time it
* last saved, and every prayer time on screen follows it.
*/
function ClockNote({ status }) {
if (status?.clock?.trusted !== false) return null;
return (
<div className="clock-note">
The clock may be wrong: connect the board to the internet, or send an update from a laptop
</div>
);
}

function gregorian(now, timezone) {
const f = new Intl.DateTimeFormat('en-US', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
Expand Down Expand Up @@ -163,6 +211,11 @@ export default function App() {
const [setupOpen, setSetupOpen] = useState(false);
const [debugOpen, setDebugOpen] = useState(false);
const [debug, setDebug] = useState(NO_DEBUG);
// The agent's latest update notice (components/NoticeBanner.jsx). Each
// gets an id so a banner timing out cannot clear the one that replaced it.
const [notice, setNotice] = useState(null);
const noticeSeq = useRef(0);
const clearNotice = useCallback((id) => setNotice((n) => (n && n.id === id ? null : n)), []);
const advanceTimer = useRef(null);

// Everything downstream reads `now`, so time travel is a single shift here.
Expand Down Expand Up @@ -401,6 +454,8 @@ export default function App() {
return connectLocalEvents({
onContent: () => refreshRef.current(),
onApp: () => window.location.reload(),
onUpdates: () => getLocalStatus().then((s) => applyLocalStatusRef.current(s)),
onNotice: (n) => setNotice({ ...n, id: ++noticeSeq.current }),
});
}, []);

Expand Down Expand Up @@ -433,12 +488,15 @@ export default function App() {
<WaitingForContent status={localStatus} />
) : (
<Status
large={Boolean(error)}
title="MusallahBoard"
detail={error ? 'Reconnecting to the board service…' : 'Version 2027'}
detail={error ? 'Reconnecting to the board service…' : `Version ${APP_VERSION}`}
error={error}
hint={error ? 'This usually clears within a minute. If it does not, switch the board off and on again.' : null}
/>
)}
{renderDebug()}
<NoticeBanner notice={notice} onDone={clearNotice} />
{setupOpen && (
<SetupModal
status={statusRef.current?.()}
Expand All @@ -463,7 +521,11 @@ export default function App() {
const boardTheme = resolveTheme(payload.deviceConfig.theme, autoTheme);
const theme = resolveTheme(debug.theme, boardTheme);

const showTicker = data.scrollingMessages.length > 0;
// Software waiting for the night's install window is announced on the
// ticker, after the board's own messages.
const updateLine = updateNotice(localStatus?.updates, now, tz);
const tickerMessages = updateLine ? [...data.scrollingMessages, updateLine] : data.scrollingMessages;
const showTicker = tickerMessages.length > 0;
// Show the Jummah card Wed–Fri (matches prior board behaviour). Read in the
// board's zone: near midnight the browser's day can be the wrong one.
const dow = zonedClock(now, tz).weekday;
Expand Down Expand Up @@ -502,18 +564,22 @@ export default function App() {
);
})}
</div>
<StaleNote status={localStatus} />
<div className="board-notes">
<ClockNote status={localStatus} />
<StaleNote status={localStatus} />
</div>
</main>

{showTicker ? (
<Ticker messages={data.scrollingMessages} now={now} />
<Ticker messages={tickerMessages} now={now} />
) : (
<div style={{ gridArea: 'ticker', background: 'var(--bg)', borderTop: '1px solid var(--line)' }} />
)}
</div>
</ScaledStage>
</div>
{renderDebug(boardTheme, autoJummah)}
<NoticeBanner notice={notice} onDone={clearNotice} onBoard />
{setupOpen && (
<SetupModal
status={statusRef.current?.()}
Expand Down
23 changes: 21 additions & 2 deletions src/api/localEvents.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
* event: app data: {"version": "..."}
* a new board app release is installed: reload, so
* the new build is what runs.
* event: notice data: {tone, headline, lines, seconds}
* an update outcome for the people at the board, shown
* as a banner (components/NoticeBanner.jsx).
* event: updates data: the status's `updates` object
* software started or stopped waiting for the night's
* install window: re-read the status for the ticker.
* : ping every 25 s, keeps the stream alive.
*
* EventSource reconnects on its own after a dropped
Expand All @@ -24,10 +30,11 @@
*/

/**
* @param {{ onContent: () => unknown, onApp: () => unknown }} handlers
* @param {{ onContent: () => unknown, onApp: () => unknown, onUpdates?: () => unknown,
* onNotice?: (notice: object) => unknown }} handlers
* @returns {() => void} disposer: closes the stream. Safe to call twice.
*/
export function connectLocalEvents({ onContent, onApp }) {
export function connectLocalEvents({ onContent, onApp, onUpdates, onNotice }) {
if (typeof EventSource === 'undefined') return () => {};

const source = new EventSource('/api/local/events');
Expand All @@ -50,6 +57,18 @@ export function connectLocalEvents({ onContent, onApp }) {
});
source.addEventListener('content', () => run(onContent, 'content refresh'));
source.addEventListener('app', () => run(onApp, 'app reload'));
if (onUpdates) source.addEventListener('updates', () => run(onUpdates, 'updates refresh'));
if (onNotice) {
source.addEventListener('notice', (e) => {
let notice;
try {
notice = JSON.parse(e.data);
} catch {
return;
}
if (notice && typeof notice.headline === 'string') run(() => onNotice(notice), 'notice');
});
}

return () => source.close();
}
12 changes: 11 additions & 1 deletion src/api/localStatus.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
* today, servingDay, daysRemaining, staleDays,
* sync: { enabled, lastSuccessAt, lastAttemptAt,
* lastError },
* update: { active } }
* update: { active },
* updates: { available: [{ type, version,
* description }], installTime,
* installAt, installing } }
* =====================================================
*/

Expand Down Expand Up @@ -46,6 +49,13 @@ const TIMEOUT_MS = 5000;
* @property {number|null} [staleDays]
* @property {{enabled:boolean,lastSuccessAt:string|null,lastAttemptAt:string|null,lastError:string|null}} [sync]
* @property {{active:boolean}} [update]
* @property {boolean} [servicePort] the ethernet upload page is on
* @property {boolean} [usbImport] USB sticks are read
* @property {{source:string,trusted:boolean}} [clock]
* where the clock's time comes from (ntp, rtc, uploader, starting,
* unverified); trusted false means the board should say it may be wrong
* @property {{available:{type:string,version:string,description:string}[],installTime:string,installAt:string|null,installing:boolean}} [updates]
* software waiting for the install window (utils/updates.js)
* @property {string} [error] installed content could not be read
*/

Expand Down
109 changes: 109 additions & 0 deletions src/components/NoticeBanner.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { useEffect, useState } from 'react';

/**
* =====================================================
* Notice banner
* =====================================================
* What the agent tells the people in front of the board
* about an update when there is nothing to install, so
* the full update screen would be too much: "Reading USB
* stick", "Already up to date", "Can't read this USB
* stick" (agent/docs/architecture.md, section 8). It
* arrives as the `notice` event on /api/local/events:
*
* { tone: "progress" | "ok" | "neutral" | "problem",
* headline, lines: [..], footer?, seconds }
*
* `footer` is a standing instruction ("You can remove the
* USB stick"), shown in the accent under the lines.
*
* `seconds` 0 means it stays until the next notice
* replaces it (a progress banner is always followed by
* its outcome). Sized in vmin, above the ticker, so it
* reads from across a room on any screen.
* =====================================================
*/

/** A progress banner nobody replaces still goes, eventually. */
const PROGRESS_MAX_MS = 3 * 60 * 1000;
const EXIT_MS = 400;

function Mark({ tone }) {
if (tone === 'progress') {
return (
<svg className="nb-mark nb-spin" viewBox="0 0 48 48" aria-hidden="true">
<circle className="nb-track" cx="24" cy="24" r="18" />
<circle className="nb-arc" cx="24" cy="24" r="18" />
</svg>
);
}
if (tone === 'ok') {
return (
<svg className="nb-mark" viewBox="0 0 48 48" aria-hidden="true">
<circle cx="24" cy="24" r="18" />
<path d="M16 24.5l5.5 5.5L32.5 19" />
</svg>
);
}
if (tone === 'problem') {
return (
<svg className="nb-mark" viewBox="0 0 48 48" aria-hidden="true">
<circle cx="24" cy="24" r="18" />
<path d="M24 14.5v12" />
<circle className="nb-dot" cx="24" cy="32.5" r="1.9" />
</svg>
);
}
return (
<svg className="nb-mark" viewBox="0 0 48 48" aria-hidden="true">
<circle cx="24" cy="24" r="18" />
<circle className="nb-dot" cx="24" cy="15.5" r="1.9" />
<path d="M24 21.5v12" />
</svg>
);
}

/**
* @param {{ notice: null | { id:number, tone:string, headline:string, lines?:string[], seconds?:number },
* onDone: (id:number) => void, onBoard?: boolean }} props
* onBoard centres the banner on the board's main area, right of the
* prayer rail, instead of on the whole screen.
*/
export default function NoticeBanner({ notice, onDone, onBoard = false }) {
// Kept after `notice` clears, so the banner can fade out with its text.
const [shown, setShown] = useState(null);
const [leaving, setLeaving] = useState(false);

useEffect(() => {
if (notice) {
setShown(notice);
setLeaving(false);
const ms = notice.seconds > 0 ? notice.seconds * 1000 : PROGRESS_MAX_MS;
const id = setTimeout(() => onDone(notice.id), ms);
return () => clearTimeout(id);
}
setLeaving(true);
const id = setTimeout(() => setShown(null), EXIT_MS);
return () => clearTimeout(id);
}, [notice, onDone]);

if (!shown) return null;
const lines = shown.lines ?? [];
return (
<div
className={`notice-banner nb-${shown.tone}${onBoard ? ' nb-on-board' : ''}${leaving ? ' is-leaving' : ''}`}
role="status"
aria-live="polite"
key={shown.id}
>
<Mark tone={shown.tone} />
<div className="nb-text">
<div className="nb-headline">{shown.headline}</div>
{lines.map((line, i) => (
<div className="nb-line" key={i}>{line}</div>
))}
{shown.footer && <div className="nb-footer">{shown.footer}</div>}
</div>
</div>
);
}
Loading
Loading