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:
71
docs/api/admin-routes.md
Normal file
71
docs/api/admin-routes.md
Normal 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
|
||||
54
docs/api/attendance-routes.md
Normal file
54
docs/api/attendance-routes.md
Normal 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
51
docs/api/auth-routes.md
Normal 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
8
docs/api/index.md
Normal 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
|
||||
58
docs/architecture/auth-flow.md
Normal file
58
docs/architecture/auth-flow.md
Normal 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)
|
||||
109
docs/architecture/deployment.md
Normal file
109
docs/architecture/deployment.md
Normal 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)
|
||||
5
docs/architecture/index.md
Normal file
5
docs/architecture/index.md
Normal 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
|
||||
51
docs/architecture/overview.md
Normal file
51
docs/architecture/overview.md
Normal 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)
|
||||
68
docs/data-model/attendance-events.md
Normal file
68
docs/data-model/attendance-events.md
Normal 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)
|
||||
40
docs/data-model/employees.md
Normal file
40
docs/data-model/employees.md
Normal 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
6
docs/data-model/index.md
Normal 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
|
||||
45
docs/frontend/design-system.md
Normal file
45
docs/frontend/design-system.md
Normal 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
6
docs/frontend/index.md
Normal 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
62
docs/frontend/stores.md
Normal 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
14
docs/index.md
Normal 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
10
docs/log.md
Normal 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.
|
||||
Reference in New Issue
Block a user