Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import type { PendingScore } from '../../shared/types'
|
||||
|
||||
const STORAGE_KEY = 'pendingScores'
|
||||
const MAX_RETRIES = 3
|
||||
const BASE_DELAY_MS = 1000
|
||||
|
||||
interface SubmitScoreParams {
|
||||
jurorId: string
|
||||
teamId: string
|
||||
criterionId: string
|
||||
value: number
|
||||
}
|
||||
|
||||
interface UseOfflineQueueReturn {
|
||||
submitScore: (params: SubmitScoreParams) => void
|
||||
pendingCount: number
|
||||
isOnline: boolean
|
||||
}
|
||||
|
||||
function loadPendingScores(): PendingScore[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (!stored) return []
|
||||
return JSON.parse(stored) as PendingScore[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function savePendingScores(scores: PendingScore[]): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(scores))
|
||||
}
|
||||
|
||||
export function useOfflineQueue(): UseOfflineQueueReturn {
|
||||
const [pending, setPending] = useState<PendingScore[]>(loadPendingScores)
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||
const retryCountsRef = useRef<Map<string, number>>(new Map())
|
||||
const processingRef = useRef(false)
|
||||
|
||||
// Track online/offline status
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true)
|
||||
const handleOffline = () => setIsOnline(false)
|
||||
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Process queue when online or when pending changes
|
||||
useEffect(() => {
|
||||
if (isOnline && pending.some(s => !s.synced)) {
|
||||
processQueue()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOnline, pending])
|
||||
|
||||
const getScoreKey = (s: PendingScore) =>
|
||||
`${s.jurorId}:${s.teamId}:${s.criterionId}:${s.timestamp}`
|
||||
|
||||
const processQueue = useCallback(async () => {
|
||||
if (processingRef.current) return
|
||||
processingRef.current = true
|
||||
|
||||
const unsynced = pending.filter(s => !s.synced)
|
||||
|
||||
for (const score of unsynced) {
|
||||
const key = getScoreKey(score)
|
||||
const retries = retryCountsRef.current.get(key) || 0
|
||||
|
||||
if (retries >= MAX_RETRIES) continue
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/scores', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jurorId: score.jurorId,
|
||||
teamId: score.teamId,
|
||||
criterionId: score.criterionId,
|
||||
value: score.value,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok || res.status === 409) {
|
||||
// Success or conflict (server has newer) — remove from queue
|
||||
setPending(prev => {
|
||||
const updated = prev.filter(s => getScoreKey(s) !== key)
|
||||
savePendingScores(updated)
|
||||
return updated
|
||||
})
|
||||
retryCountsRef.current.delete(key)
|
||||
} else {
|
||||
throw new Error(`Server returned ${res.status}`)
|
||||
}
|
||||
} catch {
|
||||
const newRetries = retries + 1
|
||||
retryCountsRef.current.set(key, newRetries)
|
||||
|
||||
if (newRetries < MAX_RETRIES) {
|
||||
// Exponential backoff
|
||||
const delay = BASE_DELAY_MS * Math.pow(2, newRetries - 1)
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processingRef.current = false
|
||||
}, [pending])
|
||||
|
||||
const submitScore = useCallback((params: SubmitScoreParams) => {
|
||||
const newScore: PendingScore = {
|
||||
jurorId: params.jurorId,
|
||||
teamId: params.teamId,
|
||||
criterionId: params.criterionId,
|
||||
value: params.value,
|
||||
timestamp: new Date().toISOString(),
|
||||
synced: false,
|
||||
}
|
||||
|
||||
setPending(prev => {
|
||||
// Replace any existing pending score for same juror/team/criterion
|
||||
const filtered = prev.filter(
|
||||
s =>
|
||||
!(
|
||||
s.jurorId === params.jurorId &&
|
||||
s.teamId === params.teamId &&
|
||||
s.criterionId === params.criterionId
|
||||
)
|
||||
)
|
||||
const updated = [...filtered, newScore]
|
||||
savePendingScores(updated)
|
||||
return updated
|
||||
})
|
||||
|
||||
// Reset retry count for this score
|
||||
const key = getScoreKey(newScore)
|
||||
retryCountsRef.current.delete(key)
|
||||
}, [])
|
||||
|
||||
const pendingCount = pending.filter(s => !s.synced).length
|
||||
|
||||
return { submitScore, pendingCount, isOnline }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { TeamResult } from '../../shared/types';
|
||||
|
||||
interface UseSSEReturn {
|
||||
results: TeamResult[];
|
||||
connected: boolean;
|
||||
lastUpdate: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom React hook that connects to the SSE endpoint for live score updates.
|
||||
* Fetches initial results immediately, then uses EventSource for live updates.
|
||||
*/
|
||||
export function useSSE(): UseSSEReturn {
|
||||
const [results, setResults] = useState<TeamResult[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState<string | null>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch initial results immediately
|
||||
fetch('/api/results')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setResults(data);
|
||||
setLastUpdate(new Date().toISOString());
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Then connect to SSE for live updates
|
||||
const eventSource = new EventSource('/api/results/stream');
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
eventSource.onopen = () => {
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
eventSource.addEventListener('scores_updated', (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
setResults(data.results);
|
||||
setLastUpdate(data.timestamp);
|
||||
} catch {
|
||||
// Ignore malformed events
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
setConnected(false);
|
||||
// EventSource will automatically attempt to reconnect
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
setConnected(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { results, connected, lastUpdate };
|
||||
}
|
||||
Reference in New Issue
Block a user