--- type: Frontend Module title: Zustand stores description: authStore, attendanceStore, and adminStore — where all app/fetch logic lives, keeping components thin. resource: frontend/src/store tags: [frontend, zustand, state] timestamp: 2026-08-12T00:00:00Z --- # Zustand stores Convention (matching the user's other `~/mywork` projects): one flat `create()` per concern in `frontend/src/store/Store.ts`, default-exported as `useStore`. State and actions live together; async/API calls are colocated directly in the actions via `set`/`get` — no separate service layer. No `persist`/`devtools`/`immer`/slices are used here. Components read via selectors (`useXStore(s => s.field)`) and call actions; they don't own fetch/interval logic themselves. # `authStore.ts` `{ user, loading, loginError }` + `init()` (calls `GET /auth/me` once on app mount, from `App.tsx`), `loginWithGoogle(credential)`, `logout()`. No persistence — the real session lives in the httpOnly cookie, so on reload the store just re-asks the backend via `init()`. # `attendanceStore.ts` Employee's own clock state. `{ status, stats, live, busy, error }` plus `load()`, `recordEvent(type)`, `startPolling()`/`stopPolling()`. - `startPolling` sets two intervals: one that calls `load()` every 60s (keeps the server truth in sync), and one that recomputes `live` every 1s from the last-fetched `stats` against a fresh `Date` — via `withLiveTime` (`frontend/src/lib/liveSession.ts`) — so an open shift's duration counts up smoothly instead of freezing until the next poll. `EmployeeApp.tsx` starts/stops this based on `status` (only polls while `working`/`on_break`). - **Gotcha this hit in practice**: `live` is a plain cached field, updated by `set()` inside the tick/poll callbacks — it is deliberately *not* a selector method like `liveStats: () => withLiveTime(...)` called as `useAttendanceStore(s => s.liveStats())`. That pattern returns a new object on every call, which breaks React's `useSyncExternalStore` (used internally by zustand v5) — "Maximum update depth exceeded" from an infinite render loop, since the snapshot is never referentially stable between renders even when nothing actually changed. # `adminStore.ts` Employee list + org/individual stats + the shared `month` ("YYYY-MM") used by both the summary table and the selected employee's detail chart. `setMonth`/`prevMonth`/`nextMonth` update `month` and re-fetch `loadStats()` plus (if an employee is selected) `selectEmployee()` together, so the summary and the detail chart always show the same period. `removeEmployee` calls the soft-delete endpoint — see [employees](/docs/data-model/employees.md). # Related - [attendance_events](/docs/data-model/attendance-events.md) — the state machine and `Session` shape these stores fetch - [Attendance API](/docs/api/attendance-routes.md) - [Admin API](/docs/api/admin-routes.md)