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.
This commit is contained in:
Michal Pemcak
2026-08-16 18:35:56 +02:00
parent f917ed06a8
commit 94933cac5c
19 changed files with 728 additions and 106 deletions

5
CLAUDE.md Normal file
View File

@@ -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.

110
README.md
View File

@@ -1,96 +1,102 @@
# EatMe — Docházka # EatMe — Attendance
Evidence docházky pro bistro EatMe. Zaměstnanci se přihlašují Google účtem a Internal staff system for the EatMe bistro. Employees sign in with Google,
logují příchod / odchod / start a konec pauzy; admin (majitel) spravuje seznam log clock-in/out and breaks, sign up for shifts, and confirm their month at
zaměstnanců (jen e-mail, žádná hesla) a vidí souhrnné i individuální statistiky the end of it; the admin (owner) manages the employee list and their hourly
s grafem a exportem do CSV. 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 frontend/ React + TypeScript PWA (Vite), Google Sign-In, Zustand
docs/ OKF (Open Knowledge Format) — architektura, datový model, provoz docs/ OKF (Open Knowledge Format) bundle — architecture, data model, ops
branding/ Logo a vygenerované favicony/PWA ikony z podkladu bistra gscript/ Original Google Apps Script version (reference only, not part of the running app)
deploy/ Šablony configů pro hosty mimo tento repo (nginx server block) branding/ Logo and generated favicons/PWA icons from the bistro's artwork
Dockerfile Multi-stage build — jeden image pro API i frontend deploy/ Config templates for hosts outside this repo (nginx server block)
compose.yaml Produkční nasazení hotového image (viz Produkce níž) 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í The look is dark, modern, sharp-edged (no rounded corners), with a single
"TUI" styl (mono font, ostré hrany, žádné barvy, inverze pro aktivní stav). 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+ - Node.js 22+
- Google Cloud projekt s nakonfigurovaným OAuth Client ID (Web application) - A Google Cloud project with an OAuth Client ID configured (Web
— origins musí obsahovat `http://localhost:5174` pro vývoj a produkční application) — authorized origins must include `http://localhost:5174`
doménu. Client ID se nastavuje do `GOOGLE_CLIENT_ID` (backend) a for dev and the production domain. The client ID goes into
`VITE_GOOGLE_CLIENT_ID` (frontend) — musí být **stejné**. `GOOGLE_CLIENT_ID` (backend) and `VITE_GOOGLE_CLIENT_ID` (frontend) —
they must be **identical**.
## Vývoj ## Development
```bash ```bash
# backend # backend
cd 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 install
npm run dev # http://localhost:4000 npm run dev # http://localhost:4000
# frontend (v druhém terminálu) # frontend (in a second terminal)
cd frontend 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 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 ```bash
cd backend && npm run typecheck && npm run build # -> dist/index.js (esbuild bundle) cd backend && npm run typecheck && npm test && npm run build # -> dist/index.js (esbuild bundle)
cd frontend && npm run build # -> dist/ (Vite, PWA) 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 One multi-stage `Dockerfile` at the repo root builds both frontend and
jako jeden kontejner — backend servíruje API na `/api/*` a staticky sbalený backend and runs them as a single container — the backend serves the API
frontend na všem ostatním. on `/api/*` and the statically-built frontend on everything else.
Image se buil lokálně (pro platformu cílového serveru) a na server se The image is built locally (for the target server's platform) and shipped
posílá hotový, ne zdrojáky: to the server already built, never as source:
```bash ```bash
# buildni pro platformu serveru (na Apple Silicon Macu proti x86_64 serveru # build for the server's platform (on an Apple Silicon Mac targeting an
# je --platform linux/amd64 povinné, jinak image tam vůbec nenaběhne) # x86_64 server, --platform linux/amd64 is required or the image won't
# run there at all)
docker build --platform linux/amd64 \ docker build --platform linux/amd64 \
--build-arg VITE_GOOGLE_CLIENT_ID=<client-id> \ --build-arg VITE_GOOGLE_CLIENT_ID=<client-id> \
-t eatme:latest . -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 <host> "gunzip | docker load" docker save eatme:latest | gzip | ssh <host> "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 <host>:~/eatme/compose.yaml scp compose.yaml <host>:~/eatme/compose.yaml
ssh <host> "cd ~/eatme && docker compose up -d" ssh <host> "cd ~/eatme && docker compose up -d"
``` ```
`VITE_GOOGLE_CLIENT_ID` musí jít jako **build arg** (Vite ho zapéká při `VITE_GOOGLE_CLIENT_ID` has to go in as a **build arg** (Vite bakes it in
buildu, ne za běhu — v `.env` na serveru se proto řeší jen runtime proměnné). at build time, not runtime — `.env` on the server only handles runtime
Pojmenovaný volume `eatme_data` (viz `compose.yaml`) drží SQLite databázi vars). The named volume `eatme_data` (see `compose.yaml`) keeps the
mimo kontejner, ať přežije redeploy. SQLite database outside the container so it survives a redeploy.
Před kontejner patří TLS-terminující reverse proxy (mimo tento repo) — A TLS-terminating reverse proxy belongs in front of the container (outside
šablona server blocku pro nginx je v `deploy/eatme.mipem.co`, proxuje na this repo) — an nginx server block template lives at
`http://<deploy-host>:8092`. `deploy/eatme.mipem.co`, proxying to `http://<deploy-host>:8092`.
## Datový model / provoz ## Data model / operations
Zaměstnanci jsou whitelist e-mailů spravovaný adminem (`ADMIN_EMAILS` v env Employees are an email whitelist managed by the admin (`ADMIN_EMAILS` env
samostatná role, není v tabulce zaměstnanců). Odebrání zaměstnance je pouze var — a separate role, not a row in the employees table). Removing an
soft-delete (`active = 0`) — historie docházky zůstává, opětovným přidáním employee is a soft delete (`active = 0`) — attendance history stays, and
stejného e-mailu se účet reaktivuje. Statistiky se počítají po kalendářních re-adding the same email reactivates the account. Employees also have a
měsících (`?month=YYYY-MM`), s možností listovat měsíci v adminově detailu versioned hourly rate, sign up for shifts from a weekly template, and
zaměstnance (graf + export CSV). confirm each calendar month before the admin locks it and runs payroll.
Víc v `docs/` (OKF bundle). More in `docs/` (OKF bundle).

View File

@@ -1,10 +1,10 @@
--- ---
type: API Endpoint type: API Endpoint
title: Admin routes 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 resource: backend/src/routes/admin.ts
tags: [api, admin] tags: [api, admin]
timestamp: 2026-08-12T00:00:00Z timestamp: 2026-08-16T00:00:00Z
--- ---
# Admin routes # Admin routes
@@ -16,8 +16,19 @@ Mounted at `/api/admin`. Every route requires `requireAuth` + `requireAdmin`
``` ```
GET /api/admin/employees 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 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 `<a href=... download>` — the session cookie rides the frontend as a plain `<a href=... download>` — the session cookie rides
along automatically since it's a same-origin GET. 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_<YYYY-MM>.csv"
```
Built by `payrollToCsv` in `backend/src/util/csv.ts`, same BOM-prefixed
pattern as the attendance CSV export below.
# Related # Related
- [employees](/docs/data-model/employees.md) - [employees](/docs/data-model/employees.md)
- [attendance_events](/docs/data-model/attendance-events.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) - [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 - [Frontend stores](/docs/frontend/stores.md) — `adminStore` owns the
selected month and drives both the summary table and the per-employee 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

View File

@@ -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`

View File

@@ -1,8 +1,10 @@
# API # API
Express routers under `/api/*`, mounted in `backend/src/index.ts`. All 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 - [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 - [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

48
docs/api/shifts-routes.md Normal file
View File

@@ -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`

View File

@@ -3,7 +3,7 @@ type: Architecture Overview
title: Build & deployment title: Build & deployment
description: esbuild backend bundle, Vite frontend build, the multi-stage Docker image, and how it ships to a host. description: esbuild backend bundle, Vite frontend build, the multi-stage Docker image, and how it ships to a host.
tags: [architecture, deployment, docker] tags: [architecture, deployment, docker]
timestamp: 2026-08-12T12:00:00Z timestamp: 2026-08-16T00:00:00Z
--- ---
# Build & deployment # Build & deployment
@@ -83,6 +83,28 @@ scp compose.yaml <host>:~/eatme/compose.yaml
ssh <host> "cd ~/eatme && docker compose up -d" ssh <host> "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 <host> '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 (`<project-dir>_<volume-key>`, 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 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 container (not part of this repo) — a template server block lives at
`deploy/<domain>` for whichever host runs nginx, proxying `deploy/<domain>` for whichever host runs nginx, proxying

View File

@@ -3,7 +3,7 @@ type: Architecture Overview
title: System overview title: System overview
description: How the EatMe frontend, backend, and database fit together. description: How the EatMe frontend, backend, and database fit together.
tags: [architecture, backend, frontend] tags: [architecture, backend, frontend]
timestamp: 2026-08-12T00:00:00Z timestamp: 2026-08-16T00:00:00Z
--- ---
# System overview # System overview
@@ -13,7 +13,9 @@ one Docker image in production.
- **`backend/`** — Express + TypeScript API, SQLite via `better-sqlite3` - **`backend/`** — Express + TypeScript API, SQLite via `better-sqlite3`
(entry point `backend/src/index.ts`). Owns all state: employee whitelist, (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/`** — Vite + React + TypeScript PWA (entry point
`frontend/src/App.tsx`). Talks to the backend only over `/api/*`; in dev, `frontend/src/App.tsx`). Talks to the backend only over `/api/*`; in dev,
Vite proxies that path to `http://localhost:4000` (see Vite proxies that path to `http://localhost:4000` (see
@@ -37,11 +39,12 @@ one Docker image in production.
## Design language ## Design language
The UI follows the "light monochrome TUI" style defined at Dark, sharp-edged (no border-radius) UI with a single warm amber accent
`~/doc/concepts/ui/light-mono-tui.md`: grey page background, black type and used sparingly (active tab, "this is mine" states) — not the colored
1px borders, mono font, inverted fills for active/hover state, no accent left/right accent-border pattern common in AI-generated designs. Started
colors, no border-radius. Implemented as plain CSS tokens in as a light monochrome TUI style and was deliberately redesigned dark on
`frontend/src/styles/tui.css`. 2026-08-16; see [Design system](/docs/frontend/design-system.md). All
tokens/component classes live in `frontend/src/styles/theme.css`.
## Related ## Related

View File

@@ -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 - [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 - [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

View File

@@ -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)

View File

@@ -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`

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 |
|---|---|
| SunThu | 18:0023:00, capacity 1 |
| Fri, Sat | 18:0002:00, capacity 1 **and** 20:0000: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)

View File

@@ -1,45 +1,97 @@
--- ---
type: Design Language type: Design Language
title: TUI monochrome design system title: Dark modern design system
description: How the light-mono-tui design language is implemented in this app's CSS. description: How EatMe's dark, sharp-edged, amber-accent design system is implemented in frontend/src/styles/theme.css.
resource: frontend/src/styles/tui.css resource: frontend/src/styles/theme.css
tags: [frontend, design, 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 Replaced the original light monochrome TUI style (mono font,
`~/doc/concepts/ui/light-mono-tui.md` (grey background, black type/borders, `~/doc/concepts/ui/light-mono-tui.md`) on 2026-08-16, on explicit request
mono font, inverted active/hover states, no accent color, no radius, no — the TUI look read as dated to the app's actual end users. All tokens and
shadow). This doc covers how EatMe implements it. 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`, # Rules this design follows
`--hover`, `--empty`, `--panel`, `--font`) and the component classes built on
them: `.tabs`/`.tab`, `.btn`/`.btn-block`/`.btn-ghost`, `.panel`, - **Dark background, sharp corners.** `--radius-lg`/`--radius`/`--radius-sm`
`.field-row`, `.list`/`.row`, `.badge` (`-solid`, `-dashed`, `-double`), are all `0` — deliberately, per explicit user preference against rounded
`.stat-grid`/`.stat-tile`, `.login-card`. 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
`<input type="date">` 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 # App-specific additions
Built for this app, following the same rules (square edges, monochrome, - **`.bar-chart-*`** (`frontend/src/components/BarChart.tsx`) — bars are
1px borders) rather than introducing new visual language: `--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
- **`.bar-chart-*`** (`frontend/src/components/BarChart.tsx`) — the the fixed-height bar track (not nested inside each bar's percentage-
per-employee daily-hours chart. Y-axis labels + a repeating 1px height column) specifically to avoid the bars visually overlapping the
`--empty`-colored gridline background at 25% steps computed from a labels.
"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 [>]` - **`.month-nav`** (`frontend/src/components/MonthNav.tsx`) — `[<] label [>]`
bordered strip, styled like the design language's tab strip. Deliberately segmented-control strip. Placed *inside* the panel it controls (employee
placed *inside* the employee-detail panel next to the chart it controls, detail chart, shift calendar, closure card), not as a page-level control
not as a page-level control — an earlier version put it at the top of the — an earlier version put it at the top of the whole admin stats page,
whole admin stats page, which tested as confusing ("can't browse history which tested as confusing since it was visually disconnected from what
on the chart") since it was visually disconnected from what it affected. 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 # Related
- [Zustand stores](./stores.md) - [Zustand stores](./stores.md)
- [System overview](/docs/architecture/overview.md)

View File

@@ -2,5 +2,10 @@
Vite + React + TypeScript PWA, Google Identity Services for sign-in. Vite + React + TypeScript PWA, Google Identity Services for sign-in.
- [Zustand stores](./stores.md) - authStore, attendanceStore, adminStore — where all app logic lives - [Zustand stores](./stores.md) - authStore, attendanceStore, adminStore, shiftPlanningStore, adminShiftStore, closureStore, adminPayrollStore — 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 - [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).

View File

@@ -1,10 +1,10 @@
--- ---
type: Frontend Module type: Frontend Module
title: Zustand stores 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 resource: frontend/src/store
tags: [frontend, zustand, state] tags: [frontend, zustand, state]
timestamp: 2026-08-12T00:00:00Z timestamp: 2026-08-16T00:00:00Z
--- ---
# Zustand stores # 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` summary and the detail chart always show the same period. `removeEmployee`
calls the soft-delete endpoint — see [employees](/docs/data-model/employees.md). 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 # Related
- [attendance_events](/docs/data-model/attendance-events.md) — the state - [attendance_events](/docs/data-model/attendance-events.md) — the state
machine and `Session` shape these stores fetch 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) - [Attendance API](/docs/api/attendance-routes.md)
- [Admin API](/docs/api/admin-routes.md) - [Admin API](/docs/api/admin-routes.md)
- [Shift planning routes](/docs/api/shifts-routes.md), [Closure routes](/docs/api/closure-routes.md)

View File

@@ -4,11 +4,14 @@ okf_version: "0.1"
# EatMe — Docházka # EatMe — Docházka
Knowledge bundle for the EatMe bistro employee attendance-tracking system: a Knowledge bundle for the EatMe bistro staff system: a Google-SSO PWA where
Google-SSO PWA where employees log clock-in/out and breaks, and the owner employees log clock-in/out and breaks, sign up for shifts, and confirm
(admin) manages the employee whitelist and views statistics. 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 - [Architecture](./architecture/) — system overview, auth flow, deployment
- [Data model](./data-model/) — SQLite tables and the attendance state machine - [Data model](./data-model/) — SQLite tables: employees, attendance, shift planning, pay rates, closures, payroll
- [API](./api/) — HTTP routes under `/api/auth`, `/api/attendance`, `/api/admin` - [API](./api/) — HTTP routes under `/api/auth`, `/api/attendance`, `/api/shifts`, `/api/closure`, `/api/admin`
- [Frontend](./frontend/) — Zustand stores and the TUI monochrome design system - [Frontend](./frontend/) — Zustand stores and the dark modern design system

View File

@@ -1,5 +1,41 @@
# Update Log # 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 ## 2026-08-12
- **Update**: Documented the actual production shipping workflow in - **Update**: Documented the actual production shipping workflow in
[Build & deployment](./architecture/deployment.md) — build locally for [Build & deployment](./architecture/deployment.md) — build locally for