16 KiB
Design Document: Jury Voting
Overview
The Jury Voting app is a standalone web application that digitizes the paper-based evaluation process for the 15. UNIKAT Ideenwettbewerb. It enables 6 jurors to score 11 teams on 5 criteria (1–10 each) via their smartphones, while a beamer/projector view shows live-updating rankings to the audience.
The system is designed for a single-event use case with minimal infrastructure requirements — deployable on a laptop connected to the projector, or a simple server. The architecture prioritizes simplicity, reliability, and a premium visual experience matching the Projekt-KIQ-HP design language.
Key design decisions:
- Monorepo structure: Single
Jury-Voting/directory containing both frontend and backend - Express.js + JSON file backend: Lightweight server with file-based persistence (no database needed for 6 jurors × 11 teams)
- Server-Sent Events (SSE) for real-time updates to the beamer view — simpler than WebSockets, no library needed, unidirectional (server → client) which is exactly what the results view needs
- Optimistic UI with local fallback: Scores are saved locally first, then synced to server. Handles brief connectivity loss gracefully.
- No authentication complexity: Simple name-based juror selection (no passwords) — appropriate for a trusted, in-person event
Architecture
graph TB
subgraph "Client Devices"
J1[Juror Phone 1]
J2[Juror Phone 2]
J6[Juror Phone 6]
B[Beamer Browser]
A[Admin Laptop]
end
subgraph "Server (single process)"
API[Express.js REST API]
SSE[SSE Endpoint]
FS[JSON File Store]
end
J1 -->|POST /api/scores| API
J2 -->|POST /api/scores| API
J6 -->|POST /api/scores| API
A -->|Admin API calls| API
API -->|Read/Write| FS
API -->|Notify| SSE
SSE -->|EventStream| B
SSE -->|EventStream| A
Architecture Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Backend | Express.js | Minimal setup, same language as frontend, easy to bundle |
| Persistence | JSON file | 6×11×5 = 330 score cells — trivial data volume, no DB overhead |
| Real-time | SSE (Server-Sent Events) | Unidirectional push is sufficient; no WebSocket library needed |
| Frontend | React 19 + Vite | Matches Projekt-KIQ-HP stack, fast HMR for development |
| Styling | CSS variables + glassmorphism | Reuses KIQ-HP design tokens, no CSS framework needed |
| Deployment | docker-compose OR npm run dev |
Flexible: production-like or quick laptop setup |
| Offline handling | localStorage queue | Scores saved locally, synced on reconnect |
Deployment Topology
graph LR
subgraph "Event Setup (laptop or server)"
V[Vite Dev Server :5173]
E[Express API :3001]
end
subgraph "Network (local WiFi)"
P1[Phone 1] --> V
P6[Phone 6] --> V
BM[Beamer PC] --> V
end
V --> E
For production, Vite builds static assets served by Express on a single port. For development, Vite proxies API calls to Express.
Components and Interfaces
Route Structure
| Route | Component | Purpose |
|---|---|---|
/ |
JurorSelect |
Juror name selection (landing page) |
/vote |
VotingDashboard |
Team list with scoring status |
/vote/:teamId |
TeamScoring |
Score input for a specific team |
/results |
ResultsView |
Beamer-optimized live rankings |
/admin |
AdminPanel |
Session management, progress, export |
Frontend Component Tree
graph TD
App --> Router
Router --> JurorSelect
Router --> VotingDashboard
Router --> TeamScoring
Router --> ResultsView
Router --> AdminPanel
VotingDashboard --> TeamCard[TeamCard × 11]
TeamScoring --> ScoreSlider[ScoreSlider × 5]
TeamScoring --> TeamHeader
ResultsView --> RankingTable
ResultsView --> SessionStatus
AdminPanel --> ProgressMatrix
AdminPanel --> SessionControls
AdminPanel --> ExportButton
Key Components
JurorSelect
- Displays 6 juror names as large tap targets (glass-panel cards)
- On selection, stores juror identity in localStorage and navigates to
/vote - Shows warning if juror is already active in another session
VotingDashboard
- Lists all 11 teams as cards
- Each card shows: team name, description snippet, scoring status (✓ complete / partial / not started)
- Tap navigates to
/vote/:teamId
TeamScoring
- Shows team name + description at top
- 5 score inputs (one per criterion), each a slider or number stepper (1–10)
- Auto-saves on change (debounced 300ms)
- Visual confirmation on save (green flash)
- Navigation: prev/next team buttons
ResultsView (Beamer)
- Full-screen, no navigation chrome
- Animated ranking table with team names, scores, ranks
- Updates via SSE — smooth transitions when scores change
- Shows "Voting in Progress" or "Final Results" status
- Designed for 1920×1080 landscape
AdminPanel
- Session controls: Start/End voting session
- Progress matrix: 6 jurors × 11 teams grid showing completion
- Export button (CSV download)
- Juror/team configuration (pre-populated)
API Endpoints
| Method | Endpoint | Purpose |
|---|---|---|
GET |
/api/session |
Get current session state |
POST |
/api/session/start |
Start voting session |
POST |
/api/session/end |
End voting session |
GET |
/api/teams |
List all teams |
GET |
/api/jurors |
List all jurors |
GET |
/api/scores |
Get all scores (admin) |
GET |
/api/scores/:jurorId |
Get scores for a juror |
POST |
/api/scores |
Submit/update a score |
GET |
/api/results |
Get aggregated results |
GET |
/api/results/stream |
SSE endpoint for live updates |
GET |
/api/export/csv |
Download CSV export |
GET |
/api/progress |
Get juror completion matrix |
Score Submission Payload
{
"jurorId": "finke",
"teamId": "dual",
"criterionId": "gesamtidee",
"score": 8
}
SSE Event Format
{
"event": "scores_updated",
"data": {
"results": [
{ "teamId": "dual", "teamName": "DUAL", "total": 187, "average": 31.2, "rank": 1 }
],
"timestamp": "2026-05-19T18:30:00Z"
}
}
Data Models
Session
interface Session {
id: string;
status: 'setup' | 'active' | 'ended';
startedAt: string | null; // ISO timestamp
endedAt: string | null; // ISO timestamp
config: SessionConfig;
}
interface SessionConfig {
criteria: Criterion[];
scaleMin: number; // default: 1
scaleMax: number; // default: 10
}
Team
interface Team {
id: string; // kebab-case slug, e.g. "dual"
number: number; // original number from submission
name: string; // display name, e.g. "DUAL"
description: string; // short description
contact?: string; // contact person (optional)
}
Juror
interface Juror {
id: string; // lowercase name, e.g. "finke"
name: string; // display name, e.g. "Finke"
active: boolean; // currently has an active session
}
Criterion
interface Criterion {
id: string; // kebab-case, e.g. "gesamtidee"
nameDE: string; // German name, e.g. "Gesamtidee"
nameEN: string; // English name, e.g. "Overall Idea"
}
Score
interface Score {
jurorId: string;
teamId: string;
criterionId: string;
value: number; // 1–10
updatedAt: string; // ISO timestamp
}
Aggregated Result
interface TeamResult {
teamId: string;
teamName: string;
total: number; // sum of all juror scores across all criteria
average: number; // total / number of jurors who scored
rank: number; // 1-based, ties get same rank
jurorCount: number; // how many jurors have scored this team
complete: boolean; // all 6 jurors scored all 5 criteria
}
JSON File Structure (persistence)
{
"session": { "id": "unikat-2026", "status": "active", "startedAt": "...", "endedAt": null, "config": { ... } },
"teams": [ ... ],
"jurors": [ ... ],
"scores": [
{ "jurorId": "finke", "teamId": "dual", "criterionId": "gesamtidee", "value": 8, "updatedAt": "..." }
]
}
The entire state fits in a single JSON file (~50KB max). The server reads/writes this file atomically on each score submission.
Score Aggregation Logic
Per team:
total = SUM(score.value) for all scores where score.teamId == team.id
jurorCount = COUNT(DISTINCT score.jurorId) where score.teamId == team.id AND all 5 criteria submitted
average = total / jurorCount (or total / 6 if all jurors scored)
Ranking:
Sort teams by total DESC
Assign rank 1, 2, 3... with ties getting the same rank (dense ranking)
Example: scores [250, 240, 240, 230] → ranks [1, 2, 2, 4]
Offline Queue (localStorage)
interface PendingScore {
jurorId: string;
teamId: string;
criterionId: string;
value: number;
timestamp: string;
synced: boolean;
}
When offline, scores are pushed to a pendingScores array in localStorage. On reconnect, the app replays them to the server in order. The server uses updatedAt to resolve conflicts (last-write-wins).
Correctness Properties
A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.
Property 1: Team Completion Status
For any juror and any set of submitted scores, a team's completion status SHALL be "complete" if and only if exactly 5 scores exist (one per criterion) for that juror-team pair, "partial" if between 1 and 4 scores exist, and "pending" if zero scores exist.
Validates: Requirements 2.4, 7.1
Property 2: Offline Queue Preservation
For any sequence of scores submitted while offline, after synchronization completes, the server state SHALL contain all scores from the offline queue with their original values and timestamps preserved.
Validates: Requirements 3.2
Property 3: Last-Write-Wins Score Resolution
For any sequence of score updates to the same (jurorId, teamId, criterionId) triple, the final persisted value SHALL equal the value of the most recent update (by timestamp).
Validates: Requirements 3.3
Property 4: Score Aggregation Correctness
For any team and any set of valid scores (each 1–10), the total score SHALL equal the arithmetic sum of all individual score values, and the average SHALL equal the total divided by the number of distinct jurors who have submitted at least one score for that team.
Validates: Requirements 4.2, 4.3
Property 5: Ranking Correctness with Ties
For any set of team totals, the ranking SHALL be sorted in descending order by total score, and any two teams with equal total scores SHALL receive the same rank number.
Validates: Requirements 4.4, 4.5
Property 6: Session Gate
For any score submission attempt, the system SHALL accept the score if and only if the current session status is "active". Submissions when session status is "setup" or "ended" SHALL be rejected.
Validates: Requirements 6.4, 6.5
Property 7: Progress Privacy
For any call to the progress endpoint, the response SHALL contain juror completion counts (number of fully-scored teams per juror) but SHALL NOT contain any individual score values.
Validates: Requirements 7.3
Property 8: Export Round-Trip
For any complete set of scores, exporting to CSV and parsing the result SHALL recover: all individual scores per (juror, team, criterion), correct aggregated totals, correct averages, correct rankings, with teams as rows and (juror × criterion) as columns.
Validates: Requirements 8.1, 8.2, 8.3, 8.4
Property 9: Results Display Completeness
For any TeamResult object, the rendered results view SHALL include the team name, total score, average score, and current rank.
Validates: Requirements 5.4
Error Handling
Network Errors (Juror Phones)
| Scenario | Handling |
|---|---|
| Score submission fails (timeout/5xx) | Store in localStorage queue, show subtle "offline" indicator, retry on reconnect |
| Server unreachable | Switch to offline mode, continue scoring locally, sync when back |
| Partial sync failure | Retry individual failed scores with exponential backoff (max 3 retries) |
Data Validation Errors
| Scenario | Handling |
|---|---|
| Score out of range (< 1 or > 10) | Reject at client and server, show validation error |
| Unknown jurorId or teamId | Return 400 with descriptive error message |
| Score submission when session not active | Return 403 with "Session not active" message |
| Duplicate score without overwrite | Return 409 with current value, client prompts for confirmation |
Server Errors
| Scenario | Handling |
|---|---|
| JSON file write failure | Return 500, log error, scores remain in client queue |
| JSON file corruption | Keep backup copy (.bak), restore from backup on startup |
| SSE connection dropped | Client auto-reconnects with EventSource built-in retry |
Admin Errors
| Scenario | Handling |
|---|---|
| End session with incomplete scores | Show warning with progress summary, require confirmation |
| Export with no scores | Return empty CSV with headers only |
Testing Strategy
Property-Based Testing
Library: fast-check (JavaScript/TypeScript PBT library)
Property-based tests will validate the 9 correctness properties defined above. Each test runs a minimum of 100 iterations with randomly generated inputs.
Test configuration:
fc.assert(fc.property(...), { numRuns: 100 })
Tag format: Each test file includes a comment referencing the design property:
// Feature: jury-voting, Property 4: Score Aggregation Correctness
Properties to test:
- Team completion status computation (pure function)
- Offline queue sync logic (mock server)
- Last-write-wins resolution (pure function)
- Score aggregation (pure function — sum and average)
- Ranking with ties (pure function — sort + rank assignment)
- Session gate logic (pure function — accept/reject based on status)
- Progress privacy (API response shape validation)
- Export round-trip (generate scores → export CSV → parse → compare)
- Results display completeness (render → check fields present)
Unit Tests (Example-Based)
Unit tests cover specific scenarios, edge cases, and integration points:
- JurorSelect: Renders 6 names, handles selection, shows conflict warning
- TeamScoring: Renders 5 criteria, auto-saves on change, shows confirmation
- VotingDashboard: Shows correct status icons for complete/partial/pending teams
- ResultsView: Shows "Voting in Progress" vs "Final Results" based on session state
- AdminPanel: Session start/end controls, export button
- API endpoints: Correct HTTP status codes, validation errors, auth checks
- SSE: Connection, reconnection, event parsing
Integration Tests
- Full scoring flow: juror selects name → scores a team → results update
- Offline/online cycle: score while offline → reconnect → verify sync
- Session lifecycle: create → start → score → end → export
- Concurrent jurors: 6 simultaneous score submissions
Test Structure
Jury-Voting/
├── src/
│ ├── __tests__/ # Unit tests (co-located)
│ └── ...
├── server/
│ ├── __tests__/ # Server unit tests
│ └── ...
└── tests/
├── properties/ # Property-based tests
│ ├── aggregation.property.test.ts
│ ├── ranking.property.test.ts
│ ├── completion.property.test.ts
│ ├── session-gate.property.test.ts
│ ├── offline-queue.property.test.ts
│ ├── last-write-wins.property.test.ts
│ ├── progress-privacy.property.test.ts
│ ├── export-roundtrip.property.test.ts
│ └── results-display.property.test.ts
└── integration/ # Integration tests
├── scoring-flow.test.ts
└── session-lifecycle.test.ts
Test Runner
- Vitest — fast, Vite-native, supports TypeScript, works with fast-check
- Run:
npx vitest --run(single execution, no watch mode)