BugInfrastructure

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.

Rootlock SRE Engine 5 min read
Diagnostic brief

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.

Severity Not rated
Confidence High
Frequency Unknown
Impact See analysis

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:

  • HYDRATE and REPLACE_STATE cases in src/context/dayflow-reducer.ts pass state through without side effects.
  • upsertTodaySnapshot is only called on mutation-type actions (e.g., adding or completing a task).
  • src/app/analytics/page.tsx reads from analyticsSnapshots — 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

  1. Identify where upsertTodaySnapshot is called in existing mutation handlers to understand its expected call signature and whether it mutates state in-place or returns a new state slice.
  1. Add an explicit upsertTodaySnapshot call inside the HYDRATE case 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);
   }
  1. Add the same call inside the REPLACE_STATE case (used by import/backup restore):
   case 'REPLACE_STATE': {
     const importedState = { ...action.payload };
     return upsertTodaySnapshot(importedState);
   }
  1. Audit upsertTodaySnapshot for an overly strict freshness guard. If the function no-ops when a today-keyed row already exists, add a force parameter 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.

  1. If upsertTodaySnapshot is 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 in src/context/dayflow-provider.tsx immediately after the dispatch({ type: 'HYDRATE', payload }) call:
   // dayflow-provider.tsx
   dispatch({ type: 'HYDRATE', payload: persisted });
   dispatch({ type: 'UPSERT_TODAY_SNAPSHOT' }); // or call helper directly

Configuration 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

  1. 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.
  1. 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.
  1. 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.
  1. Confirm historical rows are untouched:
  • Inspect analyticsSnapshots in Redux DevTools or equivalent context debugger.
  • Entries for dates prior to today must be identical before and after the fix.
  1. Rollback indicator:
  • If today's row is overwritten with incorrect data (e.g., zeroed out instead of populated), the upsertTodaySnapshot logic itself has a bug. Revert the force = true calls and investigate the snapshot computation logic separately.

Prevention

  • Unit-test all state-replacing actions against snapshot consistency. Add a test asserting that after HYDRATE or REPLACE_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 analyticsSnapshots out 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.

Developer FirstBuilt for engineers solving real problems
Evidence DrivenTechnical claims tied to available evidence
Automation ReadyStructured for CLI, APIs, and workflows
Privacy FocusedNo unnecessary data collection in this article UI
STAY AHEAD OF ISSUES

Get new root-cause analyses in your inbox

Engineering-focused updates. No fake subscriber counts. Unsubscribe anytime.