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:
2026-06-30 20:39:52 +02:00
parent 2f2b295531
commit a5f8fb49ab
1717 changed files with 447332 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.git
*.pdf
*.doc
*.xlsx
Teams/
+109
View File
@@ -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)
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
server/data/session.json
*.bak
+2
View File
@@ -0,0 +1,2 @@
{
}
Binary file not shown.
Binary file not shown.
+48
View File
@@ -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
```
+22
View File
@@ -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.
+282
View File
@@ -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: 110 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 (1050 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 24 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.
+11
View File
@@ -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
+16
View File
@@ -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>
+16
View File
@@ -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
+5142
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -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);
});
});
+149
View File
@@ -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);
}
+47
View File
@@ -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);
}
+41
View File
@@ -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": []
}
+110
View File
@@ -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;
+210
View File
@@ -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);
});
+52
View File
@@ -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);
}
}
+136
View File
@@ -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];
}
+19
View File
@@ -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"]
}
+86
View File
@@ -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; // 110
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[];
}
+25
View File
@@ -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 }
}
+61
View File
@@ -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
+10
View File
@@ -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>,
)
+178
View File
@@ -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>
);
}
+156
View File
@@ -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}`;
}
+191
View File
@@ -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}`;
}
+381
View File
@@ -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,
},
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+26
View File
@@ -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" }]
}
+23
View File
@@ -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"]
}
+16
View File
@@ -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,
},
},
},
})