Google-SSO PWA for bistro employee clock-in/out, admin employee management, and stats with CSV export. Express + SQLite backend, React + Zustand frontend in the light-mono-tui design language. Multi-stage Dockerfile, compose.yaml for image-based deploys, nginx reverse-proxy template, and an OKF documentation bundle in docs/. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2.9 KiB
type, title, description, resource, tags, timestamp
| type | title | description | resource | tags | timestamp | |||
|---|---|---|---|---|---|---|---|---|
| Frontend Module | Zustand stores | authStore, attendanceStore, and adminStore — where all app/fetch logic lives, keeping components thin. | frontend/src/store |
|
2026-08-12T00:00:00Z |
Zustand stores
Convention (matching the user's other ~/mywork projects): one flat
create<State>() per concern in frontend/src/store/<name>Store.ts,
default-exported as use<Name>Store. 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().
startPollingsets two intervals: one that callsload()every 60s (keeps the server truth in sync), and one that recomputesliveevery 1s from the last-fetchedstatsagainst a freshDate— viawithLiveTime(frontend/src/lib/liveSession.ts) — so an open shift's duration counts up smoothly instead of freezing until the next poll.EmployeeApp.tsxstarts/stops this based onstatus(only polls whileworking/on_break).- Gotcha this hit in practice:
liveis a plain cached field, updated byset()inside the tick/poll callbacks — it is deliberately not a selector method likeliveStats: () => withLiveTime(...)called asuseAttendanceStore(s => s.liveStats()). That pattern returns a new object on every call, which breaks React'suseSyncExternalStore(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.
Related
- attendance_events — the state
machine and
Sessionshape these stores fetch - Attendance API
- Admin API