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

71
docs/api/admin-routes.md Normal file
View File

@@ -0,0 +1,71 @@
---
type: API Endpoint
title: Admin routes
description: Employee CRUD (add/soft-delete), org-wide and per-employee stats, and CSV export — all requireAdmin.
resource: backend/src/routes/admin.ts
tags: [api, admin]
timestamp: 2026-08-12T00:00:00Z
---
# Admin routes
Mounted at `/api/admin`. Every route requires `requireAuth` + `requireAdmin`
(role resolved from `ADMIN_EMAILS`, see [Auth flow](/docs/architecture/auth-flow.md)).
# Examples
```
GET /api/admin/employees
200 -> { "employees": Employee[] } // includes inactive (soft-deleted) rows
```
```
POST /api/admin/employees
Body: { "email": string, "name"?: string }
201 -> { "employee": Employee }
```
Upserts by email — see [employees](/docs/data-model/employees.md) for the
reactivate-on-re-add behavior.
```
DELETE /api/admin/employees/:id
204 on success, 404 if not found.
```
Soft delete only (`active = 0`) — never removes the row or its attendance
history.
```
GET /api/admin/stats?month=YYYY-MM
200 -> {
"range": { "from": string, "to": string },
"totals": { "totalWorkedMs": number, "totalBreakMs": number, "shiftCount": number },
"employees": [{ "employee": Employee, "totalWorkedMs": number, "totalBreakMs": number, "shiftCount": number }]
}
```
Same calendar-month default as the employee's own stats endpoint.
```
GET /api/admin/stats/:id?month=YYYY-MM
200 -> { "employee": Employee, "range": {...}, "sessions": Session[], "totalWorkedMs": number, "totalBreakMs": number, "shiftCount": number }
404 -> employee not found
```
```
GET /api/admin/stats/:id/export?month=YYYY-MM
200, Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="<email>_<YYYY-MM>.csv"
```
Columns: `Datum, Příchod, Odchod, Pauza (h:mm), Odpracováno (h:mm)`, one row
per shift, sorted chronologically (`backend/src/util/csv.ts`). Prefixed with
a UTF-8 BOM so Excel renders the Czech diacritics correctly. Downloaded from
the frontend as a plain `<a href=... download>` — the session cookie rides
along automatically since it's a same-origin GET.
# Related
- [employees](/docs/data-model/employees.md)
- [attendance_events](/docs/data-model/attendance-events.md)
- [Attendance routes](./attendance-routes.md)
- [Frontend stores](/docs/frontend/stores.md) — `adminStore` owns the
selected month and drives both the summary table and the per-employee
chart off it

View File

@@ -0,0 +1,54 @@
---
type: API Endpoint
title: Attendance routes
description: POST /api/attendance/event, GET /api/attendance/state, GET /api/attendance/me — the employee's own clock in/out and stats.
resource: backend/src/routes/attendance.ts
tags: [api, attendance]
timestamp: 2026-08-12T00:00:00Z
---
# Attendance routes
Mounted at `/api/attendance`. Every route requires `requireAuth` +
`requireEmployee` — admins with no employee row get `403` here, by design
(the owner isn't necessarily clocking in themselves).
# Examples
```
POST /api/attendance/event
Body: { "type": "clock_in" | "clock_out" | "break_start" | "break_end" }
201 -> { "event": AttendanceEvent, "status": LiveStatus }
400 -> invalid type
409 -> invalid transition for the current status, e.g. break_start while
already clocked_out — { "error": string, "status": LiveStatus }
```
```
GET /api/attendance/state
200 -> { "status": "clocked_out" | "working" | "on_break" }
```
```
GET /api/attendance/me?month=YYYY-MM (or ?from=YYYY-MM-DD&to=YYYY-MM-DD)
Defaults to the current calendar month if no query params are given
— see parseRange in backend/src/util/dateRange.ts.
200 -> {
"range": { "from": string, "to": string },
"sessions": Session[],
"totalWorkedMs": number,
"totalBreakMs": number,
"shiftCount": number
}
```
`Session` and the state machine behind these responses are documented at
[attendance_events](/docs/data-model/attendance-events.md).
# Related
- [attendance_events](/docs/data-model/attendance-events.md)
- [Admin routes](./admin-routes.md) — same stats shape, but for any employee
- [Frontend stores](/docs/frontend/stores.md) — `attendanceStore` polls
`GET /state` + `GET /me` every 60s and ticks a local clock every second
between polls

51
docs/api/auth-routes.md Normal file
View File

@@ -0,0 +1,51 @@
---
type: API Endpoint
title: Auth routes
description: POST /api/auth/google, POST /api/auth/logout, GET /api/auth/me.
resource: backend/src/routes/auth.ts
tags: [api, auth]
timestamp: 2026-08-12T00:00:00Z
---
# Auth routes
Mounted at `/api/auth` (`backend/src/index.ts`).
# Examples
```
POST /api/auth/google
Body: { "credential": "<google id token>" }
200 -> { "user": SessionUser }
400 -> missing credential
401 -> invalid Google token
403 -> email not verified, or not an admin and not an active employee
```
Sets the `eatme_session` cookie on success. See
[Auth flow](/docs/architecture/auth-flow.md) for what happens before this
(role resolution) and what's inside the cookie.
```
POST /api/auth/logout
204, clears the session cookie. No auth required.
```
```
GET /api/auth/me
Requires a valid session cookie (requireAuth).
200 -> { "user": SessionUser }
401 -> not authenticated
```
`SessionUser` shape (`backend/src/types.ts`):
```ts
{ email: string; name: string | null; role: "admin" | "employee"; employeeId: number | null }
```
# Related
- [Auth flow](/docs/architecture/auth-flow.md)
- [Attendance routes](./attendance-routes.md)
- [Admin routes](./admin-routes.md)

8
docs/api/index.md Normal file
View File

@@ -0,0 +1,8 @@
# API
Express routers under `/api/*`, mounted in `backend/src/index.ts`. All
responses are JSON except the CSV export. Errors follow `{ "error": string }`.
- [Auth routes](./auth-routes.md) - `/api/auth/*` - Google sign-in, logout, current session
- [Attendance routes](./attendance-routes.md) - `/api/attendance/*` - the employee's own clock in/out and stats
- [Admin routes](./admin-routes.md) - `/api/admin/*` - employee management, org/individual stats, CSV export

View File

@@ -0,0 +1,58 @@
---
type: Architecture Overview
title: Authentication & authorization
description: Google ID-token verification, the session cookie, and the admin/employee role split.
tags: [architecture, auth, security]
timestamp: 2026-08-12T00:00:00Z
---
# Authentication & authorization
## Sign-in
1. The frontend loads the Google Identity Services script
(`https://accounts.google.com/gsi/client`, in `frontend/index.html`) and
renders the button in `frontend/src/auth/GoogleButton.tsx`.
2. Google returns a signed ID token to the browser (`credential`). The
frontend does **not** trust it — it POSTs it to `POST /api/auth/google`.
3. The backend verifies the token server-side with `google-auth-library`
(`backend/src/auth/google.ts`, `verifyGoogleIdToken`), checking the
audience against `GOOGLE_CLIENT_ID` and requiring `email_verified`.
## Role resolution
Two independent checks decide who gets in and as what role
(`backend/src/routes/auth.ts`):
- **Admin**: the verified email is in the `ADMIN_EMAILS` env var
(comma-separated, lowercased — `backend/src/env.ts`). Admins do **not**
need a row in the `employees` table.
- **Employee**: the verified email matches an **active** row in
[employees](/docs/data-model/employees.md).
If neither matches, login is rejected with 403 ("This account is not
registered as an EatMe employee"). This is how the employee whitelist is
enforced — there's no self-service signup.
## Session
On success the backend signs its own JWT (`backend/src/auth/session.ts`,
`issueSession`) containing `{ email, name, role, employeeId }`, and sets it
as an httpOnly, `SameSite=Lax` cookie (`eatme_session`, 12h TTL). All
subsequent `/api/*` requests carry that cookie
(`frontend/src/api/client.ts` sends `credentials: 'include'`); the backend
never re-derives role from the DB on every request — it trusts the signed
cookie until it expires.
`backend/src/auth/middleware.ts` provides three guards used by the routers:
- `requireAuth` — valid session cookie present
- `requireAdmin``role === 'admin'`
- `requireEmployee``employeeId != null` (blocks admins with no employee
profile from hitting `/api/attendance/*`)
## Related
- [System overview](./overview.md)
- [Auth API](/docs/api/auth-routes.md)
- [Employees table](/docs/data-model/employees.md)

View File

@@ -0,0 +1,109 @@
---
type: Architecture Overview
title: Build & deployment
description: esbuild backend bundle, Vite frontend build, the multi-stage Docker image, and how it ships to a host.
tags: [architecture, deployment, docker]
timestamp: 2026-08-12T12:00:00Z
---
# Build & deployment
## Backend build
`backend/package.json`'s `build` script runs `tsc --noEmit` (typecheck only)
then `node esbuild.config.js`, which bundles `src/index.ts` into a single
minified `dist/index.js` (~1.4MB, with sourcemap). `better-sqlite3` is
marked `external` in `esbuild.config.js` because it ships a native `.node`
binding that can't be bundled — it stays a real `node_modules` dependency at
runtime.
## Frontend build
Standard Vite build (`frontend/package.json``tsc -b && vite build`),
producing an optimized static `dist/` with a service worker
(`vite-plugin-pwa`, `frontend/vite.config.ts`). `VITE_*` env vars (just
`VITE_GOOGLE_CLIENT_ID`) are inlined at build time — they cannot be changed
at container runtime, only at image-build time.
## Docker image
The root `Dockerfile` is a three-stage build:
1. `frontend-build``npm ci` + `npm run build` in `frontend/`. Takes
`VITE_GOOGLE_CLIENT_ID` as a build arg.
2. `backend-build``npm ci` + `npm run build` in `backend/`, producing the
esbuild bundle.
3. `runtime` — fresh `npm ci --omit=dev` for `backend/package.json` on the
**same base image** as `backend-build` (so `better-sqlite3`'s native
binding is compiled for the environment it'll actually run in, rather
than copied from a different stage). Then copies in the backend bundle
(`dist/index.js`) and the frontend build output as `dist/public/`.
At runtime, `backend/src/index.ts` checks whether `dist/public` exists next
to itself and, if so, serves it with `express.static` plus a SPA fallback
route (`app.get(/^(?!\/api\/).*/, ...)`) — that's what makes the single
image serve both API and UI on one origin. In local dev this directory
doesn't exist, so the branch is skipped and the frontend's own Vite dev
server is used instead.
```bash
docker build --build-arg VITE_GOOGLE_CLIENT_ID=<id> -t eatme .
docker run -d -p 4000:4000 -v eatme-data:/app/data \
-e GOOGLE_CLIENT_ID=<id> -e ADMIN_EMAILS=<emails> \
-e JWT_SECRET=<secret> -e CORS_ORIGIN=<origin> -e COOKIE_SECURE=true \
eatme
```
The SQLite file lives at `DB_PATH` (default `/app/data/eatme.db` in the
image) — mount `/app/data` as a volume or it's lost on container recreate.
## Shipping to a host
The image is always built where its target platform matches the deploy
host, and shipped as a built artifact — never by copying the repo to the
server and building there (keeps source off prod hosts, no build toolchain
needed remotely, and the exact artifact tested locally is what runs). If
building on an Apple Silicon Mac for an x86_64 server, `--platform
linux/amd64` is required or the image won't run there at all.
```bash
docker build --platform linux/amd64 \
--build-arg VITE_GOOGLE_CLIENT_ID=<id> -t eatme:latest .
docker save eatme:latest | gzip | ssh <host> "gunzip | docker load"
```
Only `compose.yaml` (root of the repo) and a `.env` next to it (see
`.env.example`) then need to reach the host — `compose.yaml` references
`image: eatme:latest` (not `build:`), so `docker compose up -d` just starts
the already-loaded image. Redeploying a new version is the same
build → save → load → `docker compose up -d` sequence again.
```bash
scp compose.yaml <host>:~/eatme/compose.yaml
ssh <host> "cd ~/eatme && docker compose up -d"
```
TLS termination is handled by an nginx reverse proxy in front of the
container (not part of this repo) — a template server block lives at
`deploy/<domain>` for whichever host runs nginx, proxying
`https://<domain>` to `http://<deploy-host>:<published-port>`. It's a plain
file to be moved into `/etc/nginx/sites-available/` (and symlinked into
`sites-enabled/`) by whoever has sudo on that host — nothing here writes to
system nginx config directly.
## Environment variables
| Var | App | Required | Notes |
|---|---|---|---|
| `GOOGLE_CLIENT_ID` | backend | yes | must match frontend's `VITE_GOOGLE_CLIENT_ID` |
| `ADMIN_EMAILS` | backend | yes | comma-separated |
| `JWT_SECRET` | backend | yes | session signing key |
| `DB_PATH` | backend | no | default `./data/eatme.db` |
| `CORS_ORIGIN` | backend | no | default `http://localhost:5173` |
| `COOKIE_SECURE` | backend | no | set `true` behind HTTPS |
| `PORT` | backend | no | default `4000` |
| `VITE_GOOGLE_CLIENT_ID` | frontend | yes | build-time only |
## Related
- [System overview](./overview.md)

View File

@@ -0,0 +1,5 @@
# Architecture
- [System overview](./overview.md) - how the backend, frontend, and database fit together
- [Authentication & authorization](./auth-flow.md) - Google ID-token verification, session cookie, admin/employee roles
- [Build & deployment](./deployment.md) - esbuild bundle, Vite build, multi-stage Dockerfile, env vars

View File

@@ -0,0 +1,51 @@
---
type: Architecture Overview
title: System overview
description: How the EatMe frontend, backend, and database fit together.
tags: [architecture, backend, frontend]
timestamp: 2026-08-12T00:00:00Z
---
# System overview
EatMe is a small monorepo with two independently-runnable apps that ship as
one Docker image in production.
- **`backend/`** — Express + TypeScript API, SQLite via `better-sqlite3`
(entry point `backend/src/index.ts`). Owns all state: employee whitelist,
attendance events, sessions.
- **`frontend/`** — Vite + React + TypeScript PWA (entry point
`frontend/src/App.tsx`). Talks to the backend only over `/api/*`; in dev,
Vite proxies that path to `http://localhost:4000` (see
`frontend/vite.config.ts`).
## Request flow
1. Employee/admin signs in with Google Identity Services in the browser
(ID-token flow, no server-side OAuth redirect) — see
[Auth flow](./auth-flow.md).
2. The frontend POSTs the Google ID token to `POST /api/auth/google`; the
backend verifies it, issues its own JWT session cookie, and from then on
every `/api/*` call rides on that cookie (`credentials: 'include'` in
`frontend/src/api/client.ts`).
3. All app logic (fetching, polling, derived state) lives in Zustand stores
under `frontend/src/store/` — see [Frontend](/docs/frontend/index.md).
Components are thin consumers of store selectors/actions.
4. In production the backend also serves the built frontend as static files
(`dist/public/`, see [Deployment](./deployment.md)) — one process, one
container, one origin.
## Design language
The UI follows the "light monochrome TUI" style defined at
`~/doc/concepts/ui/light-mono-tui.md`: grey page background, black type and
1px borders, mono font, inverted fills for active/hover state, no accent
colors, no border-radius. Implemented as plain CSS tokens in
`frontend/src/styles/tui.css`.
## Related
- [Auth flow](./auth-flow.md)
- [Deployment](./deployment.md)
- [Data model](/docs/data-model/index.md)
- [API](/docs/api/index.md)

View File

@@ -0,0 +1,68 @@
---
type: SQLite Table
title: attendance_events
description: Append-only log of clock_in/clock_out/break_start/break_end events, and the state machine built on top of it.
resource: backend/src/db/index.ts
tags: [data-model, sqlite]
timestamp: 2026-08-12T00:00:00Z
---
# attendance_events
Append-only event log — there is no "shift" or "session" row. Shifts are
derived at read time by walking events chronologically
(`buildSessions` in `backend/src/services/attendance.ts`).
# Schema
| Column | Type | Description |
|---|---|---|
| `id` | INTEGER PK | autoincrement |
| `employee_id` | INTEGER | references [employees.id](./employees.md) |
| `type` | TEXT | one of `clock_in`, `clock_out`, `break_start`, `break_end` (CHECK constraint) |
| `ts` | TEXT | ISO 8601 UTC, set by SQLite default |
Indexed on `(employee_id, ts)`.
# State machine
`getLiveStatus` derives one of three states from an employee's most recent
event:
| Last event | Status |
|---|---|
| none / `clock_out` | `clocked_out` |
| `clock_in` / `break_end` | `working` |
| `break_start` | `on_break` |
`recordEvent` only allows the transition that's valid from the current
status (`NEXT_ALLOWED` map) — e.g. you can't `break_start` while already
`clocked_out`. An invalid attempt throws `InvalidTransitionError`, surfaced
by the API as `409`.
```
clocked_out --clock_in--> working
working --break_start--> on_break
on_break --break_end--> working
working --clock_out--> clocked_out
```
# Deriving shifts
`buildSessions(events, now)` walks a chronological event list and pairs
`clock_in`...`clock_out` into a `Session`, with nested `breaks`. A session
still missing its `clock_out` is `open: true` and its `workedMs`/`breakMs`
are computed against `now` (the request time) rather than a real end —
that's why the frontend re-derives it against a live clock between polls
(see [Frontend stores](/docs/frontend/stores.md)) instead of trusting a
stale fetch forever.
`summarize(events, now)` reduces sessions into `totalWorkedMs`,
`totalBreakMs`, `shiftCount` for a date range — the shape returned by both
the employee's own stats endpoint and the admin per-employee endpoint (see
[Attendance API](/docs/api/attendance-routes.md), [Admin API](/docs/api/admin-routes.md)).
# Related
- [employees](./employees.md)
- [Attendance API](/docs/api/attendance-routes.md)

View File

@@ -0,0 +1,40 @@
---
type: SQLite Table
title: employees
description: The whitelist of employee emails allowed to log attendance, with a soft-delete active flag.
resource: backend/src/db/index.ts
tags: [data-model, sqlite]
timestamp: 2026-08-12T00:00:00Z
---
# employees
The employee whitelist. A row here (with `active = 1`) is what lets a Google
account log in as an employee — see [Auth flow](/docs/architecture/auth-flow.md).
Admins are **not** rows in this table; they're resolved purely from the
`ADMIN_EMAILS` env var.
# Schema
| Column | Type | Description |
|---|---|---|
| `id` | INTEGER PK | autoincrement |
| `email` | TEXT | unique, lowercased on write |
| `name` | TEXT | nullable; filled in from the Google profile on first login (`touchEmployeeName` in `backend/src/services/employees.ts`), only if still null |
| `active` | INTEGER | 1 = can log in, 0 = soft-deleted |
| `created_at` | TEXT | ISO 8601 UTC, set by SQLite default |
# Behavior
- **Add** (`addEmployee`): upsert by email. If a soft-deleted row exists for
that email, it's reactivated (`active = 1`) rather than duplicated.
- **Remove** (`deactivateEmployee`): sets `active = 0`. This is a **soft**
delete by design — [attendance_events](./attendance-events.md) rows keep
referencing the employee, so historical stats for a removed employee are
still computable and re-adding the same email restores access without
losing history.
# Related
- [attendance_events](./attendance-events.md) — `employee_id` references this table
- [Employees API](/docs/api/admin-routes.md)

6
docs/data-model/index.md Normal file
View File

@@ -0,0 +1,6 @@
# Data model
SQLite via `better-sqlite3`, schema created on boot in `backend/src/db/index.ts`.
- [employees](./employees.md) - the whitelist of employee emails, with a soft-delete active flag
- [attendance_events](./attendance-events.md) - append-only clock-in/out/break log and the derived state machine

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)

14
docs/index.md Normal file
View File

@@ -0,0 +1,14 @@
---
okf_version: "0.1"
---
# EatMe — Docházka
Knowledge bundle for the EatMe bistro employee attendance-tracking system: a
Google-SSO PWA where employees log clock-in/out and breaks, and the owner
(admin) manages the employee whitelist and views statistics.
- [Architecture](./architecture/) — system overview, auth flow, deployment
- [Data model](./data-model/) — SQLite tables and the attendance state machine
- [API](./api/) — HTTP routes under `/api/auth`, `/api/attendance`, `/api/admin`
- [Frontend](./frontend/) — Zustand stores and the TUI monochrome design system

10
docs/log.md Normal file
View File

@@ -0,0 +1,10 @@
# Update Log
## 2026-08-12
- **Update**: Documented the actual production shipping workflow in
[Build & deployment](./architecture/deployment.md) — build locally for
the target platform, `docker save`/`load` over ssh (never copy source to
the server), ship only `compose.yaml` + `.env`, nginx reverse proxy
template in `deploy/`.
- **Creation**: Initial OKF bundle documenting the EatMe attendance system —
architecture, data model, API surface, frontend structure, and deployment.