diff --git a/backend/src/index.ts b/backend/src/index.ts index cf98b23..71eb8fd 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -14,6 +14,7 @@ import leaderboardRouter from './routes/leaderboard'; import adminRouter from './routes/admin'; import profileRouter from './routes/profile'; import devRouter from './routes/dev'; +import dashboardRouter from './routes/dashboard'; const app = express(); const PORT = parseInt(process.env.PORT ?? '3001'); @@ -126,6 +127,7 @@ app.use('/api/tips', tipsRouter); app.use('/api/leaderboard', leaderboardRouter); app.use('/api/admin', adminRouter); app.use('/api/profile', profileRouter); +app.use('/api/dashboard', dashboardRouter); if (process.env.NODE_ENV === 'development') { app.use('/api/dev', devRouter); } diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts new file mode 100644 index 0000000..5ba8a94 --- /dev/null +++ b/backend/src/routes/dashboard.ts @@ -0,0 +1,123 @@ +import { Router, Request, Response } from 'express'; +import { query } from '../db/client'; +import { logger } from '../services/logger'; + +const router = Router(); + +router.get('/', async (req: Request, res: Response): Promise => { + const userId = req.staffbaseUser?.sub; + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + try { + // 1. Hero: next upcoming match + const heroResult = await query( + `SELECT m.id, m.utc_date, m.status, + m.home_team_name, m.home_team_short, m.home_team_crest, + m.away_team_name, m.away_team_short, m.away_team_crest, + t.tip_home, t.tip_away, + EXTRACT(EPOCH FROM (m.utc_date - NOW())) / 60 AS minutes_until + FROM matches m + LEFT JOIN tips t ON t.match_id = m.id AND t.user_id = $1 + WHERE m.status IN ('SCHEDULED', 'TIMED') + ORDER BY m.utc_date ASC + LIMIT 1`, + [userId] + ); + + const h = heroResult[0]; + const hero = h ? { + match: { + id: h.id, + homeTeam: { name: h.home_team_name, shortName: h.home_team_short, crest: h.home_team_crest }, + awayTeam: { name: h.away_team_name, shortName: h.away_team_short, crest: h.away_team_crest }, + utcDate: h.utc_date, + status: h.status, + minutesUntilKickoff: Math.round(parseFloat(h.minutes_until)), + }, + userTip: h.tip_home != null ? { home: h.tip_home, away: h.tip_away } : null, + tippable: parseFloat(h.minutes_until) > 5, + } : null; + + // 2. Stats from leaderboard + const statsResult = await query( + `SELECT rank, total_points FROM leaderboard WHERE user_id = $1`, + [userId] + ); + const s = statsResult[0]; + + // 3. Streak: consecutive tipped matches (most recent backward) + const pastMatches = await query<{ has_tip: boolean }>( + `SELECT CASE WHEN t.id IS NOT NULL THEN true ELSE false END AS has_tip + FROM matches m + LEFT JOIN tips t ON t.match_id = m.id AND t.user_id = $1 + WHERE m.utc_date <= NOW() AND m.status IN ('FINISHED', 'IN_PLAY') + ORDER BY m.utc_date DESC`, + [userId] + ); + let streak = 0; + for (const m of pastMatches) { + if (m.has_tip) streak++; + else break; + } + + // 4. Nudges + const nudges: Array<{ type: string; text: string; matchId?: number }> = []; + + const untipped = await query<{ count: string }>( + `SELECT COUNT(*) AS count FROM matches m + LEFT JOIN tips t ON t.match_id = m.id AND t.user_id = $1 + WHERE m.utc_date::date = CURRENT_DATE + AND m.status IN ('SCHEDULED', 'TIMED') + AND t.id IS NULL`, + [userId] + ); + const untippedCount = parseInt(untipped[0]?.count || '0'); + if (untippedCount > 0) { + nudges.push({ + type: 'untipped', + text: `📅 Heute noch ${untippedCount} ${untippedCount === 1 ? 'Spiel' : 'Spiele'} ohne Tipp`, + }); + } + + const leader = await query<{ full_name: string; total_points: string }>( + `SELECT full_name, total_points FROM leaderboard ORDER BY rank ASC LIMIT 1` + ); + if (leader[0]) { + nudges.push({ type: 'leader', text: `🏆 ${leader[0].full_name} führt mit ${leader[0].total_points} Punkten` }); + } + + const latest = await query( + `SELECT m.home_team_short, m.away_team_short, m.score_home, m.score_away, t.points, m.id AS match_id + FROM tips t JOIN matches m ON m.id = t.match_id + WHERE t.user_id = $1 AND t.points IS NOT NULL + ORDER BY m.utc_date DESC LIMIT 1`, + [userId] + ); + if (latest[0]) { + const r = latest[0]; + nudges.push({ + type: 'result', + text: `🎯 Letzte Auswertung: ${r.points} Punkte für ${r.home_team_short} ${r.score_home}:${r.score_away} ${r.away_team_short}`, + matchId: r.match_id, + }); + } + + res.json({ + hero, + stats: { + rank: s ? parseInt(s.rank) : null, + totalPoints: s ? parseInt(s.total_points) : 0, + streak, + }, + nudges, + }); + } catch (error) { + logger.error('Dashboard failed', { error }); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ccbd951..b0a1ddb 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -53,6 +53,9 @@ export const api = { body: JSON.stringify({ team }), }), + // Dashboard + getDashboard: () => request('/dashboard'), + // Admin syncMatches: () => request<{ success: boolean; total: number; created: number; updated: number }>( @@ -67,6 +70,24 @@ export const api = { }; // Types (gespiegelt vom Backend) +export interface DashboardData { + hero: { + match: { + id: number; + homeTeam: { name: string; shortName: string; crest: string | null }; + awayTeam: { name: string; shortName: string; crest: string | null }; + utcDate: string; + status: string; + minutesUntilKickoff: number; + }; + userTip: { home: number; away: number } | null; + tippable: boolean; + } | null; + stats: { rank: number | null; totalPoints: number; streak: number }; + nudges: Array<{ type: string; text: string; matchId?: number }>; +} + + export interface Match { id: number; externalId: number;