Initial commit: EatMe attendance tracker

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>
This commit is contained in:
Michal Pemcak
2026-08-12 11:57:55 +02:00
commit fffcb73ea4
90 changed files with 3411 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
---
type: Design Language
title: TUI monochrome design system
description: How the light-mono-tui design language is implemented in this app's CSS.
resource: frontend/src/styles/tui.css
tags: [frontend, design, css]
timestamp: 2026-08-12T00:00:00Z
---
# TUI monochrome design system
The full design language spec lives outside this bundle at
`~/doc/concepts/ui/light-mono-tui.md` (grey background, black type/borders,
mono font, inverted active/hover states, no accent color, no radius, no
shadow). This doc covers how EatMe implements it.
`frontend/src/styles/tui.css` defines the tokens (`--bg`, `--fg`, `--muted`,
`--hover`, `--empty`, `--panel`, `--font`) and the component classes built on
them: `.tabs`/`.tab`, `.btn`/`.btn-block`/`.btn-ghost`, `.panel`,
`.field-row`, `.list`/`.row`, `.badge` (`-solid`, `-dashed`, `-double`),
`.stat-grid`/`.stat-tile`, `.login-card`.
# App-specific additions
Built for this app, following the same rules (square edges, monochrome,
1px borders) rather than introducing new visual language:
- **`.bar-chart-*`** (`frontend/src/components/BarChart.tsx`) — the
per-employee daily-hours chart. Y-axis labels + a repeating 1px
`--empty`-colored gridline background at 25% steps computed from a
"nice" rounded max (`Math.ceil` to the next whole hour); bars are solid
`--fg`, `--muted` on hover; day-of-month labels render in a *separate*
flex row below the fixed-height bar track (not nested inside each bar's
percentage-height column) specifically to avoid the bars visually
overlapping the labels.
- **`.month-nav`** (`frontend/src/components/MonthNav.tsx`) — `[<] label [>]`
bordered strip, styled like the design language's tab strip. Deliberately
placed *inside* the employee-detail panel next to the chart it controls,
not as a page-level control — an earlier version put it at the top of the
whole admin stats page, which tested as confusing ("can't browse history
on the chart") since it was visually disconnected from what it affected.
# Related
- [Zustand stores](./stores.md)

6
docs/frontend/index.md Normal file
View File

@@ -0,0 +1,6 @@
# Frontend
Vite + React + TypeScript PWA, Google Identity Services for sign-in.
- [Zustand stores](./stores.md) - authStore, attendanceStore, adminStore — where all app logic lives
- [TUI monochrome design system](./design-system.md) - the light-mono-tui design language and this app's chart/nav additions

62
docs/frontend/stores.md Normal file
View File

@@ -0,0 +1,62 @@
---
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<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()`.
- `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)