Analytics Today Snapshot Remains Stale After State Hydration or Import in DailyFlow Reducer
When the application hydrates persisted state (HYDRATE) or imports a backup (REPLACE_STATE), the reducer returns the stored state as-is without invoking upsertTodaySnapshot.
At a Glance
When the application hydrates persisted state (HYDRATE) or imports a backup (REPLACE_STATE), the reducer returns the stored state as-is without invoking upsertTodaySnapshot.
Summary
When the application hydrates persisted state (HYDRATE) or imports a backup (REPLACE_STATE), the reducer returns the stored state as-is without invoking upsertTodaySnapshot. Because the analytics charts read from pre-computed snapshot rows, today's analytics entry stays stale or shows zero values until the user performs any write operation. The Today page is unaffected because it derives values live rather than from stored snapshots.
Root-Cause Analysis
Confirmed evidence:
HYDRATEandREPLACE_STATEcases insrc/context/dayflow-reducer.tspass state through without side effects.upsertTodaySnapshotis only called on mutation-type actions (e.g., adding or completing a task).src/app/analytics/page.tsxreads fromanalyticsSnapshots— a stored collection — rather than recomputing today's values on render.
Confirmed root cause:
The snapshot update path is wired exclusively to mutation actions. Hydration and import actions both constitute a state replacement event that can carry a today-date for which no snapshot yet exists in the loaded data (or whose snapshot predates recent activity). Because neither action triggers upsertTodaySnapshot, the analytics store is left in an inconsistent state after startup or import.
Reasonable inference:
upsertTodaySnapshot almost certainly derives today's snapshot from current in-memory state (task counts, completion rates, etc.) and writes an entry keyed by today's ISO date. Calling it immediately after applying the incoming hydrated/imported payload would produce a correct today row without touching historical rows.
Alternative cause to rule out:
It is possible that the hydrated payload already contains a valid today snapshot but that the snapshot timestamp check inside upsertTodaySnapshot prevents an overwrite. If that guard is too strict (e.g., it no-ops when a row for today already exists regardless of staleness), a secondary fix to the freshness check may also be required.
Resolution Steps
- Identify where
upsertTodaySnapshotis called in existing mutation handlers to understand its expected call signature and whether it mutates state in-place or returns a new state slice.
- Add an explicit
upsertTodaySnapshotcall inside theHYDRATEcase after the incoming state is applied, operating on the fully merged state object:
// dayflow-reducer.ts
case 'HYDRATE': {
const hydratedState = { ...state, ...action.payload };
return upsertTodaySnapshot(hydratedState);
}- Add the same call inside the
REPLACE_STATEcase (used by import/backup restore):
case 'REPLACE_STATE': {
const importedState = { ...action.payload };
return upsertTodaySnapshot(importedState);
}- Audit
upsertTodaySnapshotfor an overly strict freshness guard. If the function no-ops when a today-keyed row already exists, add aforceparameter or a staleness threshold (e.g., last-written more than N minutes ago) so that hydration can always refresh the row:
export function upsertTodaySnapshot(state: DayflowState, force = false): DayflowState {
const todayKey = toISODateString(new Date());
const existing = state.analyticsSnapshots[todayKey];
if (existing && !force) return state; // skip only when explicitly not forced
// ... build and write snapshot
}Then call with force = true from the HYDRATE and REPLACE_STATE cases.
- If
upsertTodaySnapshotis async or dispatches a secondary action (unlikely in a pure reducer, but possible if it's a thunk or provider-level helper), move the call to the provider's hydration path insrc/context/dayflow-provider.tsximmediately after thedispatch({ type: 'HYDRATE', payload })call:
// dayflow-provider.tsx
dispatch({ type: 'HYDRATE', payload: persisted });
dispatch({ type: 'UPSERT_TODAY_SNAPSHOT' }); // or call helper directlyConfiguration Snippets
No configuration file changes are required. The fix is isolated to reducer logic.
// src/context/dayflow-reducer.ts — minimal diff (pure-reducer variant)
case 'HYDRATE': {
const next = { ...state, ...action.payload };
return upsertTodaySnapshot(next, true); // force refresh of today row
}
case 'REPLACE_STATE': {
const next = { ...action.payload };
return upsertTodaySnapshot(next, true); // force refresh after import
}Verification
- Reproduce the stale state before the fix:
- Open the app with existing persisted data containing historical analytics snapshots.
- Navigate to the Analytics page immediately on load — confirm today's row shows zero or stale values.
- Reload without performing any write — values should remain stale.
- After applying the fix:
- Hard-reload the application (clear in-memory state, keep persisted storage).
- Navigate immediately to Analytics without performing any write.
- Confirm today's snapshot row reflects the correct values derived from the loaded state.
- Verify import path:
- Use the import/backup-restore feature to load a file.
- Navigate to Analytics without any further interaction.
- Confirm today's row is correct.
- Confirm historical rows are untouched:
- Inspect
analyticsSnapshotsin Redux DevTools or equivalent context debugger. - Entries for dates prior to today must be identical before and after the fix.
- Rollback indicator:
- If today's row is overwritten with incorrect data (e.g., zeroed out instead of populated), the
upsertTodaySnapshotlogic itself has a bug. Revert theforce = truecalls and investigate the snapshot computation logic separately.
Prevention
- Unit-test all state-replacing actions against snapshot consistency. Add a test asserting that after
HYDRATEorREPLACE_STATE,analyticsSnapshots[today]is non-null and matches the expected computed values.
it('HYDRATE refreshes today snapshot', () => {
const state = reducer(initialState, { type: 'HYDRATE', payload: mockPersistedState });
expect(state.analyticsSnapshots[todayKey]).toBeDefined();
expect(state.analyticsSnapshots[todayKey].completedTasks).toBe(expectedCount);
});- Lint rule or code review checklist: Any new reducer case that performs a wholesale state replacement (
...action.payload) should be reviewed to ensure derived/computed collections (snapshots, caches, indexes) are recomputed.
- Separate concerns between raw state and derived state. Consider moving
analyticsSnapshotsout of persisted state entirely and recomputing it as a selector (e.g.,useMemo) over raw task data. This eliminates the class of bugs where computed state diverges from source-of-truth state after any load or replacement operation.
- Add a runtime invariant check in the analytics page that detects a missing or zero today-row on mount and dispatches a recovery action rather than silently rendering stale data:
useEffect(() => {
if (!analyticsSnapshots[todayKey]) {
dispatch({ type: 'UPSERT_TODAY_SNAPSHOT' });
}
}, []);This acts as a safety net independent of the reducer fix.