feat: add /api/dashboard endpoint with hero match, stats, streak, nudges
Returns next tippable match (hero), user rank/points from leaderboard, consecutive-tip streak, and up to 3 contextual nudges in one request. Mounts at /api/dashboard; adds getDashboard() + DashboardData type to the frontend API client. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import leaderboardRouter from './routes/leaderboard';
|
|||||||
import adminRouter from './routes/admin';
|
import adminRouter from './routes/admin';
|
||||||
import profileRouter from './routes/profile';
|
import profileRouter from './routes/profile';
|
||||||
import devRouter from './routes/dev';
|
import devRouter from './routes/dev';
|
||||||
|
import dashboardRouter from './routes/dashboard';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = parseInt(process.env.PORT ?? '3001');
|
const PORT = parseInt(process.env.PORT ?? '3001');
|
||||||
@@ -126,6 +127,7 @@ app.use('/api/tips', tipsRouter);
|
|||||||
app.use('/api/leaderboard', leaderboardRouter);
|
app.use('/api/leaderboard', leaderboardRouter);
|
||||||
app.use('/api/admin', adminRouter);
|
app.use('/api/admin', adminRouter);
|
||||||
app.use('/api/profile', profileRouter);
|
app.use('/api/profile', profileRouter);
|
||||||
|
app.use('/api/dashboard', dashboardRouter);
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
app.use('/api/dev', devRouter);
|
app.use('/api/dev', devRouter);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void> => {
|
||||||
|
const userId = req.staffbaseUser?.sub;
|
||||||
|
if (!userId) {
|
||||||
|
res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Hero: next upcoming match
|
||||||
|
const heroResult = await query<any>(
|
||||||
|
`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<any>(
|
||||||
|
`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<any>(
|
||||||
|
`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;
|
||||||
@@ -53,6 +53,9 @@ export const api = {
|
|||||||
body: JSON.stringify({ team }),
|
body: JSON.stringify({ team }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
getDashboard: () => request<DashboardData>('/dashboard'),
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
syncMatches: () =>
|
syncMatches: () =>
|
||||||
request<{ success: boolean; total: number; created: number; updated: number }>(
|
request<{ success: boolean; total: number; created: number; updated: number }>(
|
||||||
@@ -67,6 +70,24 @@ export const api = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Types (gespiegelt vom Backend)
|
// 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 {
|
export interface Match {
|
||||||
id: number;
|
id: number;
|
||||||
externalId: number;
|
externalId: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user