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:
157
synculous-client/src/app/dashboard/history/page.tsx
Normal file
157
synculous-client/src/app/dashboard/history/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
synculous-client/src/app/dashboard/layout.tsx
Normal file
105
synculous-client/src/app/dashboard/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
253
synculous-client/src/app/dashboard/medications/new/page.tsx
Normal file
253
synculous-client/src/app/dashboard/medications/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
268
synculous-client/src/app/dashboard/medications/page.tsx
Normal file
268
synculous-client/src/app/dashboard/medications/page.tsx
Normal 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} · {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 — 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>
|
||||
);
|
||||
}
|
||||
242
synculous-client/src/app/dashboard/page.tsx
Normal file
242
synculous-client/src/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
294
synculous-client/src/app/dashboard/routines/[id]/page.tsx
Normal file
294
synculous-client/src/app/dashboard/routines/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
321
synculous-client/src/app/dashboard/routines/[id]/run/page.tsx
Normal file
321
synculous-client/src/app/dashboard/routines/[id]/run/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
236
synculous-client/src/app/dashboard/routines/new/page.tsx
Normal file
236
synculous-client/src/app/dashboard/routines/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
synculous-client/src/app/dashboard/routines/page.tsx
Normal file
157
synculous-client/src/app/dashboard/routines/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
193
synculous-client/src/app/dashboard/stats/page.tsx
Normal file
193
synculous-client/src/app/dashboard/stats/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
synculous-client/src/app/dashboard/templates/page.tsx
Normal file
110
synculous-client/src/app/dashboard/templates/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user