ui update and some backend functionality adding in accordance with research on adhd and ux design
This commit is contained in:
@@ -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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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'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's been a couple days since {routine.icon} {routine.name}. That'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>
|
||||
)}
|
||||
|
||||
224
synculous-client/src/app/dashboard/routines/[id]/launch/page.tsx
Normal file
224
synculous-client/src/app/dashboard/routines/[id]/launch/page.tsx
Normal 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's Go
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
172
synculous-client/src/app/dashboard/settings/page.tsx
Normal file
172
synculous-client/src/app/dashboard/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user