Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.pdf
|
||||
*.doc
|
||||
*.xlsx
|
||||
Teams/
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
name: Deploy Jury Voting
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build frontend
|
||||
env:
|
||||
NODE_ENV: production
|
||||
run: npm run build
|
||||
|
||||
- name: Create deployment archive
|
||||
run: |
|
||||
tar czf deploy.tar.gz \
|
||||
--exclude='*.pdf' \
|
||||
--exclude='*.doc' \
|
||||
--exclude='*.xlsx' \
|
||||
--exclude='Teams' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='.git' \
|
||||
dist/ \
|
||||
server/ \
|
||||
shared/ \
|
||||
package.json \
|
||||
package-lock.json \
|
||||
jury-voting.service \
|
||||
tsconfig.json \
|
||||
tsconfig.node.json
|
||||
|
||||
- name: Deploy to VPS
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: 217.160.174.2
|
||||
username: root
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
mkdir -p /opt/jury-voting/server/data
|
||||
|
||||
- name: Upload archive
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: 217.160.174.2
|
||||
username: root
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
source: deploy.tar.gz
|
||||
target: /tmp/
|
||||
|
||||
- name: Extract and start service
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: 217.160.174.2
|
||||
username: root
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
set -e
|
||||
|
||||
# Install Node.js if not present
|
||||
if ! command -v node &> /dev/null; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y nodejs
|
||||
fi
|
||||
|
||||
# Extract archive (preserve session.json)
|
||||
cd /opt/jury-voting
|
||||
tar xzf /tmp/deploy.tar.gz --overwrite
|
||||
rm -f /tmp/deploy.tar.gz
|
||||
|
||||
# Install production dependencies
|
||||
npm ci --omit=dev
|
||||
|
||||
# Seed data if first run
|
||||
if [ ! -f server/data/session.json ]; then
|
||||
cp server/data/seed.json server/data/session.json
|
||||
fi
|
||||
|
||||
# Install and restart systemd service
|
||||
cp jury-voting.service /etc/systemd/system/jury-voting.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable jury-voting
|
||||
systemctl restart jury-voting
|
||||
|
||||
# Verify
|
||||
sleep 2
|
||||
curl -sf http://localhost:3002/api/health && echo "Deploy successful!" || (journalctl -u jury-voting -n 20 --no-pager && exit 1)
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
server/data/session.json
|
||||
*.bak
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,48 @@
|
||||
# Deployment
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Push to `main` → GitHub Actions builds frontend + deploys to VPS (217.160.174.2)
|
||||
2. App runs as systemd service `jury-voting` on port 3002
|
||||
3. Caddy (managed via NoteGraph/Caddyfile) proxies `unikat.andreknie.de` → `localhost:3002`
|
||||
4. Score data persists in `/opt/jury-voting/server/data/session.json`
|
||||
|
||||
## DNS Setup (one-time)
|
||||
|
||||
Add an A record: `unikat.andreknie.de` → `217.160.174.2`
|
||||
|
||||
Caddy handles HTTPS automatically via Let's Encrypt.
|
||||
|
||||
## GitHub Secret
|
||||
|
||||
- `DEPLOY_SSH_KEY` — SSH key for root@217.160.174.2 (already configured)
|
||||
|
||||
## URLs
|
||||
|
||||
- **Login:** `https://unikat.andreknie.de/?token=UNIKAT_jury`
|
||||
- **Results (beamer):** `https://unikat.andreknie.de/results`
|
||||
- **Admin:** `https://unikat.andreknie.de/admin`
|
||||
- **Password:** `UNIKAT_jury`
|
||||
|
||||
## Future: Move to d-hive.de
|
||||
|
||||
When ready to move to `unikat.d-hive.de`:
|
||||
1. Add DNS A record for `unikat.d-hive.de` → VPS IP
|
||||
2. Add `unikat.d-hive.de` block to Caddyfile (same as `unikat.andreknie.de`)
|
||||
|
||||
## Manual Operations (on VPS)
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
systemctl status jury-voting
|
||||
|
||||
# View logs
|
||||
journalctl -u jury-voting -f
|
||||
|
||||
# Restart
|
||||
systemctl restart jury-voting
|
||||
|
||||
# Reset all scores (start fresh)
|
||||
rm /opt/jury-voting/server/data/session.json
|
||||
systemctl restart jury-voting
|
||||
```
|
||||
@@ -0,0 +1,22 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build frontend
|
||||
RUN npm run build
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
# Set production environment
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Start server
|
||||
CMD ["node", "--import", "tsx", "server/index.ts"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,282 @@
|
||||
# UNIKAT Ideenwettbewerb 2026 – Executive Summary & Erstbewertung
|
||||
|
||||
## Bewertungskategorien (aus dem Bewertungsbogen)
|
||||
|
||||
| Nr. | Kategorie | Beschreibung |
|
||||
|-----|-----------|--------------|
|
||||
| 1 | Gesamtidee | Originalität, Innovationsgrad, Klarheit der Idee |
|
||||
| 2 | Kundennutzen | Konkreter Mehrwert für die Zielgruppe, Problemlösung |
|
||||
| 3 | Markt | Marktpotenzial, Wettbewerbsanalyse, Positionierung |
|
||||
| 4 | Realisierbarkeit | Technische/wirtschaftliche Machbarkeit, Kompetenzen, Umsetzungsplan |
|
||||
| 5 | Präsentation | Qualität der schriftlichen Darstellung (Ideenskizze) |
|
||||
|
||||
**Skala: 1–10 Punkte pro Kategorie (10 = herausragend)**
|
||||
|
||||
---
|
||||
|
||||
## 1. Second Nose (Nr. 18)
|
||||
|
||||
**Gründer:in:** Dr. Mia Reitz, Postdoktorandin Universität Kassel (FB16)
|
||||
|
||||
### Zusammenfassung
|
||||
Digitale Geruchserkennung als kompakter USB-C-Dongle fürs Smartphone. Ein Umweltsensor (Bosch BME688) misst flüchtige organische Verbindungen (VOCs/VSCs), eine vortrainierte KI auf dem Smartphone klassifiziert den Geruch in Echtzeit. Zielgruppe: Menschen mit Anosmie/Hyposmie (~5% der Bevölkerung), Smart-Health-Enthusiasten, Privathaushalte.
|
||||
|
||||
### Bewertung
|
||||
|
||||
| Kategorie | Punkte | Begründung |
|
||||
|-----------|--------|------------|
|
||||
| Gesamtidee | 8 | Hochinnovativ, klare Marktlücke, emotionaler Mehrwert. Kombination aus günstiger Sensorik + Edge-KI auf Smartphone ist neuartig im Consumer-Bereich. |
|
||||
| Kundennutzen | 8 | Starker Inklusionsaspekt für Betroffene von Riechstörungen. Praktischer Alltagsnutzen (Lebensmittelfrische, Körpergeruch). |
|
||||
| Markt | 7 | Kein direkter Wettbewerber im Consumer-Segment. Markt durch COVID-Nachwirkungen gewachsen. Allerdings: Nischenmarkt, Skalierung unklar. |
|
||||
| Realisierbarkeit | 6 | Konzept- und Prototypingphase. Sensor existiert, aber KI-Modell-Training und Genauigkeit unter realen Bedingungen sind große Herausforderungen. Kein Team, nur Einzelperson. |
|
||||
| Präsentation | 8 | Sehr gut strukturiert, klare Grafiken (Systemarchitektur, UI-Mockup), wissenschaftlich fundiert. |
|
||||
| **Gesamt** | **37** | |
|
||||
|
||||
---
|
||||
|
||||
## 2. SafeSupply OS (Nr. 21)
|
||||
|
||||
**Gründer:** Jesvin Jaksan, M.Sc. International Food Business & Consumer Studies
|
||||
|
||||
### Zusammenfassung
|
||||
Browserbasierte, offline-fähige Web-App für Lebensmittelsicherheits-Risikomanagement in KMU. Bietet Onboarding-Wizard, Risiko-Score-Engine, What-If-Simulator und Lieferantenverzeichnis. Zielgruppe: Kleine Lebensmittelbetriebe (10–50 MA), Food-Startups, Bio-Manufakturen.
|
||||
|
||||
### Bewertung
|
||||
|
||||
| Kategorie | Punkte | Begründung |
|
||||
|-----------|--------|------------|
|
||||
| Gesamtidee | 6 | Sinnvolle Digitalisierung eines realen Problems. Allerdings eher inkrementelle Innovation – es gibt bereits QM-Software, nur nicht in dieser Leichtgewichtigkeit. |
|
||||
| Kundennutzen | 7 | Klarer Nutzen für KMU ohne QM-Budget. Offline-Fähigkeit und Kostenfreiheit sind starke Argumente. Wissenschaftlich fundierter Risiko-Score. |
|
||||
| Markt | 6 | Markt existiert, aber stark fragmentiert. Freemium-Modell mit 200 zahlenden KMU für Profitabilität ist ambitioniert. Wettbewerb durch kostenlose Alternativen. |
|
||||
| Realisierbarkeit | 7 | Prototyp existiert bereits (V3.6.4, online verfügbar). Einzelperson mit relevanter Fachexpertise. Technisch machbar da rein statische Web-App. |
|
||||
| Präsentation | 6 | Umfangreich aber etwas unstrukturiert. Viele Fachbegriffe, könnte fokussierter sein. |
|
||||
| **Gesamt** | **32** | |
|
||||
|
||||
---
|
||||
|
||||
## 3. DUAL (Nr. 25)
|
||||
|
||||
**Gründer:innen:** Nicole Kozlewski & Sebastian Görs, B.Sc. Architektur (Masterstudierende)
|
||||
|
||||
### Zusammenfassung
|
||||
Vollständig rückbaubares Holztragwerk-Baukastensystem mit identischen L-förmigen Primärelementen und einem zentralen Knotenpunkt. Rein mechanische Verbindungen ermöglichen zerstörungsfreie Demontage und Wiederverwendung. Zielgruppe: Öffentliche Auftraggeber, Kommunen, Projektentwickler (Bildungsbau, Wohnungsbau, temporäres Bauen).
|
||||
|
||||
### Bewertung
|
||||
|
||||
| Kategorie | Punkte | Begründung |
|
||||
|-----------|--------|------------|
|
||||
| Gesamtidee | 9 | Herausragend durchdachtes Konzept. Radikale Reduktion auf ein Teilelement für Stütze UND Träger ist elegant. Kreislaufwirtschaft konsequent zu Ende gedacht. |
|
||||
| Kundennutzen | 7 | Klarer Nutzen für nachhaltiges, flexibles Bauen. Allerdings: Nutzen erst bei Skalierung und nach regulatorischer Zulassung realisierbar. |
|
||||
| Markt | 7 | Wachsender Markt für zirkuläres Bauen, politisch gewollt. Hohe Eintrittsbarrieren (Zulassungen, Brandschutz, statische Nachweise). |
|
||||
| Realisierbarkeit | 5 | Konzeptionell ausgereift, physische Modelle (1:5, 1:10) vorhanden. Aber: Keine statischen Berechnungen, keine Zulassung, kein 1:1-Prototyp. Langer Weg zur Marktreife. |
|
||||
| Präsentation | 9 | Exzellent strukturiert, professionelle Darstellung mit Zeichnungen und klarer Argumentation. Architektonische Qualität spürbar. |
|
||||
| **Gesamt** | **37** | |
|
||||
|
||||
---
|
||||
|
||||
## 4. Vom Lumpen zum Möbel (Nr. 31)
|
||||
|
||||
**Gründer:** Joscha Tschammer, Diplomstudent Produkt-/Möbeldesign, Kunsthochschule Kassel
|
||||
|
||||
### Zusammenfassung
|
||||
Entwicklung eines nachhaltigen Polstermaterials aus Altkleidertextilien durch Weiterentwicklung von Nähwirkdecken. Statt energieintensivem Faser-zu-Faser-Recycling wird die vorhandene Materialstruktur genutzt. Farblich definierte, geschredderte Textilien werden in obere Schichten integriert – das Recycling bleibt sichtbar als Gestaltungsmerkmal.
|
||||
|
||||
### Bewertung
|
||||
|
||||
| Kategorie | Punkte | Begründung |
|
||||
|-----------|--------|------------|
|
||||
| Gesamtidee | 7 | Origineller Ansatz: Materialehrlichkeit statt Kaschierung. Verbindung von Design und Nachhaltigkeit. Allerdings: Nähwirkdecken existieren bereits, Innovation liegt in der ästhetischen Aufwertung. |
|
||||
| Kundennutzen | 6 | Nutzen für nachhaltigkeitsbewusste Konsumenten und Polstereien. Aber: Noch kein fertiges Produkt, Nutzen bleibt abstrakt. |
|
||||
| Markt | 5 | Wachsender Markt für nachhaltige Möbel, aber Nische. Skalierung unklar, technische Prüfungen (Abrieb, Brandschutz) ausstehend. Keine klare Preispositionierung. |
|
||||
| Realisierbarkeit | 5 | Experimentelle Phase, Simulation mit einfachen Maschinen. Industrielle Anlagen benötigt, Finanzierung unklar. Abhängig von externen Partnern (Sächsisches Textilforschungsinstitut). |
|
||||
| Präsentation | 7 | Gut geschrieben, persönliche Geschichte überzeugend. Materialfotos vorhanden. Könnte wirtschaftlich konkreter sein. |
|
||||
| **Gesamt** | **30** | |
|
||||
|
||||
---
|
||||
|
||||
## 5. Doppelrad-Sortierer (Nr. 39)
|
||||
|
||||
**Gründer:** Nicht namentlich genannt, agrarwissenschaftlicher Hintergrund + ML-Expertise, Universität Kassel
|
||||
|
||||
### Zusammenfassung
|
||||
KI-gestützte Saatgutsortiermaschine für Nischenproduzenten und Kleinstbetriebe. Nutzt YOLO-basierte Bilderkennung mit Doppelrad-Prinzip (zwei Perspektiven pro Korn) zur Erkennung von Beschädigungen. Low-Budget-Ansatz: Prototyp unter 200€, Endprodukt unter 1.000€. Funktionsfähiger Prototyp mit Erbsen und Kichererbsen erprobt.
|
||||
|
||||
### Bewertung
|
||||
|
||||
| Kategorie | Punkte | Begründung |
|
||||
|-----------|--------|------------|
|
||||
| Gesamtidee | 8 | Clevere Lösung für ein reales, ungelöstes Problem. Kombination aus mechanischer Kontrolle + KI-Erkennung + Low-Budget ist überzeugend. Doppelrad-Prinzip für zwei Perspektiven ist innovativ. |
|
||||
| Kundennutzen | 8 | Direkter, messbarer Nutzen: Zeitersparnis, Qualitätssteigerung, erschwinglich für Kleinstbetriebe. Erschließt neues Wertschöpfungspotenzial. |
|
||||
| Markt | 7 | Klare Marktlücke zwischen teuren Industrieanlagen und Handarbeit. EU-Biodiversitätsstrategie stärkt Nachfrage. Nischenmarkt, aber substantiell. |
|
||||
| Realisierbarkeit | 8 | Funktionsfähiger Prototyp existiert! 98% Erkennungsrate pro Kamera belegt. Geringe Kosten, vorhandene Uni-Infrastruktur. Klarer Umsetzungsplan. |
|
||||
| Präsentation | 7 | Klar und fokussiert, technisch überzeugend. Könnte visuell stärker sein. |
|
||||
| **Gesamt** | **38** | |
|
||||
|
||||
---
|
||||
|
||||
## 6. Anima Lektor (Nr. 43)
|
||||
|
||||
**Gründer:in:** Nicht namentlich genannt, sprachwissenschaftlicher Hintergrund
|
||||
|
||||
### Zusammenfassung
|
||||
Lektoratsdienstleistung mit Spezialisierung auf Spiele (digital und physisch). Kombination aus professionellem Lektorat und Content-Erstellung (YouTube-Videos zu Sprachthemen). Expertise in mehreren Sprachen, Fokus auf Übersetzungsprobleme, Doppeldeutigkeiten und interkulturelle Referenzen.
|
||||
|
||||
### Bewertung
|
||||
|
||||
| Kategorie | Punkte | Begründung |
|
||||
|-----------|--------|------------|
|
||||
| Gesamtidee | 4 | Klassische Dienstleistung (Lektorat) mit Nischenfokus auf Spiele. Geringe technologische Innovation. YouTube-Kanal als Ergänzung ist nett, aber kein USP. |
|
||||
| Kundennutzen | 5 | Nutzen für Spieleentwickler vorhanden, aber nicht einzigartig. Viele Lektorate bieten ähnliche Dienste. |
|
||||
| Markt | 4 | Spielemarkt wächst, aber Lektorat ist ein gesättigter Dienstleistungsmarkt. Keine klare Differenzierung gegenüber bestehenden Anbietern. KI-Konkurrenz wird erwähnt aber nicht überzeugend adressiert. |
|
||||
| Realisierbarkeit | 5 | Geringe Einstiegshürden (Laptop, Tablet, Lizenzen). Aber: Kein Team, keine konkreten Kunden, keine Finanzierungsvorstellung, fehlende betriebswirtschaftliche Kompetenz eingeräumt. |
|
||||
| Präsentation | 5 | Gut geschrieben (passend zum Thema), aber wenig strukturiert. Keine Zahlen, kein Geschäftsmodell, keine Visualisierungen. |
|
||||
| **Gesamt** | **23** | |
|
||||
|
||||
---
|
||||
|
||||
## 7. Lian's Tempeh Chips (Nr. 45)
|
||||
|
||||
**Gründerin:** Dr. Berlianti Puteri, Ceria Foods and Goods GmbH
|
||||
|
||||
### Zusammenfassung
|
||||
Premium-Tempeh-Chips als neues Snack-Segment im deutschen Markt. Authentisch indonesisch, clean-label, 100% pflanzlich, glutenfrei. GmbH bereits gegründet (September 2025), Produktion gestartet, erster Import nach Deutschland für April 2026 geplant. Drei Geschmacksrichtungen: Original, Barbecue, Seaweed.
|
||||
|
||||
### Bewertung
|
||||
|
||||
| Kategorie | Punkte | Begründung |
|
||||
|-----------|--------|------------|
|
||||
| Gesamtidee | 7 | Solide Produktidee mit klarer Positionierung. Tempeh als Snack-Basis ist neu im deutschen Markt. Allerdings: Lebensmittel-Startup ohne disruptive Technologie. |
|
||||
| Kundennutzen | 7 | Klarer emotionaler + funktionaler Nutzen: Genuss ohne Schuldgefühl, neue Geschmackserfahrung, clean-label. Gut definierte Zielgruppe. |
|
||||
| Markt | 7 | Wachsende Segmente (plant-based, premium, Asian food) werden bedient. Risiko: Consumer-Unfamiliarity mit Tempeh, Preissensitivität. Gute Wettbewerbsanalyse. |
|
||||
| Realisierbarkeit | 8 | Am weitesten fortgeschritten! GmbH gegründet, Produktion läuft, Importplanung konkret. PhD in Consumer Research + Marketing. Starke Gründerin mit relevantem Background. |
|
||||
| Präsentation | 8 | Professionell, klar strukturiert, mit Produktfotos. Englisch verfasst (internationaler Anspruch). Überzeugende Argumentation. |
|
||||
| **Gesamt** | **37** | |
|
||||
|
||||
---
|
||||
|
||||
## 8. Das Any-Thing (Nr. 55)
|
||||
|
||||
**Team:** Simon Ruth (KI-Architektur), Dr. Tim Gollub (Web-Datenanalyse), Prof. Dr. Martin Potthast (Information Retrieval)
|
||||
|
||||
### Zusammenfassung
|
||||
Physischer KI-Automat, der agentische Intelligenz im öffentlichen Raum erfahrbar macht. Modulare Hardware mit Sensoren und Ein-/Ausgabegeräten. KI-Pipeline generiert kontextabhängig neue "Werkzeuge" (Apps/Funktionen), die persistent gespeichert werden. Je nach Aufstellungsort wird das System zu Info-Stand, Arcade-Automat, Kummerkasten etc.
|
||||
|
||||
### Bewertung
|
||||
|
||||
| Kategorie | Punkte | Begründung |
|
||||
|-----------|--------|------------|
|
||||
| Gesamtidee | 9 | Visionär und hochinnovativ. Verbindung von Embodied AI, agentischer App-Generierung und persistenter Werkzeugbibliothek ist einzigartig. Starkes akademisches Fundament. |
|
||||
| Kundennutzen | 6 | Nutzen für Institutionen (Museen, Hochschulen, Festivals) plausibel, aber noch abstrakt. Endnutzer-Nutzen schwer greifbar. "Erlebniswert" ist kein harter Business Case. |
|
||||
| Markt | 6 | Neue Nische (stationäre KI im öffentlichen Raum), niedrige Eintrittsbarrieren behauptet. Aber: Kein validierter Markt, Geschäftsmodell (Event-Buchung, Miete) noch vage. Datenschutz-Risiken. |
|
||||
| Realisierbarkeit | 6 | Software-Prototyp existiert, Erfahrung mit Illumulus (Buchmesse 2024). Aber: Hardware in Konzeptionsphase, öffentlicher Betrieb mit vielen Herausforderungen (Vandalismus, Datenschutz, Zuverlässigkeit). |
|
||||
| Präsentation | 8 | Sehr gut strukturiert, Systemskizze vorhanden, akademisch fundiert. Starkes Team mit komplementären Kompetenzen. |
|
||||
| **Gesamt** | **35** | |
|
||||
|
||||
---
|
||||
|
||||
## 9. ISOmedi (Nr. 59)
|
||||
|
||||
**Gründerin:** Lea Brandenstein
|
||||
|
||||
### Zusammenfassung
|
||||
Kompakte, wiederaufladbare Medikamenten-Transportbox mit passiver Temperaturstabilisierung durch Phase-Change-Material (PCM). Zweiteiliges System: Wochenvorbereitungsbox + kompakte Tagesbox. Taktile Statusverifikation (fest = aktiv kühlend). Kein Elektronik, keine Einwegteile. Zielgruppe: Chronisch Kranke mit aktivem Lebensstil.
|
||||
|
||||
### Bewertung
|
||||
|
||||
| Kategorie | Punkte | Begründung |
|
||||
|-----------|--------|------------|
|
||||
| Gesamtidee | 8 | Elegante Lösung: PCM + taktiles Feedback + kein Elektronik. Dritter Weg zwischen Organisation (Pillendosen) und aktiver Kühlung (Elektronik). Klar differenziert. |
|
||||
| Kundennutzen | 8 | Starker Nutzen für große Zielgruppe (>1/3 EU-Erwachsene mit chronischer Erkrankung). Löst reales Alltagsproblem. Emotionaler Aspekt: Sicherheit und Selbstbestimmung. |
|
||||
| Markt | 7 | Klare Positionierung zwischen Pillendosen und medizinischen Kühlsystemen. Kein direkter Wettbewerber mit dieser Kombination. Großer adressierbarer Markt. |
|
||||
| Realisierbarkeit | 7 | Funktionaler Prototyp vorhanden, thermische Berechnungen durchgeführt. Technisch machbar (keine komplexe Technologie). Nächste Schritte klar. Allerdings: Einzelperson, Produktionspartner fehlen noch. |
|
||||
| Präsentation | 8 | Professionell, mit 3D-Renderings, Explosionszeichnungen und Prototyp-Fotos. Klare Struktur, überzeugende Argumentation. |
|
||||
| **Gesamt** | **38** | |
|
||||
|
||||
---
|
||||
|
||||
## 10. fömo – Fördermittel-Monitor (Nr. 60)
|
||||
|
||||
**Team:** Florian Groh (CTO, B.Sc. Informatik + Erzieher) & Lea Groh (CEO, Germanistik/Soziologie)
|
||||
|
||||
### Zusammenfassung
|
||||
B2B-SaaS-Plattform zur automatisierten Erkennung und Aufbereitung von Fördermittelausschreibungen für soziale Einrichtungen. Vertrieb über Wohlfahrtsverbände. Dreistufige KI-Pipeline (Identifier → Extractor → Validator), Token-basiertes Zugangssystem (kein Login/Passwort), White-Label für Verbände. Prototyp im aktiven Einsatz beim Kulturzentrum Schlachthof Kassel.
|
||||
|
||||
### Bewertung
|
||||
|
||||
| Kategorie | Punkte | Begründung |
|
||||
|-----------|--------|------------|
|
||||
| Gesamtidee | 9 | Exzellent durchdacht. Löst ein konkretes, quantifizierbares Problem. Dreistufige KI-Pipeline + Token-System + White-Label ist eine clevere Kombination. Bewusster Verzicht auf KI-Antragsstellung zeigt Marktverständnis. |
|
||||
| Kundennutzen | 9 | Herausragend: 76% Zeitersparnis belegt, ROI von 1:15 berechnet. Pilotdaten vom Schlachthof Kassel validieren den Nutzen. Explorativer Zugang ermöglicht neue Projektideen. |
|
||||
| Markt | 9 | 6,8 Mio. € adressierbarer Markt klar berechnet. Vertrieb über Verbände ist skalierbar (ein Vertrag = tausende Nutzer). Kein Wettbewerber adressiert diesen Kanal. Letters of Intent vorhanden. |
|
||||
| Realisierbarkeit | 8 | Prototyp im aktiven Einsatz, 900+ Ausschreibungen in DB. Frontend-Mockup wird getestet. Klare Finanzplanung (140k€ Fixkosten, Break-even bei 30k MA). Team mit komplementären Kompetenzen. Mentor + Uni-Netzwerk. |
|
||||
| Präsentation | 9 | Hervorragend: Konkrete Zahlen, klares Geschäftsmodell, Screenshots, Schaubilder. Jede Behauptung mit Daten untermauert. |
|
||||
| **Gesamt** | **44** | |
|
||||
|
||||
---
|
||||
|
||||
## 11. Mobile Sprinkleranlage (Nr. 65)
|
||||
|
||||
**Gründer:** Björn Knoke, Fachplaner für vorbeugenden Brandschutz
|
||||
|
||||
### Zusammenfassung
|
||||
Mobile, temporäre Sprinkleranlage als Kompensationsmaßnahme bei Ausfall stationärer Anlagen, für Brandwachen bei Lithium-Ionen-Akku-Unfällen, im Messebau und Veranstaltungswesen. Patentiert (DE + PCT). Kombination aus Schläuchen, Sprinklerrohren, T-Verteiler mit Rückschlagventil und Druckluftbehälter. Anschluss an vorhandene Wandhydranten.
|
||||
|
||||
### Bewertung
|
||||
|
||||
| Kategorie | Punkte | Begründung |
|
||||
|-----------|--------|------------|
|
||||
| Gesamtidee | 8 | Klare Innovation in einem regulierten Markt. Patent erteilt – bestätigt Neuheit. Vielfältige Einsatzszenarien überzeugend dargestellt. Einfaches, robustes Konzept. |
|
||||
| Kundennutzen | 9 | Enormer wirtschaftlicher Nutzen: Vermeidung von Betriebsausfällen (Monate!), Ersatz teurer Brandwachen, Personenschutz. Jedes Szenario mit klarem Problem-Lösung-Vorteil-Schema. |
|
||||
| Markt | 8 | Kein Wettbewerber (patentbestätigt). Vielfältige Marktsegmente: Industrie, Messebau, Feuerwehr, Veranstaltungen. VDS-Zertifizierungsstelle und vfdb bereits kontaktiert. |
|
||||
| Realisierbarkeit | 6 | Patent vorhanden, Fachtagung geplant. Aber: Kein Prototyp, keine Produktionskompetenz, kein Team (sucht Kooperationspartner für Metallbau, BWL, Marketing). Zertifizierung noch ausstehend. |
|
||||
| Präsentation | 7 | Gut strukturiert mit Problem-Lösung-Tabelle. Fachlich überzeugend. Könnte visueller sein (keine Zeichnungen/Fotos). |
|
||||
| **Gesamt** | **38** | |
|
||||
|
||||
---
|
||||
|
||||
## Gesamtranking
|
||||
|
||||
| Rang | Team | Gesamt | Stärken |
|
||||
|------|------|--------|---------|
|
||||
| 1 | **fömo** | 44 | Validierter Prototyp, quantifizierter Nutzen, klares Geschäftsmodell, starker Marktkanal |
|
||||
| 2 | **Doppelrad-Sortierer** | 38 | Funktionierender Prototyp, klare Marktlücke, Low-Budget, hohe Erkennungsrate |
|
||||
| 2 | **ISOmedi** | 38 | Elegantes Design, großer Markt, Prototyp vorhanden, kein Elektronik-Risiko |
|
||||
| 2 | **Mobile Sprinkleranlage** | 38 | Patent erteilt, kein Wettbewerb, enormer wirtschaftlicher Nutzen |
|
||||
| 5 | **Second Nose** | 37 | Hochinnovativ, starker Inklusionsaspekt, klare Marktlücke |
|
||||
| 5 | **DUAL** | 37 | Brillantes Konstruktionskonzept, Kreislaufwirtschaft, exzellente Präsentation |
|
||||
| 5 | **Lian's Tempeh Chips** | 37 | Am weitesten umgesetzt (GmbH, Produktion), starke Gründerin |
|
||||
| 8 | **Das Any-Thing** | 35 | Visionär, starkes akademisches Team, aber noch zu abstrakt |
|
||||
| 9 | **SafeSupply OS** | 32 | Funktionierender Prototyp, aber inkrementelle Innovation |
|
||||
| 10 | **Vom Lumpen zum Möbel** | 30 | Origineller Designansatz, aber frühe Phase und Skalierung unklar |
|
||||
| 11 | **Anima Lektor** | 23 | Klassische Dienstleistung ohne echte Innovation oder Skalierbarkeit |
|
||||
|
||||
---
|
||||
|
||||
## Begründung der Differenzierung
|
||||
|
||||
### Warum fömo auf Platz 1?
|
||||
fömo hebt sich durch die Kombination aus **validiertem Prototyp im Realeinsatz**, **quantifiziertem ROI** (1:15), **klarem Vertriebskanal** (Wohlfahrtsverbände) und **durchdachtem Geschäftsmodell** ab. Die Pilotdaten vom Schlachthof Kassel liefern harte Zahlen statt Annahmen. Der Vertrieb über Verbände ist ein Multiplikator-Modell: Ein Vertrag erreicht tausende Nutzer. Kein anderes Team hat eine vergleichbar fundierte wirtschaftliche Argumentation.
|
||||
|
||||
### Warum die Plätze 2–4 gleichauf?
|
||||
- **Doppelrad-Sortierer**: Funktionierender Prototyp + unbesetzte Marktlücke + extrem niedrige Kosten. Überzeugt durch technische Validierung (98% Erkennungsrate).
|
||||
- **ISOmedi**: Eleganz der Lösung (keine Elektronik, taktiles Feedback) + riesige Zielgruppe + Prototyp. Besticht durch Einfachheit und Nutzerzentrierung.
|
||||
- **Mobile Sprinkleranlage**: Erteiltes Patent bestätigt Neuheit. Enormer wirtschaftlicher Nutzen pro Einsatz. Aber: Noch kein Prototyp und kein Team bremsen die Realisierbarkeit.
|
||||
|
||||
### Warum Second Nose, DUAL und Tempeh Chips gleichauf?
|
||||
- **Second Nose**: Höchste technologische Innovation, aber noch in Konzeptphase. Einzelperson, Genauigkeit unter Realbedingungen unbewiesen.
|
||||
- **DUAL**: Architektonisch brillant, aber extrem langer Weg zur Marktreife (Zulassungen, statische Nachweise, Brandschutz). Eher Forschungsprojekt als Gründung.
|
||||
- **Lian's Tempeh Chips**: Am weitesten in der Umsetzung (GmbH gegründet, Produktion läuft), aber geringerer Innovationsgrad – es ist ein Lebensmittelprodukt, keine disruptive Technologie.
|
||||
|
||||
### Warum Das Any-Thing trotz Innovationsgrad nur Platz 8?
|
||||
Hochvisionär und akademisch stark, aber der Nutzen bleibt abstrakt. "Erlebniswert" und "Profilierungswert" sind keine harten Business Cases. Hardware noch in Konzeptionsphase, Datenschutz im öffentlichen Raum ist ein erhebliches Risiko. Für einen Ideenwettbewerb mit Gründungsfokus fehlt die wirtschaftliche Konkretisierung.
|
||||
|
||||
### Warum Anima Lektor deutlich abgeschlagen?
|
||||
Es handelt sich um eine klassische Freelance-Dienstleistung (Lektorat) ohne technologische Innovation, ohne Skalierbarkeit und ohne klares Geschäftsmodell. Keine Zahlen, kein Team, keine Finanzierungsvorstellung. Für einen Ideenwettbewerb fehlt der "Ideen"-Anteil – es ist eher ein Berufswunsch als eine Geschäftsidee.
|
||||
|
||||
---
|
||||
|
||||
## Hinweise zur Bewertung
|
||||
|
||||
- Die Kategorie **Präsentation** bezieht sich hier ausschließlich auf die schriftliche Ideenskizze, da die mündliche Präsentation noch nicht stattgefunden hat.
|
||||
- Die Bewertung basiert auf den eingereichten PDF-Dokumenten und berücksichtigt: Innovationsgrad, Validierungsstand (Prototyp vs. Konzept), Marktverständnis, Teamkompetenz und Qualität der Argumentation.
|
||||
- Teams mit funktionierenden Prototypen und validierten Annahmen wurden systematisch höher bewertet als reine Konzepte – unabhängig vom Innovationsgrad der Idee.
|
||||
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
jury-voting:
|
||||
build: .
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- ./server/data:/app/server/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3001
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&display=swap" rel="stylesheet" />
|
||||
<title>UNIKAT Jury Voting</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=UNIKAT Jury Voting App
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/jury-voting
|
||||
ExecStart=/usr/bin/node --import tsx server/index.ts
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=NODE_ENV=production
|
||||
Environment=PORT=3002
|
||||
Environment=APP_PASSWORD=UNIKAT_jury
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Generated
+5142
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "jury-voting",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:server": "tsx watch server/index.ts",
|
||||
"dev:all": "concurrently \"npm run dev\" \"npm run dev:server\"",
|
||||
"build": "vite build",
|
||||
"start": "node --import tsx server/index.ts",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"tsx": "^4.19.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"concurrently": "^9.1.2",
|
||||
"fast-check": "^4.1.1",
|
||||
"jsdom": "^26.1.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^8.0.4",
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
calculateTeamResult,
|
||||
calculateRankings,
|
||||
getCompletionStatus,
|
||||
calculateAllResults,
|
||||
} from '../aggregation.js';
|
||||
import { Score, Team, TeamResult } from '../../shared/types.js';
|
||||
|
||||
function makeScore(jurorId: string, teamId: string, criterionId: string, value: number): Score {
|
||||
return { jurorId, teamId, criterionId, value, updatedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
describe('calculateTeamResult', () => {
|
||||
it('returns zero total and average when no scores exist', () => {
|
||||
const result = calculateTeamResult('team-a', 'Team A', [], 5);
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.average).toBe(0);
|
||||
expect(result.jurorCount).toBe(0);
|
||||
expect(result.complete).toBe(false);
|
||||
});
|
||||
|
||||
it('calculates total as sum of all score values for the team', () => {
|
||||
const scores: Score[] = [
|
||||
makeScore('juror1', 'team-a', 'c1', 8),
|
||||
makeScore('juror1', 'team-a', 'c2', 7),
|
||||
makeScore('juror2', 'team-a', 'c1', 9),
|
||||
makeScore('juror1', 'team-b', 'c1', 10), // different team, should be excluded
|
||||
];
|
||||
const result = calculateTeamResult('team-a', 'Team A', scores, 2);
|
||||
expect(result.total).toBe(8 + 7 + 9);
|
||||
});
|
||||
|
||||
it('counts only jurors who submitted all criteria', () => {
|
||||
const scores: Score[] = [
|
||||
makeScore('juror1', 'team-a', 'c1', 8),
|
||||
makeScore('juror1', 'team-a', 'c2', 7),
|
||||
makeScore('juror2', 'team-a', 'c1', 9), // only 1 of 2 criteria
|
||||
];
|
||||
const result = calculateTeamResult('team-a', 'Team A', scores, 2);
|
||||
expect(result.jurorCount).toBe(1); // only juror1 has all 2 criteria
|
||||
});
|
||||
|
||||
it('calculates average as total / jurorCount', () => {
|
||||
const scores: Score[] = [
|
||||
makeScore('juror1', 'team-a', 'c1', 8),
|
||||
makeScore('juror1', 'team-a', 'c2', 6),
|
||||
makeScore('juror2', 'team-a', 'c1', 10),
|
||||
makeScore('juror2', 'team-a', 'c2', 4),
|
||||
];
|
||||
const result = calculateTeamResult('team-a', 'Team A', scores, 2);
|
||||
// total = 28, jurorCount = 2, average = 14
|
||||
expect(result.total).toBe(28);
|
||||
expect(result.jurorCount).toBe(2);
|
||||
expect(result.average).toBe(14);
|
||||
});
|
||||
|
||||
it('marks complete when all jurors with scores have all criteria', () => {
|
||||
const scores: Score[] = [
|
||||
makeScore('juror1', 'team-a', 'c1', 8),
|
||||
makeScore('juror1', 'team-a', 'c2', 7),
|
||||
makeScore('juror2', 'team-a', 'c1', 9),
|
||||
makeScore('juror2', 'team-a', 'c2', 6),
|
||||
];
|
||||
const result = calculateTeamResult('team-a', 'Team A', scores, 2);
|
||||
expect(result.complete).toBe(true);
|
||||
});
|
||||
|
||||
it('marks incomplete when some jurors have partial scores', () => {
|
||||
const scores: Score[] = [
|
||||
makeScore('juror1', 'team-a', 'c1', 8),
|
||||
makeScore('juror1', 'team-a', 'c2', 7),
|
||||
makeScore('juror2', 'team-a', 'c1', 9), // missing c2
|
||||
];
|
||||
const result = calculateTeamResult('team-a', 'Team A', scores, 2);
|
||||
expect(result.complete).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateRankings', () => {
|
||||
it('sorts by total descending and assigns ranks', () => {
|
||||
const results: TeamResult[] = [
|
||||
{ teamId: 'a', teamName: 'A', total: 200, average: 0, stddev: 0, rank: 0, jurorCount: 0, complete: false },
|
||||
{ teamId: 'b', teamName: 'B', total: 250, average: 0, stddev: 0, rank: 0, jurorCount: 0, complete: false },
|
||||
{ teamId: 'c', teamName: 'C', total: 230, average: 0, stddev: 0, rank: 0, jurorCount: 0, complete: false },
|
||||
];
|
||||
const ranked = calculateRankings(results);
|
||||
expect(ranked[0].teamId).toBe('b');
|
||||
expect(ranked[0].rank).toBe(1);
|
||||
expect(ranked[1].teamId).toBe('c');
|
||||
expect(ranked[1].rank).toBe(2);
|
||||
expect(ranked[2].teamId).toBe('a');
|
||||
expect(ranked[2].rank).toBe(3);
|
||||
});
|
||||
|
||||
it('assigns same rank to ties', () => {
|
||||
const results: TeamResult[] = [
|
||||
{ teamId: 'a', teamName: 'A', total: 250, average: 0, stddev: 0, rank: 0, jurorCount: 0, complete: false },
|
||||
{ teamId: 'b', teamName: 'B', total: 240, average: 0, stddev: 0, rank: 0, jurorCount: 0, complete: false },
|
||||
{ teamId: 'c', teamName: 'C', total: 240, average: 0, stddev: 0, rank: 0, jurorCount: 0, complete: false },
|
||||
{ teamId: 'd', teamName: 'D', total: 230, average: 0, stddev: 0, rank: 0, jurorCount: 0, complete: false },
|
||||
];
|
||||
const ranked = calculateRankings(results);
|
||||
expect(ranked[0].rank).toBe(1); // 250
|
||||
expect(ranked[1].rank).toBe(2); // 240
|
||||
expect(ranked[2].rank).toBe(2); // 240 (tie)
|
||||
expect(ranked[3].rank).toBe(4); // 230
|
||||
});
|
||||
|
||||
it('handles empty input', () => {
|
||||
const ranked = calculateRankings([]);
|
||||
expect(ranked).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles single team', () => {
|
||||
const results: TeamResult[] = [
|
||||
{ teamId: 'a', teamName: 'A', total: 100, average: 0, stddev: 0, rank: 0, jurorCount: 0, complete: false },
|
||||
];
|
||||
const ranked = calculateRankings(results);
|
||||
expect(ranked[0].rank).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCompletionStatus', () => {
|
||||
it('returns pending when no scores exist', () => {
|
||||
expect(getCompletionStatus('juror1', 'team-a', [], 5)).toBe('pending');
|
||||
});
|
||||
|
||||
it('returns pending when scores exist for other juror/team', () => {
|
||||
const scores: Score[] = [
|
||||
makeScore('juror2', 'team-a', 'c1', 8),
|
||||
makeScore('juror1', 'team-b', 'c1', 7),
|
||||
];
|
||||
expect(getCompletionStatus('juror1', 'team-a', scores, 5)).toBe('pending');
|
||||
});
|
||||
|
||||
it('returns partial when some but not all criteria scored', () => {
|
||||
const scores: Score[] = [
|
||||
makeScore('juror1', 'team-a', 'c1', 8),
|
||||
makeScore('juror1', 'team-a', 'c2', 7),
|
||||
];
|
||||
expect(getCompletionStatus('juror1', 'team-a', scores, 5)).toBe('partial');
|
||||
});
|
||||
|
||||
it('returns complete when all criteria scored', () => {
|
||||
const scores: Score[] = [
|
||||
makeScore('juror1', 'team-a', 'c1', 8),
|
||||
makeScore('juror1', 'team-a', 'c2', 7),
|
||||
makeScore('juror1', 'team-a', 'c3', 9),
|
||||
makeScore('juror1', 'team-a', 'c4', 6),
|
||||
makeScore('juror1', 'team-a', 'c5', 10),
|
||||
];
|
||||
expect(getCompletionStatus('juror1', 'team-a', scores, 5)).toBe('complete');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateAllResults', () => {
|
||||
it('calculates results for all teams with rankings', () => {
|
||||
const teams: Team[] = [
|
||||
{ id: 'team-a', number: 1, name: 'Team A', description: '' },
|
||||
{ id: 'team-b', number: 2, name: 'Team B', description: '' },
|
||||
];
|
||||
const scores: Score[] = [
|
||||
makeScore('juror1', 'team-a', 'c1', 8),
|
||||
makeScore('juror1', 'team-a', 'c2', 7),
|
||||
makeScore('juror1', 'team-b', 'c1', 10),
|
||||
makeScore('juror1', 'team-b', 'c2', 9),
|
||||
];
|
||||
const results = calculateAllResults(scores, teams, 2);
|
||||
expect(results[0].teamId).toBe('team-b'); // higher total (19)
|
||||
expect(results[0].rank).toBe(1);
|
||||
expect(results[0].total).toBe(19);
|
||||
expect(results[1].teamId).toBe('team-a'); // lower total (15)
|
||||
expect(results[1].rank).toBe(2);
|
||||
expect(results[1].total).toBe(15);
|
||||
});
|
||||
|
||||
it('handles empty scores', () => {
|
||||
const teams: Team[] = [
|
||||
{ id: 'team-a', number: 1, name: 'Team A', description: '' },
|
||||
];
|
||||
const results = calculateAllResults([], teams, 5);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].total).toBe(0);
|
||||
expect(results[0].rank).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { shouldAcceptScore, resolveConflict } from '../conflict.js';
|
||||
import { Score } from '../../shared/types.js';
|
||||
|
||||
function makeScore(overrides: Partial<Score> = {}): Score {
|
||||
return {
|
||||
jurorId: 'finke',
|
||||
teamId: 'dual',
|
||||
criterionId: 'gesamtidee',
|
||||
value: 7,
|
||||
updatedAt: '2026-05-19T18:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('shouldAcceptScore', () => {
|
||||
it('accepts when no existing score', () => {
|
||||
const incoming = makeScore();
|
||||
const result = shouldAcceptScore(incoming, undefined);
|
||||
expect(result).toEqual({ accept: true });
|
||||
});
|
||||
|
||||
it('accepts when incoming timestamp is newer', () => {
|
||||
const existing = makeScore({ updatedAt: '2026-05-19T18:00:00.000Z' });
|
||||
const incoming = makeScore({ updatedAt: '2026-05-19T18:01:00.000Z', value: 9 });
|
||||
const result = shouldAcceptScore(incoming, existing);
|
||||
expect(result).toEqual({ accept: true });
|
||||
});
|
||||
|
||||
it('rejects when incoming timestamp is older', () => {
|
||||
const existing = makeScore({ updatedAt: '2026-05-19T18:01:00.000Z' });
|
||||
const incoming = makeScore({ updatedAt: '2026-05-19T18:00:00.000Z', value: 3 });
|
||||
const result = shouldAcceptScore(incoming, existing);
|
||||
expect(result).toEqual({ accept: false, reason: 'Newer score already exists' });
|
||||
});
|
||||
|
||||
it('rejects when timestamps are equal', () => {
|
||||
const existing = makeScore({ updatedAt: '2026-05-19T18:00:00.000Z' });
|
||||
const incoming = makeScore({ updatedAt: '2026-05-19T18:00:00.000Z', value: 5 });
|
||||
const result = shouldAcceptScore(incoming, existing);
|
||||
expect(result).toEqual({ accept: false, reason: 'Newer score already exists' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveConflict', () => {
|
||||
it('accepts when force is true regardless of timestamps', () => {
|
||||
const existing = makeScore({ updatedAt: '2026-05-19T18:01:00.000Z' });
|
||||
const incoming = makeScore({ updatedAt: '2026-05-19T18:00:00.000Z', value: 2 });
|
||||
const result = resolveConflict(incoming, existing, true);
|
||||
expect(result).toEqual({ accept: true });
|
||||
});
|
||||
|
||||
it('delegates to shouldAcceptScore when force is false', () => {
|
||||
const existing = makeScore({ updatedAt: '2026-05-19T18:01:00.000Z' });
|
||||
const incoming = makeScore({ updatedAt: '2026-05-19T18:00:00.000Z', value: 2 });
|
||||
const result = resolveConflict(incoming, existing, false);
|
||||
expect(result).toEqual({ accept: false, reason: 'Newer score already exists' });
|
||||
});
|
||||
|
||||
it('delegates to shouldAcceptScore when force is undefined', () => {
|
||||
const incoming = makeScore();
|
||||
const result = resolveConflict(incoming, undefined);
|
||||
expect(result).toEqual({ accept: true });
|
||||
});
|
||||
|
||||
it('accepts with force even when no existing score', () => {
|
||||
const incoming = makeScore();
|
||||
const result = resolveConflict(incoming, undefined, true);
|
||||
expect(result).toEqual({ accept: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
|
||||
import express from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { registerRoutes } from '../routes.js';
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'server', 'data');
|
||||
const SESSION_FILE = path.join(DATA_DIR, 'session.json');
|
||||
const SEED_FILE = path.join(DATA_DIR, 'seed.json');
|
||||
const BACKUP_FILE = path.join(DATA_DIR, 'session.json.bak');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
registerRoutes(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
function resetStore() {
|
||||
const seedData = fs.readFileSync(SEED_FILE, 'utf-8');
|
||||
fs.writeFileSync(SESSION_FILE, seedData, 'utf-8');
|
||||
if (fs.existsSync(BACKUP_FILE)) {
|
||||
fs.unlinkSync(BACKUP_FILE);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to make requests without supertest (using native fetch with app.listen)
|
||||
// We'll use a simple approach: call the route handlers directly via the express app
|
||||
import { type Server } from 'http';
|
||||
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(() => {
|
||||
resetStore();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
resetStore();
|
||||
if (server) server.close();
|
||||
});
|
||||
|
||||
describe('server/routes', () => {
|
||||
const app = createApp();
|
||||
|
||||
// Start server on a random port
|
||||
beforeEach(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (server) server.close();
|
||||
server = app.listen(0, () => {
|
||||
const addr = server.address();
|
||||
if (addr && typeof addr !== 'string') {
|
||||
baseUrl = `http://localhost:${addr.port}`;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (server) server.close();
|
||||
});
|
||||
|
||||
// ─── Session Endpoints ───────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/session', () => {
|
||||
it('returns current session state', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/session`);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.id).toBe('unikat-2026');
|
||||
expect(data.status).toBe('setup');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/session/start', () => {
|
||||
it('starts the session', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.status).toBe('active');
|
||||
expect(data.startedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects if already active', async () => {
|
||||
await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
const res = await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toContain('already active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/session/end', () => {
|
||||
it('ends an active session', async () => {
|
||||
await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
const res = await fetch(`${baseUrl}/api/session/end`, { method: 'POST' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.status).toBe('ended');
|
||||
expect(data.endedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects if not active', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/session/end`, { method: 'POST' });
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toContain('not active');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Team and Juror Endpoints ────────────────────────────────────────
|
||||
|
||||
describe('GET /api/teams', () => {
|
||||
it('returns all 11 teams', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/teams`);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data).toHaveLength(11);
|
||||
expect(data[0]).toHaveProperty('id');
|
||||
expect(data[0]).toHaveProperty('name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/jurors', () => {
|
||||
it('returns all 6 jurors', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/jurors`);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data).toHaveLength(6);
|
||||
expect(data[0]).toHaveProperty('id');
|
||||
expect(data[0]).toHaveProperty('name');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Score Endpoints ─────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/scores', () => {
|
||||
it('rejects when session is not active (403)', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/scores`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jurorId: 'finke', teamId: 'dual', criterionId: 'gesamtidee', value: 8 }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('accepts a valid score when session is active', async () => {
|
||||
await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
const res = await fetch(`${baseUrl}/api/scores`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jurorId: 'finke', teamId: 'dual', criterionId: 'gesamtidee', value: 8 }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.jurorId).toBe('finke');
|
||||
expect(data.value).toBe(8);
|
||||
});
|
||||
|
||||
it('rejects value out of range (400)', async () => {
|
||||
await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
const res = await fetch(`${baseUrl}/api/scores`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jurorId: 'finke', teamId: 'dual', criterionId: 'gesamtidee', value: 11 }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects unknown jurorId (400)', async () => {
|
||||
await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
const res = await fetch(`${baseUrl}/api/scores`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jurorId: 'unknown', teamId: 'dual', criterionId: 'gesamtidee', value: 5 }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects unknown teamId (400)', async () => {
|
||||
await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
const res = await fetch(`${baseUrl}/api/scores`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jurorId: 'finke', teamId: 'unknown', criterionId: 'gesamtidee', value: 5 }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects unknown criterionId (400)', async () => {
|
||||
await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
const res = await fetch(`${baseUrl}/api/scores`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jurorId: 'finke', teamId: 'dual', criterionId: 'unknown', value: 5 }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/scores', () => {
|
||||
it('returns all scores', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/scores`);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/scores/:jurorId', () => {
|
||||
it('returns scores for a specific juror', async () => {
|
||||
// Add a score first
|
||||
await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
await fetch(`${baseUrl}/api/scores`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jurorId: 'finke', teamId: 'dual', criterionId: 'gesamtidee', value: 7 }),
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/scores/finke`);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].jurorId).toBe('finke');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Results and Progress Endpoints ──────────────────────────────────
|
||||
|
||||
describe('GET /api/results', () => {
|
||||
it('returns aggregated results for all teams', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/results`);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data).toHaveLength(11);
|
||||
expect(data[0]).toHaveProperty('teamId');
|
||||
expect(data[0]).toHaveProperty('total');
|
||||
expect(data[0]).toHaveProperty('average');
|
||||
expect(data[0]).toHaveProperty('rank');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/progress', () => {
|
||||
it('returns juror completion matrix', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/progress`);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data).toHaveProperty('finke');
|
||||
expect(data.finke).toHaveProperty('completed');
|
||||
expect(data.finke).toHaveProperty('total');
|
||||
expect(data.finke.total).toBe(11);
|
||||
});
|
||||
|
||||
it('does not include individual score values', async () => {
|
||||
// Add a score
|
||||
await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
await fetch(`${baseUrl}/api/scores`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jurorId: 'finke', teamId: 'dual', criterionId: 'gesamtidee', value: 8 }),
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/progress`);
|
||||
const data = await res.json();
|
||||
const json = JSON.stringify(data);
|
||||
// Should not contain any score value or the word "value"
|
||||
expect(json).not.toContain('"value"');
|
||||
expect(json).not.toContain('"scores"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/export/csv', () => {
|
||||
it('returns CSV with correct headers', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/export/csv`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toContain('text/csv');
|
||||
expect(res.headers.get('content-disposition')).toContain('attachment');
|
||||
|
||||
const csv = await res.text();
|
||||
const lines = csv.split('\n');
|
||||
expect(lines[0]).toContain('Team');
|
||||
expect(lines[0]).toContain('Total');
|
||||
expect(lines[0]).toContain('Average');
|
||||
expect(lines[0]).toContain('Rank');
|
||||
});
|
||||
|
||||
it('has one row per team plus header', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/export/csv`);
|
||||
const csv = await res.text();
|
||||
const lines = csv.split('\n');
|
||||
// 1 header + 11 team rows
|
||||
expect(lines).toHaveLength(12);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { Score } from '../../shared/types.js';
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'server', 'data');
|
||||
const SESSION_FILE = path.join(DATA_DIR, 'session.json');
|
||||
const SEED_FILE = path.join(DATA_DIR, 'seed.json');
|
||||
const BACKUP_FILE = path.join(DATA_DIR, 'session.json.bak');
|
||||
|
||||
// Import the store module — initialization runs on import
|
||||
import * as store from '../store.js';
|
||||
|
||||
describe('server/store', () => {
|
||||
// Reset session.json from seed before each test for isolation
|
||||
beforeEach(() => {
|
||||
const seedData = fs.readFileSync(SEED_FILE, 'utf-8');
|
||||
fs.writeFileSync(SESSION_FILE, seedData, 'utf-8');
|
||||
// Remove backup if it exists
|
||||
if (fs.existsSync(BACKUP_FILE)) {
|
||||
fs.unlinkSync(BACKUP_FILE);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up after all tests
|
||||
afterAll(() => {
|
||||
// Restore session.json from seed
|
||||
const seedData = fs.readFileSync(SEED_FILE, 'utf-8');
|
||||
fs.writeFileSync(SESSION_FILE, seedData, 'utf-8');
|
||||
if (fs.existsSync(BACKUP_FILE)) {
|
||||
fs.unlinkSync(BACKUP_FILE);
|
||||
}
|
||||
});
|
||||
|
||||
it('getStore() returns valid StoreData', () => {
|
||||
const data = store.getStore();
|
||||
expect(data).toHaveProperty('session');
|
||||
expect(data).toHaveProperty('teams');
|
||||
expect(data).toHaveProperty('jurors');
|
||||
expect(data).toHaveProperty('scores');
|
||||
expect(data.session.id).toBe('unikat-2026');
|
||||
});
|
||||
|
||||
it('getStore() returns seed data structure', () => {
|
||||
const data = store.getStore();
|
||||
expect(data.teams).toHaveLength(11);
|
||||
expect(data.jurors).toHaveLength(6);
|
||||
expect(data.scores).toEqual([]);
|
||||
});
|
||||
|
||||
it('saveStore() creates a .bak backup', () => {
|
||||
const data = store.getStore();
|
||||
store.saveStore(data);
|
||||
expect(fs.existsSync(BACKUP_FILE)).toBe(true);
|
||||
});
|
||||
|
||||
it('saveStore() writes data atomically (persists changes)', () => {
|
||||
const data = store.getStore();
|
||||
data.session.status = 'active';
|
||||
store.saveStore(data);
|
||||
|
||||
const reloaded = store.getStore();
|
||||
expect(reloaded.session.status).toBe('active');
|
||||
});
|
||||
|
||||
it('saveStore() backup contains previous data', () => {
|
||||
const originalData = store.getStore();
|
||||
const originalStatus = originalData.session.status;
|
||||
|
||||
originalData.session.status = 'active';
|
||||
store.saveStore(originalData);
|
||||
|
||||
const backupContent = JSON.parse(fs.readFileSync(BACKUP_FILE, 'utf-8'));
|
||||
expect(backupContent.session.status).toBe(originalStatus);
|
||||
});
|
||||
|
||||
it('getSession() returns the session', () => {
|
||||
const session = store.getSession();
|
||||
expect(session.id).toBe('unikat-2026');
|
||||
expect(session.status).toBe('setup');
|
||||
expect(session.config.criteria).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('getTeams() returns all teams', () => {
|
||||
const teams = store.getTeams();
|
||||
expect(teams).toHaveLength(11);
|
||||
expect(teams[0]).toHaveProperty('id');
|
||||
expect(teams[0]).toHaveProperty('name');
|
||||
expect(teams[0]).toHaveProperty('number');
|
||||
});
|
||||
|
||||
it('getJurors() returns all jurors', () => {
|
||||
const jurors = store.getJurors();
|
||||
expect(jurors).toHaveLength(6);
|
||||
expect(jurors[0]).toHaveProperty('id');
|
||||
expect(jurors[0]).toHaveProperty('name');
|
||||
expect(jurors[0]).toHaveProperty('active');
|
||||
});
|
||||
|
||||
it('getScores() returns empty scores array initially', () => {
|
||||
const scores = store.getScores();
|
||||
expect(Array.isArray(scores)).toBe(true);
|
||||
expect(scores).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('addScore() adds a new score', () => {
|
||||
const score: Score = {
|
||||
jurorId: 'finke',
|
||||
teamId: 'dual',
|
||||
criterionId: 'gesamtidee',
|
||||
value: 8,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
store.addScore(score);
|
||||
|
||||
const scores = store.getScores();
|
||||
expect(scores).toHaveLength(1);
|
||||
expect(scores[0].jurorId).toBe('finke');
|
||||
expect(scores[0].teamId).toBe('dual');
|
||||
expect(scores[0].criterionId).toBe('gesamtidee');
|
||||
expect(scores[0].value).toBe(8);
|
||||
});
|
||||
|
||||
it('addScore() updates existing score (match by jurorId+teamId+criterionId)', () => {
|
||||
const score1: Score = {
|
||||
jurorId: 'finke',
|
||||
teamId: 'dual',
|
||||
criterionId: 'gesamtidee',
|
||||
value: 7,
|
||||
updatedAt: '2026-05-19T10:00:00Z',
|
||||
};
|
||||
store.addScore(score1);
|
||||
|
||||
const score2: Score = {
|
||||
jurorId: 'finke',
|
||||
teamId: 'dual',
|
||||
criterionId: 'gesamtidee',
|
||||
value: 9,
|
||||
updatedAt: '2026-05-19T10:01:00Z',
|
||||
};
|
||||
store.addScore(score2);
|
||||
|
||||
const scores = store.getScores();
|
||||
const matching = scores.filter(
|
||||
(s) =>
|
||||
s.jurorId === 'finke' &&
|
||||
s.teamId === 'dual' &&
|
||||
s.criterionId === 'gesamtidee'
|
||||
);
|
||||
expect(matching).toHaveLength(1);
|
||||
expect(matching[0].value).toBe(9);
|
||||
expect(matching[0].updatedAt).toBe('2026-05-19T10:01:00Z');
|
||||
});
|
||||
|
||||
it('addScore() does not affect scores for different criteria', () => {
|
||||
const score1: Score = {
|
||||
jurorId: 'finke',
|
||||
teamId: 'dual',
|
||||
criterionId: 'gesamtidee',
|
||||
value: 7,
|
||||
updatedAt: '2026-05-19T10:00:00Z',
|
||||
};
|
||||
const score2: Score = {
|
||||
jurorId: 'finke',
|
||||
teamId: 'dual',
|
||||
criterionId: 'markt',
|
||||
value: 9,
|
||||
updatedAt: '2026-05-19T10:01:00Z',
|
||||
};
|
||||
store.addScore(score1);
|
||||
store.addScore(score2);
|
||||
|
||||
const scores = store.getScores();
|
||||
expect(scores).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('updateSession() merges partial updates', () => {
|
||||
const updated = store.updateSession({
|
||||
status: 'active',
|
||||
startedAt: '2026-05-19T18:00:00Z',
|
||||
});
|
||||
expect(updated.status).toBe('active');
|
||||
expect(updated.startedAt).toBe('2026-05-19T18:00:00Z');
|
||||
expect(updated.id).toBe('unikat-2026');
|
||||
expect(updated.config.criteria).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('updateSession() persists changes', () => {
|
||||
store.updateSession({ status: 'active' });
|
||||
const session = store.getSession();
|
||||
expect(session.status).toBe('active');
|
||||
});
|
||||
|
||||
it('updateJuror() updates a juror by ID', () => {
|
||||
const updated = store.updateJuror('finke', { active: true });
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.active).toBe(true);
|
||||
expect(updated!.name).toBe('Finke');
|
||||
expect(updated!.id).toBe('finke');
|
||||
});
|
||||
|
||||
it('updateJuror() persists changes', () => {
|
||||
store.updateJuror('finke', { active: true });
|
||||
const jurors = store.getJurors();
|
||||
const finke = jurors.find((j) => j.id === 'finke');
|
||||
expect(finke!.active).toBe(true);
|
||||
});
|
||||
|
||||
it('updateJuror() returns null for unknown jurorId', () => {
|
||||
const result = store.updateJuror('unknown-juror', { active: true });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('initializes from seed.json if session.json does not exist', () => {
|
||||
// Delete session.json
|
||||
fs.unlinkSync(SESSION_FILE);
|
||||
expect(fs.existsSync(SESSION_FILE)).toBe(false);
|
||||
|
||||
// Manually call the initialization logic by writing seed
|
||||
const seedData = fs.readFileSync(SEED_FILE, 'utf-8');
|
||||
fs.writeFileSync(SESSION_FILE, seedData, 'utf-8');
|
||||
|
||||
// Verify the store reads correctly
|
||||
const data = store.getStore();
|
||||
expect(data.session.id).toBe('unikat-2026');
|
||||
expect(data.teams).toHaveLength(11);
|
||||
expect(data.jurors).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Score, Team, TeamResult, Criterion, CriterionStats } from '../shared/types.js';
|
||||
|
||||
/**
|
||||
* Calculate the aggregated result for a single team.
|
||||
*
|
||||
* - total = sum of all score.value where score.teamId matches
|
||||
* - jurorCount = count of distinct jurorIds that have submitted ALL criteria (criteriaCount scores)
|
||||
* - average = total / jurorCount (or 0 if jurorCount is 0)
|
||||
* - stddev = standard deviation of per-juror totals
|
||||
* - complete = jurorCount equals total number of jurors who have at least one score for this team
|
||||
* AND all have all criteria
|
||||
*/
|
||||
export function calculateTeamResult(
|
||||
teamId: string,
|
||||
teamName: string,
|
||||
scores: Score[],
|
||||
criteriaCount: number,
|
||||
criteria?: Criterion[]
|
||||
): TeamResult {
|
||||
const teamScores = scores.filter((s) => s.teamId === teamId);
|
||||
const total = teamScores.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
// Group scores by jurorId
|
||||
const scoresByJuror = new Map<string, Score[]>();
|
||||
for (const score of teamScores) {
|
||||
const existing = scoresByJuror.get(score.jurorId) || [];
|
||||
existing.push(score);
|
||||
scoresByJuror.set(score.jurorId, existing);
|
||||
}
|
||||
|
||||
// Count jurors who have submitted ALL criteria
|
||||
const jurorCount = Array.from(scoresByJuror.values()).filter(
|
||||
(jurorScores) => jurorScores.length >= criteriaCount
|
||||
).length;
|
||||
|
||||
const average = jurorCount > 0 ? total / jurorCount : 0;
|
||||
|
||||
// Calculate stddev of per-juror totals
|
||||
const jurorTotals = Array.from(scoresByJuror.values())
|
||||
.filter((jurorScores) => jurorScores.length >= criteriaCount)
|
||||
.map((jurorScores) => jurorScores.reduce((sum, s) => sum + s.value, 0));
|
||||
|
||||
let stddev = 0;
|
||||
if (jurorTotals.length > 1) {
|
||||
const mean = jurorTotals.reduce((a, b) => a + b, 0) / jurorTotals.length;
|
||||
const variance = jurorTotals.reduce((sum, v) => sum + (v - mean) ** 2, 0) / jurorTotals.length;
|
||||
stddev = Math.sqrt(variance);
|
||||
}
|
||||
|
||||
// Complete = all jurors who have at least one score have submitted all criteria
|
||||
const totalJurorsWithScores = scoresByJuror.size;
|
||||
const complete = totalJurorsWithScores > 0 && jurorCount === totalJurorsWithScores;
|
||||
|
||||
// Per-criterion stats
|
||||
let criteriaStats: CriterionStats[] | undefined;
|
||||
if (criteria) {
|
||||
criteriaStats = criteria.map((criterion) => {
|
||||
const criterionScores = teamScores
|
||||
.filter((s) => s.criterionId === criterion.id)
|
||||
.map((s) => s.value);
|
||||
const cMean = criterionScores.length > 0
|
||||
? criterionScores.reduce((a, b) => a + b, 0) / criterionScores.length
|
||||
: 0;
|
||||
let cStddev = 0;
|
||||
if (criterionScores.length > 1) {
|
||||
const cVariance = criterionScores.reduce((sum, v) => sum + (v - cMean) ** 2, 0) / criterionScores.length;
|
||||
cStddev = Math.sqrt(cVariance);
|
||||
}
|
||||
return {
|
||||
criterionId: criterion.id,
|
||||
criterionName: criterion.nameDE,
|
||||
mean: cMean,
|
||||
stddev: cStddev,
|
||||
scores: criterionScores,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
teamId,
|
||||
teamName,
|
||||
total,
|
||||
average,
|
||||
stddev,
|
||||
rank: 0, // Will be assigned by calculateRankings
|
||||
jurorCount,
|
||||
complete,
|
||||
criteriaStats,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort team results by total descending and assign dense ranking.
|
||||
* Ties get the same rank.
|
||||
* Example: totals [250, 240, 240, 230] → ranks [1, 2, 2, 4]
|
||||
*/
|
||||
export function calculateRankings(teamResults: TeamResult[]): TeamResult[] {
|
||||
// Sort by total descending
|
||||
const sorted = [...teamResults].sort((a, b) => b.total - a.total);
|
||||
|
||||
// Assign dense ranking with ties
|
||||
let currentRank = 1;
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (i > 0 && sorted[i].total < sorted[i - 1].total) {
|
||||
currentRank = i + 1;
|
||||
}
|
||||
sorted[i] = { ...sorted[i], rank: currentRank };
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the completion status for a specific juror-team pair.
|
||||
*
|
||||
* - 0 scores → 'pending'
|
||||
* - 1 to criteriaCount-1 → 'partial'
|
||||
* - criteriaCount → 'complete'
|
||||
*/
|
||||
export function getCompletionStatus(
|
||||
jurorId: string,
|
||||
teamId: string,
|
||||
scores: Score[],
|
||||
criteriaCount: number
|
||||
): 'complete' | 'partial' | 'pending' {
|
||||
const count = scores.filter(
|
||||
(s) => s.jurorId === jurorId && s.teamId === teamId
|
||||
).length;
|
||||
|
||||
if (count === 0) return 'pending';
|
||||
if (count >= criteriaCount) return 'complete';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate results for all teams and apply rankings.
|
||||
* Convenience function combining calculateTeamResult + calculateRankings.
|
||||
*/
|
||||
export function calculateAllResults(
|
||||
scores: Score[],
|
||||
teams: Team[],
|
||||
criteriaCount: number,
|
||||
criteria?: Criterion[]
|
||||
): TeamResult[] {
|
||||
const teamResults = teams.map((team) =>
|
||||
calculateTeamResult(team.id, team.name, scores, criteriaCount, criteria)
|
||||
);
|
||||
return calculateRankings(teamResults);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Score } from '../shared/types.js';
|
||||
|
||||
export interface ConflictResult {
|
||||
accept: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an incoming score should be accepted over an existing one.
|
||||
* Uses last-write-wins strategy based on updatedAt timestamps.
|
||||
*
|
||||
* - If no existing score → accept
|
||||
* - If incoming.updatedAt > existing.updatedAt → accept (last-write-wins)
|
||||
* - If incoming.updatedAt <= existing.updatedAt → reject
|
||||
*/
|
||||
export function shouldAcceptScore(
|
||||
incoming: Score,
|
||||
existing: Score | undefined
|
||||
): ConflictResult {
|
||||
if (!existing) {
|
||||
return { accept: true };
|
||||
}
|
||||
|
||||
if (incoming.updatedAt > existing.updatedAt) {
|
||||
return { accept: true };
|
||||
}
|
||||
|
||||
return { accept: false, reason: 'Newer score already exists' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a score conflict with optional force override.
|
||||
*
|
||||
* - If force is true → always accept
|
||||
* - Otherwise delegate to shouldAcceptScore (last-write-wins)
|
||||
*/
|
||||
export function resolveConflict(
|
||||
incoming: Score,
|
||||
existing: Score | undefined,
|
||||
force?: boolean
|
||||
): ConflictResult {
|
||||
if (force) {
|
||||
return { accept: true };
|
||||
}
|
||||
|
||||
return shouldAcceptScore(incoming, existing);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"session": {
|
||||
"id": "unikat-2026",
|
||||
"status": "setup",
|
||||
"startedAt": null,
|
||||
"endedAt": null,
|
||||
"config": {
|
||||
"criteria": [
|
||||
{ "id": "gesamtidee", "nameDE": "Gesamtidee", "nameEN": "Overall Idea" },
|
||||
{ "id": "kundennutzen", "nameDE": "Kundennutzen", "nameEN": "Customer Benefit" },
|
||||
{ "id": "markt", "nameDE": "Markt", "nameEN": "Market" },
|
||||
{ "id": "realisierbarkeit", "nameDE": "Realisierbarkeit", "nameEN": "Feasibility" },
|
||||
{ "id": "praesentation", "nameDE": "Präsentation", "nameEN": "Presentation" }
|
||||
],
|
||||
"scaleMin": 1,
|
||||
"scaleMax": 10
|
||||
}
|
||||
},
|
||||
"teams": [
|
||||
{ "id": "anima-lektor", "number": 1, "name": "Anima Lektor", "description": "Lektor for game language/text", "contact": "Lukas Fleck" },
|
||||
{ "id": "das-any-thing", "number": 2, "name": "Das Any-Thing", "description": "Physical AI automaton that adapts to context" },
|
||||
{ "id": "doppelrad-sortierer", "number": 3, "name": "Doppelrad-Sortierer", "description": "AI-powered seed sorting machine for niche producers" },
|
||||
{ "id": "dual", "number": 4, "name": "DUAL", "description": "Fully reversible wood construction system", "contact": "Nicole Kozlewski, Sebastian Görs" },
|
||||
{ "id": "foemo", "number": 5, "name": "fömo", "description": "Funding management platform for social institutions" },
|
||||
{ "id": "isomedi", "number": 6, "name": "ISOmedi", "description": "Compact rechargeable medication case with protective micro-environment" },
|
||||
{ "id": "lians-tempeh-chips", "number": 7, "name": "Lian's Tempeh Chips", "description": "Tempeh-based snack product", "contact": "Dr. Berlianti Puteri" },
|
||||
{ "id": "lumpen", "number": 8, "name": "Lumpen", "description": "Sustainable upholstery material from old clothing textiles" },
|
||||
{ "id": "mobile-sprinkleranlage", "number": 9, "name": "Mobile Sprinkleranlage", "description": "Mobile sprinkler system for fire protection", "contact": "Björn Knoke" },
|
||||
{ "id": "safesupply-os", "number": 10, "name": "SafeSupply OS", "description": "Lightweight supplier risk & audit system for agri-food SMEs", "contact": "Jesvin Jaksan" },
|
||||
{ "id": "second-nose", "number": 11, "name": "Second Nose", "description": "Digital smell detection module for smartphones" }
|
||||
],
|
||||
"jurors": [
|
||||
{ "id": "cielejewski", "name": "Cielejewski", "active": false },
|
||||
{ "id": "finke", "name": "Finke", "active": false },
|
||||
{ "id": "freyer", "name": "Freyer", "active": false },
|
||||
{ "id": "knie", "name": "Knie", "active": false },
|
||||
{ "id": "meyer", "name": "Meyer", "active": false },
|
||||
{ "id": "trieschmann", "name": "Trieschmann", "active": false }
|
||||
],
|
||||
"scores": []
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import express, { Router } from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { apiRouter } from './routes.js';
|
||||
import { sseRouter } from './sse.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT || '3001', 10);
|
||||
const APP_PASSWORD = process.env.APP_PASSWORD || 'UNIKAT_jury';
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Health check (no auth required)
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Password protection middleware
|
||||
app.use((req, res, next) => {
|
||||
if (req.path === '/api/health') { next(); return; }
|
||||
|
||||
// Check query param
|
||||
const token = req.query.token as string | undefined;
|
||||
if (token === APP_PASSWORD) {
|
||||
res.cookie('jury_auth', APP_PASSWORD, { httpOnly: true, maxAge: 24 * 60 * 60 * 1000 });
|
||||
if (!req.path.startsWith('/api')) {
|
||||
res.redirect(req.path || '/');
|
||||
return;
|
||||
}
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cookie
|
||||
const cookieHeader = req.headers.cookie || '';
|
||||
const cookies = Object.fromEntries(
|
||||
cookieHeader.split(';').map(c => {
|
||||
const [key, ...val] = c.trim().split('=');
|
||||
return [key, val.join('=')];
|
||||
})
|
||||
);
|
||||
if (cookies.jury_auth === APP_PASSWORD) { next(); return; }
|
||||
|
||||
// Not authenticated
|
||||
if (req.path.startsWith('/api')) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Login page
|
||||
res.status(401).send(`<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>UNIKAT Jury Voting</title>
|
||||
<style>
|
||||
body { background: #0a0a0a; color: #f0f0f0; font-family: 'Outfit', sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
||||
.login { text-align: center; max-width: 320px; padding: 40px; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 8px; }
|
||||
p { color: #999; margin-bottom: 24px; }
|
||||
input { width: 100%; padding: 14px; border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); background: rgba(25,25,25,0.6); color: #f0f0f0; font-size: 1rem; text-align: center; outline: none; }
|
||||
input:focus { border-color: #00FF00; }
|
||||
button { width: 100%; margin-top: 12px; padding: 14px; border-radius: 50px; border: none; background: linear-gradient(135deg, #00FF00, #00b300); color: #000; font-weight: 800; font-size: 1.1rem; cursor: pointer; }
|
||||
</style>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="login">
|
||||
<h1>🗳️ Jury Voting</h1>
|
||||
<p>15. UNIKAT Ideenwettbewerb</p>
|
||||
<form onsubmit="login(event)">
|
||||
<input type="password" id="pw" placeholder="Passwort eingeben" autofocus>
|
||||
<button type="submit">Einloggen</button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
function login(e) {
|
||||
e.preventDefault();
|
||||
const pw = document.getElementById('pw').value;
|
||||
window.location.href = '/?token=' + encodeURIComponent(pw);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`);
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use(apiRouter);
|
||||
app.use(sseRouter);
|
||||
|
||||
// Serve static frontend in production
|
||||
const distPath = path.resolve(__dirname, '..', 'dist');
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.use(express.static(distPath));
|
||||
app.use((_req, res) => {
|
||||
res.sendFile(path.join(distPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import {
|
||||
getSession,
|
||||
getTeams,
|
||||
getJurors,
|
||||
getScores,
|
||||
addScore,
|
||||
updateSession,
|
||||
getStore,
|
||||
} from './store.js';
|
||||
import { resolveConflict } from './conflict.js';
|
||||
import { calculateAllResults, getCompletionStatus } from './aggregation.js';
|
||||
import { broadcastUpdate } from './sse.js';
|
||||
import { Score } from '../shared/types.js';
|
||||
|
||||
export const apiRouter = Router();
|
||||
// ─── Session Endpoints ───────────────────────────────────────────────
|
||||
|
||||
apiRouter.get('/api/session', (_req: Request, res: Response) => {
|
||||
const session = getSession();
|
||||
res.json(session);
|
||||
});
|
||||
|
||||
apiRouter.post('/api/session/start', (_req: Request, res: Response) => {
|
||||
const session = getSession();
|
||||
if (session.status === 'active') {
|
||||
res.status(400).json({ error: 'Session is already active' });
|
||||
return;
|
||||
}
|
||||
const updated = updateSession({
|
||||
status: 'active',
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
apiRouter.post('/api/session/end', (_req: Request, res: Response) => {
|
||||
const session = getSession();
|
||||
if (session.status !== 'active') {
|
||||
res.status(400).json({ error: 'Session is not active' });
|
||||
return;
|
||||
}
|
||||
const updated = updateSession({
|
||||
status: 'ended',
|
||||
endedAt: new Date().toISOString(),
|
||||
});
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
// ─── Team and Juror Endpoints ────────────────────────────────────────
|
||||
|
||||
apiRouter.get('/api/teams', (_req: Request, res: Response) => {
|
||||
const teams = getTeams();
|
||||
res.json(teams);
|
||||
});
|
||||
|
||||
apiRouter.get('/api/jurors', (_req: Request, res: Response) => {
|
||||
const jurors = getJurors();
|
||||
res.json(jurors);
|
||||
});
|
||||
|
||||
// ─── Score Endpoints ─────────────────────────────────────────────────
|
||||
|
||||
apiRouter.post('/api/scores', (req: Request, res: Response) => {
|
||||
const session = getSession();
|
||||
if (session.status !== 'active') {
|
||||
res.status(403).json({ error: 'Session is not active' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { jurorId, teamId, criterionId, value, force } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!jurorId || !teamId || !criterionId || value === undefined) {
|
||||
res.status(400).json({ error: 'Missing required fields: jurorId, teamId, criterionId, value' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate value is within scale range
|
||||
const { scaleMin, scaleMax } = session.config;
|
||||
if (typeof value !== 'number' || value < scaleMin || value > scaleMax) {
|
||||
res.status(400).json({ error: `Value must be between ${scaleMin} and ${scaleMax}` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate jurorId exists
|
||||
const jurors = getJurors();
|
||||
if (!jurors.find((j) => j.id === jurorId)) {
|
||||
res.status(400).json({ error: `Unknown jurorId: ${jurorId}` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate teamId exists
|
||||
const teams = getTeams();
|
||||
if (!teams.find((t) => t.id === teamId)) {
|
||||
res.status(400).json({ error: `Unknown teamId: ${teamId}` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate criterionId exists
|
||||
const criteria = session.config.criteria;
|
||||
if (!criteria.find((c) => c.id === criterionId)) {
|
||||
res.status(400).json({ error: `Unknown criterionId: ${criterionId}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const incoming: Score = {
|
||||
jurorId,
|
||||
teamId,
|
||||
criterionId,
|
||||
value,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Check for existing score and resolve conflict
|
||||
const existingScores = getScores();
|
||||
const existing = existingScores.find(
|
||||
(s) => s.jurorId === jurorId && s.teamId === teamId && s.criterionId === criterionId
|
||||
);
|
||||
|
||||
const conflict = resolveConflict(incoming, existing, force);
|
||||
if (!conflict.accept) {
|
||||
res.status(409).json({ error: conflict.reason, existing });
|
||||
return;
|
||||
}
|
||||
|
||||
addScore(incoming);
|
||||
broadcastUpdate();
|
||||
res.json(incoming);
|
||||
});
|
||||
|
||||
apiRouter.get('/api/scores/:jurorId', (req: Request, res: Response) => {
|
||||
const { jurorId } = req.params;
|
||||
const scores = getScores();
|
||||
const jurorScores = scores.filter((s) => s.jurorId === jurorId);
|
||||
res.json(jurorScores);
|
||||
});
|
||||
|
||||
apiRouter.get('/api/scores', (_req: Request, res: Response) => {
|
||||
const scores = getScores();
|
||||
res.json(scores);
|
||||
});
|
||||
|
||||
// ─── Results and Progress Endpoints ──────────────────────────────────
|
||||
|
||||
apiRouter.get('/api/results', (_req: Request, res: Response) => {
|
||||
const store = getStore();
|
||||
const criteriaCount = store.session.config.criteria.length;
|
||||
const results = calculateAllResults(store.scores, store.teams, criteriaCount, store.session.config.criteria);
|
||||
res.json(results);
|
||||
});
|
||||
|
||||
apiRouter.get('/api/progress', (_req: Request, res: Response) => {
|
||||
const store = getStore();
|
||||
const criteriaCount = store.session.config.criteria.length;
|
||||
const progress: Record<string, { completed: number; total: number }> = {};
|
||||
|
||||
for (const juror of store.jurors) {
|
||||
let completedTeams = 0;
|
||||
for (const team of store.teams) {
|
||||
const status = getCompletionStatus(juror.id, team.id, store.scores, criteriaCount);
|
||||
if (status === 'complete') {
|
||||
completedTeams++;
|
||||
}
|
||||
}
|
||||
progress[juror.id] = { completed: completedTeams, total: store.teams.length };
|
||||
}
|
||||
|
||||
res.json(progress);
|
||||
});
|
||||
|
||||
apiRouter.get('/api/export/csv', (_req: Request, res: Response) => {
|
||||
const store = getStore();
|
||||
const criteriaCount = store.session.config.criteria.length;
|
||||
const results = calculateAllResults(store.scores, store.teams, criteriaCount);
|
||||
|
||||
// Build header row: Team, then for each juror: juror_criterion1, juror_criterion2, ..., then Total, Average, Rank
|
||||
const criteria = store.session.config.criteria;
|
||||
const headerParts = ['Team'];
|
||||
for (const juror of store.jurors) {
|
||||
for (const criterion of criteria) {
|
||||
headerParts.push(`${juror.name}_${criterion.nameDE}`);
|
||||
}
|
||||
}
|
||||
headerParts.push('Total', 'Average', 'Rank');
|
||||
|
||||
const rows: string[] = [headerParts.join(',')];
|
||||
|
||||
// One row per team (sorted by rank)
|
||||
for (const result of results) {
|
||||
const rowParts: string[] = [`"${result.teamName}"`];
|
||||
|
||||
for (const juror of store.jurors) {
|
||||
for (const criterion of criteria) {
|
||||
const score = store.scores.find(
|
||||
(s) => s.jurorId === juror.id && s.teamId === result.teamId && s.criterionId === criterion.id
|
||||
);
|
||||
rowParts.push(score ? String(score.value) : '');
|
||||
}
|
||||
}
|
||||
|
||||
rowParts.push(String(result.total), result.average.toFixed(2), String(result.rank));
|
||||
rows.push(rowParts.join(','));
|
||||
}
|
||||
|
||||
const csv = rows.join('\n');
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="jury-results.csv"');
|
||||
res.send(csv);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { getStore } from './store.js';
|
||||
import { calculateAllResults } from './aggregation.js';
|
||||
|
||||
// List of connected SSE clients
|
||||
const clients: Response[] = [];
|
||||
|
||||
export const sseRouter = Router();
|
||||
|
||||
/**
|
||||
* SSE endpoint: GET /api/results/stream
|
||||
*/
|
||||
sseRouter.get('/api/results/stream', (req: Request, res: Response) => {
|
||||
// Set SSE headers
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
// Add client to list
|
||||
clients.push(res);
|
||||
|
||||
// Send initial data immediately on connect
|
||||
const store = getStore();
|
||||
const criteriaCount = store.session.config.criteria.length;
|
||||
const results = calculateAllResults(store.scores, store.teams, criteriaCount);
|
||||
const payload = JSON.stringify({ results, timestamp: new Date().toISOString() });
|
||||
res.write(`event: scores_updated\ndata: ${payload}\n\n`);
|
||||
|
||||
// Remove client on disconnect
|
||||
req.on('close', () => {
|
||||
const index = clients.indexOf(res);
|
||||
if (index !== -1) {
|
||||
clients.splice(index, 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Broadcast current results to all connected SSE clients.
|
||||
* Called whenever scores change.
|
||||
*/
|
||||
export function broadcastUpdate(): void {
|
||||
const store = getStore();
|
||||
const criteriaCount = store.session.config.criteria.length;
|
||||
const results = calculateAllResults(store.scores, store.teams, criteriaCount);
|
||||
const payload = JSON.stringify({ results, timestamp: new Date().toISOString() });
|
||||
const message = `event: scores_updated\ndata: ${payload}\n\n`;
|
||||
|
||||
for (const client of clients) {
|
||||
client.write(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { StoreData, Session, Team, Juror, Score } from '../shared/types.js';
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'server', 'data');
|
||||
const SESSION_FILE = path.join(DATA_DIR, 'session.json');
|
||||
const SEED_FILE = path.join(DATA_DIR, 'seed.json');
|
||||
const BACKUP_FILE = path.join(DATA_DIR, 'session.json.bak');
|
||||
|
||||
/**
|
||||
* Ensure the data directory exists.
|
||||
*/
|
||||
function ensureDataDir(): void {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize session.json from seed.json if it doesn't exist.
|
||||
*/
|
||||
function initializeStore(): void {
|
||||
ensureDataDir();
|
||||
if (!fs.existsSync(SESSION_FILE)) {
|
||||
const seedData = fs.readFileSync(SEED_FILE, 'utf-8');
|
||||
fs.writeFileSync(SESSION_FILE, seedData, 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on module load
|
||||
initializeStore();
|
||||
|
||||
/**
|
||||
* Read the current store data from session.json.
|
||||
*/
|
||||
export function getStore(): StoreData {
|
||||
const raw = fs.readFileSync(SESSION_FILE, 'utf-8');
|
||||
return JSON.parse(raw) as StoreData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write store data atomically: write to temp file, then rename.
|
||||
* Creates a .bak backup before each write.
|
||||
*/
|
||||
export function saveStore(data: StoreData): void {
|
||||
ensureDataDir();
|
||||
|
||||
// Create backup of current file if it exists
|
||||
if (fs.existsSync(SESSION_FILE)) {
|
||||
fs.copyFileSync(SESSION_FILE, BACKUP_FILE);
|
||||
}
|
||||
|
||||
// Write to temp file, then rename (atomic on most filesystems)
|
||||
const tempFile = path.join(DATA_DIR, 'session.json.tmp');
|
||||
fs.writeFileSync(tempFile, JSON.stringify(data, null, 2), 'utf-8');
|
||||
fs.renameSync(tempFile, SESSION_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current session.
|
||||
*/
|
||||
export function getSession(): Session {
|
||||
const store = getStore();
|
||||
return store.session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all teams.
|
||||
*/
|
||||
export function getTeams(): Team[] {
|
||||
const store = getStore();
|
||||
return store.teams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all jurors.
|
||||
*/
|
||||
export function getJurors(): Juror[] {
|
||||
const store = getStore();
|
||||
return store.jurors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all scores.
|
||||
*/
|
||||
export function getScores(): Score[] {
|
||||
const store = getStore();
|
||||
return store.scores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a score. Matches by jurorId + teamId + criterionId.
|
||||
*/
|
||||
export function addScore(score: Score): void {
|
||||
const store = getStore();
|
||||
const existingIndex = store.scores.findIndex(
|
||||
(s) =>
|
||||
s.jurorId === score.jurorId &&
|
||||
s.teamId === score.teamId &&
|
||||
s.criterionId === score.criterionId
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
store.scores[existingIndex] = score;
|
||||
} else {
|
||||
store.scores.push(score);
|
||||
}
|
||||
|
||||
saveStore(store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session with partial updates. Returns the updated session.
|
||||
*/
|
||||
export function updateSession(updates: Partial<Session>): Session {
|
||||
const store = getStore();
|
||||
store.session = { ...store.session, ...updates };
|
||||
saveStore(store);
|
||||
return store.session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a juror by ID with partial updates. Returns the updated juror or null if not found.
|
||||
*/
|
||||
export function updateJuror(jurorId: string, updates: Partial<Juror>): Juror | null {
|
||||
const store = getStore();
|
||||
const jurorIndex = store.jurors.findIndex((j) => j.id === jurorId);
|
||||
|
||||
if (jurorIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
store.jurors[jurorIndex] = { ...store.jurors[jurorIndex], ...updates };
|
||||
saveStore(store);
|
||||
return store.jurors[jurorIndex];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist-server",
|
||||
"rootDir": "..",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["./**/*.ts", "../shared/**/*.ts"],
|
||||
"exclude": ["dist-server", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Session
|
||||
export interface Session {
|
||||
id: string;
|
||||
status: 'setup' | 'active' | 'ended';
|
||||
startedAt: string | null;
|
||||
endedAt: string | null;
|
||||
config: SessionConfig;
|
||||
}
|
||||
|
||||
export interface SessionConfig {
|
||||
criteria: Criterion[];
|
||||
scaleMin: number; // default: 1
|
||||
scaleMax: number; // default: 10
|
||||
}
|
||||
|
||||
// Team
|
||||
export interface Team {
|
||||
id: string; // kebab-case slug
|
||||
number: number; // original number
|
||||
name: string; // display name
|
||||
description: string; // short description
|
||||
contact?: string; // contact person
|
||||
}
|
||||
|
||||
// Juror
|
||||
export interface Juror {
|
||||
id: string; // lowercase name
|
||||
name: string; // display name
|
||||
active: boolean; // currently has active session
|
||||
}
|
||||
|
||||
// Criterion
|
||||
export interface Criterion {
|
||||
id: string; // kebab-case
|
||||
nameDE: string; // German name
|
||||
nameEN: string; // English name
|
||||
}
|
||||
|
||||
// Score
|
||||
export interface Score {
|
||||
jurorId: string;
|
||||
teamId: string;
|
||||
criterionId: string;
|
||||
value: number; // 1–10
|
||||
updatedAt: string; // ISO timestamp
|
||||
}
|
||||
|
||||
// Per-criterion stats
|
||||
export interface CriterionStats {
|
||||
criterionId: string;
|
||||
criterionName: string;
|
||||
mean: number;
|
||||
stddev: number;
|
||||
scores: number[];
|
||||
}
|
||||
|
||||
// Aggregated Result
|
||||
export interface TeamResult {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
total: number;
|
||||
average: number;
|
||||
stddev: number;
|
||||
rank: number;
|
||||
jurorCount: number;
|
||||
complete: boolean;
|
||||
criteriaStats?: CriterionStats[];
|
||||
}
|
||||
|
||||
// Offline queue
|
||||
export interface PendingScore {
|
||||
jurorId: string;
|
||||
teamId: string;
|
||||
criterionId: string;
|
||||
value: number;
|
||||
timestamp: string;
|
||||
synced: boolean;
|
||||
}
|
||||
|
||||
// Store data (JSON file structure)
|
||||
export interface StoreData {
|
||||
session: Session;
|
||||
teams: Team[];
|
||||
jurors: Juror[];
|
||||
scores: Score[];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import JurorSelect from './pages/JurorSelect'
|
||||
import VotingDashboard from './pages/VotingDashboard'
|
||||
import TeamScoring from './pages/TeamScoring'
|
||||
import ResultsView from './pages/ResultsView'
|
||||
import ResultsDetailView from './pages/ResultsDetailView'
|
||||
import AdminPanel from './pages/AdminPanel'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="background-mesh" />
|
||||
<Routes>
|
||||
<Route path="/" element={<JurorSelect />} />
|
||||
<Route path="/vote" element={<VotingDashboard />} />
|
||||
<Route path="/vote/:teamId" element={<TeamScoring />} />
|
||||
<Route path="/results" element={<ResultsView />} />
|
||||
<Route path="/results/detail" element={<ResultsDetailView />} />
|
||||
<Route path="/admin" element={<AdminPanel />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface JurorProgress {
|
||||
completed: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays juror voting progress (X/11 teams completed per juror).
|
||||
* Fetches from GET /api/progress. No individual scores shown.
|
||||
*/
|
||||
export default function ProgressMatrix() {
|
||||
const [progress, setProgress] = useState<Record<string, JurorProgress>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProgress();
|
||||
const interval = setInterval(fetchProgress, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
function fetchProgress() {
|
||||
fetch('/api/progress')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setProgress(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="progress-loading">Lade Fortschritt...</div>;
|
||||
}
|
||||
|
||||
const entries = Object.entries(progress);
|
||||
const allComplete = entries.length > 0 && entries.every(([, p]) => p.completed === p.total);
|
||||
|
||||
return (
|
||||
<div className="progress-matrix">
|
||||
<h3 className="progress-title">Fortschritt</h3>
|
||||
|
||||
{allComplete && (
|
||||
<div className="progress-complete-badge">
|
||||
✓ Alle Bewertungen abgeschlossen
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="progress-list">
|
||||
{entries.map(([jurorId, { completed, total }]) => {
|
||||
const percent = total > 0 ? (completed / total) * 100 : 0;
|
||||
const isComplete = completed === total;
|
||||
|
||||
return (
|
||||
<div key={jurorId} className="progress-item">
|
||||
<div className="progress-item-header">
|
||||
<span className="progress-juror-name">
|
||||
{jurorId.charAt(0).toUpperCase() + jurorId.slice(1)}
|
||||
</span>
|
||||
<span className={`progress-count ${isComplete ? 'complete' : ''}`}>
|
||||
{completed}/{total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="progress-bar-track">
|
||||
<div
|
||||
className={`progress-bar-fill ${isComplete ? 'complete' : ''}`}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { TeamResult } from '../../shared/types';
|
||||
|
||||
interface RankingTableProps {
|
||||
results: TeamResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Beamer-optimized ranking table with animated transitions.
|
||||
* Displays ranked teams with scores, highlights top 3.
|
||||
*/
|
||||
export default function RankingTable({ results }: RankingTableProps) {
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className="ranking-empty">
|
||||
<p>Noch keine Bewertungen eingegangen.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getRankStyle = (rank: number): string => {
|
||||
if (rank === 1) return 'rank-gold';
|
||||
if (rank === 2) return 'rank-silver';
|
||||
if (rank === 3) return 'rank-bronze';
|
||||
return '';
|
||||
};
|
||||
|
||||
const getRankLabel = (rank: number): string => {
|
||||
if (rank === 1) return '🥇';
|
||||
if (rank === 2) return '🥈';
|
||||
if (rank === 3) return '🥉';
|
||||
return `${rank}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ranking-table">
|
||||
<div className="ranking-header">
|
||||
<span className="ranking-col-rank">Platz</span>
|
||||
<span className="ranking-col-team">Team</span>
|
||||
<span className="ranking-col-total">Gesamt</span>
|
||||
<span className="ranking-col-avg">Ø</span>
|
||||
</div>
|
||||
<div className="ranking-rows">
|
||||
{results.map((result, index) => (
|
||||
<div
|
||||
key={result.teamId}
|
||||
className={`ranking-row glass-panel ${getRankStyle(result.rank)}`}
|
||||
style={{
|
||||
order: index,
|
||||
transition: 'transform 0.6s ease, opacity 0.4s ease',
|
||||
}}
|
||||
>
|
||||
<span className="ranking-col-rank rank-number">
|
||||
{getRankLabel(result.rank)}
|
||||
</span>
|
||||
<span className="ranking-col-team team-name">
|
||||
{result.teamName}
|
||||
</span>
|
||||
<span className="ranking-col-total total-score">
|
||||
{result.total}
|
||||
</span>
|
||||
<span className="ranking-col-avg avg-score">
|
||||
{result.average.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect } from 'react';
|
||||
import { TeamResult } from '../../shared/types';
|
||||
|
||||
interface TeamDetailModalProps {
|
||||
team: TeamResult;
|
||||
scaleMax: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal overlay showing per-category bar chart for a selected team.
|
||||
*/
|
||||
export default function TeamDetailModal({ team, scaleMax, onClose }: TeamDetailModalProps) {
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
const stats = team.criteriaStats ?? [];
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={`Ergebnisse für ${team.teamName}`}>
|
||||
<div className="modal-content glass-panel" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="modal-header">
|
||||
<h2 className="modal-title">{team.teamName}</h2>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Schließen">✕</button>
|
||||
</header>
|
||||
|
||||
<div className="modal-summary">
|
||||
<span className="modal-stat">
|
||||
<span className="modal-stat-label">Gesamt</span>
|
||||
<span className="modal-stat-value">{team.total}</span>
|
||||
</span>
|
||||
<span className="modal-stat">
|
||||
<span className="modal-stat-label">Ø</span>
|
||||
<span className="modal-stat-value">{team.average.toFixed(1)}</span>
|
||||
</span>
|
||||
<span className="modal-stat">
|
||||
<span className="modal-stat-label">σ</span>
|
||||
<span className="modal-stat-value">±{team.stddev.toFixed(1)}</span>
|
||||
</span>
|
||||
<span className="modal-stat">
|
||||
<span className="modal-stat-label">Juroren</span>
|
||||
<span className="modal-stat-value">{team.jurorCount}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{stats.length > 0 ? (
|
||||
<div className="modal-chart">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.criterionId} className="chart-row">
|
||||
<span className="chart-label">{stat.criterionName}</span>
|
||||
<div className="chart-bar-container">
|
||||
<div
|
||||
className="chart-bar"
|
||||
style={{ width: `${(stat.mean / scaleMax) * 100}%` }}
|
||||
>
|
||||
<span className="chart-bar-value">{stat.mean.toFixed(1)}</span>
|
||||
</div>
|
||||
{stat.stddev > 0 && (
|
||||
<span className="chart-stddev">±{stat.stddev.toFixed(1)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="modal-empty">Noch keine Bewertungen für dieses Team.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import type { PendingScore } from '../../shared/types'
|
||||
|
||||
const STORAGE_KEY = 'pendingScores'
|
||||
const MAX_RETRIES = 3
|
||||
const BASE_DELAY_MS = 1000
|
||||
|
||||
interface SubmitScoreParams {
|
||||
jurorId: string
|
||||
teamId: string
|
||||
criterionId: string
|
||||
value: number
|
||||
}
|
||||
|
||||
interface UseOfflineQueueReturn {
|
||||
submitScore: (params: SubmitScoreParams) => void
|
||||
pendingCount: number
|
||||
isOnline: boolean
|
||||
}
|
||||
|
||||
function loadPendingScores(): PendingScore[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (!stored) return []
|
||||
return JSON.parse(stored) as PendingScore[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function savePendingScores(scores: PendingScore[]): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(scores))
|
||||
}
|
||||
|
||||
export function useOfflineQueue(): UseOfflineQueueReturn {
|
||||
const [pending, setPending] = useState<PendingScore[]>(loadPendingScores)
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||
const retryCountsRef = useRef<Map<string, number>>(new Map())
|
||||
const processingRef = useRef(false)
|
||||
|
||||
// Track online/offline status
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true)
|
||||
const handleOffline = () => setIsOnline(false)
|
||||
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Process queue when online or when pending changes
|
||||
useEffect(() => {
|
||||
if (isOnline && pending.some(s => !s.synced)) {
|
||||
processQueue()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOnline, pending])
|
||||
|
||||
const getScoreKey = (s: PendingScore) =>
|
||||
`${s.jurorId}:${s.teamId}:${s.criterionId}:${s.timestamp}`
|
||||
|
||||
const processQueue = useCallback(async () => {
|
||||
if (processingRef.current) return
|
||||
processingRef.current = true
|
||||
|
||||
const unsynced = pending.filter(s => !s.synced)
|
||||
|
||||
for (const score of unsynced) {
|
||||
const key = getScoreKey(score)
|
||||
const retries = retryCountsRef.current.get(key) || 0
|
||||
|
||||
if (retries >= MAX_RETRIES) continue
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/scores', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jurorId: score.jurorId,
|
||||
teamId: score.teamId,
|
||||
criterionId: score.criterionId,
|
||||
value: score.value,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok || res.status === 409) {
|
||||
// Success or conflict (server has newer) — remove from queue
|
||||
setPending(prev => {
|
||||
const updated = prev.filter(s => getScoreKey(s) !== key)
|
||||
savePendingScores(updated)
|
||||
return updated
|
||||
})
|
||||
retryCountsRef.current.delete(key)
|
||||
} else {
|
||||
throw new Error(`Server returned ${res.status}`)
|
||||
}
|
||||
} catch {
|
||||
const newRetries = retries + 1
|
||||
retryCountsRef.current.set(key, newRetries)
|
||||
|
||||
if (newRetries < MAX_RETRIES) {
|
||||
// Exponential backoff
|
||||
const delay = BASE_DELAY_MS * Math.pow(2, newRetries - 1)
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processingRef.current = false
|
||||
}, [pending])
|
||||
|
||||
const submitScore = useCallback((params: SubmitScoreParams) => {
|
||||
const newScore: PendingScore = {
|
||||
jurorId: params.jurorId,
|
||||
teamId: params.teamId,
|
||||
criterionId: params.criterionId,
|
||||
value: params.value,
|
||||
timestamp: new Date().toISOString(),
|
||||
synced: false,
|
||||
}
|
||||
|
||||
setPending(prev => {
|
||||
// Replace any existing pending score for same juror/team/criterion
|
||||
const filtered = prev.filter(
|
||||
s =>
|
||||
!(
|
||||
s.jurorId === params.jurorId &&
|
||||
s.teamId === params.teamId &&
|
||||
s.criterionId === params.criterionId
|
||||
)
|
||||
)
|
||||
const updated = [...filtered, newScore]
|
||||
savePendingScores(updated)
|
||||
return updated
|
||||
})
|
||||
|
||||
// Reset retry count for this score
|
||||
const key = getScoreKey(newScore)
|
||||
retryCountsRef.current.delete(key)
|
||||
}, [])
|
||||
|
||||
const pendingCount = pending.filter(s => !s.synced).length
|
||||
|
||||
return { submitScore, pendingCount, isOnline }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { TeamResult } from '../../shared/types';
|
||||
|
||||
interface UseSSEReturn {
|
||||
results: TeamResult[];
|
||||
connected: boolean;
|
||||
lastUpdate: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom React hook that connects to the SSE endpoint for live score updates.
|
||||
* Fetches initial results immediately, then uses EventSource for live updates.
|
||||
*/
|
||||
export function useSSE(): UseSSEReturn {
|
||||
const [results, setResults] = useState<TeamResult[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState<string | null>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch initial results immediately
|
||||
fetch('/api/results')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setResults(data);
|
||||
setLastUpdate(new Date().toISOString());
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Then connect to SSE for live updates
|
||||
const eventSource = new EventSource('/api/results/stream');
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
eventSource.onopen = () => {
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
eventSource.addEventListener('scores_updated', (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
setResults(data.results);
|
||||
setLastUpdate(data.timestamp);
|
||||
} catch {
|
||||
// Ignore malformed events
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
setConnected(false);
|
||||
// EventSource will automatically attempt to reconnect
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
setConnected(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { results, connected, lastUpdate };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Session } from '../../shared/types';
|
||||
import ProgressMatrix from '../components/ProgressMatrix';
|
||||
|
||||
/**
|
||||
* Admin panel for session management, progress monitoring, and export.
|
||||
*/
|
||||
export default function AdminPanel() {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showEndConfirm, setShowEndConfirm] = useState(false);
|
||||
const [incompleteWarning, setIncompleteWarning] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSession();
|
||||
}, []);
|
||||
|
||||
function fetchSession() {
|
||||
fetch('/api/session')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setSession(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
const res = await fetch('/api/session/start', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSession(data);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEndClick() {
|
||||
// Check progress before ending
|
||||
try {
|
||||
const res = await fetch('/api/progress');
|
||||
const progress: Record<string, { completed: number; total: number }> = await res.json();
|
||||
const entries = Object.entries(progress);
|
||||
const incomplete = entries.filter(([, p]) => p.completed < p.total);
|
||||
|
||||
if (incomplete.length > 0) {
|
||||
const warnings = incomplete.map(
|
||||
([id, p]) => `${id.charAt(0).toUpperCase() + id.slice(1)}: ${p.completed}/${p.total}`
|
||||
);
|
||||
setIncompleteWarning(`Unvollständige Bewertungen:\n${warnings.join(', ')}`);
|
||||
} else {
|
||||
setIncompleteWarning(null);
|
||||
}
|
||||
} catch {
|
||||
setIncompleteWarning(null);
|
||||
}
|
||||
|
||||
setShowEndConfirm(true);
|
||||
}
|
||||
|
||||
async function handleEndConfirm() {
|
||||
const res = await fetch('/api/session/end', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSession(data);
|
||||
}
|
||||
setShowEndConfirm(false);
|
||||
setIncompleteWarning(null);
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
const a = document.createElement('a');
|
||||
a.href = '/api/export/csv';
|
||||
a.download = 'jury-results.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: string | null): string {
|
||||
if (!ts) return '—';
|
||||
return new Date(ts).toLocaleString('de-DE', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Laden...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="admin-header-row">
|
||||
<h1>Admin Panel</h1>
|
||||
</div>
|
||||
|
||||
{/* Session Status */}
|
||||
<section className="glass-panel admin-section">
|
||||
<h3 className="admin-section-title">Session</h3>
|
||||
<div className="admin-session-info">
|
||||
<div className="session-status-row">
|
||||
<span className="session-label">Status:</span>
|
||||
<span className={`session-badge status-${session?.status || 'setup'}`}>
|
||||
{session?.status === 'active' ? 'Aktiv' :
|
||||
session?.status === 'ended' ? 'Beendet' : 'Vorbereitung'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="session-status-row">
|
||||
<span className="session-label">Gestartet:</span>
|
||||
<span className="session-value">{formatTimestamp(session?.startedAt ?? null)}</span>
|
||||
</div>
|
||||
<div className="session-status-row">
|
||||
<span className="session-label">Beendet:</span>
|
||||
<span className="session-value">{formatTimestamp(session?.endedAt ?? null)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Controls */}
|
||||
<div className="admin-controls">
|
||||
{session?.status === 'setup' && (
|
||||
<button className="primary-button" onClick={handleStart}>
|
||||
Abstimmung starten
|
||||
</button>
|
||||
)}
|
||||
{session?.status === 'active' && (
|
||||
<button className="admin-end-btn" onClick={handleEndClick}>
|
||||
Abstimmung beenden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* End Confirmation Dialog */}
|
||||
{showEndConfirm && (
|
||||
<div className="admin-dialog-overlay" onClick={() => setShowEndConfirm(false)}>
|
||||
<div className="glass-panel admin-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Abstimmung beenden?</h3>
|
||||
{incompleteWarning && (
|
||||
<p className="admin-warning">{incompleteWarning}</p>
|
||||
)}
|
||||
<p className="admin-dialog-text">
|
||||
Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
</p>
|
||||
<div className="admin-dialog-actions">
|
||||
<button className="admin-cancel-btn" onClick={() => setShowEndConfirm(false)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button className="admin-confirm-btn" onClick={handleEndConfirm}>
|
||||
Beenden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Matrix */}
|
||||
<section className="glass-panel admin-section">
|
||||
<ProgressMatrix />
|
||||
</section>
|
||||
|
||||
{/* Export & Links */}
|
||||
<section className="glass-panel admin-section">
|
||||
<h3 className="admin-section-title">Export & Ansichten</h3>
|
||||
<div className="admin-actions-row">
|
||||
<button className="primary-button" onClick={handleExport}>
|
||||
CSV Export
|
||||
</button>
|
||||
<a href="/results" target="_blank" rel="noopener noreferrer" className="admin-link-btn">
|
||||
Ergebnis-Ansicht öffnen ↗
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import type { Juror } from '../../shared/types'
|
||||
|
||||
const STORAGE_KEY = 'jury-voting-juror'
|
||||
|
||||
export default function JurorSelect() {
|
||||
const navigate = useNavigate()
|
||||
const [jurors, setJurors] = useState<Juror[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/jurors')
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('Failed to load jurors')
|
||||
return res.json()
|
||||
})
|
||||
.then((data: Juror[]) => {
|
||||
setJurors(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(err => {
|
||||
setError(err.message)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSelect = (juror: Juror) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: juror.id, name: juror.name }))
|
||||
navigate('/vote')
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page-container" style={styles.center}>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Laden...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="page-container" style={styles.center}>
|
||||
<p style={{ color: '#ff6b6b' }}>{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container" style={styles.page}>
|
||||
<header style={styles.header}>
|
||||
<p style={styles.eventTitle}>15. UNIKAT Ideenwettbewerb</p>
|
||||
<h1 style={styles.title}>
|
||||
<span className="text-gradient">Jury Voting</span>
|
||||
</h1>
|
||||
<p style={styles.subtitle}>Wählen Sie Ihren Namen</p>
|
||||
</header>
|
||||
|
||||
<div style={styles.grid}>
|
||||
{jurors.map(juror => (
|
||||
<button
|
||||
key={juror.id}
|
||||
onClick={() => handleSelect(juror)}
|
||||
style={styles.card}
|
||||
className="glass-panel"
|
||||
aria-label={`Als ${juror.name} anmelden`}
|
||||
>
|
||||
<span style={styles.cardName}>{juror.name}</span>
|
||||
{juror.active && (
|
||||
<span style={styles.activeBadge}>aktiv</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
page: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
gap: '40px',
|
||||
},
|
||||
center: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
},
|
||||
header: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
eventTitle: {
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 400,
|
||||
marginBottom: '8px',
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
title: {
|
||||
fontSize: 'clamp(2rem, 8vw, 3rem)',
|
||||
marginBottom: '12px',
|
||||
},
|
||||
subtitle: {
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 300,
|
||||
},
|
||||
grid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '12px',
|
||||
width: '100%',
|
||||
maxWidth: '360px',
|
||||
},
|
||||
card: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '24px 16px',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid var(--glass-border)',
|
||||
background: 'var(--glass-bg)',
|
||||
borderRadius: '16px',
|
||||
transition: 'all 0.2s ease',
|
||||
minHeight: '80px',
|
||||
position: 'relative',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '1rem',
|
||||
fontFamily: 'inherit',
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
},
|
||||
cardName: {
|
||||
fontSize: '1.2rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
activeBadge: {
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
right: '8px',
|
||||
fontSize: '0.65rem',
|
||||
color: 'var(--accent-color)',
|
||||
border: '1px solid rgba(0, 255, 0, 0.3)',
|
||||
borderRadius: '50px',
|
||||
padding: '2px 8px',
|
||||
fontWeight: 400,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useSSE } from '../hooks/useSSE';
|
||||
import { Session, CriterionStats } from '../../shared/types';
|
||||
|
||||
/**
|
||||
* Detail view showing per-category scores (mean + stddev) for all teams.
|
||||
* Scrollable table with sticky shrinking header.
|
||||
* Teams expand to show histograms (5 categories side by side).
|
||||
*/
|
||||
export default function ResultsDetailView() {
|
||||
const { results, connected } = useSSE();
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [expandedTeams, setExpandedTeams] = useState<Set<string>>(new Set());
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/session')
|
||||
.then((res) => res.json())
|
||||
.then(setSession)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = mainRef.current;
|
||||
if (!el) return;
|
||||
const handleScroll = () => {
|
||||
setScrolled(el.scrollTop > 40);
|
||||
};
|
||||
el.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => el.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const criteria = session?.config.criteria ?? [];
|
||||
const isEnded = session?.status === 'ended';
|
||||
const scaleMax = session?.config.scaleMax ?? 10;
|
||||
|
||||
const toggleTeam = (teamId: string) => {
|
||||
setExpandedTeams((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(teamId)) {
|
||||
next.delete(teamId);
|
||||
} else {
|
||||
next.add(teamId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="results-view results-view--scrollable">
|
||||
<div className="results-bg-mesh" />
|
||||
|
||||
{/* Connection indicator */}
|
||||
<div className="results-connection">
|
||||
<span className={`connection-dot ${connected ? 'connected' : 'disconnected'}`} />
|
||||
</div>
|
||||
|
||||
{/* Sticky Header */}
|
||||
<header className={`results-header results-header--sticky ${scrolled ? 'results-header--compact' : ''}`}>
|
||||
<h1 className="results-title">15. UNIKAT Ideenwettbewerb</h1>
|
||||
<div className="results-header-extras">
|
||||
<p className={`results-status ${!isEnded ? 'voting-active' : ''}`}>
|
||||
{isEnded ? 'Endergebnis' : 'Abstimmung läuft...'}
|
||||
</p>
|
||||
<nav className="results-nav">
|
||||
<a href="/results" className="results-nav-link">Ranking</a>
|
||||
<a href="/results/detail" className="results-nav-link active">Details</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Detail Table */}
|
||||
<main className="results-main results-main--scrollable" ref={mainRef}>
|
||||
{results.length === 0 ? (
|
||||
<div className="ranking-empty">
|
||||
<p>Noch keine Bewertungen eingegangen.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="detail-table-wrapper">
|
||||
<table className="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="detail-th detail-th-rank">#</th>
|
||||
<th className="detail-th detail-th-team">Team</th>
|
||||
{criteria.map((c) => (
|
||||
<th key={c.id} className="detail-th detail-th-criterion">
|
||||
{c.nameDE}
|
||||
</th>
|
||||
))}
|
||||
<th className="detail-th detail-th-total">Gesamt</th>
|
||||
<th className="detail-th detail-th-avg">Ø</th>
|
||||
<th className="detail-th detail-th-stddev">StAbw</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((result) => {
|
||||
const isExpanded = expandedTeams.has(result.teamId);
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
key={result.teamId}
|
||||
className={`detail-row ${getRankStyle(result.rank)} detail-row--clickable`}
|
||||
onClick={() => toggleTeam(result.teamId)}
|
||||
>
|
||||
<td className="detail-td detail-td-rank">
|
||||
{getRankLabel(result.rank)}
|
||||
</td>
|
||||
<td className="detail-td detail-td-team">
|
||||
{result.teamName}
|
||||
<span className="expand-indicator-sm">{isExpanded ? ' ▲' : ' ▼'}</span>
|
||||
</td>
|
||||
{criteria.map((c) => {
|
||||
const stat = result.criteriaStats?.find(
|
||||
(s) => s.criterionId === c.id
|
||||
);
|
||||
return (
|
||||
<td key={c.id} className="detail-td detail-td-criterion">
|
||||
{stat && stat.mean > 0 ? (
|
||||
<div className="detail-cell-content">
|
||||
<span className="detail-mean">{stat.mean.toFixed(1)}</span>
|
||||
{stat.stddev > 0 && (
|
||||
<span className="detail-stddev">±{stat.stddev.toFixed(1)}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="detail-empty">–</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="detail-td detail-td-total">{result.total}</td>
|
||||
<td className="detail-td detail-td-avg">{result.average.toFixed(1)}</td>
|
||||
<td className="detail-td detail-td-stddev">±{result.stddev.toFixed(1)}</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr key={`${result.teamId}-expand`} className="detail-expand-row">
|
||||
<td colSpan={criteria.length + 5} className="detail-expand-cell">
|
||||
<div className="histograms">
|
||||
{(result.criteriaStats ?? []).map((stat) => (
|
||||
<Histogram key={stat.criterionId} stat={stat} scaleMax={scaleMax} />
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Single histogram for one criterion — vertical bars for each juror score */
|
||||
function Histogram({ stat, scaleMax }: { stat: CriterionStats; scaleMax: number }) {
|
||||
return (
|
||||
<div className="histogram">
|
||||
<div className="histogram-bars">
|
||||
{stat.scores.map((score, i) => (
|
||||
<div key={i} className="histogram-bar-wrapper">
|
||||
<div
|
||||
className="histogram-bar"
|
||||
style={{ height: `${(score / scaleMax) * 100}%` }}
|
||||
>
|
||||
<span className="histogram-bar-label">{score}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="histogram-footer">
|
||||
<span className="histogram-name">{stat.criterionName}</span>
|
||||
<span className="histogram-mean">Ø {stat.mean.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getRankStyle(rank: number): string {
|
||||
if (rank === 1) return 'rank-gold';
|
||||
if (rank === 2) return 'rank-silver';
|
||||
if (rank === 3) return 'rank-bronze';
|
||||
return '';
|
||||
}
|
||||
|
||||
function getRankLabel(rank: number): string {
|
||||
if (rank === 1) return '🥇';
|
||||
if (rank === 2) return '🥈';
|
||||
if (rank === 3) return '🥉';
|
||||
return `${rank}`;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useSSE } from '../hooks/useSSE';
|
||||
import { Session, CriterionStats } from '../../shared/types';
|
||||
|
||||
/**
|
||||
* Full-screen beamer view for live-updating rankings.
|
||||
* Scrollable with sticky shrinking header.
|
||||
* Teams expand to show histograms (5 categories side by side, vertical bars per juror score).
|
||||
*/
|
||||
export default function ResultsView() {
|
||||
const { results, connected } = useSSE();
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [expandedTeams, setExpandedTeams] = useState<Set<string>>(new Set());
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/session')
|
||||
.then((res) => res.json())
|
||||
.then(setSession)
|
||||
.catch(() => {});
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetch('/api/session')
|
||||
.then((res) => res.json())
|
||||
.then(setSession)
|
||||
.catch(() => {});
|
||||
}, 10000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = mainRef.current;
|
||||
if (!el) return;
|
||||
const handleScroll = () => {
|
||||
setScrolled(el.scrollTop > 40);
|
||||
};
|
||||
el.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => el.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const isEnded = session?.status === 'ended';
|
||||
const scaleMax = session?.config.scaleMax ?? 10;
|
||||
|
||||
const toggleTeam = (teamId: string) => {
|
||||
setExpandedTeams((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(teamId)) {
|
||||
next.delete(teamId);
|
||||
} else {
|
||||
next.add(teamId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="results-view results-view--scrollable">
|
||||
<div className="results-bg-mesh" />
|
||||
|
||||
{/* Connection indicator */}
|
||||
<div className="results-connection">
|
||||
<span className={`connection-dot ${connected ? 'connected' : 'disconnected'}`} />
|
||||
</div>
|
||||
|
||||
{/* Sticky Header — shrinks on scroll */}
|
||||
<header className={`results-header results-header--sticky ${scrolled ? 'results-header--compact' : ''}`}>
|
||||
<h1 className="results-title">15. UNIKAT Ideenwettbewerb</h1>
|
||||
<div className="results-header-extras">
|
||||
<p className={`results-status ${!isEnded ? 'voting-active' : ''}`}>
|
||||
{isEnded ? 'Endergebnis' : 'Abstimmung läuft...'}
|
||||
</p>
|
||||
<nav className="results-nav">
|
||||
<a href="/results" className="results-nav-link active">Ranking</a>
|
||||
<a href="/results/detail" className="results-nav-link">Details</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Ranking Table */}
|
||||
<main className="results-main results-main--scrollable" ref={mainRef}>
|
||||
{results.length === 0 ? (
|
||||
<div className="ranking-empty">
|
||||
<p>Noch keine Bewertungen eingegangen.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ranking-table">
|
||||
<div className="ranking-header ranking-header--with-stddev">
|
||||
<span className="ranking-col-rank">Platz</span>
|
||||
<span className="ranking-col-team">Team</span>
|
||||
<span className="ranking-col-total">Gesamt</span>
|
||||
<span className="ranking-col-avg">Ø</span>
|
||||
<span className="ranking-col-stddev">StAbw</span>
|
||||
</div>
|
||||
<div className="ranking-rows">
|
||||
{results.map((result) => {
|
||||
const isExpanded = expandedTeams.has(result.teamId);
|
||||
return (
|
||||
<div key={result.teamId} className="ranking-item">
|
||||
<div
|
||||
className={`ranking-row glass-panel ${getRankStyle(result.rank)} ranking-row--clickable ${isExpanded ? 'ranking-row--expanded' : ''}`}
|
||||
onClick={() => toggleTeam(result.teamId)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') toggleTeam(result.teamId);
|
||||
}}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`Details für ${result.teamName} ${isExpanded ? 'einklappen' : 'ausklappen'}`}
|
||||
>
|
||||
<span className="ranking-col-rank rank-number">
|
||||
{getRankLabel(result.rank)}
|
||||
</span>
|
||||
<span className="ranking-col-team team-name">
|
||||
{result.teamName}
|
||||
<span className="expand-indicator">{isExpanded ? '▲' : '▼'}</span>
|
||||
</span>
|
||||
<span className="ranking-col-total total-score">
|
||||
{result.total}
|
||||
</span>
|
||||
<span className="ranking-col-avg avg-score">
|
||||
{result.average.toFixed(1)}
|
||||
</span>
|
||||
<span className="ranking-col-stddev stddev-score">
|
||||
±{result.stddev.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Expandable: Histograms per category, side by side */}
|
||||
{isExpanded && (
|
||||
<div className="ranking-expand glass-panel">
|
||||
{result.criteriaStats && result.criteriaStats.length > 0 ? (
|
||||
<div className="histograms">
|
||||
{result.criteriaStats.map((stat) => (
|
||||
<Histogram key={stat.criterionId} stat={stat} scaleMax={scaleMax} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="expand-empty">Noch keine Einzelbewertungen vorhanden.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Single histogram for one criterion — vertical bars for each juror score */
|
||||
function Histogram({ stat, scaleMax }: { stat: CriterionStats; scaleMax: number }) {
|
||||
return (
|
||||
<div className="histogram">
|
||||
<div className="histogram-bars">
|
||||
{stat.scores.map((score, i) => (
|
||||
<div key={i} className="histogram-bar-wrapper">
|
||||
<div
|
||||
className="histogram-bar"
|
||||
style={{ height: `${(score / scaleMax) * 100}%` }}
|
||||
>
|
||||
<span className="histogram-bar-label">{score}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="histogram-footer">
|
||||
<span className="histogram-name">{stat.criterionName}</span>
|
||||
<span className="histogram-mean">Ø {stat.mean.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getRankStyle(rank: number): string {
|
||||
if (rank === 1) return 'rank-gold';
|
||||
if (rank === 2) return 'rank-silver';
|
||||
if (rank === 3) return 'rank-bronze';
|
||||
return '';
|
||||
}
|
||||
|
||||
function getRankLabel(rank: number): string {
|
||||
if (rank === 1) return '🥇';
|
||||
if (rank === 2) return '🥈';
|
||||
if (rank === 3) return '🥉';
|
||||
return `${rank}`;
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import type { Team, Score, Criterion } from '../../shared/types'
|
||||
import { useOfflineQueue } from '../hooks/useOfflineQueue'
|
||||
|
||||
const STORAGE_KEY = 'jury-voting-juror'
|
||||
const DEBOUNCE_MS = 300
|
||||
|
||||
interface JurorInfo {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export default function TeamScoring() {
|
||||
const { teamId } = useParams<{ teamId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [juror, setJuror] = useState<JurorInfo | null>(null)
|
||||
const [team, setTeam] = useState<Team | null>(null)
|
||||
const [teams, setTeams] = useState<Team[]>([])
|
||||
const [criteria, setCriteria] = useState<Criterion[]>([])
|
||||
const [scores, setScores] = useState<Record<string, number>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [savedCriterion, setSavedCriterion] = useState<string | null>(null)
|
||||
const debounceTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
|
||||
const { submitScore, pendingCount, isOnline } = useOfflineQueue()
|
||||
|
||||
// Load juror from localStorage
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (!stored) {
|
||||
navigate('/', { replace: true })
|
||||
return
|
||||
}
|
||||
try {
|
||||
setJuror(JSON.parse(stored) as JurorInfo)
|
||||
} catch {
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
// Fetch data
|
||||
useEffect(() => {
|
||||
if (!juror || !teamId) return
|
||||
|
||||
Promise.all([
|
||||
fetch('/api/teams').then(r => r.json()),
|
||||
fetch(`/api/scores/${juror.id}`).then(r => r.json()),
|
||||
fetch('/api/session').then(r => r.json()),
|
||||
])
|
||||
.then(([teamsData, scoresData, sessionData]) => {
|
||||
const allTeams = teamsData as Team[]
|
||||
setTeams(allTeams)
|
||||
setTeam(allTeams.find(t => t.id === teamId) || null)
|
||||
|
||||
const allScores = scoresData as Score[]
|
||||
const teamScores: Record<string, number> = {}
|
||||
allScores
|
||||
.filter(s => s.teamId === teamId)
|
||||
.forEach(s => {
|
||||
teamScores[s.criterionId] = s.value
|
||||
})
|
||||
setScores(teamScores)
|
||||
|
||||
setCriteria(sessionData.config?.criteria || [])
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => setLoading(false))
|
||||
}, [juror, teamId])
|
||||
|
||||
// Show green flash on save
|
||||
const flashSaved = useCallback((criterionId: string) => {
|
||||
setSavedCriterion(criterionId)
|
||||
setTimeout(() => setSavedCriterion(null), 800)
|
||||
}, [])
|
||||
|
||||
// Handle score change with debounce
|
||||
const handleScoreChange = useCallback(
|
||||
(criterionId: string, value: number) => {
|
||||
if (!juror || !teamId) return
|
||||
|
||||
setScores(prev => ({ ...prev, [criterionId]: value }))
|
||||
|
||||
// Clear existing debounce timer for this criterion
|
||||
const existing = debounceTimers.current.get(criterionId)
|
||||
if (existing) clearTimeout(existing)
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
submitScore({
|
||||
jurorId: juror.id,
|
||||
teamId,
|
||||
criterionId,
|
||||
value,
|
||||
})
|
||||
flashSaved(criterionId)
|
||||
}, DEBOUNCE_MS)
|
||||
|
||||
debounceTimers.current.set(criterionId, timer)
|
||||
},
|
||||
[juror, teamId, submitScore, flashSaved]
|
||||
)
|
||||
|
||||
// Navigation
|
||||
const currentIndex = teams.findIndex(t => t.id === teamId)
|
||||
const prevTeam = currentIndex > 0 ? teams[currentIndex - 1] : null
|
||||
const nextTeam = currentIndex < teams.length - 1 ? teams[currentIndex + 1] : null
|
||||
|
||||
if (!juror || loading) {
|
||||
return (
|
||||
<div className="page-container" style={styles.center}>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Laden...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
return (
|
||||
<div className="page-container" style={styles.center}>
|
||||
<p style={{ color: '#ff6b6b' }}>Team nicht gefunden</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container" style={styles.page}>
|
||||
{/* Offline indicator */}
|
||||
{(!isOnline || pendingCount > 0) && (
|
||||
<div style={styles.offlineBanner}>
|
||||
{!isOnline ? '⚡ Offline' : `↑ ${pendingCount} ausstehend`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<header style={styles.header}>
|
||||
<button onClick={() => navigate('/vote')} style={styles.backBtn}>
|
||||
← Zurück
|
||||
</button>
|
||||
<h1 style={styles.teamName}>{team.name}</h1>
|
||||
<p style={styles.teamDesc}>{team.description}</p>
|
||||
</header>
|
||||
|
||||
{/* Score inputs */}
|
||||
<div style={styles.criteriaList}>
|
||||
{criteria.map(criterion => {
|
||||
const value = scores[criterion.id] || 0
|
||||
const isSaved = savedCriterion === criterion.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={criterion.id}
|
||||
style={{
|
||||
...styles.criterionCard,
|
||||
borderColor: isSaved
|
||||
? 'rgba(0, 255, 0, 0.4)'
|
||||
: 'var(--glass-border)',
|
||||
boxShadow: isSaved
|
||||
? '0 0 12px rgba(0, 255, 0, 0.15)'
|
||||
: 'none',
|
||||
}}
|
||||
className="glass-panel"
|
||||
>
|
||||
<div style={styles.criterionHeader}>
|
||||
<span style={styles.criterionName}>{criterion.nameDE}</span>
|
||||
<span
|
||||
style={{
|
||||
...styles.criterionValue,
|
||||
color: value > 0 ? 'var(--accent-color)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{value > 0 ? value : '–'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.stepperRow}>
|
||||
<button
|
||||
style={styles.stepperBtn}
|
||||
onClick={() => {
|
||||
if (value > 1) handleScoreChange(criterion.id, value - 1)
|
||||
}}
|
||||
disabled={value <= 1}
|
||||
aria-label={`${criterion.nameDE} verringern`}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={10}
|
||||
value={value || 1}
|
||||
onChange={e =>
|
||||
handleScoreChange(criterion.id, parseInt(e.target.value, 10))
|
||||
}
|
||||
style={styles.slider}
|
||||
aria-label={`${criterion.nameDE} Bewertung`}
|
||||
/>
|
||||
<button
|
||||
style={styles.stepperBtn}
|
||||
onClick={() => {
|
||||
if (value < 10) handleScoreChange(criterion.id, (value || 0) + 1)
|
||||
else if (value === 0) handleScoreChange(criterion.id, 1)
|
||||
}}
|
||||
disabled={value >= 10}
|
||||
aria-label={`${criterion.nameDE} erhöhen`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div style={styles.scaleLabels}>
|
||||
<span>1</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div style={styles.navRow}>
|
||||
<button
|
||||
onClick={() => prevTeam && navigate(`/vote/${prevTeam.id}`)}
|
||||
disabled={!prevTeam}
|
||||
style={{
|
||||
...styles.navBtn,
|
||||
opacity: prevTeam ? 1 : 0.3,
|
||||
}}
|
||||
>
|
||||
← Vorheriges
|
||||
</button>
|
||||
<button
|
||||
onClick={() => nextTeam && navigate(`/vote/${nextTeam.id}`)}
|
||||
disabled={!nextTeam}
|
||||
style={{
|
||||
...styles.navBtn,
|
||||
opacity: nextTeam ? 1 : 0.3,
|
||||
}}
|
||||
>
|
||||
Nächstes →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
page: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
paddingBottom: '40px',
|
||||
},
|
||||
center: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
},
|
||||
offlineBanner: {
|
||||
position: 'fixed',
|
||||
top: '12px',
|
||||
right: '12px',
|
||||
background: 'rgba(255, 170, 0, 0.15)',
|
||||
border: '1px solid rgba(255, 170, 0, 0.3)',
|
||||
color: '#ffaa00',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '50px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
zIndex: 100,
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
},
|
||||
backBtn: {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '0.85rem',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
padding: 0,
|
||||
alignSelf: 'flex-start',
|
||||
marginBottom: '4px',
|
||||
},
|
||||
teamName: {
|
||||
fontSize: '1.6rem',
|
||||
fontWeight: 800,
|
||||
},
|
||||
teamDesc: {
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '0.9rem',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
criteriaList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '14px',
|
||||
},
|
||||
criterionCard: {
|
||||
padding: '18px',
|
||||
borderRadius: '14px',
|
||||
transition: 'border-color 0.3s ease, box-shadow 0.3s ease',
|
||||
},
|
||||
criterionHeader: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '12px',
|
||||
},
|
||||
criterionName: {
|
||||
fontSize: '0.95rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
criterionValue: {
|
||||
fontSize: '1.4rem',
|
||||
fontWeight: 800,
|
||||
minWidth: '28px',
|
||||
textAlign: 'right' as const,
|
||||
},
|
||||
stepperRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
},
|
||||
stepperBtn: {
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '50%',
|
||||
border: '1px solid var(--glass-border)',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '1.2rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: 'inherit',
|
||||
transition: 'all 0.2s ease',
|
||||
flexShrink: 0,
|
||||
},
|
||||
slider: {
|
||||
flex: 1,
|
||||
height: '6px',
|
||||
appearance: 'none' as const,
|
||||
WebkitAppearance: 'none' as const,
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '3px',
|
||||
outline: 'none',
|
||||
cursor: 'pointer',
|
||||
accentColor: 'var(--accent-color)',
|
||||
},
|
||||
scaleLabels: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
paddingLeft: '46px',
|
||||
paddingRight: '46px',
|
||||
marginTop: '4px',
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--text-secondary)',
|
||||
},
|
||||
navRow: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: '12px',
|
||||
marginTop: '12px',
|
||||
},
|
||||
navBtn: {
|
||||
flex: 1,
|
||||
padding: '14px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid var(--glass-border)',
|
||||
background: 'var(--glass-bg)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
transition: 'all 0.2s ease',
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import type { Team, Score } from '../../shared/types'
|
||||
|
||||
const STORAGE_KEY = 'jury-voting-juror'
|
||||
const CRITERIA_COUNT = 5
|
||||
|
||||
interface JurorInfo {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type CompletionStatus = 'complete' | 'partial' | 'pending'
|
||||
|
||||
function getCompletionStatus(teamId: string, scores: Score[]): CompletionStatus {
|
||||
const teamScores = scores.filter(s => s.teamId === teamId)
|
||||
if (teamScores.length === 0) return 'pending'
|
||||
if (teamScores.length >= CRITERIA_COUNT) return 'complete'
|
||||
return 'partial'
|
||||
}
|
||||
|
||||
function getStatusIcon(status: CompletionStatus): string {
|
||||
switch (status) {
|
||||
case 'complete': return '✓'
|
||||
case 'partial': return '◐'
|
||||
case 'pending': return '○'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status: CompletionStatus): string {
|
||||
switch (status) {
|
||||
case 'complete': return 'var(--accent-color)'
|
||||
case 'partial': return '#ffaa00'
|
||||
case 'pending': return 'var(--text-secondary)'
|
||||
}
|
||||
}
|
||||
|
||||
export default function VotingDashboard() {
|
||||
const navigate = useNavigate()
|
||||
const [juror, setJuror] = useState<JurorInfo | null>(null)
|
||||
const [teams, setTeams] = useState<Team[]>([])
|
||||
const [scores, setScores] = useState<Score[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (!stored) {
|
||||
navigate('/', { replace: true })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as JurorInfo
|
||||
setJuror(parsed)
|
||||
} catch {
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
useEffect(() => {
|
||||
if (!juror) return
|
||||
|
||||
Promise.all([
|
||||
fetch('/api/teams').then(r => r.json()),
|
||||
fetch(`/api/scores/${juror.id}`).then(r => r.json()),
|
||||
])
|
||||
.then(([teamsData, scoresData]) => {
|
||||
setTeams(teamsData as Team[])
|
||||
setScores(scoresData as Score[])
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => setLoading(false))
|
||||
}, [juror])
|
||||
|
||||
if (!juror || loading) {
|
||||
return (
|
||||
<div className="page-container" style={styles.center}>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Laden...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const completedCount = teams.filter(
|
||||
t => getCompletionStatus(t.id, scores) === 'complete'
|
||||
).length
|
||||
|
||||
return (
|
||||
<div className="page-container" style={styles.page}>
|
||||
<header style={styles.header}>
|
||||
<div style={styles.headerTop}>
|
||||
<p style={styles.jurorName}>{juror.name}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
navigate('/')
|
||||
}}
|
||||
style={styles.logoutBtn}
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
<div style={styles.progress}>
|
||||
<span style={styles.progressText}>
|
||||
{completedCount}/{teams.length} Teams bewertet
|
||||
</span>
|
||||
<div style={styles.progressBar}>
|
||||
<div
|
||||
style={{
|
||||
...styles.progressFill,
|
||||
width: `${(completedCount / teams.length) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={styles.teamList}>
|
||||
{teams.map(team => {
|
||||
const status = getCompletionStatus(team.id, scores)
|
||||
return (
|
||||
<button
|
||||
key={team.id}
|
||||
onClick={() => navigate(`/vote/${team.id}`)}
|
||||
style={styles.teamCard}
|
||||
className="glass-panel"
|
||||
aria-label={`Team ${team.name} bewerten`}
|
||||
>
|
||||
<div style={styles.teamInfo}>
|
||||
<span style={styles.teamName}>{team.name}</span>
|
||||
<span style={styles.teamDesc}>
|
||||
{team.description.length > 50
|
||||
? team.description.slice(0, 50) + '…'
|
||||
: team.description}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
...styles.statusIcon,
|
||||
color: getStatusColor(status),
|
||||
}}
|
||||
>
|
||||
{getStatusIcon(status)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
page: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
paddingBottom: '40px',
|
||||
},
|
||||
center: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
},
|
||||
headerTop: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
jurorName: {
|
||||
fontSize: '1.4rem',
|
||||
fontWeight: 800,
|
||||
},
|
||||
logoutBtn: {
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
color: 'var(--text-secondary)',
|
||||
padding: '6px 14px',
|
||||
borderRadius: '50px',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
progress: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
},
|
||||
progressText: {
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text-secondary)',
|
||||
},
|
||||
progressBar: {
|
||||
height: '4px',
|
||||
background: 'rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
background: 'var(--accent-color)',
|
||||
borderRadius: '2px',
|
||||
transition: 'width 0.3s ease',
|
||||
},
|
||||
teamList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
},
|
||||
teamCard: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px 18px',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid var(--glass-border)',
|
||||
background: 'var(--glass-bg)',
|
||||
borderRadius: '14px',
|
||||
transition: 'all 0.2s ease',
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
color: 'var(--text-primary)',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '1rem',
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
},
|
||||
teamInfo: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
teamName: {
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
teamDesc: {
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--text-secondary)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
statusIcon: {
|
||||
fontSize: '1.3rem',
|
||||
marginLeft: '12px',
|
||||
flexShrink: 0,
|
||||
},
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src", "shared"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"composite": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/',
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user