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:
5
CLAUDE.md
Normal file
5
CLAUDE.md
Normal 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.
|
||||||
108
README.md
108
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
|
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 buildí 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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
39
docs/api/closure-routes.md
Normal file
39
docs/api/closure-routes.md
Normal 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`
|
||||||
@@ -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
48
docs/api/shifts-routes.md
Normal 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`
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
61
docs/data-model/month-closures.md
Normal file
61
docs/data-model/month-closures.md
Normal 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)
|
||||||
47
docs/data-model/pay-rates.md
Normal file
47
docs/data-model/pay-rates.md
Normal 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`
|
||||||
64
docs/data-model/payroll.md
Normal file
64
docs/data-model/payroll.md
Normal 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)
|
||||||
48
docs/data-model/shift-signups.md
Normal file
48
docs/data-model/shift-signups.md
Normal 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)
|
||||||
51
docs/data-model/shift-slots.md
Normal file
51
docs/data-model/shift-slots.md
Normal 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 |
|
||||||
|
|---|---|
|
||||||
|
| 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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
36
docs/log.md
36
docs/log.md
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user