edf33fa932
Backend: - New /api/achievements endpoint calculating 6 badges: Scharfschütze, Serien-Tipper, Tabellenführer, Frühtipper, Globetrotter, Diamant - Each with progress tracking (current/target) Frontend: - AchievementBadge component with Stitch-inspired design - Material Symbols Outlined font (filled icons) - Unlocked: colored icon with glow + drop-shadow, rank label - Locked: grayscale, lock overlay, progress bar - ProfilePage: real badges replacing emoji placeholders - Progress bar showing X/6 collected - Mobile: 2-col grid, Desktop: 6-col grid Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
170 lines
5.4 KiB
TypeScript
170 lines
5.4 KiB
TypeScript
import { Router, Request, Response } from 'express';
|
|
import { query } from '../db/client';
|
|
import { logger } from '../services/logger';
|
|
|
|
const router = Router();
|
|
|
|
export interface Achievement {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
icon: string; // Material Symbol name
|
|
color: string; // CSS color for the glow
|
|
rankLabel: string; // e.g. "Gold-Rang", "On Fire"
|
|
unlocked: boolean;
|
|
progress: number; // 0-100 percentage
|
|
current: number; // current value
|
|
target: number; // target value
|
|
}
|
|
|
|
router.get('/', async (req: Request, res: Response): Promise<void> => {
|
|
const userId = req.staffbaseUser?.sub;
|
|
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
|
|
|
|
try {
|
|
// Get user stats
|
|
const statsResult = await query<any>(
|
|
`SELECT total_points, tips_count, exact_count, tendency_count, rank
|
|
FROM leaderboard WHERE user_id = $1`,
|
|
[userId]
|
|
);
|
|
const stats = statsResult[0];
|
|
const exactCount = parseInt(String(stats?.exact_count ?? '0'));
|
|
const tipsCount = parseInt(String(stats?.tips_count ?? '0'));
|
|
const rank = stats?.rank ? parseInt(String(stats.rank)) : null;
|
|
|
|
// Calculate streak (same logic as dashboard)
|
|
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.status IN ('FINISHED', 'IN_PLAY')
|
|
ORDER BY m.utc_date DESC`,
|
|
[userId]
|
|
);
|
|
let maxStreak = 0;
|
|
let currentStreak = 0;
|
|
for (const m of pastMatches) {
|
|
if (m.has_tip === true || (m.has_tip as unknown) === 't') {
|
|
currentStreak++;
|
|
if (currentStreak > maxStreak) maxStreak = currentStreak;
|
|
} else {
|
|
currentStreak = 0;
|
|
}
|
|
}
|
|
|
|
// Check early tipper: tips submitted >24h before kickoff
|
|
const earlyTips = await query<{ count: string }>(
|
|
`SELECT COUNT(*) AS count FROM tips t
|
|
JOIN matches m ON m.id = t.match_id
|
|
WHERE t.user_id = $1
|
|
AND m.status = 'FINISHED'
|
|
AND t.created_at < m.utc_date - interval '24 hours'`,
|
|
[userId]
|
|
);
|
|
const earlyCount = parseInt(earlyTips[0]?.count || '0');
|
|
const finishedWithTips = await query<{ count: string }>(
|
|
`SELECT COUNT(*) AS count FROM tips t
|
|
JOIN matches m ON m.id = t.match_id
|
|
WHERE t.user_id = $1 AND m.status = 'FINISHED'`,
|
|
[userId]
|
|
);
|
|
const finishedTipCount = parseInt(finishedWithTips[0]?.count || '0');
|
|
|
|
// Check globetrotter: tips in all groups
|
|
const groupsTipped = await query<{ count: string }>(
|
|
`SELECT COUNT(DISTINCT m.group_name) AS count
|
|
FROM tips t JOIN matches m ON m.id = t.match_id
|
|
WHERE t.user_id = $1 AND m.stage = 'GROUP_STAGE' AND m.group_name IS NOT NULL`,
|
|
[userId]
|
|
);
|
|
const groupCount = parseInt(groupsTipped[0]?.count || '0');
|
|
const totalGroups = 12; // WM 2026 has 12 groups (A-L)
|
|
|
|
// Build achievements
|
|
const achievements: Achievement[] = [
|
|
{
|
|
id: 'sharpshooter',
|
|
name: 'Scharfschütze',
|
|
description: '5 exakte Tipps',
|
|
icon: 'target',
|
|
color: '#f5ce53',
|
|
rankLabel: 'Gold-Rang',
|
|
unlocked: exactCount >= 5,
|
|
progress: Math.min(100, (exactCount / 5) * 100),
|
|
current: exactCount,
|
|
target: 5,
|
|
},
|
|
{
|
|
id: 'streak_master',
|
|
name: 'Serien-Tipper',
|
|
description: '10er Tipp-Serie',
|
|
icon: 'local_fire_department',
|
|
color: '#ff716c',
|
|
rankLabel: 'On Fire',
|
|
unlocked: maxStreak >= 10,
|
|
progress: Math.min(100, (maxStreak / 10) * 100),
|
|
current: maxStreak,
|
|
target: 10,
|
|
},
|
|
{
|
|
id: 'league_leader',
|
|
name: 'Tabellenführer',
|
|
description: 'Platz 1 erreicht',
|
|
icon: 'crown',
|
|
color: '#e6c047',
|
|
rankLabel: 'Prestige',
|
|
unlocked: rank === 1,
|
|
progress: rank === 1 ? 100 : 0,
|
|
current: rank === 1 ? 1 : 0,
|
|
target: 1,
|
|
},
|
|
{
|
|
id: 'early_bird',
|
|
name: 'Frühtipper',
|
|
description: 'Tipps 24h vor Anpfiff',
|
|
icon: 'alarm',
|
|
color: '#4BB7F8',
|
|
rankLabel: 'Speedster',
|
|
unlocked: finishedTipCount > 0 && earlyCount === finishedTipCount,
|
|
progress: finishedTipCount > 0 ? Math.min(100, (earlyCount / finishedTipCount) * 100) : 0,
|
|
current: earlyCount,
|
|
target: finishedTipCount || 1,
|
|
},
|
|
{
|
|
id: 'globetrotter',
|
|
name: 'Globetrotter',
|
|
description: 'Alle Gruppenspiele',
|
|
icon: 'public',
|
|
color: '#4BB7F8',
|
|
rankLabel: 'Reisender',
|
|
unlocked: groupCount >= totalGroups,
|
|
progress: Math.min(100, (groupCount / totalGroups) * 100),
|
|
current: groupCount,
|
|
target: totalGroups,
|
|
},
|
|
{
|
|
id: 'diamond',
|
|
name: 'Diamant',
|
|
description: '20 exakte Tipps',
|
|
icon: 'diamond',
|
|
color: '#a066ff',
|
|
rankLabel: 'Legendär',
|
|
unlocked: exactCount >= 20,
|
|
progress: Math.min(100, (exactCount / 20) * 100),
|
|
current: exactCount,
|
|
target: 20,
|
|
},
|
|
];
|
|
|
|
const unlockedCount = achievements.filter(a => a.unlocked).length;
|
|
|
|
res.json({ achievements, unlockedCount, total: achievements.length });
|
|
} catch (error) {
|
|
logger.error('Achievements failed', { error });
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
export default router;
|