diff --git a/client/src/app.jsx b/client/src/app.jsx index f21cf156..e99a37be 100644 --- a/client/src/app.jsx +++ b/client/src/app.jsx @@ -59,6 +59,7 @@ import { } from './utils/themes.js'; import { personalizationTokens } from './utils/personalize.js'; import SaveLayoutButton from './components/SaveLayoutButton.jsx'; +import { setHouseholdChangesMode, subscribeHouseholdChanges } from './utils/householdChanges.js'; import { updateDisplayDimming, setDimmingPaused } from './utils/displayDim.js'; import { getServerTimezoneSync } from './utils/timezone.js'; import { THEME_TOKENS_APPLIED_EVENT } from './utils/pluginThemeBridge.js'; @@ -780,6 +781,19 @@ const App = () => { return () => clearInterval(intervalId); }, [fetchHouseholdSettings]); + // Within seconds, once the server says household settings changed (issue + // #325, utils/householdChanges.js): appearance, vacation, control limits. + // The slow poll above stays as a safety net. + useEffect(() => subscribeHouseholdChanges(['settings'], () => { + void fetchHouseholdSettings(); + }), [fetchHouseholdSettings]); + + // A wall display checks for changes every 15 seconds; a phone only when + // it's opened, since nobody watches one sitting in a pocket. + useEffect(() => { + setHouseholdChangesMode(isMobile ? 'visible' : 'interval'); + }, [isMobile]); + // Recovery for a PIN check that never landed. // // While adminPinExists is unresolved, every display that remembers the PIN is diff --git a/client/src/components/CalendarWidget.jsx b/client/src/components/CalendarWidget.jsx index 3f147cdd..f1a7696b 100644 --- a/client/src/components/CalendarWidget.jsx +++ b/client/src/components/CalendarWidget.jsx @@ -39,6 +39,7 @@ import { getWeekdayOptions, fromDateKey, } from '../utils/dateUtils.js'; +import { subscribeHouseholdChanges } from '../utils/householdChanges.js'; // Stored values for the week/month start settings. Display names come from // getWeekdayOptions() so they localize; these keys never change. @@ -349,6 +350,14 @@ const CalendarWidget = ({ fetchCalendarEvents(); }, [refreshNonce]); + // An event added, edited or synced anywhere (issue #325): reload what's on + // screen. Through a ref, so it fetches the month showing now. + const fetchEventsRef = useRef(null); + fetchEventsRef.current = () => fetchCalendarEvents(); + useEffect(() => subscribeHouseholdChanges(['calendar'], () => { + fetchEventsRef.current(); + }), []); + // Refetch only when the visible month (or view mode) changes to keep the fetch small. const eventsRangeKey = `${viewMode}:${moment(currentDate).format('YYYY-MM')}`; useEffect(() => { diff --git a/client/src/components/ChoreWidget.jsx b/client/src/components/ChoreWidget.jsx index d7950685..4f44948f 100644 --- a/client/src/components/ChoreWidget.jsx +++ b/client/src/components/ChoreWidget.jsx @@ -46,6 +46,7 @@ import { filterVisibleUsers, toggleHiddenUserId, pruneHiddenUserIds } from '../u import { isControlHidden } from '../utils/displayControls.js'; import { subscribePluginEvents } from '../utils/pluginEventBridge.js'; import { subscribePluginDataChanged } from '../utils/pluginDataBridge.js'; +import { subscribeHouseholdChanges } from '../utils/householdChanges.js'; import { playSound, soundUrl } from '../utils/choreSound.js'; import { formatTime } from '../utils/dateUtils.js'; import PrizeCelebration from './PrizeCelebration.jsx'; @@ -306,6 +307,15 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { }); }, []); + // Another display, a phone or the nightly job changed something this widget + // shows (issue #325): reload all of it, one batched load. The guard in + // fetchData keeps a reload that lands mid-tap from flickering the row. + const fetchDataRef = useRef(null); + fetchDataRef.current = () => fetchData(); + useEffect(() => subscribeHouseholdChanges(['chores', 'users', 'prizes', 'settings'], () => { + fetchDataRef.current(); + }), []); + // Prize redemptions celebrate on every display showing the chore widget: // the approval emits prize.redeemed on the server bus, delivered over the // same SSE stream the plugin platform uses. Confetti + a chime (chime only diff --git a/client/src/components/PluginWidgetWrapper.jsx b/client/src/components/PluginWidgetWrapper.jsx index 54152dbb..a53fb8cc 100644 --- a/client/src/components/PluginWidgetWrapper.jsx +++ b/client/src/components/PluginWidgetWrapper.jsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { API_BASE_URL } from '../utils/apiConfig.js'; import { getDeviceName } from '../utils/deviceName.js'; import { subscribePluginEvents } from '../utils/pluginEventBridge.js'; +import { HOUSEHOLD_DOMAINS, knownHouseholdRevisions, subscribeHouseholdChanges } from '../utils/householdChanges.js'; import { acceptPluginDataMessage, emitPluginDataChanged } from '../utils/pluginDataBridge.js'; import { acceptPluginCelebrateMessage, requestCelebration } from '../utils/celebrations.js'; import { THEME_TOKENS_APPLIED_EVENT, buildPluginThemeMessage, readRoleTokens } from '../utils/pluginThemeBridge.js'; @@ -70,6 +71,9 @@ const PluginWidgetWrapper = ({ if (!eventsKey) return undefined; const declared = new Set(eventsKey.split(',')); return subscribePluginEvents((message) => { + // Delivered below, from the same source as the dashboard's own + // widgets, so it arrives once and where the stream doesn't reach. + if (message.event === 'household.changed') return; if (!declared.has(message.event)) return; const target = iframeRef.current?.contentWindow; if (!target) return; @@ -80,6 +84,26 @@ const PluginWidgetWrapper = ({ }); }, [eventsKey]); + // household.changed (issue #325), for a plugin that declared it: the kinds + // of household data that changed, from the 15-second checks or the stream, + // whichever saw it first. + useEffect(() => { + if (!eventsKey.split(',').includes('household.changed')) return undefined; + return subscribeHouseholdChanges(HOUSEHOLD_DOMAINS, (domains) => { + const target = iframeRef.current?.contentWindow; + if (!target) return; + target.postMessage( + { + type: 'homeglow:event', + event: 'household.changed', + payload: { domains, revisions: knownHouseholdRevisions() }, + emittedAt: new Date().toISOString(), + }, + TO_FRAME + ); + }); + }, [eventsKey]); + // The reverse direction: a plugin that wrote core data through the REST API // tells the host, so widgets showing that data can refetch instead of // waiting out their own refresh timer. diff --git a/client/src/components/ScreensaverOverlay.jsx b/client/src/components/ScreensaverOverlay.jsx index 5fc775a7..0c3e302c 100644 --- a/client/src/components/ScreensaverOverlay.jsx +++ b/client/src/components/ScreensaverOverlay.jsx @@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next'; import { API_BASE_URL } from '../utils/apiConfig.js'; import { formatTime, getDateLocale } from '../utils/dateUtils.js'; import { usePageVisibility } from '../hooks/useScreenActivity.js'; +import { subscribeHouseholdChanges } from '../utils/householdChanges.js'; import { addDaysToKey, buildAgenda, @@ -125,6 +126,11 @@ const ScreensaverOverlay = ({ showCalendar, calendarDays, showWeather, tabs, dri return () => clearInterval(timer); }, [load, pageVisible]); + // The agenda in the corner follows calendar changes too (issue #325). + useEffect(() => subscribeHouseholdChanges(['calendar'], () => { + void load(); + }), [load]); + const agenda = useMemo(() => { if (!showCalendar || !events) return null; return limitAgenda(buildAgenda(events, { todayKey, days: calendarDays })); diff --git a/client/src/utils/householdChanges.js b/client/src/utils/householdChanges.js new file mode 100644 index 00000000..1b91e2b4 --- /dev/null +++ b/client/src/utils/householdChanges.js @@ -0,0 +1,158 @@ +import axios from 'axios'; +import { API_BASE_URL } from './apiConfig.js'; +import { subscribePluginEvents } from './pluginEventBridge.js'; + +// Keeping every display in step (issue #325). The server keeps a revision +// counter for each kind of household data (server/utils/householdChanges.js) +// and moves it on every write. This page learns the counters two ways, and +// tells whoever subscribed which kinds moved, so they reload them: +// +// - GET /api/changes, a few bytes and 304 when nothing changed. A display +// checks every 15 seconds while it's visible, and right away when it +// becomes visible again. A phone checks only when it becomes visible: +// it's opened, glanced at and put away, so a timer would mostly spend +// its battery while nobody looks. +// - The event stream, where it reaches the browser: the same counters +// arrive within a second of the write. Where it doesn't (some proxies +// and tunnels), the check above still catches up. +// +// Coarse on purpose: a subscriber learns that a kind of data changed, never +// what, and reloads all of it. No write can be missed by a widget asking +// the wrong question. + +export const HOUSEHOLD_DOMAINS = Object.freeze(['chores', 'users', 'prizes', 'settings', 'calendar']); +export const CHECK_INTERVAL_MS = 15 * 1000; + +/** The kinds whose counters differ between two snapshots; none before the first. Pure. */ +export function changedDomains(known, next) { + if (!known || !next) return []; + return HOUSEHOLD_DOMAINS.filter((domain) => next[domain] !== undefined && next[domain] !== known[domain]); +} + +const subscribers = new Set(); +let known = null; +let etag = null; +let mode = 'interval'; +let running = false; +let checking = false; +let timer = null; +let stopEvents = null; + +const isVisible = () => typeof document === 'undefined' || document.visibilityState !== 'hidden'; + +function apply(next) { + if (!next || typeof next !== 'object') return; + if (!known) { + known = { ...next }; + return; + } + const moved = changedDomains(known, next); + if (!moved.length) return; + known = { ...known, ...next }; + for (const { domains, handler } of subscribers) { + const theirs = moved.filter((domain) => domains.has(domain)); + if (!theirs.length) continue; + try { + handler(theirs); + } catch (error) { + console.error('Household change subscriber failed:', error); + } + } +} + +/** Ask the server now. Resolves when the answer has been applied. */ +export async function checkHouseholdChanges() { + if (checking) return; + checking = true; + try { + const response = await axios.get(`${API_BASE_URL}/api/changes`, { + headers: etag ? { 'If-None-Match': etag } : {}, + validateStatus: (status) => status === 200 || status === 304, + }); + if (response.status === 200) { + etag = response.headers?.etag || null; + apply(response.data); + } + } catch { + // Offline or restarting: the next check, or the next visibility, retries. + } finally { + checking = false; + } +} + +function onVisibilityChange() { + if (isVisible()) void checkHouseholdChanges(); +} + +function startTimer() { + clearInterval(timer); + timer = null; + if (mode !== 'interval') return; + timer = setInterval(() => { + if (isVisible()) void checkHouseholdChanges(); + }, CHECK_INTERVAL_MS); +} + +function start() { + if (running) return; + running = true; + void checkHouseholdChanges(); + startTimer(); + if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibilityChange); + // The fast path. Without EventSource (tests, very old browsers) the + // checks alone keep up. + if (typeof EventSource !== 'undefined') { + stopEvents = subscribePluginEvents((message) => { + if (message?.event === 'household.changed') apply(message.payload?.revisions); + }); + } +} + +function stop() { + if (!running) return; + running = false; + clearInterval(timer); + timer = null; + if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibilityChange); + stopEvents?.(); + stopEvents = null; +} + +/** + * 'interval' (a wall display, a desktop): every 15 seconds while visible. + * 'visible' (a phone): only when it becomes visible. + */ +export function setHouseholdChangesMode(next) { + if (next !== 'interval' && next !== 'visible') return; + if (next === mode) return; + mode = next; + if (running) startTimer(); +} + +/** + * Call `handler(movedDomains)` whenever any of `domains` changes on the + * server. Returns an unsubscribe function, so it drops into a useEffect. + * The checks run while anything is subscribed. + */ +export function subscribeHouseholdChanges(domains, handler) { + const entry = { domains: new Set(domains), handler }; + subscribers.add(entry); + start(); + return () => { + subscribers.delete(entry); + if (!subscribers.size) stop(); + }; +} + +/** The counters this page last saw, or null before the first answer. */ +export const knownHouseholdRevisions = () => (known ? { ...known } : null); + +/** Tests only: forget everything. */ +export function resetHouseholdChangesForTests() { + stop(); + subscribers.clear(); + known = null; + etag = null; + mode = 'interval'; + checking = false; +} diff --git a/client/src/utils/householdChanges.test.js b/client/src/utils/householdChanges.test.js new file mode 100644 index 00000000..b317e1b7 --- /dev/null +++ b/client/src/utils/householdChanges.test.js @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('axios', () => ({ default: { get: vi.fn() } })); +vi.mock('./pluginEventBridge.js', () => ({ subscribePluginEvents: vi.fn(() => () => {}) })); + +const axios = (await import('axios')).default; +const { + CHECK_INTERVAL_MS, + changedDomains, + checkHouseholdChanges, + resetHouseholdChangesForTests, + setHouseholdChangesMode, + subscribeHouseholdChanges, +} = await import('./householdChanges.js'); + +const revisions = (overrides = {}) => ({ chores: 10, users: 10, prizes: 10, settings: 10, calendar: 10, ...overrides }); +const answer = (data, etag = `"${Object.values(data).join('-')}"`) => ({ status: 200, data, headers: { etag } }); +const notModified = { status: 304, data: '', headers: {} }; +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +// A document whose visibility the tests set, with its visibilitychange listeners. +function stubDocument() { + const listeners = new Set(); + globalThis.document = { + visibilityState: 'visible', + addEventListener: (type, fn) => { if (type === 'visibilitychange') listeners.add(fn); }, + removeEventListener: (type, fn) => { if (type === 'visibilitychange') listeners.delete(fn); }, + }; + return { + set(state) { + globalThis.document.visibilityState = state; + listeners.forEach((fn) => fn()); + }, + listeners, + }; +} + +describe('changedDomains', () => { + it('names the kinds whose counters differ, and none before the first answer', () => { + expect(changedDomains(revisions(), revisions({ chores: 11, calendar: 3 }))).toEqual(['chores', 'calendar']); + expect(changedDomains(revisions(), revisions())).toEqual([]); + expect(changedDomains(null, revisions())).toEqual([]); + }); +}); + +describe('household changes', () => { + let visibility; + beforeEach(() => { + resetHouseholdChangesForTests(); + axios.get.mockReset(); + visibility = stubDocument(); + }); + afterEach(() => { + resetHouseholdChangesForTests(); + vi.useRealTimers(); + delete globalThis.document; + }); + + it('tells each subscriber only about the kinds it asked for, after a baseline', async () => { + axios.get.mockResolvedValueOnce(answer(revisions())); + const chores = vi.fn(); + const calendar = vi.fn(); + subscribeHouseholdChanges(['chores', 'users'], chores); + subscribeHouseholdChanges(['calendar'], calendar); + await flush(); + expect(chores).not.toHaveBeenCalled(); + + axios.get.mockResolvedValueOnce(answer(revisions({ chores: 11, users: 11 }))); + await checkHouseholdChanges(); + expect(chores).toHaveBeenCalledWith(['chores', 'users']); + expect(calendar).not.toHaveBeenCalled(); + }); + + it('sends the last ETag, and a 304 changes nothing', async () => { + axios.get.mockResolvedValueOnce(answer(revisions(), '"abc"')); + const handler = vi.fn(); + subscribeHouseholdChanges(['chores'], handler); + await flush(); + axios.get.mockResolvedValueOnce(notModified); + await checkHouseholdChanges(); + expect(axios.get.mock.calls[1][1].headers).toEqual({ 'If-None-Match': '"abc"' }); + expect(handler).not.toHaveBeenCalled(); + }); + + it('a restart reads as everything changed', async () => { + axios.get.mockResolvedValueOnce(answer(revisions())); + const handler = vi.fn(); + subscribeHouseholdChanges(['chores', 'settings'], handler); + await flush(); + axios.get.mockResolvedValueOnce(answer({ chores: 5000, users: 5000, prizes: 5000, settings: 5000, calendar: 5000 })); + await checkHouseholdChanges(); + expect(handler).toHaveBeenCalledWith(['chores', 'settings']); + }); + + it('a display checks every 15 seconds while visible, and at once when it comes back', async () => { + vi.useFakeTimers(); + axios.get.mockResolvedValue(notModified); + subscribeHouseholdChanges(['chores'], () => {}); + await vi.advanceTimersByTimeAsync(0); + expect(axios.get).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(CHECK_INTERVAL_MS); + expect(axios.get).toHaveBeenCalledTimes(2); + + visibility.set('hidden'); + await vi.advanceTimersByTimeAsync(CHECK_INTERVAL_MS * 3); + expect(axios.get).toHaveBeenCalledTimes(2); + + visibility.set('visible'); + await vi.advanceTimersByTimeAsync(0); + expect(axios.get).toHaveBeenCalledTimes(3); + }); + + it('a phone checks only when it becomes visible', async () => { + vi.useFakeTimers(); + axios.get.mockResolvedValue(notModified); + setHouseholdChangesMode('visible'); + subscribeHouseholdChanges(['chores'], () => {}); + await vi.advanceTimersByTimeAsync(0); + expect(axios.get).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(CHECK_INTERVAL_MS * 4); + expect(axios.get).toHaveBeenCalledTimes(1); + + visibility.set('hidden'); + visibility.set('visible'); + await vi.advanceTimersByTimeAsync(0); + expect(axios.get).toHaveBeenCalledTimes(2); + }); + + it('stops checking when the last subscriber leaves', async () => { + vi.useFakeTimers(); + axios.get.mockResolvedValue(notModified); + const stop = subscribeHouseholdChanges(['chores'], () => {}); + await vi.advanceTimersByTimeAsync(0); + stop(); + await vi.advanceTimersByTimeAsync(CHECK_INTERVAL_MS * 3); + expect(axios.get).toHaveBeenCalledTimes(1); + expect(visibility.listeners.size).toBe(0); + }); + + it('a failed check is quiet and the next one tries again', async () => { + axios.get.mockResolvedValueOnce(answer(revisions())); + const handler = vi.fn(); + subscribeHouseholdChanges(['chores'], handler); + await flush(); + axios.get.mockRejectedValueOnce(new Error('Network Error')); + await checkHouseholdChanges(); + axios.get.mockResolvedValueOnce(answer(revisions({ chores: 12 }))); + await checkHouseholdChanges(); + expect(handler).toHaveBeenCalledWith(['chores']); + }); +}); diff --git a/client/src/utils/pluginDataBridge.js b/client/src/utils/pluginDataBridge.js index 1f8f93cb..4f51da09 100644 --- a/client/src/utils/pluginDataBridge.js +++ b/client/src/utils/pluginDataBridge.js @@ -16,9 +16,9 @@ // failure that made the issue #140 celebration invisible. Two frames on one // page need no network at all, so this works wherever the page itself works. // -// The tradeoff that buys: this refreshes only the display that was tapped. -// A second wall display still waits for its own timer. Fixing that genuinely -// does need the event stream. +// This refreshes the display that was tapped at once. Every other display +// learns of the write from the server's change counters within seconds +// (utils/householdChanges.js, issue #325). /** postMessage `type` a plugin sends to window.parent. */ export const PLUGIN_DATA_MESSAGE_TYPE = 'homeglow:data-changed'; diff --git a/docs/guides/plugin-development.md b/docs/guides/plugin-development.md index 8a0d062d..64e82348 100644 --- a/docs/guides/plugin-development.md +++ b/docs/guides/plugin-development.md @@ -390,6 +390,7 @@ const off = HomeGlow.on('clam.withdrawn', (payload, meta) => { | `chore.allCompleted` | a user's last **regular** chore for the day is done | `{ userId, username, date, reward }` | | `prize.redeemed` | a prize-store request is approved | `{ userId, prizeId, offerId, prizeName, cost, newTotal }` | | `ha.state` | Home Assistant entities changed | `{ entityIds }`: a nudge to read state again (§5a), never the state itself | +| `household.changed` | household data changed, on any display or by the server | `{ domains, revisions }`: which kinds changed (`chores`, `users`, `prizes`, `settings`, `calendar`). Reload what you show; it never says what changed. Delivered within a second where the event stream works, and within 15 seconds where it doesn't | Declaring an event not in the catalog rejects the install — typos fail loudly. Only declared events are ever delivered to your iframe. diff --git a/docs/reference/backend-api.md b/docs/reference/backend-api.md index 818608ed..ca09ad6d 100644 --- a/docs/reference/backend-api.md +++ b/docs/reference/backend-api.md @@ -96,6 +96,7 @@ segment (the browser's `localStorage` UUID). | GET | `/api/update-status` | Running version vs the latest GitHub release, checked at most every 6 hours. `HOMEGLOW_DISABLE_UPDATE_CHECK=1` turns it off. | | GET | `/api/timezone` | Server timezone. | | GET | `/api/system/backgroundTasks` | Manually trigger nightly processing. | +| GET | `/api/changes` | A revision counter for each kind of household data, `{ chores, users, prizes, settings, calendar }`, with an `ETag`; `304` for a matching `If-None-Match`. Displays check it to reload what changed (issue #325). Every successful write under the household routes moves its counters (`utils/householdChanges.js`); a chore or prize write also moves `users`, for clam totals. The nightly job, calendar sync and calendar routines move them too. The counters start from a random base on each start, so a restart reads as everything changed. | | GET | `/index.css` | Serves the app CSS for custom widgets (with fallback). | ### Custom widgets / plugins @@ -142,7 +143,7 @@ for the developer-facing detail and the `HomeGlow.*` SDK wrappers. | POST | `/api/plugin/v1/storage/:pluginId/:key/increment` | Atomic `{ path, delta }` numeric increment. | | GET | `/api/plugin/v1/settings/:pluginId` | Effective declared settings (`?device=` for device scope). | | PUT | `/api/plugin/v1/settings/:pluginId` | Write declared settings (validated against the manifest). | -| GET | `/api/plugin/v1/events/stream` | SSE stream of core events (`clam.*`, `chore.*`, `ha.state`). | +| GET | `/api/plugin/v1/events/stream` | SSE stream of core events (`clam.*`, `chore.*`, `ha.state`, `household.changed`). | | GET | `/api/plugin/v1/ha/:pluginId/state` | A Home Assistant panel's (or a plugin's declared) entities, filtered attributes, which it may operate, where each comes from (`sources`: its integration and a device tag, from Home Assistant's entity registry), and whether the live link is up. A domain grant leaves out entities hidden or disabled in Home Assistant. Answered from memory. | | POST | `/api/plugin/v1/ha/:pluginId/action` | `{ entity` or `entities, action, value, pin, device }`, checked against the panel's recipe or the plugin's manifest and turned into one service call each. Media actions: `play_media` (a key from browse or search), `play_url` (an address on the media server the plugin's `playUrlsFrom` setting names), `seek`, `shuffle`, `join` (other speakers the panel may operate, up to 8) and `unjoin`; slow ones answer `pending: true` after 1.5 seconds. 401 `needsPin` for unlocking, disarming or opening a garage door under PIN protection (not from a display that remembers the PIN); 429 over 10 a second. | | GET | `/api/plugin/v1/ha/:pluginId/camera/:entityId` | A camera snapshot, only for a camera on the panel. | diff --git a/docs/reference/frontend.md b/docs/reference/frontend.md index 75a039c8..76989294 100644 --- a/docs/reference/frontend.md +++ b/docs/reference/frontend.md @@ -39,6 +39,12 @@ custom properties. a `ScreenSaver` overlay (tab-cycling or photo slideshow), optionally full-screen. - **Settings changes** propagate through custom window events (`homeglow:device-settings-updated`, `homeglow:interface-settings-updated`). +- **Other displays' changes** arrive through `utils/householdChanges.js` (issue + #325): a display checks `GET /api/changes` every 15 seconds while visible (a + phone only when it becomes visible), takes the same counters from the event + stream where it reaches the browser, and tells subscribers which kinds of data + moved. The Chores widget, the calendar, the screensaver agenda, household + settings and plugins that declare `household.changed` reload on it. ## Component map (`client/src/components/`) @@ -92,6 +98,7 @@ custom properties. | --- | --- | | `apiConfig.js` | Resolves `API_BASE_URL` (dev: `VITE_REACT_APP_API_URL` or `localhost:5001`; prod: same-origin). | | `deviceName.js` | Generate/read/persist the per-device UUID and build the device API base. | +| `householdChanges.js` | `subscribeHouseholdChanges(domains, handler)`: the 15-second checks of `GET /api/changes` and the `household.changed` stream event, deduplicated; `setHouseholdChangesMode('interval' \| 'visible')`. | | `timezone.js` | Fetch and cache the server timezone (`initTimezone`, `getServerTimezoneSync`) so client date math matches the backend. | | `choreHelpers.js` | Cron/day-of-week helpers, "should this chore show today", today's date string in server TZ. | | `colorContrast.js` | Contrast calculations for readable text on colored backgrounds. | diff --git a/server/index.js b/server/index.js index 6f148c78..a834772e 100644 --- a/server/index.js +++ b/server/index.js @@ -225,6 +225,14 @@ axios.interceptors.request.use((config) => { let calendarSyncService = null; const pluginEvents = require('./services/pluginEvents'); +const { DOMAINS: HOUSEHOLD_DOMAINS, createHouseholdChanges, domainsForRequest } = require('./utils/householdChanges'); +// A revision counter for each kind of household data, so every display can +// tell what changed and reload it (issue #325, utils/householdChanges.js). +// Each burst of changes also goes out on the event stream, the fast path +// where it reaches the browser. +const householdChanges = createHouseholdChanges({ + onBump: (domains, revisions) => pluginEvents.emit('household.changed', { domains, revisions }), +}); const haPanelRules = require('./services/haPanels'); const { HaLive } = require('./services/haLive'); const { registerHaPanelRoutes, lazyDb } = require('./routes/haPanels'); @@ -610,7 +618,9 @@ function getPluginTokenSecret() { return pluginTokenSecret; } const CORS_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']; -const CORS_HEADERS = ['Content-Type', 'Authorization']; +// If-None-Match: a display's change checks (GET /api/changes) from the Vite +// dev server or a CORS_ORIGINS frontend, which are cross-origin. +const CORS_HEADERS = ['Content-Type', 'Authorization', 'If-None-Match']; fastify.register(require('@fastify/cors'), { delegator: (request, callback) => { const origin = request.headers.origin; @@ -656,6 +666,13 @@ fastify.addHook('onRequest', async (request, reply) => { return reply.status(403).send({ error: 'HomeGlow accepts changes only from its own pages.' }); }); +// Every successful write to household data moves its counters, here rather +// than in each route, so a new route can't forget to. +fastify.addHook('onResponse', async (request, reply) => { + const domains = domainsForRequest(request.method, request.routeOptions?.url, reply.statusCode); + if (domains.length) householdChanges.bump(domains); +}); + fastify.register(multipart, { limits: { fileSize: 25 * 1024 * 1024, // 25MB per file @@ -805,6 +822,21 @@ fastify.get('/plugin-sdk/v1.js', async (request, reply) => { }); // Add a simple test endpoint +// What changed (issue #325): a display checks this every 15 seconds and +// reloads the kinds of data whose counters moved. A handful of bytes, and +// 304 when nothing changed since the display last asked. +fastify.get('/api/changes', async (request, reply) => { + const tag = householdChanges.etag(); + reply.header('ETag', tag).header('Cache-Control', 'no-cache') + // A cross-origin frontend (dev, CORS_ORIGINS) reads the tag only when told it may. + .header('Access-Control-Expose-Headers', 'ETag'); + const ifNoneMatch = request.headers['if-none-match']; + if (ifNoneMatch && ifNoneMatch.split(',').some((value) => value.trim().replace(/^W\//, '') === tag)) { + return reply.code(304).send(); + } + return householdChanges.snapshot(); +}); + fastify.get('/api/test', async (request, reply) => { return { message: 'Server is working!', @@ -1201,6 +1233,8 @@ const routines = registerRoutineRoutes(fastify, { calendarEvents: (from, to) => (calendarSyncService || new CalendarSyncService(db, decryptPassword)).getCachedEvents(from, to), isVacation: isVacationActiveOn, awardDailyBonusesAfterRemoval: (userIds) => awardDailyBonusesAfterRemoval(userIds), + // A calendar routine's days, changed by the calendar rather than a request. + householdChanged: () => householdChanges.bump('chores', 'users'), }); // Notifications, from the chore events and plugins (services/notifications.js @@ -2491,6 +2525,8 @@ async function dailyBackgroundProcessing() { console.error('Routines nightly tidy failed:', error); } console.log(`Total: ${prunedScheduleCount + prunedChoreCount + resetScheduleCount} operations performed`); + // Written without a request, so the displays are told here. + householdChanges.bump('chores', 'users'); return results; } catch (error) { console.error('Error during daily background processing:', error); @@ -7335,6 +7371,7 @@ const start = async () => { setInterval(() => { try { resetDemoData(db); + householdChanges.bump(HOUSEHOLD_DOMAINS); // Reseeding assigns new source ids, so the running sync jobs point // at rows that no longer exist. Restart them (this also kicks off an // immediate syncAllSources to refill the wiped event cache). @@ -7373,7 +7410,9 @@ const start = async () => { // fetched (SSRF guard). The seeded "Family Calendar" placeholder // (.invalid host) is skipped by the service itself. if (DEMO_MODE) { - calendarSyncService = new CalendarSyncService(db, decryptPassword, fastify.log); + calendarSyncService = new CalendarSyncService(db, decryptPassword, fastify.log, { + onEventsChanged: () => householdChanges.bump('calendar'), + }); if (process.env.HOMEGLOW_DISABLE_CALENDAR_SYNC !== '1') { calendarSyncService.initialize(); console.log('Calendar sync enabled in demo mode (seeded demo feeds only; source management is demo-blocked)'); @@ -7381,7 +7420,9 @@ const start = async () => { console.log('Calendar sync jobs disabled in demo mode by HOMEGLOW_DISABLE_CALENDAR_SYNC=1 (cached events only)'); } } else if (process.env.HOMEGLOW_DISABLE_CALENDAR_SYNC !== '1') { - calendarSyncService = new CalendarSyncService(db, decryptPassword, fastify.log); + calendarSyncService = new CalendarSyncService(db, decryptPassword, fastify.log, { + onEventsChanged: () => householdChanges.bump('calendar'), + }); calendarSyncService.initialize(); console.log('Calendar sync service started'); } else { diff --git a/server/routes/routines.js b/server/routes/routines.js index 90cb9243..492514ee 100644 --- a/server/routes/routines.js +++ b/server/routes/routines.js @@ -22,6 +22,9 @@ function registerRoutineRoutes(fastify, deps) { timezone, today, calendarEvents = () => [], isVacation = () => false, // A routine deleted can leave its person's day done (issue #298). awardDailyBonusesAfterRemoval = () => {}, + // Days added, retimed or removed by the calendar, not by a request, so + // the displays are told (issue #325). + householdChanged = () => {}, } = deps; // (scheduleId|date) pairs this module is itself uncompleting, so the chore @@ -292,6 +295,9 @@ function registerRoutineRoutes(fastify, deps) { for (const { scheduleId } of plan.hide) db.prepare('UPDATE chore_schedules SET visible = 0 WHERE id = ?').run(scheduleId); applyExpiry(r.id); })(); + if (plan.forget.length || plan.create.length || plan.retime.length || plan.remove.length || plan.hide.length) { + householdChanged(); + } } function upcomingEvents() { diff --git a/server/services/calendarSync.js b/server/services/calendarSync.js index 3df9be8d..bb0d305d 100644 --- a/server/services/calendarSync.js +++ b/server/services/calendarSync.js @@ -44,10 +44,13 @@ function householdDateKey(value) { const ALL_DAY_RANGE_SLACK_MS = 2 * 24 * 60 * 60 * 1000; class CalendarSyncService { - constructor(db, decryptPassword, logger = CONSOLE_LOGGER) { + constructor(db, decryptPassword, logger = CONSOLE_LOGGER, { onEventsChanged = () => {} } = {}) { this.db = db; this.decryptPassword = decryptPassword; this.log = logger; + // Called when a sync rewrote a source's cached events, so the displays + // reload the calendar (issue #325). Not for a sync that found no change. + this.onEventsChanged = onEventsChanged; this.syncIntervals = new Map(); this.isSyncing = new Map(); // sha256 of the rows last written per source (see syncSource). @@ -157,6 +160,7 @@ class CalendarSyncService { }); replaceCache(); this.cacheHashes.set(sourceId, hash); + this.onEventsChanged(); } const duration = Date.now() - startTime; diff --git a/server/services/pluginEvents.js b/server/services/pluginEvents.js index 3a61bf4d..2f150441 100644 --- a/server/services/pluginEvents.js +++ b/server/services/pluginEvents.js @@ -16,6 +16,7 @@ const PLUGIN_EVENT_CATALOG = Object.freeze([ 'chore.allCompleted',// { userId, username, date, reward } — every regular chore done for the day (issue #140) 'prize.redeemed', // { userId, prizeId, offerId, prizeName, cost, newTotal } 'ha.state', // { entityIds } — Home Assistant entities changed (issue #252); a nudge to read state, never the state itself + 'household.changed', // { domains, revisions } — household data changed (issue #325): chores, users, prizes, settings or calendar; reload what you show ]); const catalogSet = new Set(PLUGIN_EVENT_CATALOG); diff --git a/server/tests/calendarSyncCache.test.js b/server/tests/calendarSyncCache.test.js index b2634523..9d73584a 100644 --- a/server/tests/calendarSyncCache.test.js +++ b/server/tests/calendarSyncCache.test.js @@ -139,3 +139,24 @@ test('an ICS feed is fetched with a timeout', async () => { } assert.ok(options?.signal instanceof AbortSignal); }); + +// Issue #325: the displays reload the calendar when a sync changed it, and +// only then. +test('a sync tells the displays only when it changed the cached events', async () => { + const db = freshDb(); + const same = [event('a', '02')]; + let told = 0; + const service = new CalendarSyncService(db, () => null, SILENT, { onEventsChanged: () => { told += 1; } }); + const feeds = [same, same, [event('a', '02'), event('b', '03')], [event('c', '04'), event('d', '05', { start: new Date('not a date') })]]; + let call = 0; + service.fetchICSEvents = async () => feeds[Math.min(call++, feeds.length - 1)]; + + await service.syncSource(1); + assert.equal(told, 1, 'first sync'); + await service.syncSource(1); + assert.equal(told, 1, 'unchanged feed'); + await service.syncSource(1); + assert.equal(told, 2, 'changed feed'); + await service.syncSource(1); + assert.equal(told, 2, 'a failed sync changed nothing'); +}); diff --git a/server/tests/householdChanges.test.js b/server/tests/householdChanges.test.js new file mode 100644 index 00000000..8e89c93d --- /dev/null +++ b/server/tests/householdChanges.test.js @@ -0,0 +1,58 @@ +// Revision counters for household data (issue #325). +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { DOMAINS, domainsForRequest, createHouseholdChanges } = require('../utils/householdChanges'); + +test('a successful write moves the kinds of data its route touches', () => { + assert.deepEqual(domainsForRequest('POST', '/api/chores/complete', 200), ['chores', 'users']); + assert.deepEqual(domainsForRequest('PATCH', '/api/chore-schedules/:id', 200), ['chores', 'users']); + assert.deepEqual(domainsForRequest('POST', '/api/routines/:id/steps/:stepId', 200), ['chores', 'users']); + assert.deepEqual(domainsForRequest('POST', '/api/users/:id/clams/add', 200), ['users']); + assert.deepEqual(domainsForRequest('POST', '/api/prize-offers/:id/approve', 200), ['prizes', 'users']); + assert.deepEqual(domainsForRequest('POST', '/api/settings', 200), ['settings']); + assert.deepEqual(domainsForRequest('PUT', '/api/timezone', 200), ['settings']); + assert.deepEqual(domainsForRequest('DELETE', '/api/calendar-sources/:id/events/:eventId', 204), ['calendar']); +}); + +test('reads, failed writes and other routes move nothing', () => { + assert.deepEqual(domainsForRequest('GET', '/api/chores', 200), []); + assert.deepEqual(domainsForRequest('HEAD', '/api/users', 200), []); + assert.deepEqual(domainsForRequest('POST', '/api/chores/complete', 409), []); + assert.deepEqual(domainsForRequest('POST', '/api/settings/search', 200), []); + assert.deepEqual(domainsForRequest('POST', '/api/calendar-sources/:id/test', 200), []); + assert.deepEqual(domainsForRequest('PATCH', '/api/devices/:deviceName/settings', 200), []); + assert.deepEqual(domainsForRequest('POST', '/api/plugin/v1/storage/:pluginId/:key', 200), []); + assert.deepEqual(domainsForRequest('POST', undefined, 200), [], 'a 404 has no route'); + // A route that only starts with a known prefix isn't that route. + assert.deepEqual(domainsForRequest('POST', '/api/usersettings', 200), []); +}); + +test('bumps move counters, and a burst goes out as one message', async () => { + const messages = []; + const changes = createHouseholdChanges({ base: 100, coalesceMs: 20, onBump: (domains, revisions) => messages.push({ domains, revisions }) }); + assert.deepEqual(changes.snapshot(), Object.fromEntries(DOMAINS.map((d) => [d, 100]))); + const before = changes.etag(); + + changes.bump(['chores', 'users']); + changes.bump('chores'); + changes.bump('not-a-domain'); + assert.equal(changes.snapshot().chores, 102); + assert.equal(changes.snapshot().users, 101); + assert.notEqual(changes.etag(), before); + assert.equal(messages.length, 0, 'not yet: coalescing'); + + await new Promise((r) => setTimeout(r, 40)); + assert.equal(messages.length, 1); + assert.deepEqual(messages[0].domains, ['chores', 'users']); + assert.equal(messages[0].revisions.chores, 102); + + changes.bump('not-a-domain'); + await new Promise((r) => setTimeout(r, 40)); + assert.equal(messages.length, 1, 'an unknown kind sends nothing'); +}); + +test('every server starts from its own base, so a restart reads as "everything changed"', () => { + const a = createHouseholdChanges().snapshot().chores; + const b = createHouseholdChanges().snapshot().chores; + assert.notEqual(a, b); +}); diff --git a/server/tests/householdChangesRoutes.test.js b/server/tests/householdChangesRoutes.test.js new file mode 100644 index 00000000..2ddf6030 --- /dev/null +++ b/server/tests/householdChangesRoutes.test.js @@ -0,0 +1,140 @@ +// GET /api/changes and what moves its counters, on a real server (issue #325). +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const fs = require('node:fs'); +const http = require('node:http'); +const os = require('node:os'); +const path = require('node:path'); +const { freePort } = require('./freePort'); + +const serverDir = path.resolve(__dirname, '..'); +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +let child; +let base; +let port; +let dataDir; + +test.before(async () => { + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'homeglow-changes-')); + port = await freePort(); + child = spawn(process.execPath, ['index.js'], { + cwd: serverDir, + env: { + ...process.env, PORT: String(port), DB_PATH: path.join(dataDir, 'tasks.db'), TZ: 'UTC', DEMO_MODE: 'false', + ENCRYPTION_KEY: Buffer.alloc(32, 6).toString('base64'), + HOMEGLOW_DISABLE_BACKGROUND_JOBS: '1', HOMEGLOW_DISABLE_CALENDAR_SYNC: '1', HOMEGLOW_DISABLE_UPDATE_CHECK: '1', + }, + stdio: ['ignore', 'ignore', 'ignore'], + }); + base = `http://127.0.0.1:${port}`; + const start = Date.now(); + while (Date.now() - start < 30000) { + try { if ((await fetch(`${base}/api/test`)).ok) return; } catch { /* starting */ } + await delay(250); + } + throw new Error('server did not start'); +}); + +test.after(async () => { + await new Promise((resolve) => { + if (child.exitCode !== null) return resolve(); + child.once('close', resolve); + child.kill(); + setTimeout(resolve, 5000); + }); + fs.rmSync(dataDir, { recursive: true, force: true }); +}); + +async function call(method, pathname, body) { + const res = await fetch(`${base}${pathname}`, { + method, + headers: body === undefined ? {} : { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + let parsed; + try { parsed = JSON.parse(text); } catch { parsed = text; } + return { status: res.status, headers: res.headers, body: parsed }; +} +const changes = async () => (await call('GET', '/api/changes')).body; +const moved = (before, after) => Object.keys(after).filter((domain) => after[domain] !== before[domain]).sort(); + +test('GET /api/changes gives one counter per kind, and 304 while nothing changes', async () => { + const first = await call('GET', '/api/changes'); + assert.equal(first.status, 200); + assert.deepEqual(Object.keys(first.body).sort(), ['calendar', 'chores', 'prizes', 'settings', 'users']); + const tag = first.headers.get('etag'); + assert.ok(tag); + assert.equal(first.headers.get('cache-control'), 'no-cache'); + + const again = await fetch(`${base}/api/changes`, { headers: { 'If-None-Match': tag } }); + assert.equal(again.status, 304); +}); + +test('a write moves the kinds it touches; reads and failures move nothing', async () => { + let before = await changes(); + const user = await call('POST', '/api/users', { username: 'Ava', email: 'ava@example.com' }); + assert.equal(user.status, 200); + let after = await changes(); + assert.deepEqual(moved(before, after), ['users']); + + before = after; + const chore = await call('POST', '/api/chores', { title: 'Dishes', clam_value: 2 }); + const schedule = await call('POST', '/api/chore-schedules', { chore_id: chore.body.id, user_id: user.body.id, crontab: '0 0 * * *' }); + assert.equal(schedule.status, 200, JSON.stringify(schedule.body)); + const done = await call('POST', '/api/chores/complete', { + chore_schedule_id: schedule.body.id, user_id: user.body.id, date: new Date().toISOString().slice(0, 10), + }); + assert.equal(done.status, 200, JSON.stringify(done.body)); + after = await changes(); + assert.deepEqual(moved(before, after), ['chores', 'users'], 'a completion moves clam totals too'); + + before = after; + await call('GET', '/api/chores'); + await call('GET', '/api/users'); + await call('POST', '/api/chores/complete', { chore_schedule_id: 999999, user_id: user.body.id, date: '2026-01-01' }); + await call('POST', '/api/settings/search', { query: 'x' }); + after = await changes(); + assert.deepEqual(moved(before, after), []); + + before = after; + await call('POST', '/api/settings', { key: 'CALENDAR_DEDUP_ENABLED', value: 'false' }); + after = await changes(); + assert.deepEqual(moved(before, after), ['settings']); +}); + +test('the nightly job tells the displays too', async () => { + const before = await changes(); + const res = await call('GET', '/api/system/backgroundTasks'); + assert.equal(res.status, 200); + assert.deepEqual(moved(before, await changes()), ['chores', 'users']); +}); + +test('the event stream carries each burst of changes', async () => { + const received = new Promise((resolve, reject) => { + const req = http.get(`${base}/api/plugin/v1/events/stream`, (res) => { + let buffer = ''; + res.on('data', (chunk) => { + buffer += chunk; + for (const line of buffer.split('\n')) { + if (!line.startsWith('data: ')) continue; + const message = JSON.parse(line.slice(6)); + // An earlier test's burst may still be on its way out. + if (message.event === 'household.changed' && message.payload.domains.includes('prizes')) { + req.destroy(); + resolve(message.payload); + } + } + }); + }); + req.on('error', (error) => { if (error.code !== 'ECONNRESET') reject(error); }); + setTimeout(() => { req.destroy(); reject(new Error('no household.changed event')); }, 5000); + }); + await delay(200); + await call('POST', '/api/prizes', { name: 'Movie night', clam_cost: 20 }); + const payload = await received; + assert.deepEqual(payload.domains, ['users', 'prizes']); + assert.deepEqual(payload.revisions, await changes()); +}); diff --git a/server/utils/householdChanges.js b/server/utils/householdChanges.js new file mode 100644 index 00000000..a7c563bd --- /dev/null +++ b/server/utils/householdChanges.js @@ -0,0 +1,85 @@ +// What changed, so every display can catch up (issue #325). +// +// The server keeps a revision counter for each kind of household data. A +// write moves its counter; a display that sees a counter move reloads that +// kind of data. Coarse on purpose: a display never needs to know what +// changed, only that something did, so no write can be missed by a widget +// asking the wrong question. +// +// Counters move in one place for requests: an onResponse hook in index.js +// asks domainsForRequest() which kinds a successful write touched. Work that +// writes without a request (the nightly job, calendar sync, calendar +// routines, the demo reset) calls bump() itself. Things that change with the +// clock alone (a snooze ending, midnight, an expiry) aren't writes: displays +// work those out from their own clock. +// +// Counters live in memory and start from a random base at boot, so after a +// restart every counter differs from what any display last saw, and every +// display reloads everything once. +const crypto = require('crypto'); + +const DOMAINS = Object.freeze(['chores', 'users', 'prizes', 'settings', 'calendar']); + +// Which kinds of data a write under each route changes. A chore completion +// and a prize redemption both move clam totals, which are user data. +const ROUTE_DOMAINS = [ + [/^\/api\/(chores|chore-schedules|chore-history|routines|routine-steps)(\/|$)/, ['chores', 'users']], + [/^\/api\/users(\/|$)/, ['users']], + [/^\/api\/(prizes|prize-offers)(\/|$)/, ['prizes', 'users']], + [/^\/api\/(settings|timezone|themes|appearance|sounds)(\/|$)/, ['settings']], + [/^\/api\/(calendar|calendar-sources|calendar-sync)(\/|$)/, ['calendar']], +]; +// POSTs that only read. +const READ_ONLY_POSTS = new Set(['/api/settings/search', '/api/calendar-sources/:id/test']); +const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +/** + * The kinds of data a finished request changed: none for a read, a failed + * write or a route outside household data. `route` is the matched route + * pattern (request.routeOptions.url), so ids in the path don't matter. + */ +function domainsForRequest(method, route, statusCode) { + if (READ_METHODS.has(String(method).toUpperCase())) return []; + if (!route || statusCode >= 400 || READ_ONLY_POSTS.has(route)) return []; + const match = ROUTE_DOMAINS.find(([pattern]) => pattern.test(route)); + return match ? match[1] : []; +} + +/** + * A set of counters. `onBump(domains, revisions)` is called once per burst + * of bumps, after `coalesceMs`, so a write touching several kinds, or a + * nightly job touching many rows, goes out as one message. + */ +function createHouseholdChanges({ onBump = () => {}, coalesceMs = 100, base = crypto.randomInt(1e6, 1e9) } = {}) { + const revisions = Object.fromEntries(DOMAINS.map((domain) => [domain, base])); + let pending = new Set(); + let timer = null; + + const snapshot = () => ({ ...revisions }); + const etag = () => `"${DOMAINS.map((domain) => revisions[domain]).join('-')}"`; + + function flush() { + timer = null; + const domains = DOMAINS.filter((domain) => pending.has(domain)); + pending = new Set(); + if (domains.length) onBump(domains, snapshot()); + } + + function bump(...domains) { + let moved = false; + for (const domain of domains.flat()) { + if (!Object.prototype.hasOwnProperty.call(revisions, domain)) continue; + revisions[domain] += 1; + pending.add(domain); + moved = true; + } + if (moved && !timer) { + timer = setTimeout(flush, coalesceMs); + timer.unref?.(); + } + } + + return { bump, snapshot, etag, flush }; +} + +module.exports = { DOMAINS, ROUTE_DOMAINS, domainsForRequest, createHouseholdChanges };