feat: Ergebnis-Banner, Dev-Simulations-Panel & Spiele-Reset

- MatchCard: farbiger Ergebnis-Banner (Exakt/Tendenz/Falsch) ersetzt
  tipRow, passender Card-Glow je Ergebnis; Lucide-Icons (lucide-react)
- MatchCard: Ändern-Button links, vertikal mittig zur Tipp-Box ausgerichtet
- DevPanel: Simulationsmodus mit User-Switcher, Zeit- & Status-Presets
- DevPanel: Reset-Section mit "Spiel zurücksetzen", "Alle Spiele" und
  "Tipps löschen" (3 Buttons, farblich differenziert)
- Backend: dev.ts mit set-time, set-status, reset-tips, reset-match
- reset-match stellt Original-Datum wieder her (original_utc_date Spalte)
- set-time sichert Original-Datum per COALESCE beim ersten Aufruf
- Supabase Migration: original_utc_date TIMESTAMPTZ zu matches hinzugefügt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ronny
2026-04-04 01:10:55 +02:00
parent e27a62a37b
commit 3d3ff097cf
13 changed files with 1023 additions and 72 deletions
+4
View File
@@ -14,6 +14,7 @@ import tipsRouter from './routes/tips';
import leaderboardRouter from './routes/leaderboard';
import adminRouter from './routes/admin';
import profileRouter from './routes/profile';
import devRouter from './routes/dev';
const app = express();
const PORT = parseInt(process.env.PORT ?? '3001');
@@ -118,6 +119,9 @@ app.use('/api/tips', tipsRouter);
app.use('/api/leaderboard', leaderboardRouter);
app.use('/api/admin', adminRouter);
app.use('/api/profile', profileRouter);
if (process.env.NODE_ENV === 'development') {
app.use('/api/dev', devRouter);
}
// ============================================================
// Frontend (React Build) statisches Serving
+143
View File
@@ -0,0 +1,143 @@
import { Router, Request, Response } from 'express';
import { query } from '../db/client';
import { logger } from '../services/logger';
/**
* Dev-only Routes — werden in Production blockiert
* Erlaubt Daten-Manipulation für Testzwecke
*/
const router = Router();
// Sicherheitscheck: nur in Development verfügbar
router.use((_req: Request, res: Response, next: Function) => {
if (process.env.NODE_ENV === 'production') {
res.status(404).json({ error: 'Not found' });
return;
}
next();
});
/**
* POST /api/dev/match/:id/set-time
* Setzt das Datum eines Spiels auf einen bestimmten Zeitpunkt
* Body: { minutesFromNow: number } (negativ = Vergangenheit)
*/
router.post('/match/:id/set-time', async (req: Request, res: Response): Promise<void> => {
const matchId = parseInt(req.params.id);
const { minutesFromNow } = req.body as { minutesFromNow: number };
if (isNaN(matchId) || typeof minutesFromNow !== 'number') {
res.status(400).json({ error: 'matchId und minutesFromNow erforderlich' });
return;
}
const newDate = new Date(Date.now() + minutesFromNow * 60 * 1000);
try {
// Original-Datum beim ersten set-time sichern (nur wenn noch nicht gesetzt)
await query(
`UPDATE matches
SET original_utc_date = COALESCE(original_utc_date, utc_date),
utc_date = $1
WHERE id = $2`,
[newDate.toISOString(), matchId]
);
logger.info(`[DEV] Match ${matchId} utc_date gesetzt auf ${newDate.toISOString()}`);
res.json({ success: true, matchId, newDate: newDate.toISOString(), minutesFromNow });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/dev/match/:id/set-status
* Setzt den Status eines Spiels (z.B. FINISHED, IN_PLAY, TIMED)
* Body: { status: string, scoreHome?: number, scoreAway?: number }
*/
router.post('/match/:id/set-status', async (req: Request, res: Response): Promise<void> => {
const matchId = parseInt(req.params.id);
const { status, scoreHome, scoreAway } = req.body as {
status: string;
scoreHome?: number;
scoreAway?: number;
};
const VALID_STATUSES = ['TIMED', 'SCHEDULED', 'IN_PLAY', 'PAUSED', 'FINISHED'];
if (!VALID_STATUSES.includes(status)) {
res.status(400).json({ error: `Status muss einer von: ${VALID_STATUSES.join(', ')} sein` });
return;
}
try {
await query(
`UPDATE matches SET status = $1, score_home = $2, score_away = $3 WHERE id = $4`,
[status, scoreHome ?? null, scoreAway ?? null, matchId]
);
logger.info(`[DEV] Match ${matchId} Status → ${status}`);
res.json({ success: true, matchId, status, scoreHome, scoreAway });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/dev/reset-match
* Setzt ein einzelnes Spiel zurück: Status → TIMED, Score → null, null
* Body: { matchId?: number } — ohne matchId werden ALLE Spiele zurückgesetzt
*/
router.post('/reset-match', async (req: Request, res: Response): Promise<void> => {
const { matchId } = req.body as { matchId?: number };
try {
if (matchId) {
await query(
`UPDATE matches
SET status = 'TIMED',
score_home = NULL,
score_away = NULL,
utc_date = COALESCE(original_utc_date, utc_date),
original_utc_date = NULL
WHERE id = $1`,
[matchId]
);
logger.info(`[DEV] Match ${matchId} zurückgesetzt auf TIMED + Original-Datum`);
res.json({ success: true, reset: 'single', matchId });
} else {
const result = await query<{ id: number }>(
`UPDATE matches
SET status = 'TIMED',
score_home = NULL,
score_away = NULL,
utc_date = COALESCE(original_utc_date, utc_date),
original_utc_date = NULL
WHERE status IN ('IN_PLAY','PAUSED','FINISHED')
OR original_utc_date IS NOT NULL
RETURNING id`
);
logger.info(`[DEV] ${result.length} Spiele zurückgesetzt`);
res.json({ success: true, reset: 'all', count: result.length });
}
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/dev/reset
* Setzt alle manipulierten Spiele zurück (löscht Dev-Tipps, setzt Status zurück)
* Nur Spiele die durch Dev-User angelegt wurden
*/
router.post('/reset-tips', async (req: Request, res: Response): Promise<void> => {
const { userId } = req.body as { userId?: string };
try {
const result = await query<{ count: string }>(
`DELETE FROM tips WHERE user_id = $1 RETURNING id`,
[userId ?? 'dev-user-001']
);
await query('REFRESH MATERIALIZED VIEW CONCURRENTLY leaderboard');
res.json({ success: true, deletedTips: result.length });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
export default router;