Fix medication system and rename to Synculous.

- Add all 14 missing database tables (medications, med_logs, routines, etc.)
- Rewrite medication scheduling: support specific days, every N days, as-needed (PRN)
- Fix taken_times matching: match by created_at date, not scheduled_time string
- Fix adherence calculation: taken / expected doses, not taken / (taken + skipped)
- Add formatSchedule() helper for readable display
- Update client types and API layer
- Rename brilli-ins-client → synculous-client
- Make client PWA: add manifest, service worker, icons
- Bind dev server to 0.0.0.0 for network access
- Fix SVG icon bugs in Icons.tsx
- Add .dockerignore for client npm caching

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 03:23:38 -06:00
parent 3e1134575b
commit 97a166f5aa
47 changed files with 5231 additions and 61 deletions

View File

@@ -0,0 +1,157 @@
'use client';
import { useEffect, useState } from 'react';
import api from '@/lib/api';
import { CalendarIcon, CheckIcon, XIcon, ClockIcon } from '@/components/ui/Icons';
interface HistorySession {
id: string;
routine_id: string;
status: string;
created_at: string;
completed_at?: string;
}
interface Routine {
id: string;
name: string;
icon?: string;
}
export default function HistoryPage() {
const [routines, setRoutines] = useState<Routine[]>([]);
const [selectedRoutine, setSelectedRoutine] = useState<string>('all');
const [history, setHistory] = useState<HistorySession[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [routinesData] = await Promise.all([
api.routines.list(),
]);
setRoutines(routinesData);
} catch (err) {
console.error('Failed to fetch data:', err);
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
useEffect(() => {
const fetchHistory = async () => {
setIsLoading(true);
try {
if (selectedRoutine === 'all') {
const allHistory: HistorySession[] = [];
for (const routine of routines) {
const sessions = await api.routines.getHistory(routine.id, 30).catch(() => []);
allHistory.push(...sessions.map(s => ({ ...s, routine_name: routine.name, routine_icon: routine.icon })));
}
allHistory.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
setHistory(allHistory.slice(0, 50));
} else {
const sessions = await api.routines.getHistory(selectedRoutine, 30);
setHistory(sessions);
}
} catch (err) {
console.error('Failed to fetch history:', err);
} finally {
setIsLoading(false);
}
};
if (routines.length > 0) {
fetchHistory();
}
}, [selectedRoutine, routines]);
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
};
const isToday = (dateStr: string) => {
const date = new Date(dateStr);
const today = new Date();
return date.toDateString() === today.toDateString();
};
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-4">
<h1 className="text-2xl font-bold text-gray-900">History</h1>
{/* Filter */}
<select
value={selectedRoutine}
onChange={(e) => setSelectedRoutine(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-xl bg-white"
>
<option value="all">All Routines</option>
{routines.map((routine) => (
<option key={routine.id} value={routine.id}>
{routine.name}
</option>
))}
</select>
{history.length === 0 ? (
<div className="bg-white rounded-xl p-8 shadow-sm text-center">
<CalendarIcon className="text-gray-400 mx-auto mb-4" size={40} />
<h3 className="font-semibold text-gray-900 mb-1">No history yet</h3>
<p className="text-gray-500 text-sm">Complete a routine to see it here</p>
</div>
) : (
<div className="space-y-3">
{history.map((session) => (
<div
key={session.id}
className="bg-white rounded-xl p-4 shadow-sm flex items-center gap-4"
>
<div className={`
w-10 h-10 rounded-full flex items-center justify-center
${session.status === 'completed' ? 'bg-green-100' : 'bg-red-100'}
`}>
{session.status === 'completed' ? (
<CheckIcon className="text-green-600" size={20} />
) : (
<XIcon className="text-red-600" size={20} />
)}
</div>
<div className="flex-1">
<p className="font-medium text-gray-900">
{(session as any).routine_name || 'Routine'}
</p>
<p className="text-sm text-gray-500">
{formatDate(session.created_at)}
</p>
</div>
<span className={`
text-xs font-medium px-2 py-1 rounded-full
${session.status === 'completed' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}
`}>
{session.status}
</span>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import { useEffect } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { useAuth } from '@/components/auth/AuthProvider';
import {
HomeIcon,
ListIcon,
CalendarIcon,
BarChartIcon,
PillIcon,
SettingsIcon,
LogOutIcon,
CopyIcon,
HeartIcon
} from '@/components/ui/Icons';
import Link from 'next/link';
const navItems = [
{ href: '/dashboard', label: 'Today', icon: HomeIcon },
{ href: '/dashboard/routines', label: 'Routines', icon: ListIcon },
{ href: '/dashboard/templates', label: 'Templates', icon: CopyIcon },
{ href: '/dashboard/history', label: 'History', icon: CalendarIcon },
{ href: '/dashboard/stats', label: 'Stats', icon: BarChartIcon },
{ href: '/dashboard/medications', label: 'Meds', icon: PillIcon },
];
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const { isAuthenticated, isLoading, logout } = useAuth();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, isLoading, router]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-pulse flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
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 justify-between px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-indigo-500 to-pink-500 rounded-lg flex items-center justify-center">
<HeartIcon className="text-white" size={16} />
</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>
</header>
<main className="pb-20">
{children}
</main>
<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 ||
(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'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<item.icon size={20} />
<span className="text-xs font-medium">{item.label}</span>
</Link>
);
})}
</div>
</nav>
</div>
);
}

View File

@@ -0,0 +1,253 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import api from '@/lib/api';
import { ArrowLeftIcon } from '@/components/ui/Icons';
const DAY_OPTIONS = [
{ value: 'mon', label: 'Mon' },
{ value: 'tue', label: 'Tue' },
{ value: 'wed', label: 'Wed' },
{ value: 'thu', label: 'Thu' },
{ value: 'fri', label: 'Fri' },
{ value: 'sat', label: 'Sat' },
{ value: 'sun', label: 'Sun' },
];
export default function NewMedicationPage() {
const router = useRouter();
const [name, setName] = useState('');
const [dosage, setDosage] = useState('');
const [unit, setUnit] = useState('mg');
const [frequency, setFrequency] = useState('daily');
const [times, setTimes] = useState<string[]>(['08:00']);
const [daysOfWeek, setDaysOfWeek] = useState<string[]>([]);
const [intervalDays, setIntervalDays] = useState(7);
const [startDate, setStartDate] = useState(new Date().toISOString().slice(0, 10));
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const handleAddTime = () => {
setTimes([...times, '12:00']);
};
const handleRemoveTime = (index: number) => {
setTimes(times.filter((_, i) => i !== index));
};
const handleTimeChange = (index: number, value: string) => {
const newTimes = [...times];
newTimes[index] = value;
setTimes(newTimes);
};
const toggleDay = (day: string) => {
setDaysOfWeek(prev =>
prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day]
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !dosage.trim()) {
setError('Name and dosage are required');
return;
}
if (frequency === 'specific_days' && daysOfWeek.length === 0) {
setError('Select at least one day of the week');
return;
}
setIsLoading(true);
setError('');
try {
await api.medications.create({
name,
dosage,
unit,
frequency,
times: frequency === 'as_needed' ? [] : times,
...(frequency === 'specific_days' && { days_of_week: daysOfWeek }),
...(frequency === 'every_n_days' && { interval_days: intervalDays, start_date: startDate }),
});
router.push('/dashboard/medications');
} catch (err) {
setError((err as Error).message || 'Failed to add medication');
} finally {
setIsLoading(false);
}
};
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">Add Medication</h1>
</div>
</header>
<form onSubmit={handleSubmit} className="p-4 space-y-6">
{error && (
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div className="bg-white rounded-xl p-4 shadow-sm space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Medication Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Vitamin D"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Dosage</label>
<input
type="text"
value={dosage}
onChange={(e) => setDosage(e.target.value)}
placeholder="e.g., 1000"
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">Unit</label>
<select
value={unit}
onChange={(e) => setUnit(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
>
<option value="mg">mg</option>
<option value="mcg">mcg</option>
<option value="g">g</option>
<option value="ml">ml</option>
<option value="IU">IU</option>
<option value="tablets">tablets</option>
<option value="capsules">capsules</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Frequency</label>
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
>
<option value="daily">Daily</option>
<option value="twice_daily">Twice Daily</option>
<option value="specific_days">Specific Days of Week</option>
<option value="every_n_days">Every N Days</option>
<option value="as_needed">As Needed (PRN)</option>
</select>
</div>
{/* Day-of-week picker for specific_days */}
{frequency === 'specific_days' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Days</label>
<div className="flex gap-2 flex-wrap">
{DAY_OPTIONS.map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => toggleDay(value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
daysOfWeek.includes(value)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white text-gray-700 border-gray-300'
}`}
>
{label}
</button>
))}
</div>
</div>
)}
{/* Interval settings for every_n_days */}
{frequency === 'every_n_days' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Every N Days</label>
<input
type="number"
min={1}
value={intervalDays}
onChange={(e) => setIntervalDays(parseInt(e.target.value) || 1)}
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">Starting From</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
</div>
)}
{/* Times picker — hidden for as_needed */}
{frequency !== 'as_needed' && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-700">Times</label>
<button
type="button"
onClick={handleAddTime}
className="text-indigo-600 text-sm font-medium"
>
+ Add Time
</button>
</div>
<div className="space-y-2">
{times.map((time, index) => (
<div key={index} className="flex gap-2">
<input
type="time"
value={time}
onChange={(e) => handleTimeChange(index, e.target.value)}
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
{times.length > 1 && (
<button
type="button"
onClick={() => handleRemoveTime(index)}
className="text-red-500 px-3"
>
Remove
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-indigo-600 text-white font-semibold py-4 rounded-xl hover:bg-indigo-700 disabled:opacity-50"
>
{isLoading ? 'Adding...' : 'Add Medication'}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,268 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import api from '@/lib/api';
import { PlusIcon, CheckIcon, PillIcon, ClockIcon, TrashIcon } from '@/components/ui/Icons';
import Link from 'next/link';
interface Medication {
id: string;
name: string;
dosage: string;
unit: string;
frequency: string;
times: string[];
days_of_week?: string[];
interval_days?: number;
start_date?: string;
next_dose_date?: string;
notes?: string;
active: boolean;
quantity_remaining?: number;
}
interface TodaysMedication {
medication: {
id: string;
name: string;
dosage: string;
unit: string;
};
scheduled_times: string[];
taken_times: string[];
is_prn?: boolean;
}
interface AdherenceEntry {
medication_id: string;
name: string;
adherence_percent: number | null;
is_prn?: boolean;
}
const formatSchedule = (med: Medication): string => {
if (med.frequency === 'specific_days' && med.days_of_week?.length) {
return med.days_of_week.map(d => d.charAt(0).toUpperCase() + d.slice(1)).join(', ');
}
if (med.frequency === 'every_n_days' && med.interval_days) {
return `Every ${med.interval_days} days`;
}
if (med.frequency === 'as_needed') return 'As needed';
if (med.frequency === 'twice_daily') return 'Twice daily';
return 'Daily';
};
export default function MedicationsPage() {
const router = useRouter();
const [medications, setMedications] = useState<Medication[]>([]);
const [todayMeds, setTodayMeds] = useState<TodaysMedication[]>([]);
const [adherence, setAdherence] = useState<AdherenceEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [medsData, todayData, adherenceData] = await Promise.all([
api.medications.list(),
api.medications.getToday().catch(() => []),
api.medications.getAdherence(30).catch(() => []),
]);
setMedications(medsData);
setTodayMeds(todayData);
setAdherence(adherenceData);
} catch (err) {
console.error('Failed to fetch medications:', err);
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
const handleTake = async (medId: string, time?: string) => {
try {
await api.medications.take(medId, time);
window.location.reload();
} catch (err) {
console.error('Failed to log medication:', err);
}
};
const handleSkip = async (medId: string, time?: string) => {
try {
await api.medications.skip(medId, time);
window.location.reload();
} catch (err) {
console.error('Failed to skip medication:', err);
}
};
const handleDelete = async (medId: string) => {
try {
await api.medications.delete(medId);
setMedications(medications.filter(m => m.id !== medId));
} catch (err) {
console.error('Failed to delete medication:', err);
}
};
const getAdherenceForMed = (medId: string) => {
const entry = adherence.find(a => a.medication_id === medId);
if (!entry) return { percent: 0, isPrn: false };
return { percent: entry.adherence_percent, isPrn: entry.is_prn || false };
};
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">Medications</h1>
<Link href="/dashboard/medications/new" className="bg-indigo-600 text-white p-2 rounded-full">
<PlusIcon size={24} />
</Link>
</div>
{/* Today's Schedule */}
{todayMeds.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Today</h2>
<div className="space-y-3">
{todayMeds.map((item) => (
<div key={item.medication.id} className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-semibold text-gray-900">{item.medication.name}</h3>
<p className="text-sm text-gray-500">{item.medication.dosage} {item.medication.unit}</p>
</div>
</div>
<div className="space-y-2">
{item.is_prn ? (
<div className="flex items-center justify-between bg-gray-50 rounded-lg p-3">
<span className="text-gray-500 text-sm">As needed</span>
<button
onClick={() => handleTake(item.medication.id)}
className="bg-green-600 text-white px-3 py-1 rounded-lg text-sm font-medium"
>
Log Dose
</button>
</div>
) : (
item.scheduled_times.map((time) => {
const isTaken = item.taken_times.includes(time);
return (
<div key={time} className="flex items-center justify-between bg-gray-50 rounded-lg p-3">
<div className="flex items-center gap-2">
<ClockIcon size={16} className="text-gray-500" />
<span className="font-medium">{time}</span>
</div>
{isTaken ? (
<span className="text-green-600 font-medium flex items-center gap-1">
<CheckIcon size={16} /> Taken
</span>
) : (
<div className="flex gap-2">
<button
onClick={() => handleTake(item.medication.id, time)}
className="bg-green-600 text-white px-3 py-1 rounded-lg text-sm font-medium"
>
Take
</button>
<button
onClick={() => handleSkip(item.medication.id, time)}
className="text-gray-500 px-2 py-1"
>
Skip
</button>
</div>
)}
</div>
);
})
)}
</div>
</div>
))}
</div>
</div>
)}
{/* All Medications */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">All Medications</h2>
{medications.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">
<PillIcon className="text-gray-400" size={32} />
</div>
<h3 className="font-semibold text-gray-900 mb-1">No medications yet</h3>
<p className="text-gray-500 text-sm mb-4">Add your medications to track them</p>
<Link href="/dashboard/medications/new" className="inline-block bg-indigo-600 text-white px-4 py-2 rounded-lg font-medium">
Add Medication
</Link>
</div>
) : (
<div className="space-y-3">
{medications.map((med) => {
const { percent: adherencePercent, isPrn } = getAdherenceForMed(med.id);
return (
<div key={med.id} className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900">{med.name}</h3>
{!med.active && (
<span className="text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded">Inactive</span>
)}
</div>
<p className="text-gray-500 text-sm">{med.dosage} {med.unit} &middot; {formatSchedule(med)}</p>
{med.times.length > 0 && (
<p className="text-gray-400 text-sm mt-1">Times: {med.times.join(', ')}</p>
)}
</div>
<button
onClick={() => handleDelete(med.id)}
className="text-red-500 p-2"
>
<TrashIcon size={18} />
</button>
</div>
{/* Adherence */}
<div className="mt-3 pt-3 border-t border-gray-100">
{isPrn || adherencePercent === null ? (
<span className="text-sm text-gray-400">PRN &mdash; no adherence tracking</span>
) : (
<>
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-500">30-day adherence</span>
<span className={`font-semibold ${adherencePercent >= 80 ? 'text-green-600' : adherencePercent >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
{adherencePercent}%
</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full ${adherencePercent >= 80 ? 'bg-green-500' : adherencePercent >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${adherencePercent}%` }}
/>
</div>
</>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,242 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import api from '@/lib/api';
import { useAuth } from '@/components/auth/AuthProvider';
import { PlayIcon, ClockIcon, FlameIcon, StarIcon, ActivityIcon } from '@/components/ui/Icons';
import Link from 'next/link';
interface Routine {
id: string;
name: string;
description?: string;
icon?: string;
}
interface ActiveSession {
session: {
id: string;
routine_id: string;
status: string;
current_step_index: number;
};
routine: {
id: string;
name: string;
icon?: string;
};
current_step: {
id: string;
name: string;
step_type: string;
duration_minutes?: number;
} | null;
}
interface WeeklySummary {
total_completed: number;
total_time_minutes: number;
routines_started: number;
routines: {
routine_id: string;
name: string;
completed_this_week: number;
}[];
}
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 [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
const [routinesData, activeData, summaryData] = await Promise.all([
api.routines.list().catch(() => []),
api.sessions.getActive().catch(() => null),
api.stats.getWeeklySummary().catch(() => null),
]);
setRoutines(routinesData);
setActiveSession(activeData);
setWeeklySummary(summaryData);
} catch (err) {
console.error('Failed to fetch dashboard data:', err);
} finally {
setIsLoading(false);
}
};
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 handleResumeSession = () => {
if (activeSession) {
router.push(`/dashboard/routines/${activeSession.routine.id}/run`);
}
};
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning';
if (hour < 17) return 'Good afternoon';
return 'Good evening';
};
const formatTime = (minutes: number) => {
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
};
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">
{/* 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="flex items-center justify-between">
<div>
<p className="text-white/80 text-sm">Continue your routine</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}
</p>
</div>
<button
onClick={handleResumeSession}
className="bg-white text-indigo-600 px-4 py-2 rounded-lg font-semibold"
>
Resume
</button>
</div>
</div>
)}
{/* 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>
</div>
{/* Weekly Stats */}
{weeklySummary && (
<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>
</div>
<p className="text-2xl font-bold text-gray-900">{weeklySummary.total_completed}</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>
</div>
<p className="text-2xl font-bold text-gray-900">{formatTime(weeklySummary.total_time_minutes)}</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>
</div>
<p className="text-2xl font-bold text-gray-900">{weeklySummary.routines_started}</p>
</div>
</div>
)}
{/* 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">
<FlameIcon className="text-gray-400" size={32} />
</div>
<h3 className="font-semibold text-gray-900 mb-1">No routines yet</h3>
<p className="text-gray-500 text-sm mb-4">Create your first routine to get started</p>
<Link
href="/dashboard/routines/new"
className="inline-block bg-indigo-600 text-white px-4 py-2 rounded-lg font-medium"
>
Create Routine
</Link>
</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"
>
<PlayIcon size={20} />
</button>
</div>
))}
</div>
)}
</div>
{/* Templates CTA */}
<div className="bg-gradient-to-r from-pink-500 to-rose-500 rounded-2xl p-4 text-white">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">Need inspiration?</h3>
<p className="text-white/80 text-sm">Browse pre-made routines</p>
</div>
<Link
href="/dashboard/templates"
className="bg-white text-pink-600 px-4 py-2 rounded-lg font-medium"
>
Browse
</Link>
</div>
</div>
{error && (
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,294 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import api from '@/lib/api';
import { ArrowLeftIcon, PlayIcon, PlusIcon, TrashIcon, GripVerticalIcon, ClockIcon } from '@/components/ui/Icons';
import Link from 'next/link';
interface Step {
id: string;
name: string;
instructions?: string;
step_type: string;
duration_minutes?: number;
position: number;
}
interface Routine {
id: string;
name: string;
description?: string;
icon?: string;
}
const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠'];
export default function RoutineDetailPage() {
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 [isLoading, setIsLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editIcon, setEditIcon] = useState('✨');
const [newStepName, setNewStepName] = useState('');
const [newStepDuration, setNewStepDuration] = useState(5);
useEffect(() => {
const fetchRoutine = async () => {
try {
const data = await api.routines.get(routineId);
setRoutine(data.routine);
setSteps(data.steps);
setEditName(data.routine.name);
setEditDescription(data.routine.description || '');
setEditIcon(data.routine.icon || '✨');
} catch (err) {
console.error('Failed to fetch routine:', err);
router.push('/dashboard/routines');
} finally {
setIsLoading(false);
}
};
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 handleSaveBasicInfo = async () => {
try {
await api.routines.update(routineId, {
name: editName,
description: editDescription,
icon: editIcon,
});
setRoutine({ ...routine!, name: editName, description: editDescription, icon: editIcon });
setIsEditing(false);
} catch (err) {
console.error('Failed to update routine:', err);
}
};
const handleAddStep = async () => {
if (!newStepName.trim()) return;
try {
const step = await api.routines.addStep(routineId, {
name: newStepName,
duration_minutes: newStepDuration,
});
setSteps([...steps, { ...step, position: steps.length + 1 }]);
setNewStepName('');
} catch (err) {
console.error('Failed to add step:', err);
}
};
const handleDeleteStep = async (stepId: string) => {
try {
await api.routines.deleteStep(routineId, stepId);
setSteps(steps.filter(s => s.id !== stepId).map((s, i) => ({ ...s, position: i + 1 })));
} catch (err) {
console.error('Failed to delete step:', err);
}
};
const totalDuration = steps.reduce((acc, s) => acc + (s.duration_minutes || 0), 0);
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 justify-between px-4 py-3">
<div className="flex items-center gap-3">
<button onClick={() => router.back()} className="p-1">
<ArrowLeftIcon size={24} />
</button>
<h1 className="text-xl font-bold text-gray-900">
{isEditing ? 'Edit Routine' : routine.name}
</h1>
</div>
{!isEditing && (
<button
onClick={() => setIsEditing(true)}
className="text-indigo-600 font-medium"
>
Edit
</button>
)}
</div>
</header>
<div className="p-4 space-y-6">
{isEditing ? (
<div className="bg-white rounded-xl p-4 shadow-sm space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Icon</label>
<div className="flex flex-wrap gap-2">
{ICONS.map((i) => (
<button
key={i}
type="button"
onClick={() => setEditIcon(i)}
className={`w-10 h-10 rounded-lg text-xl flex items-center justify-center transition ${
editIcon === i
? 'bg-indigo-100 ring-2 ring-indigo-600'
: 'bg-gray-100 hover:bg-gray-200'
}`}
>
{i}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
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">Description</label>
<input
type="text"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div className="flex gap-3">
<button
onClick={() => setIsEditing(false)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg font-medium"
>
Cancel
</button>
<button
onClick={handleSaveBasicInfo}
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium"
>
Save
</button>
</div>
</div>
) : (
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-gradient-to-br from-indigo-100 to-pink-100 rounded-2xl flex items-center justify-center">
<span className="text-4xl">{routine.icon || '✨'}</span>
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-gray-900">{routine.name}</h2>
{routine.description && (
<p className="text-gray-500">{routine.description}</p>
)}
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
<span className="flex items-center gap-1">
<ClockIcon size={14} />
{totalDuration} min
</span>
<span>{steps.length} steps</span>
</div>
</div>
</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"
>
<PlayIcon size={20} />
Start Routine
</button>
</div>
)}
{/* Steps */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Steps</h2>
<div className="bg-white rounded-xl p-4 shadow-sm space-y-3 mb-4">
<div className="flex gap-2">
<input
type="text"
value={newStepName}
onChange={(e) => setNewStepName(e.target.value)}
placeholder="New step name"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
<select
value={newStepDuration}
onChange={(e) => setNewStepDuration(Number(e.target.value))}
className="px-3 py-2 border border-gray-300 rounded-lg"
>
<option value={1}>1m</option>
<option value={5}>5m</option>
<option value={10}>10m</option>
<option value={15}>15m</option>
<option value={30}>30m</option>
</select>
<button
onClick={handleAddStep}
className="bg-indigo-600 text-white px-4 py-2 rounded-lg"
>
<PlusIcon size={20} />
</button>
</div>
</div>
{steps.length === 0 ? (
<div className="bg-white rounded-xl p-8 shadow-sm text-center">
<p className="text-gray-500">No steps yet</p>
</div>
) : (
<div className="space-y-2">
{steps.map((step, index) => (
<div
key={step.id}
className="bg-white rounded-xl p-4 shadow-sm flex items-center gap-3"
>
<div className="w-8 h-8 bg-indigo-100 text-indigo-600 rounded-full flex items-center justify-center font-semibold text-sm">
{index + 1}
</div>
<div className="flex-1">
<h3 className="font-medium text-gray-900">{step.name}</h3>
{step.duration_minutes && (
<p className="text-sm text-gray-500">{step.duration_minutes} min</p>
)}
</div>
<button
onClick={() => handleDeleteStep(step.id)}
className="text-red-500 p-2"
>
<TrashIcon size={18} />
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,321 @@
'use client';
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';
interface Step {
id: string;
name: string;
instructions?: string;
step_type: string;
duration_minutes?: number;
position: number;
}
interface Routine {
id: string;
name: string;
icon?: string;
}
interface Session {
id: string;
routine_id: string;
status: string;
current_step_index: number;
}
export default function SessionRunnerPage() {
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 [session, setSession] = useState<Session | null>(null);
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);
const touchStartX = useRef<number | null>(null);
const touchStartY = useRef<number | null>(null);
// Fetch session data
useEffect(() => {
const fetchSession = async () => {
try {
const sessionData = await api.sessions.getActive();
setSession(sessionData.session);
setRoutine(sessionData.routine);
setSteps(await api.routines.getSteps(sessionData.routine.id).then(s => s));
setCurrentStep(sessionData.current_step);
setCurrentStepIndex(sessionData.session.current_step_index);
setStatus(sessionData.session.status === 'paused' ? 'paused' : 'active');
if (sessionData.current_step?.duration_minutes) {
setTimerSeconds(sessionData.current_step.duration_minutes * 60);
}
} catch (err) {
router.push('/dashboard');
}
};
fetchSession();
}, [router]);
// Timer logic
useEffect(() => {
if (isTimerRunning && timerSeconds > 0) {
timerRef.current = setInterval(() => {
setTimerSeconds(s => Math.max(0, s - 1));
}, 1000);
}
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [isTimerRunning, timerSeconds]);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// Touch handlers for swipe
const handleTouchStart = (e: React.TouchEvent) => {
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;
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();
}
}
touchStartX.current = null;
touchStartY.current = null;
};
const handleComplete = async () => {
if (!session || !currentStep) return;
setSwipeDirection('left');
setTimeout(() => setSwipeDirection(null), 300);
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);
}
} catch (err) {
console.error('Failed to complete step:', err);
}
};
const handleSkip = async () => {
if (!session || !currentStep) return;
setSwipeDirection('right');
setTimeout(() => setSwipeDirection(null), 300);
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);
}
} catch (err) {
console.error('Failed to skip step:', err);
}
};
const handlePause = async () => {
if (!session) return;
try {
await api.sessions.pause(session.id);
setStatus('paused');
setIsTimerRunning(false);
} catch (err) {
console.error('Failed to pause:', err);
}
};
const handleResume = async () => {
if (!session) return;
try {
await api.sessions.resume(session.id);
setStatus('active');
setIsTimerRunning(true);
} catch (err) {
console.error('Failed to resume:', err);
}
};
const handleCancel = async () => {
if (!session) return;
try {
await api.sessions.cancel(session.id);
router.push('/dashboard');
} catch (err) {
console.error('Failed to cancel:', err);
}
};
if (status === 'loading' || !currentStep) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900">
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
if (status === 'completed') {
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>
<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>
<button
onClick={() => router.push('/dashboard')}
className="bg-white text-indigo-600 px-8 py-3 rounded-full font-semibold"
>
Done
</button>
</div>
);
}
const progress = ((currentStepIndex + 1) / steps.length) * 100;
return (
<div
className="min-h-screen bg-gray-900 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">
<XIcon size={24} />
</button>
<div className="text-center">
<p className="text-white/60 text-sm">{routine?.name}</p>
<p className="font-semibold">Step {currentStepIndex + 1} of {steps.length}</p>
</div>
<button onClick={status === 'paused' ? handleResume : handlePause} className="p-2">
{status === 'paused' ? <PlayIcon size={24} /> : <PauseIcon size={24} />}
</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}%` }}
/>
</div>
</div>
{/* Main Card */}
<div className="flex-1 flex flex-col items-center justify-center p-6">
<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' : ''}
`}
>
{/* 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">
{formatTime(timerSeconds)}
</div>
<p className="text-white/60">remaining</p>
</div>
{/* Step Name */}
<h2 className="text-3xl font-bold mb-4">{currentStep.name}</h2>
{/* Instructions */}
{currentStep.instructions && (
<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>
</div>
);
}

View File

@@ -0,0 +1,236 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import api from '@/lib/api';
import { ArrowLeftIcon, PlusIcon, TrashIcon, GripVerticalIcon } from '@/components/ui/Icons';
interface Step {
id: string;
name: string;
duration_minutes?: number;
position: number;
}
const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠'];
const STEP_TYPES = [
{ value: 'generic', label: 'Generic' },
{ value: 'timer', label: 'Timer' },
{ value: 'checklist', label: 'Checklist' },
{ value: 'meditation', label: 'Meditation' },
{ value: 'exercise', label: 'Exercise' },
];
export default function NewRoutinePage() {
const router = useRouter();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [icon, setIcon] = useState('✨');
const [steps, setSteps] = useState<Step[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const handleAddStep = () => {
const newStep: Step = {
id: `temp-${Date.now()}`,
name: '',
duration_minutes: 5,
position: steps.length + 1,
};
setSteps([...steps, newStep]);
};
const handleUpdateStep = (index: number, updates: Partial<Step>) => {
const newSteps = [...steps];
newSteps[index] = { ...newSteps[index], ...updates };
setSteps(newSteps);
};
const handleDeleteStep = (index: number) => {
const newSteps = steps.filter((_, i) => i !== index);
setSteps(newSteps.map((s, i) => ({ ...s, position: i + 1 })));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
setError('Please enter a routine name');
return;
}
const validSteps = steps.filter(s => s.name.trim());
if (validSteps.length === 0) {
setError('Please add at least one step');
return;
}
setIsLoading(true);
setError('');
try {
const routine = await api.routines.create({ name, description, icon });
for (const step of validSteps) {
await api.routines.addStep(routine.id, {
name: step.name,
duration_minutes: step.duration_minutes,
});
}
router.push('/dashboard/routines');
} catch (err) {
setError((err as Error).message || 'Failed to create routine');
} finally {
setIsLoading(false);
}
};
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">New Routine</h1>
</div>
</header>
<form onSubmit={handleSubmit} className="p-4 space-y-6">
{error && (
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
{/* Basic Info */}
<div className="bg-white rounded-xl p-4 shadow-sm space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Icon</label>
<div className="flex flex-wrap gap-2">
{ICONS.map((i) => (
<button
key={i}
type="button"
onClick={() => setIcon(i)}
className={`w-10 h-10 rounded-lg text-xl flex items-center justify-center transition ${
icon === i
? 'bg-indigo-100 ring-2 ring-indigo-600'
: 'bg-gray-100 hover:bg-gray-200'
}`}
>
{i}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Morning Routine"
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">Description (optional)</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Start your day right"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
</div>
{/* Steps */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-900">Steps</h2>
<button
type="button"
onClick={handleAddStep}
className="text-indigo-600 text-sm font-medium flex items-center gap-1"
>
<PlusIcon size={16} />
Add Step
</button>
</div>
{steps.length === 0 ? (
<div className="bg-white rounded-xl p-8 shadow-sm text-center">
<p className="text-gray-500 mb-4">Add steps to your routine</p>
<button
type="button"
onClick={handleAddStep}
className="text-indigo-600 font-medium"
>
+ Add your first step
</button>
</div>
) : (
<div className="space-y-3">
{steps.map((step, index) => (
<div
key={step.id}
className="bg-white rounded-xl p-4 shadow-sm flex items-start gap-3"
>
<div className="pt-3 text-gray-400 cursor-grab">
<GripVerticalIcon size={20} />
</div>
<div className="flex-1 space-y-3">
<input
type="text"
value={step.name}
onChange={(e) => handleUpdateStep(index, { name: e.target.value })}
placeholder="Step name"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
<div className="flex items-center gap-3">
<label className="text-sm text-gray-500">Duration:</label>
<select
value={step.duration_minutes || 5}
onChange={(e) => handleUpdateStep(index, { duration_minutes: Number(e.target.value) })}
className="px-3 py-1 border border-gray-300 rounded-lg text-sm"
>
<option value={1}>1 min</option>
<option value={2}>2 min</option>
<option value={5}>5 min</option>
<option value={10}>10 min</option>
<option value={15}>15 min</option>
<option value={20}>20 min</option>
<option value={30}>30 min</option>
<option value={45}>45 min</option>
<option value={60}>60 min</option>
</select>
</div>
</div>
<button
type="button"
onClick={() => handleDeleteStep(index)}
className="text-red-500 p-2"
>
<TrashIcon size={18} />
</button>
</div>
))}
</div>
)}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-indigo-600 text-white font-semibold py-4 rounded-xl hover:bg-indigo-700 disabled:opacity-50"
>
{isLoading ? 'Creating...' : 'Create Routine'}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,157 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import api from '@/lib/api';
import { PlusIcon, PlayIcon, EditIcon, TrashIcon, FlameIcon } from '@/components/ui/Icons';
import Link from 'next/link';
interface Routine {
id: string;
name: string;
description?: string;
icon?: string;
}
export default function RoutinesPage() {
const router = useRouter();
const [routines, setRoutines] = useState<Routine[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [deleteModal, setDeleteModal] = useState<string | null>(null);
useEffect(() => {
const fetchRoutines = async () => {
try {
const data = await api.routines.list();
setRoutines(data);
} catch (err) {
console.error('Failed to fetch routines:', err);
} finally {
setIsLoading(false);
}
};
fetchRoutines();
}, []);
const handleDelete = async (routineId: string) => {
try {
await api.routines.delete(routineId);
setRoutines(routines.filter(r => r.id !== routineId));
setDeleteModal(null);
} catch (err) {
console.error('Failed to delete routine:', err);
}
};
const handleStartRoutine = async (routineId: string) => {
try {
await api.sessions.start(routineId);
router.push(`/dashboard/routines/${routineId}/run`);
} catch (err) {
console.error('Failed to start routine:', err);
}
};
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-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Routines</h1>
<Link
href="/dashboard/routines/new"
className="bg-indigo-600 text-white p-2 rounded-full"
>
<PlusIcon size={24} />
</Link>
</div>
{routines.length === 0 ? (
<div className="bg-white rounded-xl p-8 shadow-sm text-center mt-4">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<FlameIcon className="text-gray-400" size={32} />
</div>
<h3 className="font-semibold text-gray-900 mb-1">No routines yet</h3>
<p className="text-gray-500 text-sm mb-4">Create your first routine to get started</p>
<Link
href="/dashboard/routines/new"
className="inline-block bg-indigo-600 text-white px-4 py-2 rounded-lg font-medium"
>
Create Routine
</Link>
</div>
) : (
<div className="space-y-3">
{routines.map((routine) => (
<div
key={routine.id}
className="bg-white rounded-xl p-4 shadow-sm"
>
<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 flex-shrink-0">
<span className="text-2xl">{routine.icon || '✨'}</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{routine.name}</h3>
{routine.description && (
<p className="text-gray-500 text-sm truncate">{routine.description}</p>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleStartRoutine(routine.id)}
className="bg-indigo-600 text-white p-2 rounded-full"
>
<PlayIcon size={18} />
</button>
<Link
href={`/dashboard/routines/${routine.id}`}
className="text-gray-500 p-2"
>
<EditIcon size={18} />
</Link>
<button
onClick={() => setDeleteModal(routine.id)}
className="text-red-500 p-2"
>
<TrashIcon size={18} />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Delete Modal */}
{deleteModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 max-w-sm w-full">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Delete Routine?</h3>
<p className="text-gray-500 mb-4">This action cannot be undone.</p>
<div className="flex gap-3">
<button
onClick={() => setDeleteModal(null)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg font-medium"
>
Cancel
</button>
<button
onClick={() => handleDelete(deleteModal)}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,193 @@
'use client';
import { useEffect, useState } from 'react';
import api from '@/lib/api';
import { FlameIcon, StarIcon, ClockIcon, ActivityIcon, TargetIcon } from '@/components/ui/Icons';
interface RoutineStats {
routine_id: string;
routine_name: string;
period_days: number;
total_sessions: number;
completed: number;
aborted: number;
completion_rate_percent: number;
avg_duration_minutes: number;
total_time_minutes: number;
}
interface Streak {
routine_id: string;
routine_name: string;
current_streak: number;
longest_streak: number;
last_completed_date?: string;
}
interface WeeklySummary {
total_completed: number;
total_time_minutes: number;
routines_started: number;
routines: {
routine_id: string;
name: string;
completed_this_week: number;
}[];
}
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 [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [routinesData, streaksData, summaryData] = await Promise.all([
api.routines.list(),
api.stats.getStreaks(),
api.stats.getWeeklySummary(),
]);
setRoutines(routinesData);
setStreaks(streaksData);
setWeeklySummary(summaryData);
if (routinesData.length > 0) {
setSelectedRoutine(routinesData[0].id);
}
} catch (err) {
console.error('Failed to fetch stats:', err);
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
useEffect(() => {
const fetchRoutineStats = async () => {
if (!selectedRoutine) return;
try {
const stats = await api.routines.getStats(selectedRoutine, 30);
setRoutineStats(stats);
} catch (err) {
console.error('Failed to fetch routine stats:', err);
}
};
fetchRoutineStats();
}, [selectedRoutine]);
const formatTime = (minutes: number) => {
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
};
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">
<h1 className="text-2xl font-bold text-gray-900">Stats</h1>
{/* Weekly Summary */}
{weeklySummary && (
<div className="grid grid-cols-3 gap-3">
<div className="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-2xl p-4 text-white">
<StarIcon className="text-white/80 mb-2" size={24} />
<p className="text-3xl font-bold">{weeklySummary.total_completed}</p>
<p className="text-white/80 text-sm">Completed</p>
</div>
<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>
</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>
</div>
</div>
)}
{/* Streaks */}
{streaks.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Streaks</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">
<div className="w-12 h-12 bg-gradient-to-br from-orange-100 to-red-100 rounded-xl flex items-center justify-center">
<FlameIcon className="text-orange-500" size={24} />
</div>
<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'}
</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>
</div>
</div>
))}
</div>
</div>
)}
{/* Per-Routine Stats */}
{routines.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Routine Stats</h2>
<select
value={selectedRoutine}
onChange={(e) => setSelectedRoutine(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-xl bg-white mb-4"
>
{routines.map((routine) => (
<option key={routine.id} value={routine.id}>
{routine.name}
</option>
))}
</select>
{routineStats && (
<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>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<ClockIcon className="text-purple-500 mb-2" size={24} />
<p className="text-2xl font-bold text-gray-900">{formatTime(routineStats.avg_duration_minutes)}</p>
<p className="text-sm text-gray-500">Avg Duration</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<StarIcon className="text-green-500 mb-2" size={24} />
<p className="text-2xl font-bold text-gray-900">{routineStats.completed}</p>
<p className="text-sm text-gray-500">Completed</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm">
<ActivityIcon className="text-pink-500 mb-2" size={24} />
<p className="text-2xl font-bold text-gray-900">{routineStats.total_sessions}</p>
<p className="text-sm text-gray-500">Total Sessions</p>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,110 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import api from '@/lib/api';
import { CopyIcon, CheckIcon, FlameIcon } from '@/components/ui/Icons';
interface Template {
id: string;
name: string;
description?: string;
icon?: string;
step_count: number;
}
export default function TemplatesPage() {
const router = useRouter();
const [templates, setTemplates] = useState<Template[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [cloningId, setCloningId] = useState<string | null>(null);
useEffect(() => {
const fetchTemplates = async () => {
try {
const data = await api.templates.list();
setTemplates(data);
} catch (err) {
console.error('Failed to fetch templates:', err);
} finally {
setIsLoading(false);
}
};
fetchTemplates();
}, []);
const handleClone = async (templateId: string) => {
setCloningId(templateId);
try {
const routine = await api.templates.clone(templateId);
router.push(`/dashboard/routines/${routine.id}`);
} catch (err) {
console.error('Failed to clone template:', err);
setCloningId(null);
}
};
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-4">
<h1 className="text-2xl font-bold text-gray-900">Templates</h1>
<p className="text-gray-500">Start with a pre-made routine</p>
{templates.length === 0 ? (
<div className="bg-white rounded-xl p-8 shadow-sm text-center mt-4">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<FlameIcon className="text-gray-400" size={32} />
</div>
<h3 className="font-semibold text-gray-900 mb-1">No templates yet</h3>
<p className="text-gray-500 text-sm">Templates will appear here when available</p>
</div>
) : (
<div className="grid gap-4">
{templates.map((template) => (
<div
key={template.id}
className="bg-white rounded-xl p-4 shadow-sm"
>
<div className="flex items-center gap-4">
<div className="w-14 h-14 bg-gradient-to-br from-indigo-100 to-pink-100 rounded-xl flex items-center justify-center flex-shrink-0">
<span className="text-3xl">{template.icon || '✨'}</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900">{template.name}</h3>
{template.description && (
<p className="text-gray-500 text-sm truncate">{template.description}</p>
)}
<p className="text-gray-400 text-xs mt-1">{template.step_count} steps</p>
</div>
<button
onClick={() => handleClone(template.id)}
disabled={cloningId === template.id}
className="bg-indigo-600 text-white px-4 py-2 rounded-lg font-medium flex items-center gap-2 disabled:opacity-50"
>
{cloningId === template.id ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Cloning...</span>
</>
) : (
<>
<CopyIcon size={18} />
<span>Use</span>
</>
)}
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}