GenerAI / worldmonitor /src /services /celebration.ts
amogaddy's picture
Integra World Monitor (AGPL-3.0, self-hosted) nello Space: pagina, menu, e arricchimento notizie per la AI (part 7)
9d2d895 verified
Raw
History Blame Contribute Delete
4.83 kB
/**
* 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<ConfettiFn>[0];
let confettiPromise: Promise<ConfettiFn> | null = null;
function loadConfetti(): Promise<ConfettiFn> {
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<string>();
// ---- 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();
}