From 36db4f2de6d3b93e8e5da3adebd6b46614300218 Mon Sep 17 00:00:00 2001 From: Adam Ecker Date: Sat, 24 Oct 2026 00:57:17 -0700 Subject: [PATCH 1/3] perf(client): instant chore toggles and fewer widget re-renders Chore check/undo felt slow, but the server was never the problem: complete/uncomplete answer in ~2-24ms. The cost was all client-side. ChoreWidget now flips the row the moment it is tapped (pending-toggles override) instead of waiting for the POST plus a full refetch behind a full-screen blurred spinner. The button disables while its toggle is in flight, so a double-tap cannot double-POST. On success only history and schedules refresh (the only things a toggle can change); clam totals come back in the POST response. Celebrations still watch server history, so nothing celebrates until the POST is confirmed; on failure the row snaps back and the existing expired-chore path is unchanged. Full loads also batched: fetchData fetches all seven sources in parallel and sets state once instead of re-rendering up to eight times per load (every tab switch remounts the widget, so every switch paid it too). Sources stay independent via Promise.allSettled. Card transitions scoped from all to transform/box-shadow, the only properties the hover animates. --- client/src/components/ChoreWidget.jsx | 129 +++++++++++++++++++------- client/src/index.css | 5 +- 2 files changed, 102 insertions(+), 32 deletions(-) diff --git a/client/src/components/ChoreWidget.jsx b/client/src/components/ChoreWidget.jsx index 4709f101..98e169c1 100644 --- a/client/src/components/ChoreWidget.jsx +++ b/client/src/components/ChoreWidget.jsx @@ -91,6 +91,14 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { const [chores, setChores] = useState([]); const [schedules, setSchedules] = useState([]); const [history, setHistory] = useState([]); + // In-flight chore toggles, keyed by schedule id: the row flips the moment + // it is tapped instead of waiting for the POST plus a full refetch (the + // old path also flashed a full-screen spinner over the widget). The + // override only changes what the row shows: celebrations still watch + // server history, so nothing celebrates until the POST succeeds and the + // history refetch lands. Cleared on success and on failure (the row + // snaps back). + const [pendingToggles, setPendingToggles] = useState({}); // The last WEEK_DAYS days of history, only while the 7-day row is on. const [weekHistory, setWeekHistory] = useState([]); const [vacation, setVacation] = useState(null); @@ -405,18 +413,47 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { celebrationSeededRef.current = true; }, [users, schedules, history, celebrationEnabled, loading, celebrateDayComplete]); + // One render for a full load: fetch every source in parallel, then set all + // state in one block. The old version let each fetcher set its own state + // as it resolved, re-rendering this large widget up to eight times per + // load — and every tab switch remounts the widget, so every switch paid + // it too. The individual fetchers stay for targeted callers (prize + // redemptions, week toggles); this is only the full-load path. Sources + // stay independent: one failing must not wipe out the rest. const fetchData = async () => { + const names = ['users', 'chores', 'schedules', 'history', 'prizes', 'prize offers', 'settings']; try { - await Promise.all([ - fetchUsers(), - fetchChores(), - fetchSchedules(), - fetchHistory(), - fetchPrizes(), - fetchPrizeOffers(), - fetchSettings(), - reloadRoutines() + const today = getTodayDateString(); + const results = await Promise.allSettled([ + axios.get(`${API_BASE_URL}/api/users`), + axios.get(`${API_BASE_URL}/api/chores`), + axios.get(`${API_BASE_URL}/api/chore-schedules?usage=chart`), + axios.get(`${API_BASE_URL}/api/chore-history?date=${today}`), + axios.get(`${API_BASE_URL}/api/prizes`), + axios.get(`${API_BASE_URL}/api/prize-offers`), + axios.get(`${API_BASE_URL}/api/settings`), ]); + results.forEach((result, i) => { + if (result.status === 'rejected') { + console.error(`Error fetching ${names[i]}:`, result.reason); + return; + } + const data = result.value.data; + if (i === 0) setUsers(data.filter(user => user.id !== 0)); + else if (i === 1) setChores(data); + else if (i === 2) setSchedules(data); + else if (i === 3) setHistory(data); + else if (i === 4) setPrizes(data); + else if (i === 5) setPrizeOffers(Array.isArray(data) ? data : []); + else if (i === 6) applySettings(data); + }); + // Week history rides along with today's, as fetchHistory does. + fetchWeekHistory(); + try { + await reloadRoutines(); + } catch (error) { + console.error('Error reloading routines:', error); + } setLoading(false); } catch (error) { console.error('Error fetching data:', error); @@ -424,18 +461,22 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { } }; + const applySettings = (data) => { + if (data.daily_completion_clam_reward) { + setDailyClamReward(parseInt(data.daily_completion_clam_reward, 10)); + } + // Defaults on: the celebration is the point of the feature, and the + // setting only exists once a parent has turned it off (issue #140). + setCelebrationEnabled(data.CHORE_CELEBRATION_ENABLED !== 'false' + && data.CHORE_CELEBRATION_ENABLED !== false); + // Vacation days read as vacation, not as days left undone. + setVacation(parseVacationModeSetting(data.vacation_mode)); + }; + const fetchSettings = async () => { try { const response = await axios.get(`${API_BASE_URL}/api/settings`); - if (response.data.daily_completion_clam_reward) { - setDailyClamReward(parseInt(response.data.daily_completion_clam_reward, 10)); - } - // Defaults on: the celebration is the point of the feature, and the - // setting only exists once a parent has turned it off (issue #140). - setCelebrationEnabled(response.data.CHORE_CELEBRATION_ENABLED !== 'false' - && response.data.CHORE_CELEBRATION_ENABLED !== false); - // Vacation days read as vacation, not as days left undone. - setVacation(parseVacationModeSetting(response.data.vacation_mode)); + applySettings(response.data); } catch (error) { console.error('Error fetching settings:', error); } @@ -660,18 +701,28 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { askParentForChore(schedule); return; } + // A tap already in flight for this chore: ignore the second one rather + // than POSTing twice (the second would 409 and snap the row back). + if (pendingToggles[schedule.id] !== undefined) return; + const today = getTodayDateString(); + // Flip the row now; the server round-trip happens behind it. No + // full-screen spinner: a checkbox tap should never block the widget. + setPendingToggles((prev) => ({ ...prev, [schedule.id]: !isCompleted })); + const clearPending = () => setPendingToggles((prev) => { + const next = { ...prev }; + delete next[schedule.id]; + return next; + }); try { - setIsLoading(true); - const today = getTodayDateString(); - + let result; if (isCompleted) { - await axios.post(`${API_BASE_URL}/api/chores/uncomplete`, { + result = await axios.post(`${API_BASE_URL}/api/chores/uncomplete`, { chore_schedule_id: schedule.id, user_id: schedule.user_id, date: today }); } else { - await axios.post(`${API_BASE_URL}/api/chores/complete`, { + result = await axios.post(`${API_BASE_URL}/api/chores/complete`, { chore_schedule_id: schedule.id, user_id: schedule.user_id, date: today @@ -680,13 +731,24 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { if (routineDays[schedule.id]) requestCelebration('chores'); } - await fetchData(); + // Server-confirmed: drop the override and refresh only what a toggle + // can change — today's history (the row state, celebrations, + // follow-ups) and the schedules (a once-completed parent reschedules, + // an uncomplete re-arms a transfer bonus). Clam totals come back in + // the response, so users, chores, prizes, settings and routines need + // no refetch here. The celebration effect watches history, so it + // still fires only on confirmed data. + clearPending(); + if (result?.data?.clam_total !== undefined) { + setUsers((prev) => prev.map((u) => (u.id === schedule.user_id ? { ...u, clam_total: result.data.clam_total } : u))); + } + await Promise.all([fetchHistory(), fetchSchedules()]); } catch (error) { + // Roll the row back to server truth. + clearPending(); // Expired since this screen last looked: ask for a parent after all. if (error?.response?.data?.code === 'CHORE_EXPIRED') askParentForChore(schedule); else console.error('Error toggling chore completion:', error); - } finally { - setIsLoading(false); } }; @@ -999,15 +1061,19 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { }) .map(schedule => { const today = getTodayDateString(); - const completed = history.some(h => - h.chore_schedule_id === schedule.id && - h.user_id === userId && - h.date === today - ); + const pending = pendingToggles[schedule.id]; + const completed = pending !== undefined + ? pending + : history.some(h => + h.chore_schedule_id === schedule.id && + h.user_id === userId && + h.date === today + ); return { ...schedule, completed, + pending: pending !== undefined, id: schedule.id }; }); @@ -1230,6 +1296,7 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { with no icon keep the checkmark. */} toggleChoreCompletion(schedule, schedule.completed)} size="small" aria-label={schedule.completed diff --git a/client/src/index.css b/client/src/index.css index 0584361d..fe32be0f 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -100,7 +100,10 @@ body { border-radius: var(--hg-radius-xl); padding: var(--dynamic-card-padding); box-shadow: var(--shadow); - transition: all 0.3s ease; + /* Only what :hover animates. `all` made the browser track every + property change on every card (and cards are everywhere), which + costs frames on weak GPUs whenever any card state changes. */ + transition: transform 0.3s ease, box-shadow 0.3s ease; backdrop-filter: var(--backdrop-blur); } From 1f4b7c93770c8af70cdda6cdae65e6b940ceeee1 Mon Sep 17 00:00:00 2001 From: adamecker Date: Sun, 25 Oct 2026 09:37:29 -0700 Subject: [PATCH 2/3] perf(client): reload everything after a tap and hold the tick until it lands Address review on #324: replace the surgical post-tap refresh (clearPending + clam patch + history/schedules only) with the full batched fetchData, clearing the pending override only once that reload finishes (or at once on POST failure). Add a load-sequence guard so a reload overtaken by a newer one drops its results instead of settling rows in stale state. Removes the now-unused fetchHistory/fetchSchedules helpers. --- client/src/components/ChoreWidget.jsx | 67 +++++++++++---------------- 1 file changed, 28 insertions(+), 39 deletions(-) diff --git a/client/src/components/ChoreWidget.jsx b/client/src/components/ChoreWidget.jsx index 98e169c1..7e89456c 100644 --- a/client/src/components/ChoreWidget.jsx +++ b/client/src/components/ChoreWidget.jsx @@ -96,9 +96,14 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { // old path also flashed a full-screen spinner over the widget). The // override only changes what the row shows: celebrations still watch // server history, so nothing celebrates until the POST succeeds and the - // history refetch lands. Cleared on success and on failure (the row - // snaps back). + // full reload lands. Cleared once that reload finishes, or at once on + // failure (the row snaps back). const [pendingToggles, setPendingToggles] = useState({}); + // Monotonic id of the latest fetchData run. A load that a newer one has + // overtaken drops its results instead of overwriting fresher state — + // without this, a slow reload landing after a newer one could settle a + // row in the wrong state or flicker it through the pre-tap value. + const loadSeqRef = useRef(0); // The last WEEK_DAYS days of history, only while the 7-day row is on. const [weekHistory, setWeekHistory] = useState([]); const [vacation, setVacation] = useState(null); @@ -421,6 +426,7 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { // redemptions, week toggles); this is only the full-load path. Sources // stay independent: one failing must not wipe out the rest. const fetchData = async () => { + const seq = ++loadSeqRef.current; const names = ['users', 'chores', 'schedules', 'history', 'prizes', 'prize offers', 'settings']; try { const today = getTodayDateString(); @@ -433,6 +439,10 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { axios.get(`${API_BASE_URL}/api/prize-offers`), axios.get(`${API_BASE_URL}/api/settings`), ]); + // A newer load started while this one was in flight: its results are + // at least as fresh, so this one's are dropped wholesale rather than + // partially applied over them. + if (seq !== loadSeqRef.current) return; results.forEach((result, i) => { if (result.status === 'rejected') { console.error(`Error fetching ${names[i]}:`, result.reason); @@ -447,7 +457,7 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { else if (i === 5) setPrizeOffers(Array.isArray(data) ? data : []); else if (i === 6) applySettings(data); }); - // Week history rides along with today's, as fetchHistory does. + // Week history rides along with today's. fetchWeekHistory(); try { await reloadRoutines(); @@ -500,28 +510,8 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { } }; - const fetchSchedules = async () => { - try { - const response = await axios.get(`${API_BASE_URL}/api/chore-schedules?usage=chart`); - setSchedules(response.data); - } catch (error) { - console.error('Error fetching schedules:', error); - } - }; - - const fetchHistory = async () => { - try { - const today = getTodayDateString(); - const response = await axios.get(`${API_BASE_URL}/api/chore-history?date=${today}`); - setHistory(response.data); - } catch (error) { - console.error('Error fetching history:', error); - } - fetchWeekHistory(); - }; - - // Read through a ref so fetchHistory, called from long-lived handlers, sees - // the current setting. + // Read through a ref so fetchWeekHistory, called from long-lived handlers, + // sees the current setting. const showWeekRef = useRef(showWeek); showWeekRef.current = showWeek; const fetchWeekHistory = async () => { @@ -714,15 +704,14 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { return next; }); try { - let result; if (isCompleted) { - result = await axios.post(`${API_BASE_URL}/api/chores/uncomplete`, { + await axios.post(`${API_BASE_URL}/api/chores/uncomplete`, { chore_schedule_id: schedule.id, user_id: schedule.user_id, date: today }); } else { - result = await axios.post(`${API_BASE_URL}/api/chores/complete`, { + await axios.post(`${API_BASE_URL}/api/chores/complete`, { chore_schedule_id: schedule.id, user_id: schedule.user_id, date: today @@ -731,18 +720,18 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { if (routineDays[schedule.id]) requestCelebration('chores'); } - // Server-confirmed: drop the override and refresh only what a toggle - // can change — today's history (the row state, celebrations, - // follow-ups) and the schedules (a once-completed parent reschedules, - // an uncomplete re-arms a transfer bonus). Clam totals come back in - // the response, so users, chores, prizes, settings and routines need - // no refetch here. The celebration effect watches history, so it - // still fires only on confirmed data. + // Server-confirmed, but the row stays optimistic until a full reload + // lands: a hand-picked refresh can leave other sources stale, and + // clearing the override before the reload returns flickers the row + // through its pre-tap state. The reload is one render now that the + // load is batched, so it costs no more than the selective refresh + // did. Clearing after the reload is safe even if a newer load + // overtook this one — the guard drops the stale results, and the + // newer load read server state from after this POST. The + // celebration effect watches history, so it still fires only on + // confirmed data. + await fetchData(); clearPending(); - if (result?.data?.clam_total !== undefined) { - setUsers((prev) => prev.map((u) => (u.id === schedule.user_id ? { ...u, clam_total: result.data.clam_total } : u))); - } - await Promise.all([fetchHistory(), fetchSchedules()]); } catch (error) { // Roll the row back to server truth. clearPending(); From 5266b11a5f8cc028b795a5199acc331c18c21017 Mon Sep 17 00:00:00 2001 From: jherforth Date: Sun, 25 Oct 2026 14:43:05 -0400 Subject: [PATCH 3/3] fix(client): an overtaken chore reload waits for the newest one A load that a newer one overtook returned as soon as its own responses arrived, before the newer load had applied. A tap awaiting it then cleared its override while the widget still held the pre-tap history, so with two quick taps on a slow network the first row went checked, unchecked, checked (about half a second in Edge with reads delayed 1.2 s). fetchData now resolves once the newest load has applied. Co-Authored-By: Claude Opus 5.5 --- client/src/components/ChoreWidget.jsx | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/client/src/components/ChoreWidget.jsx b/client/src/components/ChoreWidget.jsx index 7e89456c..c9f3f4a6 100644 --- a/client/src/components/ChoreWidget.jsx +++ b/client/src/components/ChoreWidget.jsx @@ -104,6 +104,8 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { // without this, a slow reload landing after a newer one could settle a // row in the wrong state or flicker it through the pre-tap value. const loadSeqRef = useRef(0); + // The newest load, for an overtaken one to wait on. + const latestLoadRef = useRef(Promise.resolve()); // The last WEEK_DAYS days of history, only while the 7-day row is on. const [weekHistory, setWeekHistory] = useState([]); const [vacation, setVacation] = useState(null); @@ -425,8 +427,15 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { // it too. The individual fetchers stay for targeted callers (prize // redemptions, week toggles); this is only the full-load path. Sources // stay independent: one failing must not wipe out the rest. - const fetchData = async () => { - const seq = ++loadSeqRef.current; + // Resolves once the newest load has applied, so a caller that awaits it + // (a tap clearing its override) never sees state older than its own write. + const fetchData = () => { + const load = loadAll(++loadSeqRef.current); + latestLoadRef.current = load; + return load; + }; + + const loadAll = async (seq) => { const names = ['users', 'chores', 'schedules', 'history', 'prizes', 'prize offers', 'settings']; try { const today = getTodayDateString(); @@ -442,7 +451,7 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { // A newer load started while this one was in flight: its results are // at least as fresh, so this one's are dropped wholesale rather than // partially applied over them. - if (seq !== loadSeqRef.current) return; + if (seq !== loadSeqRef.current) return latestLoadRef.current; results.forEach((result, i) => { if (result.status === 'rejected') { console.error(`Error fetching ${names[i]}:`, result.reason); @@ -725,9 +734,8 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => { // clearing the override before the reload returns flickers the row // through its pre-tap state. The reload is one render now that the // load is batched, so it costs no more than the selective refresh - // did. Clearing after the reload is safe even if a newer load - // overtook this one — the guard drops the stale results, and the - // newer load read server state from after this POST. The + // did. If a newer load overtakes this one, fetchData waits for that + // load to apply, which read server state from after this POST. The // celebration effect watches history, so it still fires only on // confirmed data. await fetchData();