Initial commit: EatMe attendance tracker
Google-SSO PWA for bistro employee clock-in/out, admin employee management, and stats with CSV export. Express + SQLite backend, React + Zustand frontend in the light-mono-tui design language. Multi-stage Dockerfile, compose.yaml for image-based deploys, nginx reverse-proxy template, and an OKF documentation bundle in docs/. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
68
docs/data-model/attendance-events.md
Normal file
68
docs/data-model/attendance-events.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
type: SQLite Table
|
||||
title: attendance_events
|
||||
description: Append-only log of clock_in/clock_out/break_start/break_end events, and the state machine built on top of it.
|
||||
resource: backend/src/db/index.ts
|
||||
tags: [data-model, sqlite]
|
||||
timestamp: 2026-08-12T00:00:00Z
|
||||
---
|
||||
|
||||
# attendance_events
|
||||
|
||||
Append-only event log — there is no "shift" or "session" row. Shifts are
|
||||
derived at read time by walking events chronologically
|
||||
(`buildSessions` in `backend/src/services/attendance.ts`).
|
||||
|
||||
# Schema
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK | autoincrement |
|
||||
| `employee_id` | INTEGER | references [employees.id](./employees.md) |
|
||||
| `type` | TEXT | one of `clock_in`, `clock_out`, `break_start`, `break_end` (CHECK constraint) |
|
||||
| `ts` | TEXT | ISO 8601 UTC, set by SQLite default |
|
||||
|
||||
Indexed on `(employee_id, ts)`.
|
||||
|
||||
# State machine
|
||||
|
||||
`getLiveStatus` derives one of three states from an employee's most recent
|
||||
event:
|
||||
|
||||
| Last event | Status |
|
||||
|---|---|
|
||||
| none / `clock_out` | `clocked_out` |
|
||||
| `clock_in` / `break_end` | `working` |
|
||||
| `break_start` | `on_break` |
|
||||
|
||||
`recordEvent` only allows the transition that's valid from the current
|
||||
status (`NEXT_ALLOWED` map) — e.g. you can't `break_start` while already
|
||||
`clocked_out`. An invalid attempt throws `InvalidTransitionError`, surfaced
|
||||
by the API as `409`.
|
||||
|
||||
```
|
||||
clocked_out --clock_in--> working
|
||||
working --break_start--> on_break
|
||||
on_break --break_end--> working
|
||||
working --clock_out--> clocked_out
|
||||
```
|
||||
|
||||
# Deriving shifts
|
||||
|
||||
`buildSessions(events, now)` walks a chronological event list and pairs
|
||||
`clock_in`...`clock_out` into a `Session`, with nested `breaks`. A session
|
||||
still missing its `clock_out` is `open: true` and its `workedMs`/`breakMs`
|
||||
are computed against `now` (the request time) rather than a real end —
|
||||
that's why the frontend re-derives it against a live clock between polls
|
||||
(see [Frontend stores](/docs/frontend/stores.md)) instead of trusting a
|
||||
stale fetch forever.
|
||||
|
||||
`summarize(events, now)` reduces sessions into `totalWorkedMs`,
|
||||
`totalBreakMs`, `shiftCount` for a date range — the shape returned by both
|
||||
the employee's own stats endpoint and the admin per-employee endpoint (see
|
||||
[Attendance API](/docs/api/attendance-routes.md), [Admin API](/docs/api/admin-routes.md)).
|
||||
|
||||
# Related
|
||||
|
||||
- [employees](./employees.md)
|
||||
- [Attendance API](/docs/api/attendance-routes.md)
|
||||
40
docs/data-model/employees.md
Normal file
40
docs/data-model/employees.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
type: SQLite Table
|
||||
title: employees
|
||||
description: The whitelist of employee emails allowed to log attendance, with a soft-delete active flag.
|
||||
resource: backend/src/db/index.ts
|
||||
tags: [data-model, sqlite]
|
||||
timestamp: 2026-08-12T00:00:00Z
|
||||
---
|
||||
|
||||
# employees
|
||||
|
||||
The employee whitelist. A row here (with `active = 1`) is what lets a Google
|
||||
account log in as an employee — see [Auth flow](/docs/architecture/auth-flow.md).
|
||||
Admins are **not** rows in this table; they're resolved purely from the
|
||||
`ADMIN_EMAILS` env var.
|
||||
|
||||
# Schema
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK | autoincrement |
|
||||
| `email` | TEXT | unique, lowercased on write |
|
||||
| `name` | TEXT | nullable; filled in from the Google profile on first login (`touchEmployeeName` in `backend/src/services/employees.ts`), only if still null |
|
||||
| `active` | INTEGER | 1 = can log in, 0 = soft-deleted |
|
||||
| `created_at` | TEXT | ISO 8601 UTC, set by SQLite default |
|
||||
|
||||
# Behavior
|
||||
|
||||
- **Add** (`addEmployee`): upsert by email. If a soft-deleted row exists for
|
||||
that email, it's reactivated (`active = 1`) rather than duplicated.
|
||||
- **Remove** (`deactivateEmployee`): sets `active = 0`. This is a **soft**
|
||||
delete by design — [attendance_events](./attendance-events.md) rows keep
|
||||
referencing the employee, so historical stats for a removed employee are
|
||||
still computable and re-adding the same email restores access without
|
||||
losing history.
|
||||
|
||||
# Related
|
||||
|
||||
- [attendance_events](./attendance-events.md) — `employee_id` references this table
|
||||
- [Employees API](/docs/api/admin-routes.md)
|
||||
6
docs/data-model/index.md
Normal file
6
docs/data-model/index.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Data model
|
||||
|
||||
SQLite via `better-sqlite3`, schema created on boot in `backend/src/db/index.ts`.
|
||||
|
||||
- [employees](./employees.md) - the whitelist of employee emails, with a soft-delete active flag
|
||||
- [attendance_events](./attendance-events.md) - append-only clock-in/out/break log and the derived state machine
|
||||
Reference in New Issue
Block a user