/** * Celebration Service * * Wraps canvas-confetti with milestone detection for species recovery * announcements, renewable energy records, and similar positive breakthroughs. * * Design: "Warm, not birthday party" -- moderate particle counts (40-80), * nature-inspired colors (greens, golds, blues), session-level deduplication * so celebrations feel special, not repetitive. * * Respects prefers-reduced-motion: no animations when that media query matches. */ // canvas-confetti (~10KB) is only needed when a positive milestone actually fires — // never at boot. Lazy-load + cache it on first celebration so it ships off the eager // main entry. Bursts are fire-and-forget, so a one-tick load delay is imperceptible. type ConfettiFn = typeof import('canvas-confetti'); type ConfettiOptions = Parameters[0]; let confettiPromise: Promise | null = null; function loadConfetti(): Promise { if (!confettiPromise) { confettiPromise = import('canvas-confetti') // canvas-confetti is `export =`; the runtime namespace wraps it under .default // (esbuild/Vite CJS interop), while the type is the function itself. .then((m) => ((m as { default?: ConfettiFn }).default ?? m) as ConfettiFn) .catch((err) => { confettiPromise = null; throw err; }); } return confettiPromise; } function fireConfetti(options: ConfettiOptions): void { void loadConfetti().then((confetti) => { confetti(options); }).catch(() => { /* best-effort */ }); } // ---- Types ---- export interface MilestoneData { speciesRecoveries?: Array<{ name: string; status: string }>; renewablePercent?: number; newSpeciesCount?: number; } // ---- Constants ---- /** Checked once at module load -- if user prefers reduced motion, skip all celebrations. */ const REDUCED_MOTION = typeof window !== 'undefined' ? window.matchMedia('(prefers-reduced-motion: reduce)').matches : false; /** Nature-inspired warm palette matching the happy theme. */ const WARM_COLORS = ['#6B8F5E', '#C4A35A', '#7BA5C4', '#8BAF7A', '#E8B96E', '#7FC4C4']; /** Session-level dedup set. Stores milestone keys that have already been celebrated this session. */ const celebrated = new Set(); // ---- Public API ---- /** * Fire a confetti celebration with warm, nature-inspired colors. * * @param type - 'milestone' for species recovery (40 particles, single burst), * 'record' for renewable energy records (80 particles, double burst). */ export function celebrate(type: 'milestone' | 'record' = 'milestone'): void { if (REDUCED_MOTION) return; if (type === 'milestone') { fireConfetti({ particleCount: 40, spread: 60, origin: { y: 0.7 }, colors: WARM_COLORS, disableForReducedMotion: true, }); } else { // 'record' -- double burst for extra emphasis fireConfetti({ particleCount: 80, spread: 90, origin: { y: 0.6 }, colors: WARM_COLORS, disableForReducedMotion: true, }); setTimeout(() => { fireConfetti({ particleCount: 80, spread: 90, origin: { y: 0.6 }, colors: WARM_COLORS, disableForReducedMotion: true, }); }, 300); } } /** * Check data for milestone events and fire a celebration if a new one is found. * * Only fires ONE celebration per call (first matching milestone wins) to prevent * multiple confetti bursts overlapping. Session dedup (Set in memory) ensures * the same milestone is never celebrated twice in a single browser session. */ export function checkMilestones(data: MilestoneData): void { // --- Species recovery milestone --- if (data.speciesRecoveries) { for (const species of data.speciesRecoveries) { const status = species.status.toLowerCase(); if (status === 'recovered' || status === 'stabilized') { const key = `species:${species.name}`; if (!celebrated.has(key)) { celebrated.add(key); celebrate('milestone'); return; // one celebration per call } } } } // --- Renewable energy record (every 5% threshold) --- if (data.renewablePercent != null && data.renewablePercent > 0) { const threshold = Math.floor(data.renewablePercent / 5) * 5; const key = `renewable:${threshold}`; if (!celebrated.has(key)) { celebrated.add(key); celebrate('record'); return; } } // --- New species count --- if (data.newSpeciesCount != null && data.newSpeciesCount > 0) { const key = `species-count:${data.newSpeciesCount}`; if (!celebrated.has(key)) { celebrated.add(key); celebrate('milestone'); return; } } } /** * Clear the celebrated set. Exported for testing purposes. */ export function resetCelebrations(): void { celebrated.clear(); }