File size: 4,834 Bytes
9d2d895
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
/**
 * 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();
}