docs: move plan, spec, and design handoff from FPTutor repo
This commit is contained in:
243
docs/specs/2026-04-27-attendance-tracking-design.md
Normal file
243
docs/specs/2026-04-27-attendance-tracking-design.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# Attendance Tracking Tool — Design Spec
|
||||
|
||||
**Date:** 2026-04-27
|
||||
**Status:** Approved for implementation
|
||||
|
||||
## Context
|
||||
|
||||
FPTutor is used to manage a functional programming tutoring course (~19 students, Thursdays). Students earn 3 bonus points per attended Projektstunde. Currently attendance is tracked on paper sheets; this tool replaces and extends that.
|
||||
|
||||
The tool is designed to be reusable across future semesters and other tutorien.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Stack
|
||||
|
||||
- **Backend:** Rust + Axum, `sqlx` (SQLite), JWT auth for tutors
|
||||
- **Frontend:** SvelteKit with `adapter-static` (SPA, served by Axum)
|
||||
- **Database:** SQLite via a Kubernetes PersistentVolumeClaim
|
||||
- **Deployment:** Single container on a k8s namespace, `tutor.puchstein.dev`
|
||||
|
||||
Single binary + static files, one container, one PVC. No Node server at runtime — minimizes node load. SQLite keeps the footprint small and makes end-of-semester teardown trivial.
|
||||
|
||||
Axum must serve `index.html` as fallback for all non-`/api` routes so that SvelteKit client-side routing works on direct navigation or page refresh.
|
||||
|
||||
### Repository layout
|
||||
|
||||
```
|
||||
tools/attendance/
|
||||
├── backend/ # Rust/Axum
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs
|
||||
│ │ ├── db.rs # sqlx pool setup, migrations
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── admin.rs # tutor-facing endpoints
|
||||
│ │ │ ├── checkin.rs # student-facing endpoints
|
||||
│ │ │ └── export.rs # CSV, Markdown, SQLite backup
|
||||
│ │ └── auth.rs # JWT middleware
|
||||
│ └── Cargo.toml
|
||||
├── frontend/ # SvelteKit
|
||||
│ ├── src/
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── admin/ # tutor panel
|
||||
│ │ │ └── s/[code]/ # student check-in
|
||||
│ │ └── lib/
|
||||
│ └── svelte.config.js # adapter-static
|
||||
└── k8s/
|
||||
├── deployment.yaml
|
||||
├── service.yaml
|
||||
├── ingress.yaml
|
||||
├── pvc.yaml
|
||||
└── cronjob.yaml # daily SQLite backup, retains last 7
|
||||
```
|
||||
|
||||
Visual/frontend design is handled separately via Claude Design — this spec covers structure and flows only.
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
```sql
|
||||
-- Reusable across semesters and courses
|
||||
CREATE TABLE courses (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
semester TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Tutors are global accounts; course assignment via tutor_courses join table
|
||||
CREATE TABLE tutors (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Many-to-many: one tutor can manage multiple courses across semesters
|
||||
CREATE TABLE tutor_courses (
|
||||
tutor_id INTEGER REFERENCES tutors(id),
|
||||
course_id INTEGER REFERENCES courses(id),
|
||||
PRIMARY KEY (tutor_id, course_id)
|
||||
);
|
||||
|
||||
CREATE TABLE students (
|
||||
id INTEGER PRIMARY KEY,
|
||||
course_id INTEGER NOT NULL REFERENCES courses(id),
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Room layout stored as JSON for flexibility
|
||||
-- layout_json: [{id, label, x, y, width, height, type}]
|
||||
-- type: "seat" | "table" | "gap" | "door"
|
||||
CREATE TABLE rooms (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
layout_json TEXT NOT NULL -- JSON array of layout elements
|
||||
);
|
||||
|
||||
-- A week unit covering all parallel slots that week
|
||||
-- UNIQUE ensures one session per course per week
|
||||
CREATE TABLE sessions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
course_id INTEGER NOT NULL REFERENCES courses(id),
|
||||
week_nr INTEGER NOT NULL,
|
||||
date TEXT NOT NULL CHECK (date GLOB '????-??-??'), -- ISO 8601 YYYY-MM-DD
|
||||
UNIQUE(course_id, week_nr)
|
||||
);
|
||||
|
||||
-- One parallel slot per session (e.g. 14-15h room A, 17-18h room B)
|
||||
-- room_id is nullable: manual/paper-only slots have no layout
|
||||
-- status lives here so individual slots can be opened/closed independently
|
||||
CREATE TABLE slots (
|
||||
id INTEGER PRIMARY KEY,
|
||||
session_id INTEGER NOT NULL REFERENCES sessions(id),
|
||||
room_id INTEGER REFERENCES rooms(id), -- NULL = no layout (manual entry only)
|
||||
tutor_id INTEGER NOT NULL REFERENCES tutors(id),
|
||||
start_time TEXT NOT NULL, -- HH:MM
|
||||
end_time TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'closed', -- closed | open | locked
|
||||
code TEXT UNIQUE -- short alphanumeric, set when status → open
|
||||
);
|
||||
|
||||
-- Attendance record
|
||||
-- seat_id NULL = no seat assigned (manual entry or slot without layout)
|
||||
-- UNIQUE prevents double check-in at the DB level
|
||||
CREATE TABLE attendances (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slot_id INTEGER NOT NULL REFERENCES slots(id),
|
||||
student_id INTEGER NOT NULL REFERENCES students(id),
|
||||
seat_id TEXT, -- layout element id, NULL if manual or no layout
|
||||
checked_in_at TEXT NOT NULL CHECK (checked_in_at GLOB '????-??-??T??:??:??*'), -- ISO 8601 datetime
|
||||
UNIQUE(slot_id, student_id),
|
||||
UNIQUE(slot_id, seat_id) -- FCFS seat lock (SQLite NULLs are not deduplicated,
|
||||
-- so multiple NULL seat_ids are allowed — intentional for manual entries)
|
||||
);
|
||||
|
||||
-- SQLite does not enforce foreign keys by default.
|
||||
-- The backend must issue `PRAGMA foreign_keys = ON` on every connection from the sqlx pool.
|
||||
|
||||
-- Tutor notes per student per slot (visible only to tutors, not students)
|
||||
CREATE TABLE notes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slot_id INTEGER NOT NULL REFERENCES slots(id),
|
||||
student_id INTEGER NOT NULL REFERENCES students(id),
|
||||
tutor_id INTEGER NOT NULL REFERENCES tutors(id),
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL CHECK (updated_at GLOB '????-??-??T??:??:??*'),
|
||||
UNIQUE(slot_id, student_id, tutor_id)
|
||||
);
|
||||
```
|
||||
|
||||
**Key decisions:**
|
||||
- `status` is on `slots`, not `sessions` — tutors can open the 14h slot before the 17h slot is ready.
|
||||
- `manual` boolean removed — `seat_id IS NULL` is the canonical signal for a non-digital entry.
|
||||
- `tutors.course_id` replaced by `tutor_courses` join table for cross-semester reuse.
|
||||
- `UNIQUE(slot_id, student_id)` prevents double check-in; backend uses INSERT + explicit conflict handling.
|
||||
- `UNIQUE(slot_id, seat_id)` enforces FCFS at the DB level; concurrent requests get a constraint error, backend returns HTTP 409 + "seat taken, please choose another" to the client.
|
||||
- `slots.code` and `slots.status` must be set atomically in a single `UPDATE slots SET status = 'open', code = ? WHERE id = ?`. The backend refuses to serve a check-in page for any slot where `status = 'open'` but `code IS NULL`.
|
||||
- For layout-bearing slots (`room_id IS NOT NULL`), the backend rejects `seat_id = NULL` submissions at the application layer — the DB NULL-deduplication behaviour cannot enforce this.
|
||||
- **Seat change:** A student may change their seat while the slot is `open`. The backend deletes the existing attendance row for `(slot_id, student_id)` then inserts the new one atomically in a transaction. The previously held seat becomes free immediately. Once the slot is `locked`, no changes are possible.
|
||||
- **Cookie trust:** Cookies are unsigned in the initial implementation — accepted risk for a small in-person group where the tutor physically observes the room. Tutor must cross-check the seat map against visible students before locking. The `checkin.rs` auth layer is designed for a drop-in HMAC replacement without further changes.
|
||||
|
||||
Rooms are created independently of sessions and can be reused across semesters. The student dropdown on the check-in page is filtered by the slot's course, preventing cross-course name leakage.
|
||||
|
||||
---
|
||||
|
||||
## Check-in Code
|
||||
|
||||
- Generated when a tutor sets a slot to `open`.
|
||||
- 8-character alphanumeric (upper-case, no ambiguous chars like 0/O/1/I): ~2.8 trillion combinations — collision risk negligible for this scale.
|
||||
- Unique globally (enforced by `slots.code UNIQUE`).
|
||||
- Code is valid as long as `slots.status = 'open'`. Once locked or closed, the URL returns the seat map in read-only mode so returning students can still see their own seat highlighted.
|
||||
|
||||
---
|
||||
|
||||
## UI Flows
|
||||
|
||||
### Tutor Admin Panel (`/admin`)
|
||||
|
||||
Requires JWT login.
|
||||
|
||||
| Section | Actions |
|
||||
|---|---|
|
||||
| Dashboard | List of all slots with status badge; per-slot open/close/lock toggle; copy check-in link |
|
||||
| Courses & Students | Create course, add/import students (manual or CSV upload) |
|
||||
| Rooms | Create room, open layout editor (drag-and-drop seats onto canvas); rooms are course-independent |
|
||||
| Sessions | Create weekly session (course + week_nr + date), add slots (room, tutor, time) |
|
||||
| Attendance | Per-week table, per-student table, manual entry for any slot, export |
|
||||
| Notes | Live seat map per slot (tutor-only, names visible); click student → inline note editor (upsert); notes appear in per-student and per-week views |
|
||||
| Backups | Download current SQLite file; automatic daily backup via a Kubernetes CronJob (`k8s/cronjob.yaml`) that copies the DB to `backup-YYYY-MM-DD.sqlite` on the PVC and prunes files older than 7 days |
|
||||
|
||||
### Student Check-in (`/s/{code}`)
|
||||
|
||||
No login required.
|
||||
|
||||
1. Tutor opens a slot → `code` generated → tutor projects `tutor.puchstein.dev/s/{code}` on the beamer.
|
||||
2. Student opens URL on phone/laptop.
|
||||
3. If no cookie for this code: student selects their name from a dropdown (names filtered to the slot's course only).
|
||||
4. Cookie `attendance_identity` set: `{ code, student_id }`, `httpOnly`, `SameSite=Strict`, 24h expiry. Scoping to `code` means each week starts fresh without requiring cookie clearing.
|
||||
5. Room layout renders: free seats selectable; occupied seats shown as grey — no name visible (privacy). Student's own seat (if already checked in) highlighted.
|
||||
6. Student clicks a seat → `POST /api/checkin` → backend attempts insert:
|
||||
- Success → seat locked, confirmation shown.
|
||||
- HTTP 409 (seat taken) → UI prompts student to pick another seat.
|
||||
7. If slot is `locked` or `closed`, the page is read-only: own seat highlighted if checked in, otherwise "check-in not available" message.
|
||||
|
||||
### Backward Compatibility (Week 1 Paper List)
|
||||
|
||||
Tutor creates the week-1 session and its slots with `room_id = NULL` (no layout). Attendance is entered manually per student in the admin panel. `seat_id = NULL` for all entries.
|
||||
|
||||
---
|
||||
|
||||
## Export
|
||||
|
||||
All exports are scoped to a course.
|
||||
|
||||
| Export | Format | Scope |
|
||||
|---|---|---|
|
||||
| Weekly attendance | CSV, Markdown | One session — all slots merged, student list with present/absent |
|
||||
| Full course matrix | CSV, Markdown | All sessions × all students; `Bonus` column = `3` if unexcused absences ≤ 1, else `0` |
|
||||
| SQLite backup | `.sqlite` file download | Full database via HTTP stream |
|
||||
|
||||
Markdown exports are structured for `pandoc` conversion to PDF.
|
||||
|
||||
**Bonus rule:** A student earns 3 bonus points if their unexcused absences across all sessions ≤ 1. An "unexcused absence" is any session where the student has no attendance record for any of its slots. Students with 2+ unexcused absences receive 0 bonus points.
|
||||
|
||||
---
|
||||
|
||||
## Privacy & Extensibility
|
||||
|
||||
- Student names are never shown on occupied seats — only the seat owner sees their own name (via cookie).
|
||||
- Cookie contains only `student_id` (opaque integer); no PII in the cookie value.
|
||||
- The auth layer (cookie → student_id) is isolated in `checkin.rs` so a future HMAC-signed token, short PIN, or session-scoped QR code can replace the trust-based flow without touching the rest of the system.
|
||||
- The student dropdown only shows students belonging to the slot's course — no cross-course name leakage.
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Visual/frontend design (handled via Claude Design)
|
||||
- Real-time seat map sync (polling on page load is sufficient for ~20 students)
|
||||
- Email notifications
|
||||
- Grade calculation (grader tool handles assignment feedback separately)
|
||||
Reference in New Issue
Block a user