MusallahBoard
+
+
diff --git a/package-lock.json b/package-lock.json
index 1be83ec..197514d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "musallah-board",
- "version": "2.0.0",
+ "version": "2.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "musallah-board",
- "version": "2.0.0",
+ "version": "2.1.0",
"dependencies": {
"@fontsource/amiri": "^5.3.0",
"@fontsource/cinzel": "^5.3.0",
diff --git a/package.json b/package.json
index bb35275..46692dc 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "musallah-board",
- "version": "2.0.0",
+ "version": "2.1.0",
"type": "module",
"scripts": {
"dev": "vite --host",
diff --git a/src/App.jsx b/src/App.jsx
index c8b3099..1466d65 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -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;
@@ -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 (
;
+ }
if (!(status?.staleDays > 0)) return null;
const b = status?.content;
let when = b?.lastDay ?? '';
@@ -134,6 +146,42 @@ function StaleNote({ status }) {
return
Content last updated {when}
;
}
+/** 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
;
+}
+
+/**
+ * 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 (
+
+ The clock may be wrong: connect the board to the internet, or send an update from a laptop
+
+ );
+}
+
function gregorian(now, timezone) {
const f = new Intl.DateTimeFormat('en-US', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
@@ -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.
@@ -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 }),
});
}, []);
@@ -433,12 +488,15 @@ export default function App() {
) : (
)}
{renderDebug()}
+
{setupOpen && (
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;
@@ -502,11 +564,14 @@ export default function App() {
);
})}
-
+
+
+
+
{showTicker ? (
-
+
) : (
)}
@@ -514,6 +579,7 @@ export default function App() {
{renderDebug(boardTheme, autoJummah)}
+
{setupOpen && (
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');
@@ -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();
}
diff --git a/src/api/localStatus.js b/src/api/localStatus.js
index 4a62f1d..d7d00a5 100644
--- a/src/api/localStatus.js
+++ b/src/api/localStatus.js
@@ -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 } }
* =====================================================
*/
@@ -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
*/
diff --git a/src/components/NoticeBanner.jsx b/src/components/NoticeBanner.jsx
new file mode 100644
index 0000000..b6112dd
--- /dev/null
+++ b/src/components/NoticeBanner.jsx
@@ -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 (
+
+ );
+ }
+ if (tone === 'ok') {
+ return (
+
+ );
+ }
+ if (tone === 'problem') {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
+
+/**
+ * @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 (
+
+
+
+
{shown.headline}
+ {lines.map((line, i) => (
+
{line}
+ ))}
+ {shown.footer &&
{shown.footer}
}
+
+
+ );
+}
diff --git a/src/styles.css b/src/styles.css
index 0b8a1df..0b8ab01 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -594,16 +594,26 @@ img { display: block; max-width: 100%; }
opacity: 0.45;
}
-/* Offline board whose content bundle has run out (bottom-left of stage-main).
- Deliberately quiet — --fg-3 on the recessed ground, the eyebrow type — so it
- informs whoever maintains the board without competing with the slide. */
-.stale-note {
+/* Notes for whoever maintains the board (bottom-left of stage-main), stacked:
+ the clock note above the stale-content note. */
+.board-notes {
position: absolute;
bottom: 14px; left: var(--pad-stage, 48px);
z-index: 5;
- padding: 5px 14px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ pointer-events: none;
+}
+
+/* Offline board whose content bundle has run out. Deliberately quiet: --fg-3
+ on the recessed ground, the eyebrow type, so it informs whoever maintains
+ the board without competing with the slide. */
+.stale-note {
+ padding: 6px 16px;
font-family: var(--f-mono);
- font-size: 11px;
+ font-size: 15px;
letter-spacing: 0.18em;
text-transform: uppercase;
white-space: nowrap;
@@ -614,6 +624,31 @@ img { display: block; max-width: 100%; }
pointer-events: none;
}
+/* The board's clock is not confirmed since boot, so the prayer times on
+ screen may be wrong. Louder than the stale note, since what it doubts is
+ what the congregation reads the board for, but still a note, not an alarm:
+ sentence case at a readable size, with the accent's diamond. */
+.clock-note {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 9px 20px 9px 16px;
+ font-family: var(--f-sans);
+ font-size: 22px;
+ color: var(--fg-2);
+ background: color-mix(in oklch, var(--bg-soft) 92%, transparent);
+ border: 1px solid var(--line);
+ border-radius: var(--r-pill);
+}
+.clock-note::before {
+ content: "";
+ flex: none;
+ width: 8px; height: 8px;
+ transform: rotate(45deg);
+ background: var(--accent);
+ border-radius: 1px;
+}
+
/* Slide indicators (bottom-right of stage-main) */
.slide-indicators {
position: absolute;
@@ -1792,3 +1827,94 @@ body.cursor-hidden, body.cursor-hidden * { cursor: none !important; }
max-width: 340px;
text-wrap: balance;
}
+
+/* =====================================================
+ Notice banner (components/NoticeBanner.jsx)
+ =====================================================
+ An update outcome over the running board. Fixed to the
+ viewport rather than the scaled stage, so it also shows
+ over the waiting screens, and sized in vmin so it is
+ the same share of any screen. The card is the update
+ screen's navy whatever the theme: it belongs to the
+ agent, not to the board, and should look it. */
+.notice-banner {
+ position: fixed;
+ left: 50%;
+ bottom: calc(64 / 1080 * 100vh + 3vmin);
+ z-index: 50;
+ display: flex;
+ align-items: flex-start;
+ gap: 2vmin;
+ /* Placed from its left edge, a fixed box only gets the width to the right
+ of that edge; max-content lets max-width decide instead. */
+ width: max-content;
+ max-width: min(78vw, 110vmin);
+ padding: 2vmin 3.2vmin 2.2vmin 2.4vmin;
+ font-family: var(--f-sans);
+ color: #fff;
+ background: color-mix(in oklch, #082D5D 92%, transparent);
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ border-radius: 2vmin;
+ box-shadow: 0 1.6vmin 5vmin rgba(0, 0, 0, 0.35);
+ backdrop-filter: blur(8px);
+ transform: translateX(-50%);
+ pointer-events: none;
+ animation: nb-in 420ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
+}
+/* On the board, centred on the main area: the stage is 1920 wide with a
+ 520px prayer rail, so the area's centre is at 1220 / 1920. */
+.notice-banner.nb-on-board {
+ left: calc(1220 / 1920 * 100vw);
+ max-width: min(66vw, 100vmin);
+}
+.notice-banner.is-leaving { animation: nb-out 400ms ease both; }
+@keyframes nb-in {
+ from { opacity: 0; transform: translate(-50%, 2vmin); }
+ to { opacity: 1; transform: translate(-50%, 0); }
+}
+@keyframes nb-out {
+ from { opacity: 1; transform: translate(-50%, 0); }
+ to { opacity: 0; transform: translate(-50%, 1vmin); }
+}
+
+.nb-mark {
+ flex: none;
+ width: 4.6vmin;
+ height: 4.6vmin;
+ margin-top: 0.2vmin;
+ fill: none;
+ stroke: var(--gold);
+ stroke-width: 3;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+.nb-mark .nb-dot { fill: var(--gold); stroke: none; }
+.nb-neutral .nb-mark { stroke: rgba(255, 255, 255, 0.75); }
+.nb-neutral .nb-mark .nb-dot { fill: rgba(255, 255, 255, 0.75); }
+.nb-spin .nb-track { stroke: rgba(255, 255, 255, 0.18); }
+.nb-spin .nb-arc {
+ stroke-dasharray: 30 83;
+ transform-origin: 24px 24px;
+ animation: nb-spin 1.1s linear infinite;
+}
+@keyframes nb-spin { to { transform: rotate(360deg); } }
+
+.nb-text { min-width: 0; }
+.nb-headline {
+ font-size: 3.4vmin;
+ font-weight: 500;
+ line-height: 1.25;
+}
+.nb-footer {
+ margin-top: 1vmin;
+ font-size: 2.6vmin;
+ font-weight: 500;
+ color: var(--gold);
+}
+.nb-line {
+ margin-top: 0.6vmin;
+ font-size: 2.6vmin;
+ line-height: 1.4;
+ color: rgba(255, 255, 255, 0.78);
+ text-wrap: pretty;
+}
diff --git a/src/utils/updates.js b/src/utils/updates.js
new file mode 100644
index 0000000..25ccd0a
--- /dev/null
+++ b/src/utils/updates.js
@@ -0,0 +1,70 @@
+/**
+ * =====================================================
+ * Update notice
+ * =====================================================
+ * The agent holds board software from the release
+ * channels until the board's quiet window (23:00 by
+ * default) and lists it under `updates` in
+ * /api/local/status (agent/docs/architecture.md,
+ * section 9.4):
+ *
+ * { available: [{ type, version, description }],
+ * installTime: "23:00",
+ * installAt: "2026-09-30T23:00:00-04:00" | null,
+ * installing: false }
+ *
+ * While something waits, the ticker says so.
+ * =====================================================
+ */
+
+import { isoDateKey, zonedClock } from '../models/index.js';
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+/** "11:00 PM" in the board's zone. */
+function clockTime(instant, timezone) {
+ try {
+ return new Intl.DateTimeFormat('en-US', {
+ hour: 'numeric', minute: '2-digit', timeZone: timezone || undefined,
+ }).format(instant);
+ } catch {
+ return instant.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
+ }
+}
+
+/** "tonight", "tomorrow", "on Friday": when `at` falls, seen from `now`. */
+function dayWords(at, now, timezone) {
+ const evening = zonedClock(at, timezone).hours >= 17;
+ const day = isoDateKey(at, timezone);
+ if (day === isoDateKey(now, timezone)) return evening ? 'tonight' : 'today';
+ if (day === isoDateKey(new Date(now.getTime() + DAY_MS), timezone)) {
+ return evening ? 'tomorrow night' : 'tomorrow';
+ }
+ try {
+ const weekday = new Intl.DateTimeFormat('en-US', {
+ weekday: 'long', timeZone: timezone || undefined,
+ }).format(at);
+ return `on ${weekday}`;
+ } catch {
+ return `on ${day}`;
+ }
+}
+
+/**
+ * The ticker line for waiting updates, or null when nothing waits.
+ * @param {{available?: {description:string}[], installAt?: string|null, installing?: boolean}|null|undefined} updates
+ * @param {Date} now
+ * @param {string} [timezone] the board's IANA zone
+ * @returns {string|null}
+ */
+export function updateNotice(updates, now, timezone) {
+ const available = updates?.available ?? [];
+ if (!available.length) return null;
+ const what = available.map((u) => u.description).join(' and ');
+ if (updates.installing) return `Installing update: ${what}`;
+ const at = updates.installAt ? new Date(updates.installAt) : null;
+ if (!at || Number.isNaN(at.getTime()) || at.getTime() - now.getTime() < 60 * 1000) {
+ return `Update available: ${what} will be installed shortly`;
+ }
+ return `Update available: ${what} will be installed at ${clockTime(at, timezone)} ${dayWords(at, now, timezone)}`;
+}
diff --git a/src/utils/updates.test.js b/src/utils/updates.test.js
new file mode 100644
index 0000000..f25f513
--- /dev/null
+++ b/src/utils/updates.test.js
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest';
+import { updateNotice } from './updates.js';
+
+const TZ = 'America/Toronto';
+const app = { type: 'app', version: '2.1.0', description: 'board app 2.1.0' };
+const agent = { type: 'agent', version: '0.3.0', description: 'agent 0.3.0' };
+
+describe('updateNotice', () => {
+ const now = new Date('2026-09-25T14:00:00-04:00');
+
+ it('is null when nothing waits', () => {
+ expect(updateNotice(null, now, TZ)).toBeNull();
+ expect(updateNotice({ available: [], installAt: null }, now, TZ)).toBeNull();
+ });
+
+ it('says tonight for this evening', () => {
+ expect(updateNotice({ available: [app], installAt: '2026-09-25T23:00:00-04:00' }, now, TZ))
+ .toBe('Update available: board app 2.1.0 will be installed at 11:00 PM tonight');
+ });
+
+ it('names every waiting update', () => {
+ expect(updateNotice({ available: [agent, app], installAt: '2026-09-25T23:00:00-04:00' }, now, TZ))
+ .toBe('Update available: agent 0.3.0 and board app 2.1.0 will be installed at 11:00 PM tonight');
+ });
+
+ it('says tomorrow night after the window has passed', () => {
+ const morning = new Date('2026-09-26T08:00:00-04:00');
+ expect(updateNotice({ available: [app], installAt: '2026-09-26T23:00:00-04:00' }, morning, TZ))
+ .toContain('at 11:00 PM tonight');
+ expect(updateNotice({ available: [app], installAt: '2026-09-26T23:00:00-04:00' }, now, TZ))
+ .toContain('at 11:00 PM tomorrow night');
+ });
+
+ it('reads the time in the board zone, not the runtime zone', () => {
+ expect(updateNotice({ available: [app], installAt: '2026-09-26T03:00:00Z' }, now, TZ))
+ .toContain('at 11:00 PM tonight');
+ });
+
+ it('says shortly when the window is open', () => {
+ expect(updateNotice({ available: [app], installAt: now.toISOString() }, now, TZ))
+ .toBe('Update available: board app 2.1.0 will be installed shortly');
+ });
+
+ it('says installing while it installs', () => {
+ expect(updateNotice({ available: [app], installAt: now.toISOString(), installing: true }, now, TZ))
+ .toBe('Installing update: board app 2.1.0');
+ });
+});