ui update and some backend functionality adding in accordance with research on adhd and ux design

This commit is contained in:
2026-02-14 17:21:37 -06:00
parent 4d3a9fbd54
commit fb480eacb2
32 changed files with 9549 additions and 248 deletions

View File

@@ -1,8 +1,9 @@
'use client';
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { useAuth } from '@/components/auth/AuthProvider';
import api from '@/lib/api';
import {
HomeIcon,
ListIcon,
@@ -34,12 +35,23 @@ export default function DashboardLayout({
const router = useRouter();
const pathname = usePathname();
const tzSynced = useRef(false);
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, isLoading, router]);
// Sync timezone offset to backend once per session
useEffect(() => {
if (isAuthenticated && !tzSynced.current) {
tzSynced.current = true;
const offset = new Date().getTimezoneOffset();
api.preferences.update({ timezone_offset: offset }).catch(() => {});
}
}, [isAuthenticated]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
@@ -55,6 +67,13 @@ export default function DashboardLayout({
return null;
}
// Hide chrome during active session run
const isRunMode = pathname.includes('/run');
if (isRunMode) {
return <>{children}</>;
}
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
@@ -65,12 +84,17 @@ export default function DashboardLayout({
</div>
<span className="font-bold text-gray-900">Synculous</span>
</div>
<button
onClick={logout}
className="p-2 text-gray-500 hover:text-gray-700"
>
<LogOutIcon size={20} />
</button>
<div className="flex items-center gap-2">
<Link href="/dashboard/settings" className="p-2 text-gray-500 hover:text-gray-700">
<SettingsIcon size={20} />
</Link>
<button
onClick={logout}
className="p-2 text-gray-500 hover:text-gray-700"
>
<LogOutIcon size={20} />
</button>
</div>
</div>
</header>
@@ -81,15 +105,15 @@ export default function DashboardLayout({
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 safe-area-bottom">
<div className="flex justify-around py-2">
{navItems.map((item) => {
const isActive = pathname === item.href ||
const isActive = pathname === item.href ||
(item.href !== '/dashboard' && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
className={`flex flex-col items-center gap-1 px-3 py-2 rounded-lg transition-colors ${
isActive
? 'text-indigo-600'
isActive
? 'text-indigo-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>

View File

@@ -45,26 +45,37 @@ interface WeeklySummary {
}[];
}
interface Streak {
routine_id: string;
routine_name: string;
current_streak: number;
longest_streak: number;
last_completed_date?: string;
}
export default function DashboardPage() {
const { user } = useAuth();
const router = useRouter();
const [routines, setRoutines] = useState<Routine[]>([]);
const [activeSession, setActiveSession] = useState<ActiveSession | null>(null);
const [weeklySummary, setWeeklySummary] = useState<WeeklySummary | null>(null);
const [streaks, setStreaks] = useState<Streak[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
const [routinesData, activeData, summaryData] = await Promise.all([
const [routinesData, activeData, summaryData, streaksData] = await Promise.all([
api.routines.list().catch(() => []),
api.sessions.getActive().catch(() => null),
api.stats.getWeeklySummary().catch(() => null),
api.stats.getStreaks().catch(() => []),
]);
setRoutines(routinesData);
setActiveSession(activeData);
setWeeklySummary(summaryData);
setStreaks(streaksData);
} catch (err) {
console.error('Failed to fetch dashboard data:', err);
} finally {
@@ -75,12 +86,12 @@ export default function DashboardPage() {
fetchData();
}, []);
const handleStartRoutine = async (routineId: string) => {
try {
await api.sessions.start(routineId);
router.push(`/dashboard/routines/${routineId}/run`);
} catch (err) {
setError((err as Error).message);
const handleStartRoutine = (routineId: string) => {
// If there's an active session, go straight to run
if (activeSession) {
router.push(`/dashboard/routines/${activeSession.routine.id}/run`);
} else {
router.push(`/dashboard/routines/${routineId}/launch`);
}
};
@@ -104,6 +115,17 @@ export default function DashboardPage() {
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
};
// Find routines with broken streaks for "never miss twice" recovery
const getRecoveryRoutines = () => {
const today = new Date();
return streaks.filter(s => {
if (!s.last_completed_date || s.current_streak > 0) return false;
const last = new Date(s.last_completed_date);
const daysSince = Math.floor((today.getTime() - last.getTime()) / (1000 * 60 * 60 * 24));
return daysSince >= 2 && daysSince <= 14;
});
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
@@ -112,14 +134,16 @@ export default function DashboardPage() {
);
}
const recoveryRoutines = getRecoveryRoutines();
return (
<div className="p-4 space-y-6">
{/* Active Session Banner */}
{activeSession && activeSession.session.status === 'active' && (
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 rounded-2xl p-4 text-white">
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 rounded-2xl p-4 text-white ring-2 ring-indigo-400/50 ring-offset-2 ring-offset-gray-50 animate-gentle-pulse">
<div className="flex items-center justify-between">
<div>
<p className="text-white/80 text-sm">Continue your routine</p>
<p className="text-white/80 text-sm">Continue where you left off</p>
<h2 className="text-xl font-bold">{activeSession.routine.name}</h2>
<p className="text-white/80 text-sm mt-1">
Step {activeSession.session.current_step_index + 1}: {activeSession.current_step?.name}
@@ -138,32 +162,59 @@ export default function DashboardPage() {
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">{getGreeting()}, {user?.username}!</h1>
<p className="text-gray-500 mt-1">Let's build some great habits today.</p>
<p className="text-gray-500 mt-1">Let&apos;s build some great habits today.</p>
</div>
{/* Weekly Stats */}
{weeklySummary && (
{/* "Never miss twice" Recovery Cards */}
{recoveryRoutines.map(recovery => {
const routine = routines.find(r => r.id === recovery.routine_id);
if (!routine) return null;
return (
<div key={recovery.routine_id} className="bg-amber-50 border border-amber-200 rounded-2xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-amber-800 text-sm font-medium mb-1">Welcome back</p>
<p className="text-amber-700 text-sm">
It&apos;s been a couple days since {routine.icon} {routine.name}. That&apos;s completely okay picking it back up today is what matters most.
</p>
</div>
<button
onClick={() => handleStartRoutine(routine.id)}
className="bg-amber-600 text-white px-4 py-2 rounded-lg font-medium text-sm shrink-0 ml-3"
>
Start
</button>
</div>
</div>
);
})}
{/* Weekly Stats — identity-based language */}
{weeklySummary && weeklySummary.total_completed > 0 && (
<div className="grid grid-cols-3 gap-3">
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-center gap-2 text-indigo-600 mb-1">
<StarIcon size={18} />
<span className="text-xs font-medium">Completed</span>
<span className="text-xs font-medium">Done</span>
</div>
<p className="text-2xl font-bold text-gray-900">{weeklySummary.total_completed}</p>
<p className="text-xs text-gray-400 mt-0.5">this week</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-center gap-2 text-purple-600 mb-1">
<ClockIcon size={18} />
<span className="text-xs font-medium">Time</span>
<span className="text-xs font-medium">Invested</span>
</div>
<p className="text-2xl font-bold text-gray-900">{formatTime(weeklySummary.total_time_minutes)}</p>
<p className="text-xs text-gray-400 mt-0.5">in yourself</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-center gap-2 text-pink-600 mb-1">
<ActivityIcon size={18} />
<span className="text-xs font-medium">Started</span>
<span className="text-xs font-medium">Active</span>
</div>
<p className="text-2xl font-bold text-gray-900">{weeklySummary.routines_started}</p>
<p className="text-xs text-gray-400 mt-0.5">routines</p>
</div>
</div>
)}
@@ -171,7 +222,7 @@ export default function DashboardPage() {
{/* Quick Start Routines */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Your Routines</h2>
{routines.length === 0 ? (
<div className="bg-white rounded-xl p-8 shadow-sm text-center">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
@@ -188,30 +239,38 @@ export default function DashboardPage() {
</div>
) : (
<div className="space-y-3">
{routines.map((routine) => (
<div
key={routine.id}
className="bg-white rounded-xl p-4 shadow-sm flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-indigo-100 to-pink-100 rounded-xl flex items-center justify-center">
<span className="text-2xl">{routine.icon || ''}</span>
</div>
<div>
<h3 className="font-semibold text-gray-900">{routine.name}</h3>
{routine.description && (
<p className="text-gray-500 text-sm">{routine.description}</p>
)}
</div>
</div>
<button
onClick={() => handleStartRoutine(routine.id)}
className="bg-indigo-600 text-white p-3 rounded-full"
{routines.map((routine) => {
const streak = streaks.find(s => s.routine_id === routine.id);
return (
<div
key={routine.id}
className="bg-white rounded-xl p-4 shadow-sm flex items-center justify-between"
>
<PlayIcon size={20} />
</button>
</div>
))}
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-indigo-100 to-pink-100 rounded-xl flex items-center justify-center">
<span className="text-2xl">{routine.icon || '✨'}</span>
</div>
<div>
<h3 className="font-semibold text-gray-900">{routine.name}</h3>
{streak && streak.current_streak > 0 ? (
<p className="text-sm text-orange-500 flex items-center gap-1">
<FlameIcon size={14} />
{streak.current_streak} day{streak.current_streak !== 1 ? 's' : ''}
</p>
) : routine.description ? (
<p className="text-gray-500 text-sm">{routine.description}</p>
) : null}
</div>
</div>
<button
onClick={() => handleStartRoutine(routine.id)}
className="bg-indigo-600 text-white p-3 rounded-full"
>
<PlayIcon size={20} />
</button>
</div>
);
})}
</div>
)}
</div>
@@ -233,7 +292,7 @@ export default function DashboardPage() {
</div>
{error && (
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
<div className="bg-amber-50 text-amber-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}

View File

@@ -0,0 +1,224 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import api from '@/lib/api';
import { ArrowLeftIcon, PlayIcon, CheckIcon, ClockIcon } from '@/components/ui/Icons';
interface Routine {
id: string;
name: string;
description?: string;
icon?: string;
location?: string;
environment_prompts?: string[];
habit_stack_after?: string;
}
interface Step {
id: string;
name: string;
duration_minutes?: number;
}
interface Schedule {
days: string[];
time: string;
}
const EMOTION_OPTIONS = [
{ emoji: '💪', label: 'Accomplished' },
{ emoji: '😌', label: 'Relieved' },
{ emoji: '🌟', label: 'Proud' },
{ emoji: '😊', label: 'Good' },
];
export default function LaunchScreen() {
const router = useRouter();
const params = useParams();
const routineId = params.id as string;
const [routine, setRoutine] = useState<Routine | null>(null);
const [steps, setSteps] = useState<Step[]>([]);
const [schedule, setSchedule] = useState<Schedule | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isStarting, setIsStarting] = useState(false);
const [selectedEmotion, setSelectedEmotion] = useState<string | null>(null);
const [environmentChecked, setEnvironmentChecked] = useState<Set<number>>(new Set());
useEffect(() => {
const fetchData = async () => {
try {
const [routineData, scheduleData] = await Promise.all([
api.routines.get(routineId),
api.routines.getSchedule(routineId).catch(() => null),
]);
setRoutine(routineData.routine as Routine);
setSteps(routineData.steps);
setSchedule(scheduleData);
} catch (err) {
console.error('Failed to load routine:', err);
router.push('/dashboard');
} finally {
setIsLoading(false);
}
};
fetchData();
}, [routineId, router]);
const handleStart = async () => {
setIsStarting(true);
try {
await api.sessions.start(routineId);
router.push(`/dashboard/routines/${routineId}/run`);
} catch (err) {
const msg = (err as Error).message;
if (msg.includes('already have active session')) {
router.push(`/dashboard/routines/${routineId}/run`);
} else {
console.error('Failed to start:', err);
setIsStarting(false);
}
}
};
const toggleEnvironmentCheck = (index: number) => {
setEnvironmentChecked(prev => {
const next = new Set(prev);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
});
};
const totalDuration = steps.reduce((acc, s) => acc + (s.duration_minutes || 0), 0);
const envPrompts = routine?.environment_prompts || [];
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
if (!routine) return null;
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="flex items-center gap-3 px-4 py-3">
<button onClick={() => router.back()} className="p-1">
<ArrowLeftIcon size={24} />
</button>
<h1 className="text-xl font-bold text-gray-900">Ready to start</h1>
</div>
</header>
<div className="p-4 space-y-6">
{/* Routine info */}
<div className="text-center py-4">
<div className="w-20 h-20 bg-gradient-to-br from-indigo-100 to-pink-100 rounded-2xl flex items-center justify-center mx-auto mb-3">
<span className="text-5xl">{routine.icon || '✨'}</span>
</div>
<h2 className="text-2xl font-bold text-gray-900">{routine.name}</h2>
<div className="flex items-center justify-center gap-2 text-gray-500 mt-2">
<ClockIcon size={16} />
<span>~{totalDuration} minutes</span>
<span>·</span>
<span>{steps.length} steps</span>
</div>
</div>
{/* Implementation Intention */}
{(routine.habit_stack_after || (schedule && routine.location)) && (
<div className="bg-indigo-50 border border-indigo-200 rounded-2xl p-4">
{routine.habit_stack_after && (
<p className="text-indigo-800 text-sm mb-2">
After <span className="font-semibold">{routine.habit_stack_after}</span>, I will start{' '}
<span className="font-semibold">{routine.name}</span>
</p>
)}
{schedule && routine.location && (
<p className="text-indigo-700 text-sm">
at <span className="font-semibold">{schedule.time}</span> in{' '}
<span className="font-semibold">{routine.location}</span>
</p>
)}
</div>
)}
{/* Environment Check */}
{envPrompts.length > 0 && (
<div className="bg-white rounded-2xl p-4 shadow-sm">
<h3 className="font-semibold text-gray-900 mb-3">Quick check</h3>
<div className="space-y-2">
{envPrompts.map((prompt, i) => (
<button
key={i}
onClick={() => toggleEnvironmentCheck(i)}
className={`w-full flex items-center gap-3 p-3 rounded-xl transition ${
environmentChecked.has(i)
? 'bg-green-50 border border-green-200'
: 'bg-gray-50 border border-gray-200'
}`}
>
<div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center ${
environmentChecked.has(i)
? 'border-green-500 bg-green-500'
: 'border-gray-300'
}`}>
{environmentChecked.has(i) && <CheckIcon size={14} className="text-white" />}
</div>
<span className={`text-sm ${environmentChecked.has(i) ? 'text-green-800' : 'text-gray-700'}`}>
{prompt}
</span>
</button>
))}
</div>
</div>
)}
{/* Emotion Bridge */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<h3 className="font-semibold text-gray-900 mb-1">
When you finish in ~{totalDuration} minutes, how will you feel?
</h3>
<p className="text-gray-500 text-sm mb-3">You <em>get to</em> do this</p>
<div className="grid grid-cols-2 gap-2">
{EMOTION_OPTIONS.map((option) => (
<button
key={option.label}
onClick={() => setSelectedEmotion(option.label)}
className={`flex items-center gap-2 p-3 rounded-xl border transition ${
selectedEmotion === option.label
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<span className="text-xl">{option.emoji}</span>
<span className="text-sm font-medium text-gray-900">{option.label}</span>
</button>
))}
</div>
</div>
{/* Start Button */}
<button
onClick={handleStart}
disabled={isStarting}
className="w-full bg-indigo-600 text-white font-semibold py-4 rounded-2xl flex items-center justify-center gap-2 text-lg disabled:opacity-50 shadow-lg shadow-indigo-500/25"
>
{isStarting ? (
<div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<>
<PlayIcon size={24} />
Let&apos;s Go
</>
)}
</button>
</div>
</div>
);
}

View File

@@ -20,6 +20,9 @@ interface Routine {
name: string;
description?: string;
icon?: string;
location?: string;
environment_prompts?: string[];
habit_stack_after?: string;
}
const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠'];
@@ -36,6 +39,10 @@ export default function RoutineDetailPage() {
const [editName, setEditName] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editIcon, setEditIcon] = useState('✨');
const [editLocation, setEditLocation] = useState('');
const [editHabitStack, setEditHabitStack] = useState('');
const [editEnvPrompts, setEditEnvPrompts] = useState<string[]>([]);
const [newEnvPrompt, setNewEnvPrompt] = useState('');
const [newStepName, setNewStepName] = useState('');
const [newStepDuration, setNewStepDuration] = useState(5);
@@ -48,6 +55,9 @@ export default function RoutineDetailPage() {
setEditName(data.routine.name);
setEditDescription(data.routine.description || '');
setEditIcon(data.routine.icon || '✨');
setEditLocation((data.routine as Routine).location || '');
setEditHabitStack((data.routine as Routine).habit_stack_after || '');
setEditEnvPrompts((data.routine as Routine).environment_prompts || []);
} catch (err) {
console.error('Failed to fetch routine:', err);
router.push('/dashboard/routines');
@@ -58,13 +68,8 @@ export default function RoutineDetailPage() {
fetchRoutine();
}, [routineId, router]);
const handleStart = async () => {
try {
await api.sessions.start(routineId);
router.push(`/dashboard/routines/${routineId}/run`);
} catch (err) {
console.error('Failed to start routine:', err);
}
const handleStart = () => {
router.push(`/dashboard/routines/${routineId}/launch`);
};
const handleSaveBasicInfo = async () => {
@@ -73,8 +78,19 @@ export default function RoutineDetailPage() {
name: editName,
description: editDescription,
icon: editIcon,
location: editLocation || null,
habit_stack_after: editHabitStack || null,
environment_prompts: editEnvPrompts,
});
setRoutine({
...routine!,
name: editName,
description: editDescription,
icon: editIcon,
location: editLocation || undefined,
habit_stack_after: editHabitStack || undefined,
environment_prompts: editEnvPrompts,
});
setRoutine({ ...routine!, name: editName, description: editDescription, icon: editIcon });
setIsEditing(false);
} catch (err) {
console.error('Failed to update routine:', err);
@@ -179,6 +195,69 @@ export default function RoutineDetailPage() {
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Location</label>
<input
type="text"
value={editLocation}
onChange={(e) => setEditLocation(e.target.value)}
placeholder="Where do you do this? e.g., bathroom, kitchen"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Anchor habit</label>
<input
type="text"
value={editHabitStack}
onChange={(e) => setEditHabitStack(e.target.value)}
placeholder="What do you do right before? e.g., finish breakfast"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Environment prompts</label>
<p className="text-xs text-gray-500 mb-2">Quick checklist shown before starting</p>
<div className="space-y-2 mb-2">
{editEnvPrompts.map((prompt, i) => (
<div key={i} className="flex items-center gap-2">
<span className="flex-1 text-sm text-gray-700 bg-gray-50 px-3 py-2 rounded-lg">{prompt}</span>
<button
onClick={() => setEditEnvPrompts(editEnvPrompts.filter((_, j) => j !== i))}
className="text-red-500 text-sm px-2"
>
Remove
</button>
</div>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={newEnvPrompt}
onChange={(e) => setNewEnvPrompt(e.target.value)}
placeholder="e.g., Water bottle nearby?"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 outline-none"
onKeyDown={(e) => {
if (e.key === 'Enter' && newEnvPrompt.trim()) {
setEditEnvPrompts([...editEnvPrompts, newEnvPrompt.trim()]);
setNewEnvPrompt('');
}
}}
/>
<button
onClick={() => {
if (newEnvPrompt.trim()) {
setEditEnvPrompts([...editEnvPrompts, newEnvPrompt.trim()]);
setNewEnvPrompt('');
}
}}
className="px-3 py-2 bg-gray-200 rounded-lg text-sm font-medium"
>
Add
</button>
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => setIsEditing(false)}
@@ -216,9 +295,9 @@ export default function RoutineDetailPage() {
</div>
<button
onClick={handleStart}
className="w-full mt-4 bg-indigo-600 text-white font-semibold py-3 rounded-xl flex items-center justify-center gap-2"
className="w-full mt-4 bg-gradient-to-r from-indigo-600 to-purple-600 text-white font-bold py-4 rounded-2xl flex items-center justify-center gap-2 text-lg shadow-lg shadow-indigo-500/25 active:scale-[0.98] transition-transform"
>
<PlayIcon size={20} />
<PlayIcon size={24} />
Start Routine
</button>
</div>
@@ -226,7 +305,17 @@ export default function RoutineDetailPage() {
{/* Steps */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Steps</h2>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-900">Steps</h2>
{steps.length >= 4 && steps.length <= 7 && (
<span className="text-xs text-green-600 bg-green-50 px-2 py-1 rounded-full">Good length</span>
)}
</div>
{steps.length > 7 && (
<p className="text-sm text-amber-600 bg-amber-50 px-3 py-2 rounded-lg mb-3">
Tip: Routines with 4-7 steps tend to feel more manageable. Consider combining related steps.
</p>
)}
<div className="bg-white rounded-xl p-4 shadow-sm space-y-3 mb-4">
<div className="flex gap-2">

View File

@@ -4,6 +4,10 @@ import { useEffect, useState, useCallback, useRef } from 'react';
import { useRouter, useParams } from 'next/navigation';
import api from '@/lib/api';
import { ArrowLeftIcon, PauseIcon, PlayIcon, StopIcon, SkipForwardIcon, CheckIcon, XIcon } from '@/components/ui/Icons';
import AnimatedCheckmark from '@/components/ui/AnimatedCheckmark';
import VisualTimeline from '@/components/session/VisualTimeline';
import { playStepComplete, playCelebration } from '@/lib/sounds';
import { hapticSuccess, hapticCelebration } from '@/lib/haptics';
interface Step {
id: string;
@@ -27,6 +31,15 @@ interface Session {
current_step_index: number;
}
interface CelebrationData {
streak_current: number;
streak_longest: number;
session_duration_minutes: number;
total_completions: number;
steps_completed: number;
steps_skipped: number;
}
export default function SessionRunnerPage() {
const router = useRouter();
const params = useParams();
@@ -38,12 +51,24 @@ export default function SessionRunnerPage() {
const [currentStep, setCurrentStep] = useState<Step | null>(null);
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [status, setStatus] = useState<'loading' | 'active' | 'paused' | 'completed'>('loading');
const [timerSeconds, setTimerSeconds] = useState(0);
const [isTimerRunning, setIsTimerRunning] = useState(false);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(null);
// UI states
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
// Animation states
const [completionPhase, setCompletionPhase] = useState<'idle' | 'completing' | 'transitioning' | 'arriving'>('idle');
const [skipPhase, setSkipPhase] = useState<'idle' | 'skipping' | 'transitioning' | 'arriving'>('idle');
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
const [celebrationData, setCelebrationData] = useState<CelebrationData | null>(null);
const [celebrationPhase, setCelebrationPhase] = useState(0);
const [reward, setReward] = useState<{ content: string; emoji?: string; rarity: string } | null>(null);
const [soundEnabled, setSoundEnabled] = useState(false);
const [hapticEnabled, setHapticEnabled] = useState(true);
const touchStartX = useRef<number | null>(null);
const touchStartY = useRef<number | null>(null);
@@ -58,10 +83,23 @@ export default function SessionRunnerPage() {
setCurrentStep(sessionData.current_step);
setCurrentStepIndex(sessionData.session.current_step_index);
setStatus(sessionData.session.status === 'paused' ? 'paused' : 'active');
// Mark previous steps as completed
const completed = new Set<number>();
for (let i = 0; i < sessionData.session.current_step_index; i++) {
completed.add(i);
}
setCompletedSteps(completed);
if (sessionData.current_step?.duration_minutes) {
setTimerSeconds(sessionData.current_step.duration_minutes * 60);
}
// Fetch user preferences for sound/haptics
api.preferences.get().then((prefs: { sound_enabled?: boolean; haptic_enabled?: boolean }) => {
if (prefs.sound_enabled !== undefined) setSoundEnabled(prefs.sound_enabled);
if (prefs.haptic_enabled !== undefined) setHapticEnabled(prefs.haptic_enabled);
}).catch(() => {});
} catch (err) {
router.push('/dashboard');
}
@@ -89,25 +127,24 @@ export default function SessionRunnerPage() {
// Touch handlers for swipe
const handleTouchStart = (e: React.TouchEvent) => {
if (completionPhase !== 'idle' || skipPhase !== 'idle') return;
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
};
const handleTouchEnd = (e: React.TouchEvent) => {
if (touchStartX.current === null || touchStartY.current === null) return;
if (completionPhase !== 'idle' || skipPhase !== 'idle') return;
const touchEndX = e.changedTouches[0].clientX;
const touchEndY = e.changedTouches[0].clientY;
const diffX = touchEndX - touchStartX.current;
const diffY = touchEndY - touchStartY.current;
// Only trigger if horizontal swipe is dominant
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
if (diffX < 0) {
// Swipe left - complete
handleComplete();
} else {
// Swipe right - skip
handleSkip();
}
}
@@ -117,46 +154,93 @@ export default function SessionRunnerPage() {
};
const handleComplete = async () => {
if (!session || !currentStep) return;
setSwipeDirection('left');
setTimeout(() => setSwipeDirection(null), 300);
if (!session || !currentStep || completionPhase !== 'idle') return;
// Auto-resume if paused
if (status === 'paused') {
setStatus('active');
setIsTimerRunning(true);
}
// Phase 1: Instant green glow + pulse on complete button
setCompletionPhase('completing');
if (soundEnabled) playStepComplete();
if (hapticEnabled) hapticSuccess();
try {
const result = await api.sessions.completeStep(session.id, currentStep.id);
if (result.next_step) {
setCurrentStep(result.next_step);
setCurrentStepIndex(result.session.current_step_index!);
setTimerSeconds((result.next_step.duration_minutes || 5) * 60);
setIsTimerRunning(true);
} else {
setStatus('completed');
setIsTimerRunning(false);
}
// Mark current step as completed for progress bar
setCompletedSteps(prev => new Set([...prev, currentStepIndex]));
// Phase 2: Slide out (after brief glow)
setTimeout(() => {
setCompletionPhase('transitioning');
}, 150);
// Phase 3: Bring in next step
setTimeout(() => {
if (result.next_step) {
setCurrentStep(result.next_step);
setCurrentStepIndex(result.session.current_step_index!);
setTimerSeconds((result.next_step.duration_minutes || 5) * 60);
setIsTimerRunning(true);
setCompletionPhase('arriving');
} else {
setCelebrationData(result.celebration || null);
setStatus('completed');
setCompletionPhase('idle');
}
}, 400);
// Phase 4: Back to idle
setTimeout(() => {
setCompletionPhase('idle');
}, 700);
} catch (err) {
console.error('Failed to complete step:', err);
setCompletionPhase('idle');
}
};
const handleSkip = async () => {
if (!session || !currentStep) return;
setSwipeDirection('right');
setTimeout(() => setSwipeDirection(null), 300);
if (!session || !currentStep || skipPhase !== 'idle') return;
// Auto-resume if paused
if (status === 'paused') {
setStatus('active');
setIsTimerRunning(true);
}
setSkipPhase('skipping');
try {
const result = await api.sessions.skipStep(session.id, currentStep.id);
if (result.next_step) {
setCurrentStep(result.next_step);
setCurrentStepIndex(result.session.current_step_index!);
setTimerSeconds((result.next_step.duration_minutes || 5) * 60);
setIsTimerRunning(true);
} else {
setStatus('completed');
setIsTimerRunning(false);
}
setTimeout(() => {
setSkipPhase('transitioning');
}, 150);
setTimeout(() => {
if (result.next_step) {
setCurrentStep(result.next_step);
setCurrentStepIndex(result.session.current_step_index!);
setTimerSeconds((result.next_step.duration_minutes || 5) * 60);
setIsTimerRunning(true);
setSkipPhase('arriving');
} else {
setCelebrationData(result.celebration || null);
setStatus('completed');
setSkipPhase('idle');
}
}, 400);
setTimeout(() => {
setSkipPhase('idle');
}, 700);
} catch (err) {
console.error('Failed to skip step:', err);
setSkipPhase('idle');
}
};
@@ -182,7 +266,11 @@ export default function SessionRunnerPage() {
}
};
const handleCancel = async () => {
const handleCancelRequest = () => {
setShowCancelConfirm(true);
};
const handleCancelConfirm = async () => {
if (!session) return;
try {
await api.sessions.cancel(session.id);
@@ -192,25 +280,131 @@ export default function SessionRunnerPage() {
}
};
// Celebration phase sequencing + reward fetch
useEffect(() => {
if (status !== 'completed') return;
setCelebrationPhase(0);
// Play celebration feedback
if (soundEnabled) playCelebration();
if (hapticEnabled) hapticCelebration();
// Fetch random reward
api.rewards.getRandom('completion').then(data => {
if (data.reward) setReward(data.reward);
}).catch(() => {});
const timers = [
setTimeout(() => setCelebrationPhase(1), 100), // bg gradient
setTimeout(() => setCelebrationPhase(2), 400), // checkmark
setTimeout(() => setCelebrationPhase(3), 900), // message
setTimeout(() => setCelebrationPhase(4), 1300), // stats + reward
setTimeout(() => setCelebrationPhase(5), 1700), // done button
];
return () => timers.forEach(clearTimeout);
}, [status]);
if (status === 'loading' || !currentStep) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900">
<div className="min-h-screen flex items-center justify-center bg-gray-950">
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
// ── Celebration Screen ──────────────────────────────────────
if (status === 'completed') {
const formatDuration = (mins: number) => {
if (mins < 1) return 'less than a minute';
if (mins < 60) return `${Math.round(mins)} minute${Math.round(mins) !== 1 ? 's' : ''}`;
const h = Math.floor(mins / 60);
const m = Math.round(mins % 60);
return m > 0 ? `${h}h ${m}m` : `${h}h`;
};
const streakMessage = (streak: number) => {
if (streak <= 1) return "You showed up today";
if (streak <= 3) return `${streak} days and counting`;
if (streak <= 7) return `${streak} days — you're building something real`;
return `${streak} days — you're someone who shows up`;
};
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500 flex flex-col items-center justify-center p-6">
<div className="w-24 h-24 bg-white rounded-full flex items-center justify-center mb-6">
<CheckIcon className="text-green-500" size={48} />
<div className={`min-h-screen flex flex-col items-center justify-center p-6 transition-all duration-500 ${
celebrationPhase >= 1
? 'bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500'
: 'bg-gray-950'
}`}>
{/* Animated Checkmark */}
<div className={`mb-8 ${celebrationPhase >= 2 ? 'animate-celebration-scale' : 'opacity-0'}`}>
<div className="w-28 h-28 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center">
{celebrationPhase >= 2 && (
<AnimatedCheckmark size={64} color="#ffffff" strokeWidth={2.5} />
)}
</div>
</div>
<h1 className="text-3xl font-bold text-white mb-2">Great job!</h1>
<p className="text-white/80 text-lg mb-8">You completed your routine</p>
{/* Message */}
<div className={`text-center mb-8 ${celebrationPhase >= 3 ? 'animate-fade-in-up' : 'opacity-0'}`}>
<h1 className="text-3xl font-bold text-white mb-2">
{routine?.name || 'Routine'} complete
</h1>
<p className="text-white/80 text-lg">You showed up for yourself</p>
</div>
{/* Stats */}
{celebrationData && (
<div className={`w-full max-w-sm space-y-3 mb-10 ${celebrationPhase >= 4 ? 'animate-fade-in-up' : 'opacity-0'}`}
style={{ animationDelay: '100ms' }}>
{/* Streak */}
<div className="bg-white/15 backdrop-blur-sm rounded-2xl p-4 text-center">
<p className="text-white/60 text-sm mb-1">Your streak</p>
<p className="text-white text-xl font-semibold">
{streakMessage(celebrationData.streak_current)}
</p>
</div>
{/* Duration + Steps */}
<div className="flex gap-3">
<div className="flex-1 bg-white/15 backdrop-blur-sm rounded-2xl p-4 text-center">
<p className="text-white/60 text-sm mb-1">Time</p>
<p className="text-white text-lg font-semibold">
{formatDuration(celebrationData.session_duration_minutes)}
</p>
</div>
<div className="flex-1 bg-white/15 backdrop-blur-sm rounded-2xl p-4 text-center">
<p className="text-white/60 text-sm mb-1">Steps done</p>
<p className="text-white text-lg font-semibold">
{celebrationData.steps_completed}/{celebrationData.steps_completed + celebrationData.steps_skipped}
</p>
</div>
</div>
{/* Total completions */}
{celebrationData.total_completions > 1 && (
<p className="text-white/50 text-sm text-center">
That&apos;s {celebrationData.total_completions} total completions
</p>
)}
{/* Variable Reward */}
{reward && (
<div className={`bg-white/10 backdrop-blur-sm rounded-2xl p-4 text-center border ${
reward.rarity === 'rare' ? 'border-yellow-400/50' : 'border-white/10'
}`}>
<span className="text-2xl">{reward.emoji || '✨'}</span>
<p className="text-white text-sm mt-1">{reward.content}</p>
</div>
)}
</div>
)}
{/* Done button */}
<button
onClick={() => router.push('/dashboard')}
className="bg-white text-indigo-600 px-8 py-3 rounded-full font-semibold"
className={`bg-white text-indigo-600 px-10 py-3.5 rounded-full font-semibold text-lg shadow-lg ${
celebrationPhase >= 5 ? 'animate-fade-in-up' : 'opacity-0'
}`}
>
Done
</button>
@@ -218,20 +412,32 @@ export default function SessionRunnerPage() {
);
}
const progress = ((currentStepIndex + 1) / steps.length) * 100;
// ── Active Session ──────────────────────────────────────────
// Card animation class
const getCardAnimation = () => {
if (completionPhase === 'completing') return 'animate-green-glow';
if (completionPhase === 'transitioning') return 'animate-slide-out-left';
if (completionPhase === 'arriving') return 'animate-slide-in-right';
if (skipPhase === 'skipping') return '';
if (skipPhase === 'transitioning') return 'animate-slide-out-right';
if (skipPhase === 'arriving') return 'animate-slide-in-right';
return '';
};
return (
<div
className="min-h-screen bg-gray-900 text-white flex flex-col"
<div
className="min-h-screen bg-gray-950 text-white flex flex-col"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Header */}
<header className="flex items-center justify-between px-4 py-4">
<button onClick={handleCancel} className="p-2">
<button onClick={handleCancelRequest} className="p-2">
<XIcon size={24} />
</button>
<div className="text-center">
<p className="text-white/40 text-xs tracking-wide uppercase mb-0.5">Focus Mode</p>
<p className="text-white/60 text-sm">{routine?.name}</p>
<p className="font-semibold">Step {currentStepIndex + 1} of {steps.length}</p>
</div>
@@ -240,34 +446,44 @@ export default function SessionRunnerPage() {
</button>
</header>
{/* Progress bar */}
<div className="px-4">
<div className="h-1 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 transition-all duration-500"
style={{ width: `${progress}%` }}
/>
{/* Segmented Progress Bar */}
<div className="px-4 pb-2">
<div className="flex gap-1">
{steps.map((_, i) => (
<div key={i} className="flex-1 h-2 rounded-full overflow-hidden bg-gray-800">
<div
className={`h-full rounded-full transition-all duration-500 ${
completedSteps.has(i)
? 'bg-indigo-500 w-full'
: i === currentStepIndex
? 'bg-indigo-400 w-full animate-gentle-pulse'
: 'w-0'
}`}
/>
</div>
))}
</div>
</div>
{/* Visual Timeline — collapsible step overview */}
<VisualTimeline
steps={steps}
currentStepIndex={currentStepIndex}
completedSteps={completedSteps}
/>
{/* Main Card */}
<div className="flex-1 flex flex-col items-center justify-center p-6">
<div
<div
className={`
w-full max-w-md bg-gray-800 rounded-3xl p-8 text-center
transition-transform duration-300
${swipeDirection === 'left' ? 'translate-x-20 opacity-50' : ''}
${swipeDirection === 'right' ? '-translate-x-20 opacity-50' : ''}
shadow-2xl shadow-indigo-500/10
${getCardAnimation()}
`}
>
{/* Step Type Badge */}
<div className="inline-block px-3 py-1 bg-indigo-600 rounded-full text-sm mb-4">
{currentStep.step_type || 'Generic'}
</div>
{/* Timer */}
<div className="mb-6">
<div className="text-7xl font-bold font-mono mb-2">
<div className="text-7xl font-bold font-mono mb-2" style={{ textShadow: '0 0 20px rgba(99, 102, 241, 0.3)' }}>
{formatTime(timerSeconds)}
</div>
<p className="text-white/60">remaining</p>
@@ -281,41 +497,67 @@ export default function SessionRunnerPage() {
<p className="text-white/80 text-lg mb-6">{currentStep.instructions}</p>
)}
{/* Timer Controls */}
<div className="flex justify-center gap-4 mt-8">
<button
onClick={handleSkip}
className="w-16 h-16 bg-gray-700 rounded-full flex items-center justify-center hover:bg-gray-600 transition"
>
<SkipForwardIcon size={28} />
</button>
<button
onClick={status === 'paused' ? handleResume : handlePause}
className="w-20 h-20 bg-indigo-600 rounded-full flex items-center justify-center hover:bg-indigo-500 transition"
>
{status === 'paused' ? <PlayIcon size={32} /> : <PauseIcon size={32} />}
</button>
<button
onClick={handleComplete}
className="w-16 h-16 bg-green-600 rounded-full flex items-center justify-center hover:bg-green-500 transition"
>
<CheckIcon size={28} />
</button>
</div>
</div>
{/* Swipe Hints */}
<div className="flex justify-between w-full max-w-md mt-8 text-white/40 text-sm">
<div className="flex items-center gap-2">
<ArrowLeftIcon size={16} />
<span>Swipe left to complete</span>
</div>
<div className="flex items-center gap-2">
<span>Swipe right to skip</span>
<ArrowLeftIcon size={16} className="rotate-180" />
</div>
</div>
</div>
{/* Action Buttons — pinned to bottom for one-handed use */}
<div className="px-6 pb-8 pt-4">
<div className="flex justify-center items-center gap-6">
<button
onClick={handleSkip}
disabled={skipPhase !== 'idle' || completionPhase !== 'idle'}
className="w-16 h-16 bg-gray-700 rounded-full flex items-center justify-center hover:bg-gray-600 transition disabled:opacity-50"
>
<SkipForwardIcon size={28} />
</button>
<button
onClick={status === 'paused' ? handleResume : handlePause}
className="w-20 h-20 bg-indigo-600 rounded-full flex items-center justify-center hover:bg-indigo-500 transition"
>
{status === 'paused' ? <PlayIcon size={32} /> : <PauseIcon size={32} />}
</button>
<button
onClick={handleComplete}
disabled={completionPhase !== 'idle' || skipPhase !== 'idle'}
className={`w-16 h-16 rounded-full flex items-center justify-center transition disabled:opacity-50 ${
completionPhase === 'completing'
? 'bg-green-500 animate-step-complete'
: 'bg-green-600 hover:bg-green-500'
}`}
>
<CheckIcon size={28} />
</button>
</div>
{/* Swipe Hints */}
<div className="flex justify-between mt-4 text-white/30 text-xs">
<span>Swipe left to complete</span>
<span>Swipe right to skip</span>
</div>
</div>
{/* Gentle Cancel Confirmation */}
{showCancelConfirm && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-6">
<div className="bg-gray-800 rounded-2xl p-6 max-w-sm w-full text-center">
<p className="text-white text-lg font-semibold mb-2">Step away for now?</p>
<p className="text-white/60 text-sm mb-6">You can always come back. Your progress today still counts.</p>
<div className="flex gap-3">
<button
onClick={() => setShowCancelConfirm(false)}
className="flex-1 py-3 bg-indigo-600 text-white rounded-xl font-medium"
>
Keep Going
</button>
<button
onClick={handleCancelConfirm}
className="flex-1 py-3 bg-gray-700 text-white/70 rounded-xl font-medium"
>
Leave
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,172 @@
'use client';
import { useEffect, useState } from 'react';
import api from '@/lib/api';
import { VolumeIcon, VolumeOffIcon, SparklesIcon } from '@/components/ui/Icons';
import { playStepComplete } from '@/lib/sounds';
import { hapticTap } from '@/lib/haptics';
interface Preferences {
sound_enabled: boolean;
haptic_enabled: boolean;
show_launch_screen: boolean;
celebration_style: string;
}
export default function SettingsPage() {
const [prefs, setPrefs] = useState<Preferences>({
sound_enabled: false,
haptic_enabled: true,
show_launch_screen: true,
celebration_style: 'standard',
});
const [isLoading, setIsLoading] = useState(true);
const [saved, setSaved] = useState(false);
useEffect(() => {
api.preferences.get()
.then((data: Preferences) => setPrefs(data))
.catch(() => {})
.finally(() => setIsLoading(false));
}, []);
const updatePref = async (key: keyof Preferences, value: boolean | string) => {
const updated = { ...prefs, [key]: value };
setPrefs(updated);
try {
await api.preferences.update({ [key]: value });
setSaved(true);
setTimeout(() => setSaved(false), 1500);
} catch {
// revert on failure
setPrefs(prefs);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
return (
<div className="p-4 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
{saved && (
<span className="text-sm text-green-600 animate-fade-in-up">Saved</span>
)}
</div>
{/* Session Experience */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Session Experience</h2>
<div className="bg-white rounded-xl shadow-sm divide-y divide-gray-100">
{/* Sound */}
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
{prefs.sound_enabled ? (
<VolumeIcon size={20} className="text-indigo-500" />
) : (
<VolumeOffIcon size={20} className="text-gray-400" />
)}
<div>
<p className="font-medium text-gray-900">Sound effects</p>
<p className="text-sm text-gray-500">Subtle audio cues on step completion</p>
</div>
</div>
<button
onClick={() => {
updatePref('sound_enabled', !prefs.sound_enabled);
if (!prefs.sound_enabled) playStepComplete();
}}
className={`w-12 h-7 rounded-full transition-colors ${
prefs.sound_enabled ? 'bg-indigo-500' : 'bg-gray-300'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
prefs.sound_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{/* Haptics */}
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<SparklesIcon size={20} className={prefs.haptic_enabled ? 'text-indigo-500' : 'text-gray-400'} />
<div>
<p className="font-medium text-gray-900">Haptic feedback</p>
<p className="text-sm text-gray-500">Gentle vibration on actions</p>
</div>
</div>
<button
onClick={() => {
updatePref('haptic_enabled', !prefs.haptic_enabled);
if (!prefs.haptic_enabled) hapticTap();
}}
className={`w-12 h-7 rounded-full transition-colors ${
prefs.haptic_enabled ? 'bg-indigo-500' : 'bg-gray-300'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
prefs.haptic_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{/* Launch Screen */}
<div className="flex items-center justify-between p-4">
<div>
<p className="font-medium text-gray-900">Pre-routine launch screen</p>
<p className="text-sm text-gray-500">Environment check and emotion bridge</p>
</div>
<button
onClick={() => updatePref('show_launch_screen', !prefs.show_launch_screen)}
className={`w-12 h-7 rounded-full transition-colors ${
prefs.show_launch_screen ? 'bg-indigo-500' : 'bg-gray-300'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
prefs.show_launch_screen ? 'translate-x-5' : ''
}`} />
</button>
</div>
</div>
</div>
{/* Celebration Style */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Celebration Style</h2>
<div className="bg-white rounded-xl shadow-sm divide-y divide-gray-100">
{[
{ value: 'standard', label: 'Standard', desc: 'Full animated celebration with stats and rewards' },
{ value: 'quick', label: 'Quick', desc: 'Brief confirmation, then back to dashboard' },
{ value: 'none', label: 'None', desc: 'No celebration screen, return immediately' },
].map(option => (
<button
key={option.value}
onClick={() => updatePref('celebration_style', option.value)}
className="w-full flex items-center justify-between p-4 text-left"
>
<div>
<p className="font-medium text-gray-900">{option.label}</p>
<p className="text-sm text-gray-500">{option.desc}</p>
</div>
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
prefs.celebration_style === option.value
? 'border-indigo-500'
: 'border-gray-300'
}`}>
{prefs.celebration_style === option.value && (
<div className="w-2.5 h-2.5 rounded-full bg-indigo-500" />
)}
</div>
</button>
))}
</div>
</div>
</div>
);
}

View File

@@ -35,26 +35,50 @@ interface WeeklySummary {
}[];
}
function getStreakMessage(streak: number): string {
if (streak === 0) return 'Ready for a fresh start';
if (streak === 1) return "You're back! Day 1";
if (streak <= 3) return `${streak} days of showing up`;
if (streak <= 7) return `${streak} days — building momentum`;
return `${streak} days — you're someone who shows up`;
}
function getCompletionLabel(rate: number): { label: string; color: string } {
if (rate >= 80) return { label: 'Rock solid', color: 'text-green-600' };
if (rate >= 60) return { label: 'Strong habit', color: 'text-indigo-600' };
if (rate >= 30) return { label: 'Building momentum', color: 'text-amber-600' };
return { label: 'Getting started', color: 'text-gray-600' };
}
interface Victory {
type: string;
message: string;
date?: string;
}
export default function StatsPage() {
const [routines, setRoutines] = useState<{ id: string; name: string }[]>([]);
const [selectedRoutine, setSelectedRoutine] = useState<string>('');
const [routineStats, setRoutineStats] = useState<RoutineStats | null>(null);
const [streaks, setStreaks] = useState<Streak[]>([]);
const [weeklySummary, setWeeklySummary] = useState<WeeklySummary | null>(null);
const [victories, setVictories] = useState<Victory[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [routinesData, streaksData, summaryData] = await Promise.all([
const [routinesData, streaksData, summaryData, victoriesData] = await Promise.all([
api.routines.list(),
api.stats.getStreaks(),
api.stats.getWeeklySummary(),
api.victories.get(30).catch(() => []),
]);
setRoutines(routinesData);
setStreaks(streaksData);
setWeeklySummary(summaryData);
setVictories(victoriesData);
if (routinesData.length > 0) {
setSelectedRoutine(routinesData[0].id);
}
@@ -97,7 +121,7 @@ export default function StatsPage() {
return (
<div className="p-4 space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Stats</h1>
<h1 className="text-2xl font-bold text-gray-900">Your Progress</h1>
{/* Weekly Summary */}
{weeklySummary && (
@@ -110,20 +134,42 @@ export default function StatsPage() {
<div className="bg-gradient-to-br from-pink-500 to-rose-600 rounded-2xl p-4 text-white">
<ClockIcon className="text-white/80 mb-2" size={24} />
<p className="text-3xl font-bold">{formatTime(weeklySummary.total_time_minutes)}</p>
<p className="text-white/80 text-sm">Time</p>
<p className="text-white/80 text-sm">Invested</p>
</div>
<div className="bg-gradient-to-br from-amber-500 to-orange-600 rounded-2xl p-4 text-white">
<ActivityIcon className="text-white/80 mb-2" size={24} />
<p className="text-3xl font-bold">{weeklySummary.routines_started}</p>
<p className="text-white/80 text-sm">Started</p>
<p className="text-white/80 text-sm">Active</p>
</div>
</div>
)}
{/* Streaks */}
{/* Wins This Month */}
{victories.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Wins This Month</h2>
<div className="space-y-2">
{victories.map((victory, i) => (
<div key={i} className="bg-white rounded-xl p-4 shadow-sm flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-green-100 to-emerald-100 rounded-xl flex items-center justify-center">
<span className="text-lg">
{victory.type === 'comeback' ? '💪' :
victory.type === 'weekend' ? '🎉' :
victory.type === 'variety' ? '🌈' :
victory.type === 'consistency' ? '🔥' : '⭐'}
</span>
</div>
<p className="text-sm text-gray-700 flex-1">{victory.message}</p>
</div>
))}
</div>
</div>
)}
{/* Consistency (formerly Streaks) */}
{streaks.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Streaks</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Your Consistency</h2>
<div className="space-y-2">
{streaks.map((streak) => (
<div key={streak.routine_id} className="bg-white rounded-xl p-4 shadow-sm flex items-center gap-4">
@@ -133,15 +179,27 @@ export default function StatsPage() {
<div className="flex-1">
<p className="font-medium text-gray-900">{streak.routine_name}</p>
<p className="text-sm text-gray-500">
Last: {streak.last_completed_date ? new Date(streak.last_completed_date).toLocaleDateString() : 'Never'}
{getStreakMessage(streak.current_streak)}
</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-orange-500">{streak.current_streak}</p>
<p className="text-xs text-gray-500">day streak</p>
{streak.current_streak > 0 ? (
<>
<p className="text-2xl font-bold text-orange-500">{streak.current_streak}</p>
<p className="text-xs text-gray-500">days</p>
</>
) : (
<p className="text-sm text-gray-400">Ready</p>
)}
</div>
</div>
))}
{/* Longest streak callout */}
{streaks.some(s => s.longest_streak > 0) && (
<p className="text-sm text-gray-400 text-center mt-2">
Your personal best: {Math.max(...streaks.map(s => s.longest_streak))} days you&apos;ve done it before
</p>
)}
</div>
</div>
)}
@@ -149,7 +207,7 @@ export default function StatsPage() {
{/* Per-Routine Stats */}
{routines.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Routine Stats</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Routine Details</h2>
<select
value={selectedRoutine}
onChange={(e) => setSelectedRoutine(e.target.value)}
@@ -166,8 +224,10 @@ export default function StatsPage() {
<div className="grid grid-cols-2 gap-3">
<div className="bg-white rounded-xl p-4 shadow-sm">
<TargetIcon className="text-indigo-500 mb-2" size={24} />
<p className="text-2xl font-bold text-gray-900">{routineStats.completion_rate_percent}%</p>
<p className="text-sm text-gray-500">Completion Rate</p>
<p className={`text-lg font-bold ${getCompletionLabel(routineStats.completion_rate_percent).color}`}>
{getCompletionLabel(routineStats.completion_rate_percent).label}
</p>
<p className="text-sm text-gray-400">{routineStats.completion_rate_percent}%</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<ClockIcon className="text-purple-500 mb-2" size={24} />
@@ -186,6 +246,15 @@ export default function StatsPage() {
</div>
</div>
)}
{/* Plateau messaging */}
{routineStats && routineStats.total_sessions >= 5 && (
<p className="text-sm text-gray-400 text-center mt-3">
{routineStats.completion_rate_percent >= 60
? "You're showing up consistently — that's the hard part. The exact rate doesn't matter."
: "Life has seasons. The fact that you're checking in shows this matters to you."}
</p>
)}
</div>
)}
</div>

View File

@@ -24,3 +24,96 @@ body {
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
/* ── Animation Keyframes ─────────────────────────────────── */
@keyframes checkmark-draw {
0% { stroke-dashoffset: 24; }
100% { stroke-dashoffset: 0; }
}
@keyframes step-complete-pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.12); opacity: 0.85; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes slide-out-left {
0% { transform: translateX(0); opacity: 1; }
100% { transform: translateX(-120%); opacity: 0; }
}
@keyframes slide-in-right {
0% { transform: translateX(80%); opacity: 0; }
100% { transform: translateX(0); opacity: 1; }
}
@keyframes slide-out-right {
0% { transform: translateX(0); opacity: 1; }
100% { transform: translateX(120%); opacity: 0; }
}
@keyframes progress-fill {
0% { transform: scaleX(0); }
100% { transform: scaleX(1); }
}
@keyframes progress-glow {
0% { box-shadow: 0 0 4px rgba(99, 102, 241, 0.4); }
50% { box-shadow: 0 0 12px rgba(99, 102, 241, 0.8); }
100% { box-shadow: 0 0 4px rgba(99, 102, 241, 0.4); }
}
@keyframes celebration-scale {
0% { transform: scale(0.3); opacity: 0; }
60% { transform: scale(1.1); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes fade-in-up {
0% { transform: translateY(20px); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
}
@keyframes gentle-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
@keyframes green-glow {
0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.5); }
50% { box-shadow: 0 0 20px 4px rgba(34, 197, 94, 0.3); }
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
}
/* Utility animation classes */
.animate-checkmark-draw {
animation: checkmark-draw 0.4s ease-out forwards;
}
.animate-step-complete {
animation: step-complete-pulse 0.3s ease-out;
}
.animate-slide-out-left {
animation: slide-out-left 0.25s ease-in forwards;
}
.animate-slide-in-right {
animation: slide-in-right 0.3s ease-out forwards;
}
.animate-slide-out-right {
animation: slide-out-right 0.25s ease-in forwards;
}
.animate-celebration-scale {
animation: celebration-scale 0.5s ease-out forwards;
}
.animate-fade-in-up {
animation: fade-in-up 0.4s ease-out forwards;
}
.animate-gentle-pulse {
animation: gentle-pulse 2s ease-in-out infinite;
}
.animate-green-glow {
animation: green-glow 0.5s ease-out;
}
.animate-progress-glow {
animation: progress-glow 0.6s ease-out;
}

View File

@@ -0,0 +1,104 @@
'use client';
import { useEffect, useState } from 'react';
import api from '@/lib/api';
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export default function PushNotificationToggle() {
const [supported, setSupported] = useState(false);
const [enabled, setEnabled] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
const check = async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
setLoading(false);
return;
}
setSupported(true);
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
setEnabled(!!sub);
} catch {
// ignore
}
setLoading(false);
};
check();
}, []);
const toggle = async () => {
if (loading) return;
setLoading(true);
try {
const reg = await navigator.serviceWorker.ready;
if (enabled) {
// Unsubscribe
const sub = await reg.pushManager.getSubscription();
if (sub) {
await api.notifications.unsubscribe(sub.endpoint);
await sub.unsubscribe();
}
setEnabled(false);
} else {
// Subscribe
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
setLoading(false);
return;
}
const { public_key } = await api.notifications.getVapidPublicKey();
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(public_key).buffer as ArrayBuffer,
});
const subJson = sub.toJSON();
await api.notifications.subscribe(subJson);
setEnabled(true);
}
} catch (err) {
console.error('Push notification toggle failed:', err);
}
setLoading(false);
};
if (!supported) return null;
return (
<div className="flex items-center justify-between bg-white rounded-xl p-4 shadow-sm">
<div>
<h3 className="font-semibold text-gray-900 text-sm">Push Notifications</h3>
<p className="text-xs text-gray-500">Get reminders on this device</p>
</div>
<button
onClick={toggle}
disabled={loading}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? 'bg-indigo-600' : 'bg-gray-300'
} ${loading ? 'opacity-50' : ''}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
import { useState } from 'react';
import { CheckIcon } from '@/components/ui/Icons';
interface Step {
id: string;
name: string;
duration_minutes?: number;
}
interface VisualTimelineProps {
steps: Step[];
currentStepIndex: number;
completedSteps: Set<number>;
}
export default function VisualTimeline({ steps, currentStepIndex, completedSteps }: VisualTimelineProps) {
const [expanded, setExpanded] = useState(false);
if (steps.length === 0) return null;
return (
<div
className="px-4 py-2 cursor-pointer"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
// Expanded: show step names
<div className="flex flex-col gap-1">
{steps.map((step, i) => {
const isCompleted = completedSteps.has(i);
const isCurrent = i === currentStepIndex;
const isUpcoming = !isCompleted && !isCurrent;
return (
<div key={step.id} className="flex items-center gap-2">
<div className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 transition-all ${
isCompleted
? 'bg-indigo-500'
: isCurrent
? 'bg-indigo-400 ring-2 ring-indigo-400/50 ring-offset-2 ring-offset-gray-950'
: 'bg-gray-700'
}`}>
{isCompleted ? (
<CheckIcon size={14} className="text-white" />
) : (
<span className={`text-xs font-medium ${isCurrent ? 'text-white' : 'text-gray-400'}`}>
{i + 1}
</span>
)}
</div>
<span className={`text-sm truncate ${
isCompleted ? 'text-white/40 line-through' :
isCurrent ? 'text-white font-medium' :
'text-white/50'
}`}>
{step.name}
</span>
{step.duration_minutes && (
<span className="text-xs text-white/30 ml-auto shrink-0">
{step.duration_minutes}m
</span>
)}
</div>
);
})}
<p className="text-white/30 text-xs text-center mt-1">Tap to collapse</p>
</div>
) : (
// Collapsed: just circles
<div className="flex items-center justify-center gap-1.5">
{steps.map((step, i) => {
const isCompleted = completedSteps.has(i);
const isCurrent = i === currentStepIndex;
return (
<div
key={step.id}
className={`rounded-full transition-all ${
isCompleted
? 'w-3 h-3 bg-indigo-500'
: isCurrent
? 'w-4 h-4 bg-indigo-400 animate-gentle-pulse'
: 'w-3 h-3 bg-gray-700'
}`}
/>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
interface AnimatedCheckmarkProps {
size?: number;
color?: string;
strokeWidth?: number;
delay?: number;
}
export default function AnimatedCheckmark({
size = 48,
color = '#22c55e',
strokeWidth = 3,
delay = 0,
}: AnimatedCheckmarkProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
style={{ animationDelay: `${delay}ms` }}
>
<circle
cx="12"
cy="12"
r="10"
stroke={color}
strokeWidth={strokeWidth}
opacity={0.2}
/>
<path
d="M7 12.5l3.5 3.5 6.5-7"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray="24"
strokeDashoffset="24"
className="animate-checkmark-draw"
style={{ animationDelay: `${delay}ms` }}
/>
</svg>
);
}

View File

@@ -708,6 +708,135 @@ export function TimerIcon({ className = '', size = 24 }: IconProps) {
);
}
export function LockIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
}
export function SparklesIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
<path d="M5 3v4" />
<path d="M19 17v4" />
<path d="M3 5h4" />
<path d="M17 19h4" />
</svg>
);
}
export function TrophyIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
<path d="M4 22h16" />
<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" />
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" />
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
</svg>
);
}
export function MapPinIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
);
}
export function VolumeIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
</svg>
);
}
export function VolumeOffIcon({ className = '', size = 24 }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<line x1="22" y1="9" x2="16" y2="15" />
<line x1="16" y1="9" x2="22" y2="15" />
</svg>
);
}
export function SkipForwardIcon({ className = '', size = 24 }: IconProps) {
return (
<svg

View File

@@ -20,6 +20,7 @@ async function request<T>(
const token = getToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
'X-Timezone-Offset': String(new Date().getTimezoneOffset()),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
};
@@ -375,6 +376,14 @@ export const api = {
duration_minutes?: number;
position: number;
} | null;
celebration?: {
streak_current: number;
streak_longest: number;
session_duration_minutes: number;
total_completions: number;
steps_completed: number;
steps_skipped: number;
};
}>(`/api/sessions/${sessionId}/complete-step`, {
method: 'POST',
body: JSON.stringify({ step_id: stepId }),
@@ -392,6 +401,14 @@ export const api = {
duration_minutes?: number;
position: number;
} | null;
celebration?: {
streak_current: number;
streak_longest: number;
session_duration_minutes: number;
total_completions: number;
steps_completed: number;
steps_skipped: number;
};
}>(`/api/sessions/${sessionId}/skip-step`, {
method: 'POST',
body: JSON.stringify({ step_id: stepId }),
@@ -546,6 +563,68 @@ export const api = {
},
},
// Victories
victories: {
get: async (days = 30) => {
return request<Array<{
type: string;
message: string;
date?: string;
}>>(`/api/victories?days=${days}`, { method: 'GET' });
},
},
// Rewards
rewards: {
getRandom: async (context = 'completion') => {
return request<{
reward: {
id: string;
category: string;
content: string;
emoji?: string;
rarity: string;
} | null;
}>(`/api/rewards/random?context=${context}`, { method: 'GET' });
},
getHistory: async () => {
return request<Array<{
earned_at: string;
context?: string;
category: string;
content: string;
emoji?: string;
rarity?: string;
}>>('/api/rewards/history', { method: 'GET' });
},
},
// Preferences
preferences: {
get: async () => {
return request<{
sound_enabled: boolean;
haptic_enabled: boolean;
show_launch_screen: boolean;
celebration_style: string;
}>('/api/preferences', { method: 'GET' });
},
update: async (data: {
sound_enabled?: boolean;
haptic_enabled?: boolean;
show_launch_screen?: boolean;
celebration_style?: string;
timezone_offset?: number;
}) => {
return request<Record<string, unknown>>('/api/preferences', {
method: 'PUT',
body: JSON.stringify(data),
});
},
},
// Notifications
notifications: {
getVapidPublicKey: async () => {

View File

@@ -0,0 +1,19 @@
'use client';
function vibrate(pattern: number | number[]): void {
if (typeof navigator !== 'undefined' && navigator.vibrate) {
navigator.vibrate(pattern);
}
}
export function hapticTap() {
vibrate(10);
}
export function hapticSuccess() {
vibrate([10, 50, 10]);
}
export function hapticCelebration() {
vibrate([10, 30, 10, 30, 50]);
}

View File

@@ -0,0 +1,48 @@
'use client';
let audioContext: AudioContext | null = null;
function getAudioContext(): AudioContext {
if (!audioContext) {
audioContext = new AudioContext();
}
return audioContext;
}
function playTone(frequency: number, duration: number, type: OscillatorType = 'sine', volume = 0.15) {
try {
const ctx = getAudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.type = type;
oscillator.frequency.setValueAtTime(frequency, ctx.currentTime);
gainNode.gain.setValueAtTime(volume, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + duration);
} catch {
// Audio not available
}
}
export function playStepComplete() {
playTone(523, 0.1, 'sine', 0.12);
setTimeout(() => playTone(659, 0.15, 'sine', 0.12), 80);
}
export function playCelebration() {
const notes = [523, 659, 784, 1047];
notes.forEach((freq, i) => {
setTimeout(() => playTone(freq, 0.2, 'sine', 0.1), i * 120);
});
}
export function playTimerEnd() {
playTone(880, 0.15, 'triangle', 0.1);
setTimeout(() => playTone(880, 0.15, 'triangle', 0.1), 200);
}