From 94933cac5cf3436af1ba4a0417b03468a75e2c9b Mon Sep 17 00:00:00 2001 From: Michal Pemcak Date: Sun, 16 Aug 2026 18:35:56 +0200 Subject: [PATCH] Update OKF docs bundle, README, and CLAUDE.md for today's features Documents the shift planning and closure/payroll modules (new tables, routes, stores), the dark redesign, and the docker-compose-broken-host workaround. Adds CLAUDE.md pointing agents at docs/. Translates README to English and keeps host-specific infrastructure details out of the repo. --- CLAUDE.md | 5 ++ README.md | 110 +++++++++++++++-------------- docs/api/admin-routes.md | 111 +++++++++++++++++++++++++++-- docs/api/closure-routes.md | 39 +++++++++++ docs/api/index.md | 6 +- docs/api/shifts-routes.md | 48 +++++++++++++ docs/architecture/deployment.md | 24 ++++++- docs/architecture/overview.md | 17 +++-- docs/data-model/index.md | 5 ++ docs/data-model/month-closures.md | 61 ++++++++++++++++ docs/data-model/pay-rates.md | 47 +++++++++++++ docs/data-model/payroll.md | 64 +++++++++++++++++ docs/data-model/shift-signups.md | 48 +++++++++++++ docs/data-model/shift-slots.md | 51 ++++++++++++++ docs/frontend/design-system.md | 112 ++++++++++++++++++++++-------- docs/frontend/index.md | 9 ++- docs/frontend/stores.md | 26 ++++++- docs/index.md | 15 ++-- docs/log.md | 36 ++++++++++ 19 files changed, 728 insertions(+), 106 deletions(-) create mode 100644 CLAUDE.md create mode 100644 docs/api/closure-routes.md create mode 100644 docs/api/shifts-routes.md create mode 100644 docs/data-model/month-closures.md create mode 100644 docs/data-model/pay-rates.md create mode 100644 docs/data-model/payroll.md create mode 100644 docs/data-model/shift-signups.md create mode 100644 docs/data-model/shift-slots.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1915181 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +All project knowledge (architecture, data model, API, frontend, deployment) +lives in [`docs/`](docs/index.md) — an OKF (Open Knowledge Format) bundle. +Start there. diff --git a/README.md b/README.md index 5390b83..0b55acb 100644 --- a/README.md +++ b/README.md @@ -1,96 +1,102 @@ -# EatMe — Docházka +# EatMe — Attendance -Evidence docházky pro bistro EatMe. Zaměstnanci se přihlašují Google účtem a -logují příchod / odchod / start a konec pauzy; admin (majitel) spravuje seznam -zaměstnanců (jen e-mail, žádná hesla) a vidí souhrnné i individuální statistiky -s grafem a exportem do CSV. +Internal staff system for the EatMe bistro. Employees sign in with Google, +log clock-in/out and breaks, sign up for shifts, and confirm their month at +the end of it; the admin (owner) manages the employee list and their hourly +rates, plans shifts, approves closures, and runs payroll (tips, bonuses, +CSV export). ``` -backend/ Express + TypeScript API, SQLite (better-sqlite3) +backend/ Express + TypeScript API, SQLite (better-sqlite3), jest tests frontend/ React + TypeScript PWA (Vite), Google Sign-In, Zustand -docs/ OKF (Open Knowledge Format) — architektura, datový model, provoz -branding/ Logo a vygenerované favicony/PWA ikony z podkladu bistra -deploy/ Šablony configů pro hosty mimo tento repo (nginx server block) -Dockerfile Multi-stage build — jeden image pro API i frontend -compose.yaml Produkční nasazení hotového image (viz Produkce níž) +docs/ OKF (Open Knowledge Format) bundle — architecture, data model, ops +gscript/ Original Google Apps Script version (reference only, not part of the running app) +branding/ Logo and generated favicons/PWA icons from the bistro's artwork +deploy/ Config templates for hosts outside this repo (nginx server block) +Dockerfile Multi-stage build — one image for API and frontend +compose.yaml Production deployment of the built image (see Production below) ``` -Vzhled je podle `~/doc/concepts/ui/light-mono-tui.md` — světlý monochromní -"TUI" styl (mono font, ostré hrany, žádné barvy, inverze pro aktivní stav). +The look is dark, modern, sharp-edged (no rounded corners), with a single +amber accent used sparingly — no colored accent borders. Details in +[`docs/frontend/design-system.md`](docs/frontend/design-system.md). -## Požadavky +## Requirements - Node.js 22+ -- Google Cloud projekt s nakonfigurovaným OAuth Client ID (Web application) - — origins musí obsahovat `http://localhost:5174` pro vývoj a produkční - doménu. Client ID se nastavuje do `GOOGLE_CLIENT_ID` (backend) a - `VITE_GOOGLE_CLIENT_ID` (frontend) — musí být **stejné**. +- A Google Cloud project with an OAuth Client ID configured (Web + application) — authorized origins must include `http://localhost:5174` + for dev and the production domain. The client ID goes into + `GOOGLE_CLIENT_ID` (backend) and `VITE_GOOGLE_CLIENT_ID` (frontend) — + they must be **identical**. -## Vývoj +## Development ```bash # backend cd backend -cp .env.example .env # doplň GOOGLE_CLIENT_ID, ADMIN_EMAILS, JWT_SECRET +cp .env.example .env # fill in GOOGLE_CLIENT_ID, ADMIN_EMAILS, JWT_SECRET npm install npm run dev # http://localhost:4000 -# frontend (v druhém terminálu) +# frontend (in a second terminal) cd frontend -cp .env.example .env # VITE_GOOGLE_CLIENT_ID stejné jako backend +cp .env.example .env # VITE_GOOGLE_CLIENT_ID same as backend npm install -npm run dev # http://localhost:5174 (port je pinnutý — musí sedět s Google origin) +npm run dev # http://localhost:5174 (port is pinned — must match the Google origin) ``` -Frontend v dev módu proxuje `/api/*` na backend (viz `frontend/vite.config.ts`). +The frontend dev server proxies `/api/*` to the backend (see +`frontend/vite.config.ts`). -### Typecheck / build +### Typecheck / build / test ```bash -cd backend && npm run typecheck && npm run build # -> dist/index.js (esbuild bundle) -cd frontend && npm run build # -> dist/ (Vite, PWA) +cd backend && npm run typecheck && npm test && npm run build # -> dist/index.js (esbuild bundle) +cd frontend && npm run build # -> dist/ (Vite, PWA) ``` -## Produkce (Docker) +## Production (Docker) -Jeden multi-stage `Dockerfile` v rootu postaví frontend i backend a spustí je -jako jeden kontejner — backend servíruje API na `/api/*` a staticky sbalený -frontend na všem ostatním. +One multi-stage `Dockerfile` at the repo root builds both frontend and +backend and runs them as a single container — the backend serves the API +on `/api/*` and the statically-built frontend on everything else. -Image se buildí lokálně (pro platformu cílového serveru) a na server se -posílá hotový, ne zdrojáky: +The image is built locally (for the target server's platform) and shipped +to the server already built, never as source: ```bash -# buildni pro platformu serveru (na Apple Silicon Macu proti x86_64 serveru -# je --platform linux/amd64 povinné, jinak image tam vůbec nenaběhne) +# build for the server's platform (on an Apple Silicon Mac targeting an +# x86_64 server, --platform linux/amd64 is required or the image won't +# run there at all) docker build --platform linux/amd64 \ --build-arg VITE_GOOGLE_CLIENT_ID= \ -t eatme:latest . -# přenes hotový image přes ssh (žádný build na serveru) +# ship the built image over ssh (no build on the server) docker save eatme:latest | gzip | ssh "gunzip | docker load" -# pošli jen compose.yaml + .env (viz .env.example) a nastartuj +# send just compose.yaml + .env (see .env.example) and start it scp compose.yaml :~/eatme/compose.yaml ssh "cd ~/eatme && docker compose up -d" ``` -`VITE_GOOGLE_CLIENT_ID` musí jít jako **build arg** (Vite ho zapéká při -buildu, ne za běhu — v `.env` na serveru se proto řeší jen runtime proměnné). -Pojmenovaný volume `eatme_data` (viz `compose.yaml`) drží SQLite databázi -mimo kontejner, ať přežije redeploy. +`VITE_GOOGLE_CLIENT_ID` has to go in as a **build arg** (Vite bakes it in +at build time, not runtime — `.env` on the server only handles runtime +vars). The named volume `eatme_data` (see `compose.yaml`) keeps the +SQLite database outside the container so it survives a redeploy. -Před kontejner patří TLS-terminující reverse proxy (mimo tento repo) — -šablona server blocku pro nginx je v `deploy/eatme.mipem.co`, proxuje na -`http://:8092`. +A TLS-terminating reverse proxy belongs in front of the container (outside +this repo) — an nginx server block template lives at +`deploy/eatme.mipem.co`, proxying to `http://:8092`. -## Datový model / provoz +## Data model / operations -Zaměstnanci jsou whitelist e-mailů spravovaný adminem (`ADMIN_EMAILS` v env — -samostatná role, není v tabulce zaměstnanců). Odebrání zaměstnance je pouze -soft-delete (`active = 0`) — historie docházky zůstává, opětovným přidáním -stejného e-mailu se účet reaktivuje. Statistiky se počítají po kalendářních -měsících (`?month=YYYY-MM`), s možností listovat měsíci v adminově detailu -zaměstnance (graf + export CSV). +Employees are an email whitelist managed by the admin (`ADMIN_EMAILS` env +var — a separate role, not a row in the employees table). Removing an +employee is a soft delete (`active = 0`) — attendance history stays, and +re-adding the same email reactivates the account. Employees also have a +versioned hourly rate, sign up for shifts from a weekly template, and +confirm each calendar month before the admin locks it and runs payroll. -Víc v `docs/` (OKF bundle). +More in `docs/` (OKF bundle). diff --git a/docs/api/admin-routes.md b/docs/api/admin-routes.md index ee6c8cb..1cc1180 100644 --- a/docs/api/admin-routes.md +++ b/docs/api/admin-routes.md @@ -1,10 +1,10 @@ --- type: API Endpoint title: Admin routes -description: Employee CRUD (add/soft-delete), org-wide and per-employee stats, and CSV export — all requireAdmin. +description: Employee CRUD + pay rates, org-wide and per-employee stats, shift planning admin, payroll, and CSV export — all requireAdmin. resource: backend/src/routes/admin.ts tags: [api, admin] -timestamp: 2026-08-12T00:00:00Z +timestamp: 2026-08-16T00:00:00Z --- # Admin routes @@ -16,8 +16,19 @@ Mounted at `/api/admin`. Every route requires `requireAuth` + `requireAdmin` ``` GET /api/admin/employees -200 -> { "employees": Employee[] } // includes inactive (soft-deleted) rows +200 -> { "employees": (Employee & { hourly_rate: number })[] } // includes inactive (soft-deleted) rows ``` +`hourly_rate` is the employee's *current* rate (`getCurrentRate`, see +[pay_rates](/docs/data-model/pay-rates.md)) — joined in at read time, not a +column on `employees` itself. + +``` +POST /api/admin/employees/:id/rate +Body: { "hourly_rate": number, "valid_from"?: "YYYY-MM-DD" } +201 -> { "hourly_rate": number } +``` +Appends a new [pay_rates](/docs/data-model/pay-rates.md) row rather than +overwriting — see that doc for the retroactive-first-rate behavior. ``` POST /api/admin/employees @@ -61,11 +72,103 @@ a UTF-8 BOM so Excel renders the Czech diacritics correctly. Downloaded from the frontend as a plain `` — the session cookie rides along automatically since it's a same-origin GET. +## Shift planning admin + +See [shift_slots](/docs/data-model/shift-slots.md) / +[shift_signups](/docs/data-model/shift-signups.md) for the data model. + +``` +GET /api/admin/shifts?period=YYYY-MM +200 -> { "slots": AdminSlot[] } // AdminSlot includes occupied employees per slot +``` +Same lazy-generation trigger as the employee-side +[`GET /api/shifts/available`](./shifts-routes.md). + +``` +POST /api/admin/shifts +Body: { "date", "start_time", "end_time", "capacity", "note"? } +201 -> { "id": number } +400 -> "Stejná směna už existuje." (unique date+start+end already taken) +``` + +``` +PATCH /api/admin/shifts/:slotId +Body: { "date", "start_time", "end_time", "capacity", "note"? } +200 -> { "ok": true } +400 -> capacity dropped below the number of already-approved signups +``` + +``` +POST /api/admin/shifts/:slotId/assign +Body: { "employeeId": number } +201 -> { "ok": true } +``` +Goes through the exact same collision/capacity checks as a self-signup — +see [shift_signups](/docs/data-model/shift-signups.md). + +``` +DELETE /api/admin/shifts/:slotId/assign/:employeeId +204 on success +``` + +## Payroll admin + +See [month_closures](/docs/data-model/month-closures.md) and +[payroll](/docs/data-model/payroll.md) for the state machines these drive. + +``` +GET /api/admin/payroll?period=YYYY-MM +200 -> { "rows": PayrollOverviewRow[] } +``` +One row per **active** employee, whether or not they've touched their +closure for that period yet (`closure_status` defaults to +`"waiting_employee"` when no row exists). + +``` +POST /api/admin/payroll/:employeeId/adjustments +Body: { "period", "tips_amount", "bonus_amount", "other_amount" } +200 -> { "payroll": Payroll } +400 -> not confirmed yet, or already locked +``` + +``` +POST /api/admin/payroll/:employeeId/lock +Body: { "period": "YYYY-MM" } +200 -> { "payroll": Payroll } +400 -> employee hasn't confirmed, or still has an open shift +``` + +``` +POST /api/admin/payroll/:employeeId/reopen +Body: { "period": "YYYY-MM" } +200 -> { "payroll": Payroll } +400 -> not currently locked+ready (e.g. already paid) +``` + +``` +POST /api/admin/payroll/:employeeId/paid +Body: { "period": "YYYY-MM" } +200 -> { "payroll": Payroll } +400 -> not yet locked (status isn't "ready") +``` + +``` +GET /api/admin/payroll/export?period=YYYY-MM +200, Content-Type: text/csv; charset=utf-8 +Content-Disposition: attachment; filename="mzdy_.csv" +``` +Built by `payrollToCsv` in `backend/src/util/csv.ts`, same BOM-prefixed +pattern as the attendance CSV export below. + # Related - [employees](/docs/data-model/employees.md) - [attendance_events](/docs/data-model/attendance-events.md) +- [pay_rates](/docs/data-model/pay-rates.md), [month_closures](/docs/data-model/month-closures.md), [payroll](/docs/data-model/payroll.md) +- [shift_slots](/docs/data-model/shift-slots.md), [shift_signups](/docs/data-model/shift-signups.md) - [Attendance routes](./attendance-routes.md) +- [Shift planning routes (employee)](./shifts-routes.md), [Closure routes (employee)](./closure-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 + chart off it; `adminShiftStore` and `adminPayrollStore` own the other + two admin tabs diff --git a/docs/api/closure-routes.md b/docs/api/closure-routes.md new file mode 100644 index 0000000..c98bd10 --- /dev/null +++ b/docs/api/closure-routes.md @@ -0,0 +1,39 @@ +--- +type: API Endpoint +title: Closure routes (employee) +description: GET /api/closure, POST /api/closure/confirm — the employee's own month-end confirmation. +resource: backend/src/routes/closure.ts +tags: [api, payroll] +timestamp: 2026-08-16T00:00:00Z +--- + +# Closure routes (employee) + +Mounted at `/api/closure`. Every route requires `requireAuth` + +`requireEmployee`. + +# Examples + +``` +GET /api/closure?period=YYYY-MM +200 -> { "closure": MonthClosure, "summary": ClosureSummary } +``` +Lazily creates the closure row (`waiting_employee`) the first time this +period is opened. `summary` (`worked_minutes`, `shift_count`, +`earned_estimate`) is always computed live from +[attendance_events](/docs/data-model/attendance-events.md) — never stored +on the closure row itself. + +``` +POST /api/closure/confirm +Body: { "period": "YYYY-MM" } +200 -> { "ok": true } +400 -> "V tomto měsíci máš stále otevřenou směnu." (open shift blocks it) + | "Docházka je už uzamčena." (already locked) +``` + +# Related + +- [month_closures](/docs/data-model/month-closures.md) +- [Admin routes](./admin-routes.md) — lock/reopen, tips/bonus adjustments, mark paid +- [Frontend stores](/docs/frontend/stores.md) — `closureStore` diff --git a/docs/api/index.md b/docs/api/index.md index 181ce72..84c5980 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -1,8 +1,10 @@ # API Express routers under `/api/*`, mounted in `backend/src/index.ts`. All -responses are JSON except the CSV export. Errors follow `{ "error": string }`. +responses are JSON except the CSV exports. 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 +- [Shift planning routes (employee)](./shifts-routes.md) - `/api/shifts/*` - browse open slots, sign up, cancel +- [Closure routes (employee)](./closure-routes.md) - `/api/closure/*` - confirm the month's docházka +- [Admin routes](./admin-routes.md) - `/api/admin/*` - employee management + pay rates, org/individual stats, shift planning admin, payroll, CSV exports diff --git a/docs/api/shifts-routes.md b/docs/api/shifts-routes.md new file mode 100644 index 0000000..4e2c58f --- /dev/null +++ b/docs/api/shifts-routes.md @@ -0,0 +1,48 @@ +--- +type: API Endpoint +title: Shift planning routes (employee) +description: GET /api/shifts/available, GET /api/shifts/mine, POST/DELETE /api/shifts/:slotId/signup. +resource: backend/src/routes/shifts.ts +tags: [api, shift-planning] +timestamp: 2026-08-16T00:00:00Z +--- + +# Shift planning routes (employee) + +Mounted at `/api/shifts`. Every route requires `requireAuth` + +`requireEmployee`. + +# Examples + +``` +GET /api/shifts/available?period=YYYY-MM +200 -> { "slots": AvailableSlot[] } +``` +Open slots the employee isn't already signed up for. `period` defaults to +the current month if omitted/invalid. Also the trigger point for lazily +generating that month's template — see +[shift_slots](/docs/data-model/shift-slots.md). + +``` +GET /api/shifts/mine?period=YYYY-MM +200 -> { "slots": MySlot[] } +``` + +``` +POST /api/shifts/:slotId/signup +201 -> { "ok": true } +400 -> collision with another approved slot, already full, already signed + up, or the slot is closed — see [shift_signups](/docs/data-model/shift-signups.md) +``` + +``` +DELETE /api/shifts/:slotId/signup +204 on success +400 -> not signed up for that slot +``` + +# Related + +- [shift_slots](/docs/data-model/shift-slots.md), [shift_signups](/docs/data-model/shift-signups.md) +- [Admin routes](./admin-routes.md) — the admin-side calendar, manual assignment, and month generation +- [Frontend stores](/docs/frontend/stores.md) — `shiftPlanningStore` diff --git a/docs/architecture/deployment.md b/docs/architecture/deployment.md index 1b1e717..db443f2 100644 --- a/docs/architecture/deployment.md +++ b/docs/architecture/deployment.md @@ -3,7 +3,7 @@ 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 +timestamp: 2026-08-16T00:00:00Z --- # Build & deployment @@ -83,6 +83,28 @@ scp compose.yaml :~/eatme/compose.yaml ssh "cd ~/eatme && docker compose up -d" ``` +**Known issue on some hosts**: an outdated `docker compose` plugin can fail +outright on `up` with an API-version-negotiation error +(`client version 1.43 is too old`), with no sudo available to upgrade it. +Until that's fixed on the affected host, redeploy there with plain +`docker` commands instead, replicating `compose.yaml` by hand: + +```bash +ssh 'cd ~/eatme && docker stop eatme && docker rm eatme && \ + set -a && source ./.env && set +a && docker run -d \ + --name eatme --restart unless-stopped -p 8092:4000 \ + -e GOOGLE_CLIENT_ID="$GOOGLE_CLIENT_ID" -e ADMIN_EMAILS="$ADMIN_EMAILS" \ + -e JWT_SECRET="$JWT_SECRET" -e CORS_ORIGIN="$CORS_ORIGIN" \ + -e COOKIE_SECURE="$COOKIE_SECURE" \ + -v eatme_eatme_data:/app/data eatme:latest' +``` + +The volume name matters — it must match what `docker compose` would have +named it (`_`, i.e. `eatme_eatme_data` for this +repo) or the container starts with a fresh empty database instead of the +existing one. Check the running container first if unsure: +`docker inspect eatme --format '{{json .Mounts}}'`. + 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/` for whichever host runs nginx, proxying diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 47796b8..2d3b9c3 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -3,7 +3,7 @@ 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 +timestamp: 2026-08-16T00:00:00Z --- # System overview @@ -13,7 +13,9 @@ 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. + attendance events, shift planning, pay rates, month closures, and + payroll. Jest tests (`backend/src/services/*.test.ts`) cover the + service-layer workflows. - **`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 @@ -37,11 +39,12 @@ one Docker image in production. ## 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`. +Dark, sharp-edged (no border-radius) UI with a single warm amber accent +used sparingly (active tab, "this is mine" states) — not the colored +left/right accent-border pattern common in AI-generated designs. Started +as a light monochrome TUI style and was deliberately redesigned dark on +2026-08-16; see [Design system](/docs/frontend/design-system.md). All +tokens/component classes live in `frontend/src/styles/theme.css`. ## Related diff --git a/docs/data-model/index.md b/docs/data-model/index.md index 654dccc..8809d90 100644 --- a/docs/data-model/index.md +++ b/docs/data-model/index.md @@ -4,3 +4,8 @@ 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 +- [shift_slots](./shift-slots.md) - bookable shifts generated from a weekly template or added ad hoc +- [shift_signups](./shift-signups.md) - an employee's claim on a slot +- [pay_rates](./pay-rates.md) - versioned hourly rate per employee +- [month_closures](./month-closures.md) - the confirm -> lock workflow per employee per month +- [payroll](./payroll.md) - computed + admin-adjusted pay per employee per month diff --git a/docs/data-model/month-closures.md b/docs/data-model/month-closures.md new file mode 100644 index 0000000..0c8c8e7 --- /dev/null +++ b/docs/data-model/month-closures.md @@ -0,0 +1,61 @@ +--- +type: SQLite Table +title: month_closures +description: One row per employee per calendar month tracking the confirm -> lock workflow that gates payroll. +resource: backend/src/db/index.ts +tags: [data-model, sqlite, payroll] +timestamp: 2026-08-16T00:00:00Z +--- + +# month_closures + +Tracks where a given employee's given month is in the confirm/lock +workflow. Rows are created lazily — the first time the employee opens that +period — not proactively for every employee/month combination. + +# Schema + +| Column | Type | Description | +|---|---|---| +| `id` | INTEGER PK | autoincrement | +| `employee_id` | INTEGER | references [employees](./employees.md) | +| `period` | TEXT | `YYYY-MM` | +| `status` | TEXT | `waiting_employee` \| `confirmed` \| `locked` | +| `employee_confirmed_at` | TEXT \| NULL | set on confirm | +| `locked_at` | TEXT \| NULL | set on lock, cleared on reopen | + +`UNIQUE (employee_id, period)`. + +# State machine + +``` +waiting_employee --employee confirms--> confirmed +confirmed --admin locks--> locked +locked --admin reopens--> confirmed (undoes a premature lock) +``` + +- **Confirm** (`confirmClosure`, `backend/src/services/closure.ts`): + employee-only action, blocked while any session in that period is still + `open` (see [attendance_events](./attendance-events.md)). Idempotent — + confirming an already-confirmed month is a no-op, not an error. +- **Lock** (`lockClosure`, called from `lockAndFinalizePayroll` in + `backend/src/services/payroll.ts`): admin-only, requires `confirmed`, + re-checks for an open session (defensive — time may have passed since + confirm), freezes the [payroll](./payroll.md) row. +- **Reopen** (`reopenPayroll`): undoes a lock made before the numbers were + actually right (e.g. the hourly rate wasn't set yet) — back to + `confirmed`, with the payroll row reset to `draft`. Only possible while + payroll status is `ready`, not once `paid` — that's the point past which + a mistake has to be corrected some other way, not silently rewritten. + +Unlike the source Google Apps Script this was ported from +(`gscript/ClosureService.js`), there's no separate `MANAGER_APPROVED` +state — that system modeled independent ADMIN/MANAGER/ACCOUNTANT roles; +this app has one owner-admin, so "manager approves" and "admin locks" +collapse into a single action here. + +# Related + +- [payroll](./payroll.md) +- [attendance_events](./attendance-events.md) — `hasOpenSessionInPeriod` gate +- [Closure API](/docs/api/closure-routes.md) diff --git a/docs/data-model/pay-rates.md b/docs/data-model/pay-rates.md new file mode 100644 index 0000000..704c0da --- /dev/null +++ b/docs/data-model/pay-rates.md @@ -0,0 +1,47 @@ +--- +type: SQLite Table +title: pay_rates +description: Hourly rate history per employee, keyed by the date it takes effect. +resource: backend/src/db/index.ts +tags: [data-model, sqlite, payroll] +timestamp: 2026-08-16T00:00:00Z +--- + +# pay_rates + +Versioned hourly rate per employee. There's no `valid_to` column — the +active rate for a given date is simply the row with the largest +`valid_from <= that date` (`getRateAt` in `backend/src/services/payRates.ts`). +A new rate implicitly ends the previous one. + +# Schema + +| Column | Type | Description | +|---|---|---| +| `id` | INTEGER PK | autoincrement | +| `employee_id` | INTEGER | references [employees](./employees.md) | +| `hourly_rate` | REAL | Kč/hour | +| `valid_from` | TEXT | `YYYY-MM-DD`, takes effect from this date inclusive | +| `created_at` | TEXT | ISO 8601 UTC | + +`UNIQUE (employee_id, valid_from)` — setting a rate again for the same date +overwrites it (`ON CONFLICT ... DO UPDATE`) rather than erroring. + +# Behavior + +- **First rate ever set for an employee** defaults `valid_from` to the + employee's own `created_at` date when the admin doesn't specify one — so + hours already worked before the admin got around to entering a rate + still get paid correctly, rather than pricing at 0. This was a real bug + found live: an admin set a rate today, and it didn't apply to shifts + worked earlier that same month. +- **Every later rate change** (a raise) defaults to today instead — not + retroactive. This asymmetry lives in `defaultValidFrom` in + `backend/src/services/payRates.ts` and is covered by a test in + `backend/src/services/payroll.test.ts`. + +# Related + +- [employees](./employees.md) +- [month_closures](./month-closures.md) / [payroll](./payroll.md) — consume + `getRateAt` when computing `earned_estimate` / `base_amount` diff --git a/docs/data-model/payroll.md b/docs/data-model/payroll.md new file mode 100644 index 0000000..ed9577d --- /dev/null +++ b/docs/data-model/payroll.md @@ -0,0 +1,64 @@ +--- +type: SQLite Table +title: payroll +description: Computed + admin-adjusted pay per employee per month — base hours pay, tips, bonus, other, and payment status. +resource: backend/src/db/index.ts +tags: [data-model, sqlite, payroll] +timestamp: 2026-08-16T00:00:00Z +--- + +# payroll + +One row per employee per period, created the first time the admin either +saves a draft adjustment or locks the month. + +# Schema + +| Column | Type | Description | +|---|---|---| +| `id` | INTEGER PK | autoincrement | +| `employee_id` | INTEGER | references [employees](./employees.md) | +| `period` | TEXT | `YYYY-MM` | +| `worked_minutes` | INTEGER | snapshotted at last save/lock, not live | +| `base_amount` | REAL | worked hours × the rate active on each shift's own date — see [pay_rates](./pay-rates.md) | +| `tips_amount` / `bonus_amount` / `other_amount` | REAL | admin-entered, default 0 | +| `final_amount` | REAL | sum of the four amounts above | +| `status` | TEXT | `draft` \| `ready` \| `paid` | +| `payment_date` | TEXT \| NULL | set when marked `paid` | +| `updated_at` | TEXT | ISO 8601 UTC | + +`UNIQUE (employee_id, period)`. + +# Status lifecycle + +``` +draft --admin locks the month closure--> ready --admin marks paid--> paid +ready --admin reopens--> draft +``` + +- **`draft`**: editable. `saveDraftAdjustments` + (`backend/src/services/payroll.ts`) recomputes `base_amount` / + `worked_minutes` fresh from + [attendance_events](./attendance-events.md) on every save, and requires + the linked [month_closures](./month-closures.md) row to be at least + `confirmed` (not still `waiting_employee`). +- **`ready`**: frozen. Set by `lockAndFinalizePayroll`, which locks the + closure in the same call. `saveDraftAdjustments` refuses to touch a + non-`draft` row — this is what turns "already locked" into the right + error message instead of the generic "not confirmed yet" one (a real bug + caught by `payroll.test.ts`: the two checks used to overlap). +- **`paid`**: terminal. Set by `markPaid`, stamps today's date. Not + reopenable — a mistake past this point needs a manual correction, not a + silent rewrite. + +This collapses the source system's four-stage accountant pipeline +(`READY_FOR_ACCOUNTANT` / `PROCESSING` / `PROCESSED` / `PAID` in +`gscript/PayrollService.js`) down to two — `ready` and `paid` — since +there's no separate accountant role here for the intermediate stages to +mean anything. + +# Related + +- [month_closures](./month-closures.md) +- [pay_rates](./pay-rates.md) +- [Closure API](/docs/api/closure-routes.md), [Admin API](/docs/api/admin-routes.md) diff --git a/docs/data-model/shift-signups.md b/docs/data-model/shift-signups.md new file mode 100644 index 0000000..77b8e79 --- /dev/null +++ b/docs/data-model/shift-signups.md @@ -0,0 +1,48 @@ +--- +type: SQLite Table +title: shift_signups +description: An employee's claim on a shift slot — approved or cancelled. +resource: backend/src/db/index.ts +tags: [data-model, sqlite, shift-planning] +timestamp: 2026-08-16T00:00:00Z +--- + +# shift_signups + +# Schema + +| Column | Type | Description | +|---|---|---| +| `id` | INTEGER PK | autoincrement | +| `slot_id` | INTEGER | references [shift_slots](./shift-slots.md) | +| `employee_id` | INTEGER | references [employees](./employees.md) | +| `status` | TEXT | `approved` \| `cancelled` | +| `created_at` | TEXT | ISO 8601 UTC | +| `cancelled_at` / `cancelled_by` | TEXT \| NULL | when, and by whom (admin email or the employee's own) | + +Cancelling never deletes the row (`status = 'cancelled'`), so who was ever +signed up for a slot stays visible in history. + +# Signup rules + +Enforced in `signupInternal` (`backend/src/services/shiftPlanning.ts`), in +this order, identically whether the employee signs themselves up or the +admin assigns them: + +1. Slot exists and isn't `closed`. +2. Not already approved for this exact slot. +3. **No time overlap** with any of the employee's other approved slots, + anywhere — not just the same day. Computed from real start/end + `Date`s (`slotStart`/`slotEnd`), so a slot whose `end_time <= start_time` + correctly counts as ending the next day. +4. Slot isn't already at capacity. + +All of this runs synchronously in a single request — better-sqlite3 is +synchronous, so there's no `LockService`-style mutex needed the way the +source Apps Script version required +(`signupEmployeeToSlot_` in `gscript/ShiftPlanningService.js`). + +# Related + +- [shift_slots](./shift-slots.md) +- [Shift planning API](/docs/api/shifts-routes.md) diff --git a/docs/data-model/shift-slots.md b/docs/data-model/shift-slots.md new file mode 100644 index 0000000..8bb1fad --- /dev/null +++ b/docs/data-model/shift-slots.md @@ -0,0 +1,51 @@ +--- +type: SQLite Table +title: shift_slots +description: Bookable shift slots (a date + time range + headcount capacity), generated from a weekly template or created ad hoc by the admin. +resource: backend/src/db/index.ts +tags: [data-model, sqlite, shift-planning] +timestamp: 2026-08-16T00:00:00Z +--- + +# shift_slots + +# Schema + +| Column | Type | Description | +|---|---|---| +| `id` | INTEGER PK | autoincrement | +| `date` | TEXT | `YYYY-MM-DD` | +| `start_time` / `end_time` | TEXT | `HH:MM`; `end_time <= start_time` means the slot rolls past midnight | +| `capacity` | INTEGER | headcount | +| `status` | TEXT | `open` \| `full` \| `closed` — recomputed from capacity vs. approved signups on every change | +| `note` | TEXT | free text | +| `generated` | INTEGER | 1 if created by the weekly template, 0 if manually added by the admin | +| `created_at` / `updated_at` | TEXT | ISO 8601 UTC | + +`UNIQUE (date, start_time, end_time)` — this is what makes template +generation idempotent (`INSERT OR IGNORE`). + +# Weekly template + +`ensureSlotsForPeriod` (`backend/src/services/shiftPlanning.ts`) generates a +whole calendar month lazily, the first time anyone (employee or admin) +requests that period — not on a cron schedule. There's no scheduler in this +app, and lazy + idempotent is simpler and just as reliable as the source +system's Apps Script monthly trigger +(`createShiftPlanningTrigger_` in `gscript/ShiftPlanningService.js`, which +could only fire in a coarse hourly window). + +| Day | Slots | +|---|---| +| Sun–Thu | 18:00–23:00, capacity 1 | +| Fri, Sat | 18:00–02:00, capacity 1 **and** 20:00–00:00, capacity 1 | + +The two Friday/Saturday slots deliberately overlap in time — a real +scheduling choice from the source bistro (an early shift and a late shift), +not a bug. It's also exactly what the collision test in +`backend/src/services/shiftPlanning.test.ts` exercises. + +# Related + +- [shift_signups](./shift-signups.md) +- [Shift planning API](/docs/api/shifts-routes.md) diff --git a/docs/frontend/design-system.md b/docs/frontend/design-system.md index f1dcfba..a764990 100644 --- a/docs/frontend/design-system.md +++ b/docs/frontend/design-system.md @@ -1,45 +1,97 @@ --- 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 +title: Dark modern design system +description: How EatMe's dark, sharp-edged, amber-accent design system is implemented in frontend/src/styles/theme.css. +resource: frontend/src/styles/theme.css tags: [frontend, design, css] -timestamp: 2026-08-12T00:00:00Z +timestamp: 2026-08-16T00:00:00Z --- -# TUI monochrome design system +# Dark modern 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. +Replaced the original light monochrome TUI style (mono font, +`~/doc/concepts/ui/light-mono-tui.md`) on 2026-08-16, on explicit request +— the TUI look read as dated to the app's actual end users. All tokens and +component classes live in `frontend/src/styles/theme.css` (was +`tui.css` — renamed since "TUI" stopped being an accurate description). -`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`. +# Rules this design follows + +- **Dark background, sharp corners.** `--radius-lg`/`--radius`/`--radius-sm` + are all `0` — deliberately, per explicit user preference against rounded + corners even in an otherwise "modern SaaS" direction. +- **One accent color, used sparingly.** `--accent` (warm amber, `#f2a93b`) + marks state, not decoration: the active tab, "this shift is mine", + today's calendar cell, focus rings. It is *not* used as a per-card or + per-row accent border. +- **No colored border-left/border-right accent stripes.** Explicitly + rejected during design — a common AI-generated-UI tic. Emphasis instead + comes from filled backgrounds (`.badge-solid`, `.tab.active`, + `.calendar-slot.is-mine`) or a full inset ring around the whole element + (`.month-calendar-cell.is-today`, `.month-calendar-day-btn.is-selected`), + never a single colored edge. +- **System sans-serif for body text**, `--font-mono` reserved for numbers/ + timestamps that benefit from tabular alignment (`.stat-value`, + calendar time chips, the bar chart's axis labels) — a common dashboard + convention (Linear, Vercel), not an all-monospace TUI throwback. +- **Neutral hairline dividers**, not colored ones. `.list`/`.row`, + `.stat-grid`, and the calendar grid all use a 1px `--border`-colored + gap/border for internal separators. +- **`color-scheme: dark`** is set globally so native form controls (date/ + time pickers, number spinners) render dark by default — otherwise bare + `` etc. render as light-mode browser chrome floating + in an otherwise-dark page, which is what a generic `input, select` base + rule plus this fixes. + +# Tokens + +`--bg`, `--surface` (panels), `--surface-hover`, `--surface-2` (nested/ +input backgrounds), `--border`, `--border-strong`, `--fg`, `--muted`, +`--muted-2`, `--accent`, `--accent-strong`, `--accent-fg` (text color for +content sitting *on* the accent), `--radius-lg`/`--radius`/`--radius-sm` +(all `0`), `--shadow-sm`/`--shadow-md`, `--font`, `--font-mono`. + +# Component classes + +Same class names as the previous TUI stylesheet — this was a pure CSS +rewrite, no component file needed to change: `.tabs`/`.tab`, +`.btn`/`.btn-block`/`.btn-ghost`, `.panel`, `.field-row`, `.list`/`.row`, +`.badge` (`-solid`, `-dashed` — both restyled as pills, no more literal +dashed border), `.stat-grid`/`.stat-tile`, `.login-card`, +`.month-calendar-*`, `.calendar-slot`/`.calendar-chip`. `.badge-double` +(from the old TUI system) was dropped — it had no remaining usages. + +There's intentionally only one `.btn` visual style (no `.btn-primary`) — +introducing a primary/secondary distinction would have meant touching +every component that renders a button to classify its actions, which was +out of scope for a CSS-only redesign pass. # 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. +- **`.bar-chart-*`** (`frontend/src/components/BarChart.tsx`) — bars are + `--accent`-filled with a rounded top edge only, y-axis and day labels in + `--font-mono`; 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. + segmented-control strip. Placed *inside* the panel it controls (employee + detail chart, shift calendar, closure card), not as a page-level control + — an earlier version put it at the top of the whole admin stats page, + which tested as confusing since it was visually disconnected from what + it affected. +- **`.month-calendar-*`** (`frontend/src/components/MonthCalendar.tsx`) — + shared 7-column Monday-first grid used by both + [`ShiftPlanning`](/docs/frontend/index.md) (employee) and + [`ShiftPlanManager`](/docs/frontend/index.md) (admin), via a + `renderDay(dateStr, dayNumber)` render-prop rather than two separate + calendar implementations. The employee view renders interactive slot + buttons directly in each cell; the admin view renders compact read-only + chips and a day-select button, since full slot editing (capacity, notes, + assign dropdown) doesn't fit in a calendar cell — clicking a day opens a + detail panel below the grid instead. # Related - [Zustand stores](./stores.md) +- [System overview](/docs/architecture/overview.md) diff --git a/docs/frontend/index.md b/docs/frontend/index.md index d1c24de..396c920 100644 --- a/docs/frontend/index.md +++ b/docs/frontend/index.md @@ -2,5 +2,10 @@ 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 +- [Zustand stores](./stores.md) - authStore, attendanceStore, adminStore, shiftPlanningStore, adminShiftStore, closureStore, adminPayrollStore — where all app logic lives +- [Dark modern design system](./design-system.md) - theme.css tokens/components, and this app's chart/nav/calendar additions + +Notable components beyond the design system doc's list: `MonthCalendar` +(shared 7-column calendar grid), `ShiftPlanning` / `ShiftPlanManager` +(employee / admin shift planning UI built on it), `ClosureCard` (employee +month-end confirmation), `PayrollManager` (admin payroll workflow). diff --git a/docs/frontend/stores.md b/docs/frontend/stores.md index 16dbee1..b4bf11b 100644 --- a/docs/frontend/stores.md +++ b/docs/frontend/stores.md @@ -1,10 +1,10 @@ --- type: Frontend Module title: Zustand stores -description: authStore, attendanceStore, and adminStore — where all app/fetch logic lives, keeping components thin. +description: authStore, attendanceStore, adminStore, shiftPlanningStore, adminShiftStore, closureStore, adminPayrollStore — where all app/fetch logic lives, keeping components thin. resource: frontend/src/store tags: [frontend, zustand, state] -timestamp: 2026-08-12T00:00:00Z +timestamp: 2026-08-16T00:00:00Z --- # Zustand stores @@ -54,9 +54,31 @@ 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). +# `shiftPlanningStore.ts` / `adminShiftStore.ts` + +Employee and admin sides of shift planning, kept as two separate stores +(not one shared with a role flag) since the shapes barely overlap — the +employee store tracks `available`/`mine` slot lists for a period, the +admin store tracks the full per-period slot list plus create/update/assign +actions. Both hold their own `period` ("YYYY-MM") and re-fetch on +`prevPeriod`/`nextPeriod`, same pattern as `adminStore`'s `month`. + +# `closureStore.ts` / `adminPayrollStore.ts` + +Employee and admin sides of the month-end/payroll workflow. `closureStore` +is small — `{ period, closure, summary }` plus `confirm()`. +`adminPayrollStore` owns the per-period overview rows and the +`saveAdjustments`/`lock`/`reopen`/`markPaid` actions, each just POSTing and +then re-`load()`-ing rather than optimistically patching local state — +deliberate, since the server recomputes `base_amount` from live attendance +data on several of these calls and the UI should always reflect that, not +a stale client guess. + # Related - [attendance_events](/docs/data-model/attendance-events.md) — the state machine and `Session` shape these stores fetch +- [shift_slots](/docs/data-model/shift-slots.md), [month_closures](/docs/data-model/month-closures.md), [payroll](/docs/data-model/payroll.md) - [Attendance API](/docs/api/attendance-routes.md) - [Admin API](/docs/api/admin-routes.md) +- [Shift planning routes](/docs/api/shifts-routes.md), [Closure routes](/docs/api/closure-routes.md) diff --git a/docs/index.md b/docs/index.md index e8dc450..6a68328 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,11 +4,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. +Knowledge bundle for the EatMe bistro staff system: a Google-SSO PWA where +employees log clock-in/out and breaks, sign up for shifts, and confirm +their month; the owner (admin) manages the employee whitelist and pay +rates, plans shifts, and runs payroll. `gscript/` at the repo root is the +original Google Apps Script version this was ported from (kept for +reference, not part of the running app). - [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 +- [Data model](./data-model/) — SQLite tables: employees, attendance, shift planning, pay rates, closures, payroll +- [API](./api/) — HTTP routes under `/api/auth`, `/api/attendance`, `/api/shifts`, `/api/closure`, `/api/admin` +- [Frontend](./frontend/) — Zustand stores and the dark modern design system diff --git a/docs/log.md b/docs/log.md index ce0c704..583f914 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,41 @@ # Update Log +## 2026-08-16 +- **Creation**: Shift planning module — `shift_slots`/`shift_signups` + tables, weekly-template generation (lazy, idempotent, no cron), employee + signup/cancel with collision + capacity checks, admin calendar UI + (`MonthCalendar`, `ShiftPlanning`, `ShiftPlanManager`). Ported from + `gscript/ShiftPlanningService.js`, rewritten against this app's own data + model rather than copied. +- **Creation**: Month closure + payroll module — `pay_rates`, + `month_closures`, `payroll` tables; employee confirms the month, admin + locks/reopens/marks paid, tips/bonus/other adjustments, CSV export. + Collapses the source system's multi-role (ADMIN/MANAGER/ACCOUNTANT) + approval chain down to fit this app's single-admin reality. Ported from + `gscript/ClosureService.js` + `gscript/PayrollService.js`. +- **Fix**: A shift left open more than 12h (forgotten clock-out) is now + auto-closed at `clock_in + 12h`, checked lazily on read + (`attendance.ts`) — no admin action or scheduler needed. +- **Fix**: The first pay rate ever set for an employee now applies + retroactively to their whole history instead of defaulting to "today" — + caught live when an admin set a rate after the employee had already + worked hours that month. +- **Fix**: Locking a month closure with `saveDraftAdjustments` afterward + now correctly reports "already locked" instead of the misleading "not + confirmed yet" — the two guard conditions used to overlap. +- **Addition**: jest + ts-jest added to `backend/` (previously no test + runner); coverage for shift planning, closure/payroll, and the + forgotten-clock-out auto-close, each against a throwaway temp SQLite DB. +- **Update**: Full dark, sharp-edged redesign (`tui.css` → `theme.css`), + replacing the light monochrome TUI look with a dark modern SaaS style — + amber accent used sparingly, no rounded corners, no colored accent + borders. Every existing CSS class name kept, so no component logic + needed to change. See [Design system](./frontend/design-system.md). +- **Note**: `docker compose` on one of the deploy hosts is now broken even + for `up` (not just `ps`/`logs`/`down` as before) — redeploy there with + plain `docker run` instead; documented in + [Deployment](./architecture/deployment.md). + ## 2026-08-12 - **Update**: Documented the actual production shipping workflow in [Build & deployment](./architecture/deployment.md) — build locally for