From 9fe3f3b17dad6bce6da91be09b6c5c2a0807885a Mon Sep 17 00:00:00 2001 From: Auto Date: Tue, 30 Dec 2025 19:34:33 +0200 Subject: [PATCH] celebrate --- ui/package-lock.json | 19 ++++ ui/package.json | 2 + ui/src/App.tsx | 4 + ui/src/hooks/useCelebration.ts | 184 +++++++++++++++++++++++++++++++++ ui/tsconfig.tsbuildinfo | 2 +- 5 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 ui/src/hooks/useCelebration.ts diff --git a/ui/package-lock.json b/ui/package-lock.json index c2e5fc3..e3df446 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -12,6 +12,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-tooltip": "^1.1.3", "@tanstack/react-query": "^5.60.0", + "canvas-confetti": "^1.9.4", "clsx": "^2.1.1", "lucide-react": "^0.460.0", "react": "^18.3.1", @@ -20,6 +21,7 @@ "devDependencies": { "@eslint/js": "^9.13.0", "@tailwindcss/vite": "^4.0.0-beta.4", + "@types/canvas-confetti": "^1.9.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", @@ -2287,6 +2289,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/canvas-confetti": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", + "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2787,6 +2796,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", diff --git a/ui/package.json b/ui/package.json index 81ba088..a558f1e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,6 +14,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-tooltip": "^1.1.3", "@tanstack/react-query": "^5.60.0", + "canvas-confetti": "^1.9.4", "clsx": "^2.1.1", "lucide-react": "^0.460.0", "react": "^18.3.1", @@ -22,6 +23,7 @@ "devDependencies": { "@eslint/js": "^9.13.0", "@tailwindcss/vite": "^4.0.0-beta.4", + "@types/canvas-confetti": "^1.9.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index c01be93..a268f84 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react' import { useProjects, useFeatures } from './hooks/useProjects' import { useProjectWebSocket } from './hooks/useWebSocket' import { useFeatureSound } from './hooks/useFeatureSound' +import { useCelebration } from './hooks/useCelebration' const STORAGE_KEY = 'autonomous-coder-selected-project' import { ProjectSelector } from './components/ProjectSelector' @@ -37,6 +38,9 @@ function App() { // Play sounds when features move between columns useFeatureSound(features) + // Celebrate when all features are complete + useCelebration(features, selectedProject) + // Persist selected project to localStorage const handleSelectProject = useCallback((project: string | null) => { setSelectedProject(project) diff --git a/ui/src/hooks/useCelebration.ts b/ui/src/hooks/useCelebration.ts new file mode 100644 index 0000000..71b28a4 --- /dev/null +++ b/ui/src/hooks/useCelebration.ts @@ -0,0 +1,184 @@ +/** + * Hook for triggering celebration effects when all features are complete + * Plays confetti cannons from both sides and a triumphant fanfare + */ + +import { useEffect, useRef } from 'react' +import confetti from 'canvas-confetti' +import type { FeatureListResponse } from '../lib/types' + +/** + * Play a triumphant fanfare using Web Audio API + * Rising major chord progression: C5 -> E5 -> G5 -> C6 + */ +function playFanfare(): void { + try { + const audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)() + + // Frequencies for triumphant fanfare (C major arpeggio going up) + const notes = [ + { freq: 523.25, start: 0 }, // C5 + { freq: 659.25, start: 0.15 }, // E5 + { freq: 783.99, start: 0.30 }, // G5 + { freq: 1046.50, start: 0.45 }, // C6 (octave higher) + ] + + const noteDuration = 0.25 + + notes.forEach(({ freq, start }) => { + const oscillator = audioContext.createOscillator() + const gainNode = audioContext.createGain() + + oscillator.connect(gainNode) + gainNode.connect(audioContext.destination) + + oscillator.type = 'sine' + oscillator.frequency.setValueAtTime(freq, audioContext.currentTime + start) + + // Envelope for smooth, triumphant sound + const startTime = audioContext.currentTime + start + gainNode.gain.setValueAtTime(0, startTime) + gainNode.gain.linearRampToValueAtTime(0.4, startTime + 0.03) + gainNode.gain.setValueAtTime(0.4, startTime + noteDuration * 0.6) + gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + noteDuration) + + oscillator.start(startTime) + oscillator.stop(startTime + noteDuration) + }) + + // Add a final sustained chord for extra triumph + const chordFreqs = [523.25, 659.25, 783.99, 1046.50] // C major chord + const chordStart = 0.65 + const chordDuration = 0.5 + + chordFreqs.forEach((freq) => { + const oscillator = audioContext.createOscillator() + const gainNode = audioContext.createGain() + + oscillator.connect(gainNode) + gainNode.connect(audioContext.destination) + + oscillator.type = 'sine' + oscillator.frequency.setValueAtTime(freq, audioContext.currentTime + chordStart) + + const startTime = audioContext.currentTime + chordStart + gainNode.gain.setValueAtTime(0, startTime) + gainNode.gain.linearRampToValueAtTime(0.2, startTime + 0.05) + gainNode.gain.setValueAtTime(0.2, startTime + chordDuration * 0.5) + gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + chordDuration) + + oscillator.start(startTime) + oscillator.stop(startTime + chordDuration) + }) + + // Clean up audio context after sounds finish + setTimeout(() => { + audioContext.close() + }, 1500) + } catch { + // Audio not supported or blocked, fail silently + } +} + +/** + * Fire confetti cannons from both sides of the screen + */ +function fireConfetti(): void { + const duration = 2000 + const end = Date.now() + duration + + // Initial burst from both sides + confetti({ + particleCount: 100, + spread: 70, + origin: { x: 0, y: 0.6 }, + angle: 60, + colors: ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7', '#dfe6e9'] + }) + + confetti({ + particleCount: 100, + spread: 70, + origin: { x: 1, y: 0.6 }, + angle: 120, + colors: ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7', '#dfe6e9'] + }) + + // Continue firing for a bit + const interval = setInterval(() => { + if (Date.now() > end) { + clearInterval(interval) + return + } + + confetti({ + particleCount: 30, + spread: 60, + origin: { x: 0, y: 0.6 }, + angle: 60, + colors: ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7', '#dfe6e9'] + }) + + confetti({ + particleCount: 30, + spread: 60, + origin: { x: 1, y: 0.6 }, + angle: 120, + colors: ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7', '#dfe6e9'] + }) + }, 250) +} + +/** + * Check if all features are complete (none pending or in progress, at least one done) + */ +function isAllComplete(features: FeatureListResponse | undefined): boolean { + if (!features) return false + return ( + features.pending.length === 0 && + features.in_progress.length === 0 && + features.done.length > 0 + ) +} + +/** + * Hook that triggers celebration when all features are complete + * Tracks per-project to allow re-celebration when switching between completed projects + */ +export function useCelebration( + features: FeatureListResponse | undefined, + projectName: string | null +): void { + // Track which projects have celebrated in this session + const celebratedProjectsRef = useRef>(new Set()) + // Track if we've initialized for the current project (to avoid celebrating on initial load) + const initializedForProjectRef = useRef(null) + + useEffect(() => { + if (!features || !projectName) return + + const isComplete = isAllComplete(features) + + // If this is a new project, mark as initialized but don't celebrate yet + // This prevents celebrating when first loading an already-complete project + if (initializedForProjectRef.current !== projectName) { + initializedForProjectRef.current = projectName + // If project is already complete on first load, mark it as celebrated + // so we don't trigger when data refreshes + if (isComplete) { + celebratedProjectsRef.current.add(projectName) + } + return + } + + // Check if we should celebrate + if (isComplete && !celebratedProjectsRef.current.has(projectName)) { + // Mark as celebrated before firing to prevent double-triggers + celebratedProjectsRef.current.add(projectName) + + // Fire the celebration! + fireConfetti() + playFanfare() + } + }, [features, projectName]) +} diff --git a/ui/tsconfig.tsbuildinfo b/ui/tsconfig.tsbuildinfo index 23d0ee1..6792149 100644 --- a/ui/tsconfig.tsbuildinfo +++ b/ui/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/chatmessage.tsx","./src/components/debuglogviewer.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/typingindicator.tsx","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/chatmessage.tsx","./src/components/debuglogviewer.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/typingindicator.tsx","./src/hooks/usecelebration.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"} \ No newline at end of file