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
+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" />