Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/PR_FIX2_CANVAS_CONFETTI_BODY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# PR: Optional canvas-confetti via CDN (Fix 2)

Copy from "Summary" below for the PR description.

---

## Summary

Makes the celebration service work when the `canvas-confetti` package is not installed in `node_modules`, so the app builds and runs without "Failed to resolve import 'canvas-confetti'" (e.g. after a fresh clone before `npm install`, or in environments where dependencies are not fully installed).

**When the import fails:** The error occurs when Vite tries to resolve `canvas-confetti` at build or dev startup and the package is missing from `node_modules` — for example right after cloning the repo without running `npm install`, or in a workspace where dependencies have not been installed yet. We did not observe it in CI, Tauri build, or a specific Node version; it was the "package not installed" case.

**Implementation:**
- Load confetti at runtime from a CDN script instead of bundling the npm package. No `import 'canvas-confetti'` in source, so Vite never tries to resolve it.
- Use a **module-scoped cached promise** for the load so that only one `<script>` tag is ever appended. The record celebration calls `run()` twice (300ms apart); without the cache, a second script would be appended if the first load had not finished. All callers now share the same promise.
- Do **not** add `optimizeDeps.include: ['canvas-confetti']` in vite.config, so the package remains optional and Vite does not try to pre-bundle it at dev startup.

**Trade-off:** If the CDN is unavailable or the user is offline, celebrations still run but no confetti is shown. When the package is installed (after `npm install`), the CDN is used the same way so behavior is consistent.

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Other

## Affected areas

- [ ] Map / Globe
- [ ] News panels / RSS feeds
- [ ] Other: celebration service (`src/services/celebration.ts`)

## Checklist

- [x] No API keys or secrets committed
- [x] TypeScript compiles (run `npm install` first if typecheck fails for canvas-confetti)

## Screenshots

None. Manual check: run the app without `canvas-confetti` in node_modules (e.g. remove it from node_modules and run `npm run dev`); app should start and celebrations should load confetti from CDN when triggered.
70 changes: 46 additions & 24 deletions src/services/celebration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,23 @@
* so celebrations feel special, not repetitive.
*
* Respects prefers-reduced-motion: no animations when that media query matches.
*
* Loads confetti at runtime via CDN when the canvas-confetti package is not
* in node_modules (e.g. after clone without npm install). Uses a single cached
* promise so only one script tag is ever appended even when celebrate() is
* called multiple times in quick succession (e.g. record type calls run() twice).
*/

import confetti from 'canvas-confetti';

// ---- Types ----

type ConfettiFn = (opts: {
particleCount: number;
spread: number;
origin: { y: number };
colors: string[];
disableForReducedMotion: boolean;
}) => void;

export interface MilestoneData {
speciesRecoveries?: Array<{ name: string; status: string }>;
renewablePercent?: number;
Expand All @@ -34,6 +45,30 @@ const WARM_COLORS = ['#6B8F5E', '#C4A35A', '#7BA5C4', '#8BAF7A', '#E8B96E', '#7F
/** Session-level dedup set. Stores milestone keys that have already been celebrated this session. */
const celebrated = new Set<string>();

/** Cached promise for loading confetti from CDN so we never append more than one script tag. */
let confettiLoadPromise: Promise<ConfettiFn | null> | null = null;

/**
* Load confetti from CDN at runtime. Returns the same promise for every call so
* multiple celebrate() calls (e.g. record type with two bursts 300ms apart) share
* one script load and only one script tag is appended.
*/
function loadConfetti(): Promise<ConfettiFn | null> {
if (typeof window === 'undefined') return Promise.resolve(null);
const w = window as Window & { confetti?: ConfettiFn };
if (typeof w.confetti === 'function') return Promise.resolve(w.confetti);
if (confettiLoadPromise !== null) return confettiLoadPromise;
confettiLoadPromise = new Promise<ConfettiFn | null>((resolve) => {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.4/dist/canvas-confetti.min.js';
script.async = true;
script.onload = () => (typeof w.confetti === 'function' ? resolve(w.confetti!) : resolve(null));
script.onerror = () => resolve(null);
document.head.appendChild(script);
});
return confettiLoadPromise;
}

// ---- Public API ----

/**
Expand All @@ -45,31 +80,18 @@ const celebrated = new Set<string>();
export function celebrate(type: 'milestone' | 'record' = 'milestone'): void {
if (REDUCED_MOTION) return;

if (type === 'milestone') {
void confetti({
particleCount: 40,
spread: 60,
origin: { y: 0.7 },
colors: WARM_COLORS,
disableForReducedMotion: true,
const run = (opts: { particleCount: number; spread: number; origin: { y: number }; colors: string[] }) => {
void loadConfetti().then((confetti) => {
if (confetti) confetti({ ...opts, disableForReducedMotion: true });
});
};

if (type === 'milestone') {
run({ particleCount: 40, spread: 60, origin: { y: 0.7 }, colors: WARM_COLORS });
} else {
// 'record' -- double burst for extra emphasis
void confetti({
particleCount: 80,
spread: 90,
origin: { y: 0.6 },
colors: WARM_COLORS,
disableForReducedMotion: true,
});
run({ particleCount: 80, spread: 90, origin: { y: 0.6 }, colors: WARM_COLORS });
setTimeout(() => {
void confetti({
particleCount: 80,
spread: 90,
origin: { y: 0.6 },
colors: WARM_COLORS,
disableForReducedMotion: true,
});
run({ particleCount: 80, spread: 90, origin: { y: 0.6 }, colors: WARM_COLORS });
}, 300);
}
}
Expand Down
Loading