diff --git a/index.html b/index.html index 6331d55..bf9e790 100644 --- a/index.html +++ b/index.html @@ -6,8 +6,26 @@ 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
{status.daysRemaining === 0 ? 'Content ends today' : `Content ends ${when}`}
; +} + +/** + * 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'); + }); +});