Skip to content

perf(client): instant chore toggles and fewer widget re-renders - #324

Merged
jherforth merged 3 commits into
jherforth:mainfrom
adamecker:feature/dashboard-performance
Oct 25, 2026
Merged

jherforth merged 3 commits into
jherforth:mainfrom
adamecker:feature/dashboard-performance

Conversation

@adamecker

@adamecker adamecker commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Chore toggles on the dashboard felt sluggish because every tap waited on a full reload behind a spinner. This makes them feel instant and cuts redundant re-renders, client-side only (no server or DB changes):

  • Optimistic chore toggles (ChoreWidget.jsx): tapping a chore flips its row immediately via a pending-toggles override, and the button disables while its POST is in flight (double-tap guard). No full-screen spinner for taps. After a successful tap the row stays optimistic until a full batched reload lands, and only then is the override cleared (on POST failure it clears straight away, snapping the row back). Celebrations still wait on server-confirmed history, so nothing celebrates before the server agrees. The expired-chore parent-approval flow is unchanged.
  • Batched loads: fetchData fetches all seven sources in parallel (Promise.allSettled, sources stay independent on failure) and sets state once, instead of re-rendering the widget up to eight times per load. Tab switches remount widgets, so this speeds those up too. A load-sequence guard drops the results of any load a newer one has overtaken, so a slow reload cannot settle a row in stale state or flicker it through its pre-tap value.
  • Scoped .card transition (index.css): from all to transform/box-shadow only, the properties its hover actually animates. No visual change.

Deliberately not changed: tab keep-alive (switching tabs still remounts + refetches — real, but keep-alive changes polling/layout semantics, so it belongs in a follow-up), the routine-step tick (still a full refetch), and the bundle (already lazy-loaded).

Why

Measured on a test backend, the server is fast (complete 24 ms / uncomplete 2 ms, prod GETs 2-5 ms) — the slowness was client-side rendering and the blocking spinner, not the API. The full reload after a tap is cheap now that the load is batched to one render, so correctness on slow displays does not depend on anything being fast.

Testing

  • Client tests: 749/749 pass, build clean.
  • Real-world tested on the author's production instance (deployed from this branch, daily family use): toggles flip instantly with no spinner, and the row correctly reflects the server result on success or failure.

Generated with Muse AI 🤖

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.

@jherforth jherforth left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. Instant taps and the batched load are both worth having. One core change before this merges.

Keep

  • fetchData as one batched load: Promise.allSettled, all state set in one block, sources independent on failure.
  • The optimistic tick: the pendingToggles override, the disabled button while a POST is in flight, the double-tap guard, and no full-screen spinner for a tap.
  • The narrower .card transition.

Change: after a tap, reload everything, and hold the tick until that reload lands

Remove the hand-picked refresh after a successful toggle. That's clearPending(), then the clam_total patch into users, then fetchHistory() + fetchSchedules().

Replace it with the full batched fetchData(), and clear the pending override only once that reload has finished, or straight away if the POST failed:

const toggleChoreCompletion = async (schedule, isCompleted) => {
  // ...expired-chore check and double-tap guard exactly as now...
  setPendingToggles((prev) => ({ ...prev, [schedule.id]: !isCompleted }));
  try {
    if (isCompleted) {
      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`, { chore_schedule_id: schedule.id, user_id: schedule.user_id, date: today });
      if (routineDays[schedule.id]) requestCelebration('chores');
    }
    await fetchData();       // the whole widget, server-confirmed, one render
  } catch (error) {
    if (error?.response?.data?.code === 'CHORE_EXPIRED') askParentForChore(schedule);
    else console.error('Error toggling chore completion:', error);
  } finally {
    clearPending();          // only now: after the reload, or after the failure
  }
};

fetchData sets no loading state, so the reload stays invisible. The widget only shows what the server confirmed, once it has it.

Also guard fetchData against out-of-order responses. Give each call a number from a ref, and apply results only from the newest call:

const loadSeq = useRef(0);
const fetchData = async () => {
  const seq = ++loadSeq.current;
  const results = await Promise.allSettled([/* ...as now... */]);
  if (seq !== loadSeq.current) return;   // a newer load started: this one is stale
  // ...set state as now, then routines...
};

Why

  1. The hand-picked list puts server knowledge into the widget, and it's already incomplete. A completion changes more than history and schedules.

    • Routines: the routine card (RoutineSteps) gets its ticks, n/m, streak and on-time chip from /api/routines/today, which this PR no longer reloads after a tap. Complete or undo a routine with its card's done button, and the row turns green while the card keeps its old ticks and streak, until the next tab switch.
    • Future features: the next server feature that reacts to a completion would be missed the same way. A full reload has no list to keep up to date.
  2. Clearing the override before the reload returns makes the row flicker. Between the two, the row shows the pre-tap history: checked → unchecked → checked.

  3. The timing figures aren't a benchmark. "Server 2–5 ms, complete 24 ms" was measured on one machine and network. Displays run on wall tablets, Raspberry Pis, old phones and busy Wi-Fi, so both the round trip and the render take very different times from one display to the next. So the change can't rely on anything being fast for correctness:

    • hold the optimistic state until the confirmed reload lands, however long that takes;
    • ignore a load that a newer one has overtaken (the guard above).

    On a fast device you won't notice the difference. On a slow one, this is what keeps the row from flickering or settling in the wrong state.

  4. The full reload is cheap now, because your batching made it one render. That was the real cost, not the network, so the selective refresh saves very little.

Out of scope here

Other displays seeing the tap is a separate, larger change, #325. On main today, other displays don't refresh on another display's tap either. That design lets the server say what changed, so a tap shows on every display within seconds. This PR is the tapping display's half, and with the change above it fits that design as it is.

Checks before re-review

  • Routines: completing, then undoing, a routine with its card's done button updates the ticks, streak and on-time chip without a tab switch.
  • Slow network: with the network throttled (DevTools, "Slow 3G" or 2 s of added latency), a tap never flips back before it settles.
  • Two quick taps on different chores: both end in the server's state, including when the network is throttled.
  • A failed POST (stop the server mid-tap) returns the row to the server's state, as now.
  • The all-done celebration still fires only after the server confirms, and only once.
  • Client tests and the build pass.

…t lands

Address review on jherforth#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.
@adamecker

Copy link
Copy Markdown
Contributor Author

Implemented as requested in 1f4b7c9:

  • The surgical post-tap refresh is gone (no more clearPending() + clam patch + fetchHistory()/fetchSchedules(), and those two helpers are removed as now-unused).
  • After a successful tap the code now runs the full batched fetchData() and clears the pending override only once that reload finishes; on POST failure it still clears straight away, as before.
  • Out-of-order guard: fetchData takes a monotonic sequence number and drops its results wholesale if a newer load started while it was in flight, so a slow reload can neither overwrite fresher state nor flicker a row through its pre-tap value. Clearing after the reload is safe even when this load was the overtaken one — any load that overtook it started after this POST completed, so it read server state from after the tap.

How the checks map: routines reload inside fetchData (via reloadRoutines), so complete/undo updates ticks, streak, and the on-time chip without a tab switch; the held override plus the guard are what keep slow-network taps, and two quick taps on different chores (each clears only its own entry), in server state; failed POSTs still snap the row back immediately; the celebration effect still watches server history only, so it fires once and only on confirmed data. Client tests 749/749, build clean. The slow-network and two-tap paths are covered by the guard logic above rather than a live DevTools run — happy to adjust if the re-review wants anything else.

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 <[email protected]>
@jherforth

Copy link
Copy Markdown
Owner

Thanks, this is what the review asked for, and it reads well. I added one commit (5266b11) for the last gap. An overtaken load returned as soon as its own responses arrived, before the newer load had applied. So a tap awaiting it cleared its override while the widget still held the pre-tap history. In Edge, with reads delayed 1.2 s and two taps 300 ms apart, the first row went checked → unchecked → checked for about half a second. fetchData now resolves once the newest load has applied (latestLoadRef), and the same run stays ticked throughout. Client tests and CI pass.

@jherforth
jherforth marked this pull request as ready for review October 25, 2026 18:47
@jherforth
jherforth merged commit 03d80bc into jherforth:main Oct 25, 2026
3 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in HomeGlow Kanban Oct 25, 2026
adamecker pushed a commit to adamecker/HomeGlow that referenced this pull request Oct 26, 2026
… revision counter jherforth#339, dimming jherforth#334, Save layout jherforth#336, chore perf jherforth#324, admin jherforth#330/jherforth#331/jherforth#332, PIN hardening jherforth#329

Conflicts resolved taking upstream's rewrite, preserving fork(prod) features:
- displayDim.js: upstream jherforth#334 base plus fork-only kiosk URL dim
  overrides and LCD pixel shift (both absent upstream)
- ChoreWidget/app.jsx/ChoreSchedulesTab: upstream jherforth#324/jherforth#336 rework
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants