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
14 changes: 14 additions & 0 deletions client/src/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions client/src/components/CalendarWidget.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(() => {
Expand Down
10 changes: 10 additions & 0 deletions client/src/components/ChoreWidget.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions client/src/components/PluginWidgetWrapper.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions client/src/components/ScreensaverOverlay.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }));
Expand Down
158 changes: 158 additions & 0 deletions client/src/utils/householdChanges.js
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading