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
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 buil 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=<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 <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
ssh <host> "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://<deploy-host>: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://<deploy-host>: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).

View File

@@ -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 `<a href=... download>` — 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_<YYYY-MM>.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

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

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
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 <host>:~/eatme/compose.yaml
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
container (not part of this repo) — a template server block lives at
`deploy/<domain>` for whichever host runs nginx, proxying

View File

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

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
- [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
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
`<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
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)

View File

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

View File

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

View File

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

View File

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