Skip to content

Commit 7c8d693

Browse files
mabry1985Claude Agentclaude
authored
feat: add gamification celebration animations and toasts (#20)
Adds dopamine-inducing micro-interactions that fire automatically when gamification WebSocket events arrive. Celebrations are proportional to accomplishment: XP gains show a small toast, streak milestones add confetti, achievement unlocks show a top banner + medium confetti, and level-ups trigger a full-screen overlay with a 3-second rainbow confetti burst. - New component: apps/ui/src/components/gamification/celebrations.tsx - XP gained: gold toast with mini progress bar, 3s auto-dismiss - Achievement unlock: top-center banner + gold/white confetti, 5s - Level up: full-screen overlay with animated counter + rainbow confetti - Streak milestone (5/10/25/50/100): orange toast + small confetti - Home health +5: subtle green toast - New feature flags: gamificationCelebrations (default true), gamificationSounds (default false) - canvas-confetti 1.9.3 + @types/canvas-confetti added to apps/ui - CelebrationProvider mounted in both main app layout and dashboard route Co-authored-by: Claude Agent <agent@protolabsai.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3048101 commit 7c8d693

File tree

6 files changed

+333
-0
lines changed

6 files changed

+333
-0
lines changed

apps/ui/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@
119119
"@xterm/xterm": "5.5.0",
120120
"@xyflow/react": "12.10.0",
121121
"ai": "^6.0.90",
122+
"canvas-confetti": "1.9.3",
122123
"class-variance-authority": "0.7.1",
123124
"clsx": "2.1.1",
124125
"cmdk": "1.1.1",
@@ -167,6 +168,7 @@
167168
"@storybook/react-vite": "^10.2.8",
168169
"@tailwindcss/vite": "4.1.18",
169170
"@tanstack/router-plugin": "1.141.7",
171+
"@types/canvas-confetti": "^1.9.0",
170172
"@types/dagre": "0.7.53",
171173
"@types/node": "22.19.3",
172174
"@types/react": "19.2.7",
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
/**
2+
* CelebrationProvider — listens to gamification WebSocket events and triggers
3+
* proportional micro-interactions: toasts, confetti bursts, and overlays.
4+
*
5+
* Celebration calibration:
6+
* XP gains → toast (small)
7+
* Streak milestones → toast + small confetti (orange)
8+
* Achievement unlock → banner toast + medium confetti (gold/white)
9+
* Level up → full-screen overlay + large rainbow confetti
10+
* Home health +5 → subtle green toast
11+
*/
12+
13+
import { useEffect, useRef, useState } from 'react';
14+
import { createPortal } from 'react-dom';
15+
import { toast } from 'sonner';
16+
import confetti from 'canvas-confetti';
17+
import { Flame, TrendingUp, Star } from 'lucide-react';
18+
import { getHttpApiClient } from '@/lib/http-api-client';
19+
import { useAppStore } from '@/store/app-store';
20+
import type { AchievementDefinition } from '@protolabsai/types';
21+
22+
// ── Constants ────────────────────────────────────────────────────────────────
23+
24+
const STREAK_MILESTONES = new Set([5, 10, 25, 50, 100]);
25+
26+
// ── Event payload types ──────────────────────────────────────────────────────
27+
28+
interface XpGainedPayload {
29+
source: string;
30+
amount: number;
31+
newTotal: number;
32+
}
33+
34+
interface LevelUpPayload {
35+
level: number;
36+
title: string;
37+
xp: number;
38+
}
39+
40+
interface AchievementUnlockedPayload {
41+
achievement: AchievementDefinition;
42+
xpReward: number;
43+
}
44+
45+
interface StreakUpdatedPayload {
46+
type: 'maintenance' | 'budget';
47+
current: number;
48+
best: number;
49+
isNewBest: boolean;
50+
}
51+
52+
interface HealthScoreChangedPayload {
53+
old: number;
54+
new: number;
55+
pillarChanges: {
56+
maintenance: number;
57+
inventory: number;
58+
budget: number;
59+
systems: number;
60+
};
61+
}
62+
63+
interface LevelUpState {
64+
oldLevel: number;
65+
newLevel: number;
66+
title: string;
67+
}
68+
69+
// ── Sub-components ────────────────────────────────────────────────────────────
70+
71+
function XpToast({
72+
amount,
73+
source,
74+
newTotal,
75+
}: {
76+
amount: number;
77+
source: string;
78+
newTotal: number;
79+
}) {
80+
// Approximate progress within current level using XP mod 100
81+
const progressPct = Math.min((newTotal % 100) * 1, 100);
82+
83+
return (
84+
<div className="flex flex-col gap-1.5 bg-card border border-amber-500/30 rounded-lg px-4 py-3 shadow-lg min-w-[240px]">
85+
<div className="flex items-center gap-2">
86+
<Star className="h-4 w-4 shrink-0 fill-amber-500 text-amber-500" />
87+
<span className="font-semibold text-amber-500">+{amount} XP</span>
88+
<span className="text-sm text-muted-foreground truncate">{source}</span>
89+
</div>
90+
<div className="h-1 bg-muted rounded-full overflow-hidden">
91+
<div
92+
className="h-full bg-amber-500 rounded-full transition-[width] duration-1000"
93+
style={{ width: `${progressPct}%` }}
94+
/>
95+
</div>
96+
</div>
97+
);
98+
}
99+
100+
function AchievementBanner({
101+
achievement,
102+
xpReward,
103+
}: {
104+
achievement: AchievementDefinition;
105+
xpReward: number;
106+
}) {
107+
return (
108+
<div className="w-full max-w-lg bg-card border border-border rounded-xl px-5 py-4 shadow-xl flex items-center gap-4">
109+
<span className="text-3xl shrink-0" role="img" aria-label={achievement.title}>
110+
{achievement.icon}
111+
</span>
112+
<div className="flex-1 min-w-0">
113+
<div className="flex items-center gap-2 flex-wrap mb-0.5">
114+
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
115+
Achievement Unlocked
116+
</span>
117+
<span className="text-xs bg-amber-500/20 text-amber-600 dark:text-amber-400 rounded-full px-2 py-0.5 font-semibold">
118+
+{xpReward} XP
119+
</span>
120+
</div>
121+
<p className="font-bold text-foreground truncate">{achievement.title}</p>
122+
<p className="text-sm text-muted-foreground truncate">{achievement.description}</p>
123+
</div>
124+
</div>
125+
);
126+
}
127+
128+
function LevelUpOverlay({ state, onDismiss }: { state: LevelUpState; onDismiss: () => void }) {
129+
const [displayLevel, setDisplayLevel] = useState(state.oldLevel);
130+
131+
// Animate the counter from old level to new level after a short delay
132+
useEffect(() => {
133+
const timer = setTimeout(() => setDisplayLevel(state.newLevel), 400);
134+
return () => clearTimeout(timer);
135+
}, [state.newLevel]);
136+
137+
// Auto-dismiss after 4 seconds
138+
useEffect(() => {
139+
const timer = setTimeout(onDismiss, 4000);
140+
return () => clearTimeout(timer);
141+
}, [onDismiss]);
142+
143+
return createPortal(
144+
<div
145+
role="dialog"
146+
aria-label={`Level up! You reached level ${state.newLevel}`}
147+
className="fixed inset-0 z-[9999] flex items-center justify-center bg-background/90 backdrop-blur-sm cursor-pointer animate-in fade-in duration-300"
148+
onClick={onDismiss}
149+
>
150+
<div className="text-center space-y-4 select-none">
151+
<p className="text-muted-foreground text-sm font-semibold uppercase tracking-widest">
152+
Level Up!
153+
</p>
154+
<div className="text-[clamp(6rem,20vw,10rem)] font-black text-primary tabular-nums leading-none transition-all duration-500">
155+
{displayLevel}
156+
</div>
157+
<p className="text-2xl font-bold text-foreground">{state.title}</p>
158+
<p className="text-sm text-muted-foreground">Tap to dismiss</p>
159+
</div>
160+
</div>,
161+
document.body
162+
);
163+
}
164+
165+
// ── Provider ──────────────────────────────────────────────────────────────────
166+
167+
export function CelebrationProvider() {
168+
const gamificationCelebrations = useAppStore(
169+
(state) => state.featureFlags.gamificationCelebrations
170+
);
171+
const [levelUpState, setLevelUpState] = useState<LevelUpState | null>(null);
172+
const prevLevelRef = useRef<number | null>(null);
173+
174+
const handleDismissLevelUp = () => setLevelUpState(null);
175+
176+
useEffect(() => {
177+
if (!gamificationCelebrations) return;
178+
179+
const api = getHttpApiClient();
180+
const unsubscribe = api.subscribeToEvents((type, payload) => {
181+
switch (type as string) {
182+
case 'gamification:xp-gained': {
183+
const data = payload as XpGainedPayload;
184+
toast.custom(
185+
() => <XpToast amount={data.amount} source={data.source} newTotal={data.newTotal} />,
186+
{ duration: 3000, position: 'bottom-right' }
187+
);
188+
break;
189+
}
190+
191+
case 'gamification:achievement-unlocked': {
192+
const data = payload as AchievementUnlockedPayload;
193+
void confetti({
194+
particleCount: 120,
195+
spread: 70,
196+
origin: { y: 0.2 },
197+
colors: ['#f59e0b', '#fcd34d', '#ffffff', '#fef3c7', '#fbbf24'],
198+
});
199+
toast.custom(
200+
() => <AchievementBanner achievement={data.achievement} xpReward={data.xpReward} />,
201+
{ duration: 5000, position: 'top-center' }
202+
);
203+
break;
204+
}
205+
206+
case 'gamification:level-up': {
207+
const data = payload as LevelUpPayload;
208+
const oldLevel = prevLevelRef.current ?? data.level - 1;
209+
prevLevelRef.current = data.level;
210+
setLevelUpState({ oldLevel, newLevel: data.level, title: data.title });
211+
212+
// Continuous rainbow confetti for 3 seconds
213+
const end = Date.now() + 3000;
214+
const burst = () => {
215+
void confetti({
216+
particleCount: 6,
217+
angle: 60,
218+
spread: 55,
219+
origin: { x: 0 },
220+
});
221+
void confetti({
222+
particleCount: 6,
223+
angle: 120,
224+
spread: 55,
225+
origin: { x: 1 },
226+
});
227+
if (Date.now() < end) requestAnimationFrame(burst);
228+
};
229+
requestAnimationFrame(burst);
230+
break;
231+
}
232+
233+
case 'gamification:streak-updated': {
234+
const data = payload as StreakUpdatedPayload;
235+
if (STREAK_MILESTONES.has(data.current)) {
236+
void confetti({
237+
particleCount: 50,
238+
spread: 45,
239+
origin: { y: 0.7 },
240+
colors: ['#f97316', '#fb923c', '#fed7aa', '#ea580c'],
241+
});
242+
toast.custom(
243+
() => (
244+
<div className="flex items-center gap-3 bg-card border border-orange-500/30 rounded-lg px-4 py-3 shadow-lg">
245+
<Flame className="h-5 w-5 shrink-0 fill-orange-500 text-orange-500" />
246+
<div>
247+
<p className="font-semibold text-orange-500">{data.current}-day streak!</p>
248+
<p className="text-xs text-muted-foreground capitalize">
249+
{data.type} streak milestone
250+
</p>
251+
</div>
252+
</div>
253+
),
254+
{ duration: 4000, position: 'bottom-right' }
255+
);
256+
}
257+
break;
258+
}
259+
260+
case 'gamification:health-score-changed': {
261+
const data = payload as HealthScoreChangedPayload;
262+
const delta = data.new - data.old;
263+
if (delta >= 5) {
264+
toast.custom(
265+
() => (
266+
<div className="flex items-center gap-3 bg-card border border-green-500/30 rounded-lg px-4 py-3 shadow-lg">
267+
<TrendingUp className="h-5 w-5 shrink-0 text-green-500" />
268+
<span className="font-semibold text-green-600 dark:text-green-400">
269+
Home Health +{delta}
270+
</span>
271+
</div>
272+
),
273+
{ duration: 3000, position: 'bottom-right' }
274+
);
275+
}
276+
break;
277+
}
278+
}
279+
});
280+
281+
return unsubscribe;
282+
}, [gamificationCelebrations]);
283+
284+
if (!levelUpState) return null;
285+
286+
return <LevelUpOverlay state={levelUpState} onDismiss={handleDismissLevelUp} />;
287+
}

apps/ui/src/components/views/settings-view/developer/developer-section.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ const FEATURE_FLAG_LABELS: Record<keyof FeatureFlags, { label: string; descripti
4444
description:
4545
'Enable human-in-the-loop interrupt forms from PM Agent, Signal Intake, and Lead Engineer. When off, gated actions are auto-approved or escalated to Ava.',
4646
},
47+
gamificationCelebrations: {
48+
label: 'Gamification Celebrations',
49+
description:
50+
'Enable confetti bursts, toast notifications, and overlays for XP gains, achievements, level-ups, and streak milestones.',
51+
},
52+
gamificationSounds: {
53+
label: 'Gamification Sounds',
54+
description: 'Enable optional completion chime sounds alongside gamification celebrations.',
55+
},
4756
};
4857

4958
// Role badge colour mapping

apps/ui/src/routes/__root.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import { useMobileVisibility } from '@/hooks/use-mobile-visibility';
5454
import { useVirtualKeyboardResize } from '@/hooks/use-virtual-keyboard-resize';
5555
import { BottomPanel } from '@/components/layout/bottom-panel';
5656
import { UpdateNotification } from '@/components/layout/update-notification';
57+
import { CelebrationProvider } from '@/components/gamification/celebrations';
5758
import {
5859
Panel,
5960
PanelGroup,
@@ -936,6 +937,7 @@ function RootLayoutContent() {
936937
<main className="h-screen-safe overflow-hidden" data-testid="app-container">
937938
<Outlet />
938939
<Toaster richColors position="bottom-right" theme={isDarkTheme ? 'dark' : 'light'} />
940+
<CelebrationProvider />
939941
</main>
940942
<SandboxRiskDialog
941943
open={showSandboxDialog}
@@ -1010,6 +1012,7 @@ function RootLayoutContent() {
10101012
<ChatModal />
10111013
<MobileBottomNav />
10121014
<Toaster richColors position="bottom-right" theme={isDarkTheme ? 'dark' : 'light'} />
1015+
<CelebrationProvider />
10131016
</main>
10141017
<SandboxRiskDialog
10151018
open={showSandboxDialog}

libs/types/src/global-settings.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,17 @@ export interface FeatureFlags {
213213
* are auto-approved or escalated to Ava instead. Off by default.
214214
*/
215215
hitlForms: boolean;
216+
/**
217+
* Gamification Celebrations — enables dopamine-inducing micro-interactions
218+
* (confetti, toasts, overlays) that fire on XP gains, achievements, level-ups,
219+
* streak milestones, and home health score improvements. On by default.
220+
*/
221+
gamificationCelebrations: boolean;
222+
/**
223+
* Gamification Sounds — enables optional completion chime sounds alongside
224+
* gamification celebrations. Off by default.
225+
*/
226+
gamificationSounds: boolean;
216227
}
217228

218229
/** Default feature flags — all off by default, opt-in per environment */
@@ -223,6 +234,8 @@ export const DEFAULT_FEATURE_FLAGS: FeatureFlags = {
223234
userPresenceDetection: false,
224235
reactorEnabled: false,
225236
hitlForms: false,
237+
gamificationCelebrations: true,
238+
gamificationSounds: false,
226239
};
227240

228241
// ============================================================================

0 commit comments

Comments
 (0)