Files
Orchestrator/.kiro/specs/jury-voting/design.md
2026-06-30 20:37:40 +02:00

16 KiB
Raw Permalink Blame History

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 (110 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 (110)
  • 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;       // 110
  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 110), 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:

  1. Team completion status computation (pure function)
  2. Offline queue sync logic (mock server)
  3. Last-write-wins resolution (pure function)
  4. Score aggregation (pure function — sum and average)
  5. Ranking with ties (pure function — sort + rank assignment)
  6. Session gate logic (pure function — accept/reject based on status)
  7. Progress privacy (API response shape validation)
  8. Export round-trip (generate scores → export CSV → parse → compare)
  9. 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)