import { getDeviceId } from '../lib/device'; import { DEFAULT_LOCALE } from '../i18n/messages'; export interface AchievementBadge { id: string; title: string; description: string; earned: boolean; progress: number; target: number; } export interface LeaderboardEntry { guest: string; photos: number; likes: number; } export interface TopPhotoHighlight { photoId: number; guest: string; likes: number; task?: string | null; createdAt: string; thumbnail: string | null; } export interface TrendingEmotionHighlight { emotionId: number; name: string; count: number; } export interface TimelinePoint { date: string; photos: number; guests: number; } export interface FeedEntry { photoId: number; guest: string; task?: string | null; likes: number; createdAt: string; thumbnail: string | null; } export interface AchievementsPayload { summary: { totalPhotos: number; uniqueGuests: number; tasksSolved: number; likesTotal: number; }; personal: { guestName: string; photos: number; tasks: number; likes: number; badges: AchievementBadge[]; } | null; leaderboards: { uploads: LeaderboardEntry[]; likes: LeaderboardEntry[]; }; highlights: { topPhoto: TopPhotoHighlight | null; trendingEmotion: TrendingEmotionHighlight | null; timeline: TimelinePoint[]; }; feed: FeedEntry[]; } function toNumber(value: unknown, fallback = 0): number { if (typeof value === 'number' && Number.isFinite(value)) { return value; } if (typeof value === 'string' && value !== '') { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } return fallback; } function safeString(value: unknown): string { return typeof value === 'string' ? value : ''; } type FetchAchievementsOptions = { guestName?: string; locale?: string; signal?: AbortSignal; forceRefresh?: boolean; }; type AchievementsCacheEntry = { data: AchievementsPayload; etag: string | null; }; const achievementsCache = new Map(); export async function fetchAchievements( eventToken: string, options: FetchAchievementsOptions = {} ): Promise { const { guestName, signal, forceRefresh } = options; const locale = options.locale ?? DEFAULT_LOCALE; const params = new URLSearchParams(); if (guestName && guestName.trim().length > 0) { params.set('guest_name', guestName.trim()); } if (locale) { params.set('locale', locale); } const deviceId = getDeviceId(); const cacheKey = [eventToken, locale, guestName?.trim() ?? '', deviceId].join(':'); const cached = forceRefresh ? null : achievementsCache.get(cacheKey); const headers: HeadersInit = { 'X-Device-Id': deviceId, 'Cache-Control': 'no-store', Accept: 'application/json', 'X-Locale': locale, }; if (cached?.etag) { headers['If-None-Match'] = cached.etag; } const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/achievements?${params.toString()}`, { method: 'GET', headers, signal, }); if (response.status === 304 && cached) { return cached.data; } if (!response.ok) { const message = await response.text(); throw new Error(message || 'Achievements request failed'); } const json = await response.json(); const summary = json.summary ?? {}; const personalRaw = json.personal ?? null; const leaderboards = json.leaderboards ?? {}; const highlights = json.highlights ?? {}; const feedRaw = Array.isArray(json.feed) ? json.feed : []; const personal = personalRaw ? { guestName: safeString(personalRaw.guest_name), photos: toNumber(personalRaw.photos), tasks: toNumber(personalRaw.tasks), likes: toNumber(personalRaw.likes), badges: Array.isArray(personalRaw.badges) ? personalRaw.badges.map((badge): AchievementBadge => { const record = badge as Record; return { id: safeString(record.id), title: safeString(record.title), description: safeString(record.description), earned: Boolean(record.earned), progress: toNumber(record.progress), target: toNumber(record.target, 1), }; }) : [], } : null; const uploadsBoard = Array.isArray(leaderboards.uploads) ? leaderboards.uploads.map((row): LeaderboardEntry => { const record = row as Record; return { guest: safeString(record.guest), photos: toNumber(record.photos), likes: toNumber(record.likes), }; }) : []; const likesBoard = Array.isArray(leaderboards.likes) ? leaderboards.likes.map((row): LeaderboardEntry => { const record = row as Record; return { guest: safeString(record.guest), photos: toNumber(record.photos), likes: toNumber(record.likes), }; }) : []; const topPhotoRaw = highlights.top_photo ?? null; const topPhoto = topPhotoRaw ? { photoId: toNumber(topPhotoRaw.photo_id), guest: safeString(topPhotoRaw.guest), likes: toNumber(topPhotoRaw.likes), task: topPhotoRaw.task ?? null, createdAt: safeString(topPhotoRaw.created_at), thumbnail: topPhotoRaw.thumbnail ? safeString(topPhotoRaw.thumbnail) : null, } : null; const trendingRaw = highlights.trending_emotion ?? null; const trendingEmotion = trendingRaw ? { emotionId: toNumber(trendingRaw.emotion_id), name: safeString(trendingRaw.name), count: toNumber(trendingRaw.count), } : null; const timeline = Array.isArray(highlights.timeline) ? highlights.timeline.map((row): TimelinePoint => { const record = row as Record; return { date: safeString(record.date), photos: toNumber(record.photos), guests: toNumber(record.guests), }; }) : []; const feed = feedRaw.map((row): FeedEntry => { const record = row as Record; return { photoId: toNumber(record.photo_id), guest: safeString(record.guest), task: (record as { task?: string }).task ?? null, likes: toNumber(record.likes), createdAt: safeString(record.created_at), thumbnail: record.thumbnail ? safeString(record.thumbnail) : null, }; }); const payload: AchievementsPayload = { summary: { totalPhotos: toNumber(summary.total_photos), uniqueGuests: toNumber(summary.unique_guests), tasksSolved: toNumber(summary.tasks_solved), likesTotal: toNumber(summary.likes_total), }, personal, leaderboards: { uploads: uploadsBoard, likes: likesBoard, }, highlights: { topPhoto, trendingEmotion, timeline, }, feed, }; achievementsCache.set(cacheKey, { data: payload, etag: response.headers.get('ETag'), }); return payload; }