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>
|
||||
);
|
||||
}
|
||||
BIN
synculous-client/src/app/favicon.ico
Normal file
BIN
synculous-client/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
26
synculous-client/src/app/globals.css
Normal file
26
synculous-client/src/app/globals.css
Normal file
@@ -0,0 +1,26 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
45
synculous-client/src/app/layout.tsx
Normal file
45
synculous-client/src/app/layout.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { AuthProvider } from "@/components/auth/AuthProvider";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Synculous",
|
||||
description: "Visual routine planner and timer for building healthy habits",
|
||||
manifest: "/manifest.json",
|
||||
themeColor: "#4f46e5",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "Synculous",
|
||||
},
|
||||
viewport: {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js')}`,
|
||||
}}
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
107
synculous-client/src/app/login/page.tsx
Normal file
107
synculous-client/src/app/login/page.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/components/auth/AuthProvider';
|
||||
import { HeartIcon } from '@/components/ui/Icons';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { login, register } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
if (isLogin) {
|
||||
await login(username, password);
|
||||
} else {
|
||||
await register(username, password);
|
||||
await login(username, password);
|
||||
}
|
||||
router.push('/');
|
||||
} catch (err) {
|
||||
setError((err as Error).message || 'An error occurred');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-8">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-indigo-500 to-pink-500 rounded-2xl flex items-center justify-center mb-4">
|
||||
<HeartIcon className="text-white" size={32} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Synculous</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
{isLogin ? 'Welcome back!' : 'Create your account'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition"
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full bg-gradient-to-r from-indigo-500 to-pink-500 text-white font-semibold py-3 px-4 rounded-lg hover:opacity-90 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Loading...' : isLogin ? 'Sign In' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
onClick={() => setIsLogin(!isLogin)}
|
||||
className="text-indigo-600 hover:text-indigo-800 text-sm font-medium"
|
||||
>
|
||||
{isLogin
|
||||
? "Don't have an account? Sign up"
|
||||
: 'Already have an account? Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
synculous-client/src/app/page.tsx
Normal file
29
synculous-client/src/app/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/components/auth/AuthProvider';
|
||||
|
||||
export default function Home() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
if (isAuthenticated) {
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
router.push('/login');
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
106
synculous-client/src/components/auth/AuthProvider.tsx
Normal file
106
synculous-client/src/components/auth/AuthProvider.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import api from '@/lib/api';
|
||||
import { User } from '@/types';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
register: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
const storedToken = api.auth.getToken();
|
||||
if (!storedToken) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setToken(storedToken);
|
||||
try {
|
||||
const tokenParts = storedToken.split('.');
|
||||
if (tokenParts.length === 3) {
|
||||
const payload = JSON.parse(atob(tokenParts[1]));
|
||||
const userId = payload.sub;
|
||||
if (userId) {
|
||||
const userData = await api.user.get(userId);
|
||||
setUser(userData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh user:', error);
|
||||
api.auth.logout();
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshUser();
|
||||
}, [refreshUser]);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const result = await api.auth.login(username, password);
|
||||
const storedToken = api.auth.getToken();
|
||||
setToken(storedToken);
|
||||
|
||||
const tokenParts = storedToken!.split('.');
|
||||
const payload = JSON.parse(atob(tokenParts[1]));
|
||||
const userId = payload.sub;
|
||||
|
||||
if (userId) {
|
||||
const userData = await api.user.get(userId);
|
||||
setUser(userData);
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (username: string, password: string) => {
|
||||
await api.auth.register(username, password);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
api.auth.logout();
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
token,
|
||||
isLoading,
|
||||
isAuthenticated: !!user,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
refreshUser,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
725
synculous-client/src/components/ui/Icons.tsx
Normal file
725
synculous-client/src/components/ui/Icons.tsx
Normal file
@@ -0,0 +1,725 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface IconProps {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function CheckIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function XIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlusIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PauseIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<rect x="6" y="4" width="4" height="16" />
|
||||
<rect x="14" y="4" width="4" height="16" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function StopIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClockIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomeIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<line x1="3" y1="6" x2="3.01" y2="6" />
|
||||
<line x1="3" y1="12" x2="3.01" y2="12" />
|
||||
<line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogOutIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrashIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowLeftIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<line x1="19" y1="12" x2="5" y2="12" />
|
||||
<polyline points="12 19 5 12 12 5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowRightIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronDownIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronUpIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polyline points="18 15 12 9 6 15" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GripVerticalIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<circle cx="9" cy="12" r="1" />
|
||||
<circle cx="9" cy="5" r="1" />
|
||||
<circle cx="9" cy="19" r="1" />
|
||||
<circle cx="15" cy="12" r="1" />
|
||||
<circle cx="15" cy="5" r="1" />
|
||||
<circle cx="15" cy="19" r="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlameIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function StarIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TargetIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PillIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="m10.5 20.5 10-10a4.95 4.95 0 1 0-7-7l-10 10a4.95 4.95 0 1 0 7 7Z" />
|
||||
<path d="m8.5 8.5 7 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeartIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrainIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z" />
|
||||
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoonIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SunIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function RefreshIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||
<path d="M8 16H3v5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarChartIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<line x1="12" y1="20" x2="12" y2="10" />
|
||||
<line x1="18" y1="20" x2="18" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="16" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertCircleIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4" />
|
||||
<path d="M12 8h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimerIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<line x1="10" y1="13" x2="14" y2="13" />
|
||||
<line x1="12" y1="2" x2="12" y2="6" />
|
||||
<line x1="12" y1="18" x2="12" y2="22" />
|
||||
<line x1="4.93" y1="4.93" x2="7.76" y2="7.76" />
|
||||
<line x1="16.24" y1="16.24" x2="19.07" y2="19.07" />
|
||||
<line x1="2" y1="12" x2="6" y2="12" />
|
||||
<line x1="18" y1="12" x2="22" y2="12" />
|
||||
<line x1="4.93" y1="19.07" x2="7.76" y2="16.24" />
|
||||
<line x1="16.24" y1="7.76" x2="19.07" y2="4.93" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkipForwardIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<polygon points="5 4 15 12 5 20 5 4" />
|
||||
<line x1="19" y1="5" x2="19" y2="19" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
265
synculous-client/src/hooks/useSwipe.ts
Normal file
265
synculous-client/src/hooks/useSwipe.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import api from '@/lib/api';
|
||||
|
||||
interface UseSwipeOptions {
|
||||
onSwipeLeft?: () => void;
|
||||
onSwipeRight?: () => void;
|
||||
onSwipeUp?: () => void;
|
||||
onSwipeDown?: () => void;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export function useSwipe({
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
onSwipeUp,
|
||||
onSwipeDown,
|
||||
threshold = 50,
|
||||
}: UseSwipeOptions) {
|
||||
const touchStart = useRef<{ x: number; y: number } | null>(null);
|
||||
const touchEnd = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
touchStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
touchEnd.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (!touchStart.current || !touchEnd.current) return;
|
||||
|
||||
const diffX = touchEnd.current.x - touchStart.current.x;
|
||||
const diffY = touchEnd.current.y - touchStart.current.y;
|
||||
|
||||
if (Math.abs(diffX) > Math.abs(diffY)) {
|
||||
if (Math.abs(diffX) > threshold) {
|
||||
if (diffX > 0 && onSwipeRight) {
|
||||
onSwipeRight();
|
||||
} else if (diffX < 0 && onSwipeLeft) {
|
||||
onSwipeLeft();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (Math.abs(diffY) > threshold) {
|
||||
if (diffY > 0 && onSwipeDown) {
|
||||
onSwipeDown();
|
||||
} else if (diffY < 0 && onSwipeUp) {
|
||||
onSwipeUp();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
touchStart.current = null;
|
||||
touchEnd.current = null;
|
||||
}, [onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown, threshold]);
|
||||
|
||||
return {
|
||||
handleTouchStart,
|
||||
handleTouchMove,
|
||||
handleTouchEnd,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTimer(initialMinutes: number = 0) {
|
||||
const [seconds, setSeconds] = useState(initialMinutes * 60);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRunning && !isPaused && seconds > 0) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
setSeconds((s) => Math.max(0, s - 1));
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [isRunning, isPaused, seconds]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
setIsRunning(true);
|
||||
setIsPaused(false);
|
||||
}, []);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
setIsPaused(true);
|
||||
}, []);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
setIsPaused(false);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback((minutes?: number) => {
|
||||
setSeconds((minutes ?? initialMinutes) * 60);
|
||||
setIsRunning(false);
|
||||
setIsPaused(false);
|
||||
}, [initialMinutes]);
|
||||
|
||||
const formatTime = useCallback((totalSeconds: number) => {
|
||||
const mins = Math.floor(totalSeconds / 60);
|
||||
const secs = totalSeconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
seconds,
|
||||
isRunning,
|
||||
isPaused,
|
||||
start,
|
||||
pause,
|
||||
resume,
|
||||
reset,
|
||||
formattedTime: formatTime(seconds),
|
||||
};
|
||||
}
|
||||
|
||||
export function useActiveSession() {
|
||||
const [session, setSession] = useState<{
|
||||
id: string;
|
||||
routineId: string;
|
||||
routineName: string;
|
||||
routineIcon?: string;
|
||||
status: string;
|
||||
currentStepIndex: number;
|
||||
} | null>(null);
|
||||
const [currentStep, setCurrentStep] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
instructions?: string;
|
||||
stepType: string;
|
||||
durationMinutes?: number;
|
||||
position: number;
|
||||
} | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchActiveSession = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.sessions.getActive();
|
||||
if (data.session && data.current_step) {
|
||||
setSession({
|
||||
id: data.session.id,
|
||||
routineId: data.session.routine_id,
|
||||
routineName: data.routine.name,
|
||||
routineIcon: data.routine.icon,
|
||||
status: data.session.status,
|
||||
currentStepIndex: data.session.current_step_index,
|
||||
});
|
||||
setCurrentStep({
|
||||
id: data.current_step.id,
|
||||
name: data.current_step.name,
|
||||
instructions: data.current_step.instructions,
|
||||
stepType: data.current_step.step_type,
|
||||
durationMinutes: data.current_step.duration_minutes,
|
||||
position: data.current_step.position,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).message !== 'no active session') {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const completeStep = useCallback(async () => {
|
||||
if (!session) return;
|
||||
try {
|
||||
const result = await api.sessions.completeStep(session.id, currentStep!.id);
|
||||
if (result.next_step) {
|
||||
setCurrentStep({
|
||||
id: result.next_step.id,
|
||||
name: result.next_step.name,
|
||||
instructions: result.next_step.instructions,
|
||||
stepType: result.next_step.step_type,
|
||||
durationMinutes: result.next_step.duration_minutes,
|
||||
position: result.next_step.position,
|
||||
});
|
||||
setSession((s) => s ? { ...s, currentStepIndex: result.session.current_step_index! } : null);
|
||||
} else {
|
||||
setSession(null);
|
||||
setCurrentStep(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}, [session, currentStep]);
|
||||
|
||||
const skipStep = useCallback(async () => {
|
||||
if (!session) return;
|
||||
try {
|
||||
const result = await api.sessions.skipStep(session.id, currentStep!.id);
|
||||
if (result.next_step) {
|
||||
setCurrentStep({
|
||||
id: result.next_step.id,
|
||||
name: result.next_step.name,
|
||||
instructions: result.next_step.instructions,
|
||||
stepType: result.next_step.step_type,
|
||||
durationMinutes: result.next_step.duration_minutes,
|
||||
position: result.next_step.position,
|
||||
});
|
||||
setSession((s) => s ? { ...s, currentStepIndex: result.session.current_step_index! } : null);
|
||||
} else {
|
||||
setSession(null);
|
||||
setCurrentStep(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}, [session, currentStep]);
|
||||
|
||||
const pause = useCallback(async () => {
|
||||
if (!session) return;
|
||||
try {
|
||||
await api.sessions.pause(session.id);
|
||||
setSession((s) => s ? { ...s, status: 'paused' } : null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const resume = useCallback(async () => {
|
||||
if (!session) return;
|
||||
try {
|
||||
await api.sessions.resume(session.id);
|
||||
setSession((s) => s ? { ...s, status: 'active' } : null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const cancel = useCallback(async () => {
|
||||
if (!session) return;
|
||||
try {
|
||||
await api.sessions.cancel(session.id);
|
||||
setSession(null);
|
||||
setCurrentStep(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
return {
|
||||
session,
|
||||
currentStep,
|
||||
isLoading,
|
||||
error,
|
||||
fetchActiveSession,
|
||||
completeStep,
|
||||
skipStep,
|
||||
pause,
|
||||
resume,
|
||||
cancel,
|
||||
};
|
||||
}
|
||||
697
synculous-client/src/lib/api.ts
Normal file
697
synculous-client/src/lib/api.ts
Normal file
@@ -0,0 +1,697 @@
|
||||
const API_URL = '';
|
||||
|
||||
function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
|
||||
function setToken(token: string): void {
|
||||
localStorage.setItem('token', token);
|
||||
}
|
||||
|
||||
function clearToken(): void {
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
||||
throw new Error(error.error || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Auth
|
||||
auth: {
|
||||
login: async (username: string, password: string) => {
|
||||
const result = await request<{ token: string }>('/api/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
setToken(result.token);
|
||||
return result;
|
||||
},
|
||||
|
||||
register: async (username: string, password: string) => {
|
||||
return request<{ success: boolean }>('/api/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
clearToken();
|
||||
},
|
||||
|
||||
getToken,
|
||||
},
|
||||
|
||||
// User
|
||||
user: {
|
||||
get: async (userId: string) => {
|
||||
return request<{ id: string; username: string; created_at: string }>(
|
||||
`/api/user/${userId}`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
},
|
||||
|
||||
getUUID: async (username: string) => {
|
||||
return request<{ id: string }>(`/api/getUserUUID/${username}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
},
|
||||
|
||||
update: async (userId: string, data: Record<string, unknown>) => {
|
||||
return request<{ success: boolean }>(`/api/user/${userId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// Routines
|
||||
routines: {
|
||||
list: async () => {
|
||||
return request<Array<{
|
||||
id: string;
|
||||
user_uuid: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
created_at: string;
|
||||
}>>('/api/routines', { method: 'GET' });
|
||||
},
|
||||
|
||||
get: async (routineId: string) => {
|
||||
return request<{
|
||||
routine: {
|
||||
id: string;
|
||||
user_uuid: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
};
|
||||
steps: Array<{
|
||||
id: string;
|
||||
routine_id: string;
|
||||
name: string;
|
||||
instructions?: string;
|
||||
step_type: string;
|
||||
duration_minutes?: number;
|
||||
media_url?: string;
|
||||
position: number;
|
||||
}>;
|
||||
}>(`/api/routines/${routineId}`, { method: 'GET' });
|
||||
},
|
||||
|
||||
create: async (data: { name: string; description?: string; icon?: string }) => {
|
||||
return request<{ id: string }>('/api/routines', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
update: async (routineId: string, data: Record<string, unknown>) => {
|
||||
return request<{ id: string }>(`/api/routines/${routineId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
delete: async (routineId: string) => {
|
||||
return request<{ deleted: boolean }>(`/api/routines/${routineId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
// Steps
|
||||
getSteps: async (routineId: string) => {
|
||||
return request<Array<{
|
||||
id: string;
|
||||
routine_id: string;
|
||||
name: string;
|
||||
instructions?: string;
|
||||
step_type: string;
|
||||
duration_minutes?: number;
|
||||
media_url?: string;
|
||||
position: number;
|
||||
}>>(`/api/routines/${routineId}/steps`, { method: 'GET' });
|
||||
},
|
||||
|
||||
addStep: async (
|
||||
routineId: string,
|
||||
data: { name: string; duration_minutes?: number; position?: number }
|
||||
) => {
|
||||
return request<{ id: string }>(`/api/routines/${routineId}/steps`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
updateStep: async (
|
||||
routineId: string,
|
||||
stepId: string,
|
||||
data: Record<string, unknown>
|
||||
) => {
|
||||
return request<{ id: string }>(`/api/routines/${routineId}/steps/${stepId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
deleteStep: async (routineId: string, stepId: string) => {
|
||||
return request<{ deleted: boolean }>(
|
||||
`/api/routines/${routineId}/steps/${stepId}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
},
|
||||
|
||||
reorderSteps: async (routineId: string, stepIds: string[]) => {
|
||||
return request<Array<{ id: string }>>(
|
||||
`/api/routines/${routineId}/steps/reorder`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ step_ids: stepIds }),
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
// Step extended
|
||||
updateStepInstructions: async (
|
||||
routineId: string,
|
||||
stepId: string,
|
||||
instructions: string
|
||||
) => {
|
||||
return request<{ id: string }>(
|
||||
`/api/routines/${routineId}/steps/${stepId}/instructions`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ instructions }),
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
updateStepType: async (
|
||||
routineId: string,
|
||||
stepId: string,
|
||||
stepType: string
|
||||
) => {
|
||||
return request<{ id: string }>(
|
||||
`/api/routines/${routineId}/steps/${stepId}/type`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ step_type: stepType }),
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
updateStepMedia: async (
|
||||
routineId: string,
|
||||
stepId: string,
|
||||
mediaUrl: string
|
||||
) => {
|
||||
return request<{ id: string }>(
|
||||
`/api/routines/${routineId}/steps/${stepId}/media`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ media_url: mediaUrl }),
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
// Scheduling
|
||||
getSchedule: async (routineId: string) => {
|
||||
return request<{
|
||||
id: string;
|
||||
routine_id: string;
|
||||
days: string[];
|
||||
time: string;
|
||||
remind: boolean;
|
||||
}>(`/api/routines/${routineId}/schedule`, { method: 'GET' });
|
||||
},
|
||||
|
||||
setSchedule: async (
|
||||
routineId: string,
|
||||
data: { days: string[]; time: string; remind?: boolean }
|
||||
) => {
|
||||
return request<{ id: string }>(`/api/routines/${routineId}/schedule`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
deleteSchedule: async (routineId: string) => {
|
||||
return request<{ deleted: boolean }>(
|
||||
`/api/routines/${routineId}/schedule`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
},
|
||||
|
||||
// History
|
||||
getHistory: async (routineId: string, days = 7) => {
|
||||
return request<Array<{
|
||||
id: string;
|
||||
routine_id: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
}>>(`/api/routines/${routineId}/history?days=${days}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
},
|
||||
|
||||
// Stats
|
||||
getStats: async (routineId: string, days = 30) => {
|
||||
return request<{
|
||||
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;
|
||||
}>(`/api/routines/${routineId}/stats?days=${days}`, { method: 'GET' });
|
||||
},
|
||||
|
||||
getStreak: async (routineId: string) => {
|
||||
return request<{
|
||||
routine_id: string;
|
||||
routine_name: string;
|
||||
current_streak: number;
|
||||
longest_streak: number;
|
||||
last_completed_date?: string;
|
||||
}>(`/api/routines/${routineId}/streak`, { method: 'GET' });
|
||||
},
|
||||
|
||||
// Tags
|
||||
getTags: async (routineId: string) => {
|
||||
return request<Array<{ id: string; name: string; color: string }>>(
|
||||
`/api/routines/${routineId}/tags`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
},
|
||||
|
||||
addTags: async (routineId: string, tagIds: string[]) => {
|
||||
return request<Array<{ id: string; name: string; color: string }>>(
|
||||
`/api/routines/${routineId}/tags`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tag_ids: tagIds }),
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
removeTag: async (routineId: string, tagId: string) => {
|
||||
return request<{ removed: boolean }>(
|
||||
`/api/routines/${routineId}/tags/${tagId}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
// Sessions
|
||||
sessions: {
|
||||
start: async (routineId: string) => {
|
||||
return request<{
|
||||
session: { id: string; status: string };
|
||||
current_step: {
|
||||
id: string;
|
||||
name: string;
|
||||
instructions?: string;
|
||||
step_type: string;
|
||||
duration_minutes?: number;
|
||||
position: number;
|
||||
};
|
||||
}>(`/api/routines/${routineId}/start`, { method: 'POST' });
|
||||
},
|
||||
|
||||
getActive: async () => {
|
||||
return request<{
|
||||
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;
|
||||
instructions?: string;
|
||||
step_type: string;
|
||||
duration_minutes?: number;
|
||||
position: number;
|
||||
} | null;
|
||||
}>('/api/sessions/active', { method: 'GET' });
|
||||
},
|
||||
|
||||
completeStep: async (sessionId: string, stepId: string) => {
|
||||
return request<{
|
||||
session: { status: string; current_step_index?: number };
|
||||
next_step: {
|
||||
id: string;
|
||||
name: string;
|
||||
instructions?: string;
|
||||
step_type: string;
|
||||
duration_minutes?: number;
|
||||
position: number;
|
||||
} | null;
|
||||
}>(`/api/sessions/${sessionId}/complete-step`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ step_id: stepId }),
|
||||
});
|
||||
},
|
||||
|
||||
skipStep: async (sessionId: string, stepId: string) => {
|
||||
return request<{
|
||||
session: { status: string; current_step_index?: number };
|
||||
next_step: {
|
||||
id: string;
|
||||
name: string;
|
||||
instructions?: string;
|
||||
step_type: string;
|
||||
duration_minutes?: number;
|
||||
position: number;
|
||||
} | null;
|
||||
}>(`/api/sessions/${sessionId}/skip-step`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ step_id: stepId }),
|
||||
});
|
||||
},
|
||||
|
||||
pause: async (sessionId: string) => {
|
||||
return request<{ status: string }>(`/api/sessions/${sessionId}/pause`, {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
resume: async (sessionId: string) => {
|
||||
return request<{ status: string }>(`/api/sessions/${sessionId}/resume`, {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
cancel: async (sessionId: string) => {
|
||||
return request<{ status: string }>(`/api/sessions/${sessionId}/cancel`, {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
abort: async (sessionId: string, reason?: string) => {
|
||||
return request<{ status: string; reason: string }>(
|
||||
`/api/sessions/${sessionId}/abort`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason }),
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
addNote: async (
|
||||
sessionId: string,
|
||||
stepIndex: number | undefined,
|
||||
note: string
|
||||
) => {
|
||||
return request<{ id: string }>(`/api/sessions/${sessionId}/note`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ step_index: stepIndex, note }),
|
||||
});
|
||||
},
|
||||
|
||||
setDuration: async (sessionId: string, durationMinutes: number) => {
|
||||
return request<{ id: string }>(`/api/sessions/${sessionId}/duration`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ actual_duration_minutes: durationMinutes }),
|
||||
});
|
||||
},
|
||||
|
||||
getDetails: async (sessionId: string) => {
|
||||
return request<{
|
||||
session: {
|
||||
id: string;
|
||||
routine_id: string;
|
||||
status: string;
|
||||
current_step_index: number;
|
||||
};
|
||||
routine: { id: string; name: string };
|
||||
steps: Array<{ id: string; name: string; position: number }>;
|
||||
notes: Array<{ id: string; step_index?: number; note: string }>;
|
||||
}>(`/api/sessions/${sessionId}`, { method: 'GET' });
|
||||
},
|
||||
},
|
||||
|
||||
// Templates
|
||||
templates: {
|
||||
list: async () => {
|
||||
return request<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
step_count: number;
|
||||
}>>('/api/templates', { method: 'GET' });
|
||||
},
|
||||
|
||||
get: async (templateId: string) => {
|
||||
return request<{
|
||||
template: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
};
|
||||
steps: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
instructions?: string;
|
||||
step_type: string;
|
||||
duration_minutes?: number;
|
||||
position: number;
|
||||
}>;
|
||||
}>(`/api/templates/${templateId}`, { method: 'GET' });
|
||||
},
|
||||
|
||||
clone: async (templateId: string) => {
|
||||
return request<{ id: string }>(`/api/templates/${templateId}/clone`, {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// Tags
|
||||
tags: {
|
||||
list: async () => {
|
||||
return request<Array<{ id: string; name: string; color: string }>>(
|
||||
'/api/tags',
|
||||
{ method: 'GET' }
|
||||
);
|
||||
},
|
||||
|
||||
create: async (data: { name: string; color?: string }) => {
|
||||
return request<{ id: string }>('/api/tags', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
delete: async (tagId: string) => {
|
||||
return request<{ deleted: boolean }>(`/api/tags/${tagId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// Stats
|
||||
stats: {
|
||||
getWeeklySummary: async () => {
|
||||
return request<{
|
||||
total_completed: number;
|
||||
total_time_minutes: number;
|
||||
routines_started: number;
|
||||
routines: Array<{
|
||||
routine_id: string;
|
||||
name: string;
|
||||
completed_this_week: number;
|
||||
}>;
|
||||
}>('/api/routines/weekly-summary', { method: 'GET' });
|
||||
},
|
||||
|
||||
getStreaks: async () => {
|
||||
return request<Array<{
|
||||
routine_id: string;
|
||||
routine_name: string;
|
||||
current_streak: number;
|
||||
longest_streak: number;
|
||||
last_completed_date?: string;
|
||||
}>>('/api/routines/streaks', { method: 'GET' });
|
||||
},
|
||||
},
|
||||
|
||||
// Medications
|
||||
medications: {
|
||||
list: async () => {
|
||||
return request<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
dosage: string;
|
||||
unit: string;
|
||||
frequency: string;
|
||||
times: string[];
|
||||
notes?: string;
|
||||
active: boolean;
|
||||
quantity_remaining?: number;
|
||||
refill_date?: string;
|
||||
}>>('/api/medications', { method: 'GET' });
|
||||
},
|
||||
|
||||
get: async (medId: string) => {
|
||||
return request<{
|
||||
id: string;
|
||||
name: string;
|
||||
dosage: string;
|
||||
unit: string;
|
||||
frequency: string;
|
||||
times: string[];
|
||||
notes?: string;
|
||||
active: boolean;
|
||||
quantity_remaining?: number;
|
||||
refill_date?: string;
|
||||
}>(`/api/medications/${medId}`, { method: 'GET' });
|
||||
},
|
||||
|
||||
create: async (data: {
|
||||
name: string;
|
||||
dosage: string;
|
||||
unit: string;
|
||||
frequency: string;
|
||||
times?: string[];
|
||||
days_of_week?: string[];
|
||||
interval_days?: number;
|
||||
start_date?: string;
|
||||
notes?: string;
|
||||
}) => {
|
||||
return request<{ id: string }>('/api/medications', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
update: async (medId: string, data: Record<string, unknown>) => {
|
||||
return request<{ id: string }>(`/api/medications/${medId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
delete: async (medId: string) => {
|
||||
return request<{ deleted: boolean }>(`/api/medications/${medId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
take: async (medId: string, scheduledTime?: string, notes?: string) => {
|
||||
return request<{ id: string }>(`/api/medications/${medId}/take`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ scheduled_time: scheduledTime, notes }),
|
||||
});
|
||||
},
|
||||
|
||||
skip: async (medId: string, scheduledTime?: string, reason?: string) => {
|
||||
return request<{ id: string }>(`/api/medications/${medId}/skip`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ scheduled_time: scheduledTime, reason }),
|
||||
});
|
||||
},
|
||||
|
||||
snooze: async (medId: string, minutes = 15) => {
|
||||
return request<{ snoozed_until_minutes: number }>(
|
||||
`/api/medications/${medId}/snooze`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ minutes }),
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
getLog: async (medId: string, days = 30) => {
|
||||
return request<Array<{
|
||||
id: string;
|
||||
medication_id: string;
|
||||
action: string;
|
||||
scheduled_time?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
}>>(`/api/medications/${medId}/log?days=${days}`, { method: 'GET' });
|
||||
},
|
||||
|
||||
getToday: async () => {
|
||||
return request<Array<{
|
||||
medication: {
|
||||
id: string;
|
||||
name: string;
|
||||
dosage: string;
|
||||
unit: string;
|
||||
};
|
||||
scheduled_times: string[];
|
||||
taken_times: string[];
|
||||
}>>('/api/medications/today', { method: 'GET' });
|
||||
},
|
||||
|
||||
getAdherence: async (days = 30) => {
|
||||
return request<Array<{
|
||||
medication_id: string;
|
||||
name: string;
|
||||
taken: number;
|
||||
skipped: number;
|
||||
adherence_percent: number;
|
||||
}>>(`/api/medications/adherence?days=${days}`, { method: 'GET' });
|
||||
},
|
||||
|
||||
getRefillsDue: async (daysAhead = 7) => {
|
||||
return request<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
dosage: string;
|
||||
quantity_remaining?: number;
|
||||
refill_date?: string;
|
||||
}>>(`/api/medications/refills-due?days_ahead=${daysAhead}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
},
|
||||
|
||||
setRefill: async (
|
||||
medId: string,
|
||||
data: {
|
||||
quantity_remaining?: number;
|
||||
refill_date?: string;
|
||||
pharmacy_notes?: string;
|
||||
}
|
||||
) => {
|
||||
return request<{ id: string }>(`/api/medications/${medId}/refill`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
180
synculous-client/src/types/index.ts
Normal file
180
synculous-client/src/types/index.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Routine {
|
||||
id: string;
|
||||
user_uuid: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RoutineWithSteps extends Routine {
|
||||
steps: RoutineStep[];
|
||||
}
|
||||
|
||||
export interface RoutineStep {
|
||||
id: string;
|
||||
routine_id: string;
|
||||
name: string;
|
||||
instructions?: string;
|
||||
step_type: StepType;
|
||||
duration_minutes?: number;
|
||||
media_url?: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export type StepType = 'generic' | 'timer' | 'checklist' | 'meditation' | 'exercise';
|
||||
|
||||
export interface RoutineSchedule {
|
||||
id: string;
|
||||
routine_id: string;
|
||||
days: string[];
|
||||
time: string;
|
||||
remind: boolean;
|
||||
}
|
||||
|
||||
export interface RoutineSession {
|
||||
id: string;
|
||||
routine_id: string;
|
||||
user_uuid: string;
|
||||
status: SessionStatus;
|
||||
current_step_index: number;
|
||||
created_at: string;
|
||||
paused_at?: string;
|
||||
completed_at?: string;
|
||||
abort_reason?: string;
|
||||
actual_duration_minutes?: number;
|
||||
}
|
||||
|
||||
export type SessionStatus = 'active' | 'paused' | 'completed' | 'cancelled' | 'aborted';
|
||||
|
||||
export interface RoutineTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
step_count: number;
|
||||
}
|
||||
|
||||
export interface RoutineTemplateWithSteps extends RoutineTemplate {
|
||||
steps: RoutineTemplateStep[];
|
||||
}
|
||||
|
||||
export interface RoutineTemplateStep {
|
||||
id: string;
|
||||
template_id: string;
|
||||
name: string;
|
||||
instructions?: string;
|
||||
step_type: StepType;
|
||||
duration_minutes?: number;
|
||||
media_url?: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export interface RoutineStreak {
|
||||
routine_id: string;
|
||||
routine_name: string;
|
||||
current_streak: number;
|
||||
longest_streak: number;
|
||||
last_completed_date?: string;
|
||||
}
|
||||
|
||||
export interface WeeklySummary {
|
||||
total_completed: number;
|
||||
total_time_minutes: number;
|
||||
routines_started: number;
|
||||
routines: {
|
||||
routine_id: string;
|
||||
name: string;
|
||||
completed_this_week: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface RoutineTag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export type MedicationFrequency = 'daily' | 'twice_daily' | 'specific_days' | 'every_n_days' | 'as_needed';
|
||||
|
||||
export interface Medication {
|
||||
id: string;
|
||||
user_uuid: string;
|
||||
name: string;
|
||||
dosage: string;
|
||||
unit: string;
|
||||
frequency: MedicationFrequency;
|
||||
times: string[];
|
||||
days_of_week?: string[];
|
||||
interval_days?: number;
|
||||
start_date?: string;
|
||||
next_dose_date?: string;
|
||||
notes?: string;
|
||||
active: boolean;
|
||||
quantity_remaining?: number;
|
||||
refill_date?: string;
|
||||
pharmacy_notes?: string;
|
||||
}
|
||||
|
||||
export interface MedicationLog {
|
||||
id: string;
|
||||
medication_id: string;
|
||||
user_uuid: string;
|
||||
action: 'taken' | 'skipped';
|
||||
scheduled_time?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TodaysMedication {
|
||||
medication: Medication;
|
||||
scheduled_times: string[];
|
||||
taken_times: string[];
|
||||
is_prn?: boolean;
|
||||
}
|
||||
|
||||
export interface MedicationAdherence {
|
||||
medication_id: string;
|
||||
name: string;
|
||||
taken: number;
|
||||
skipped: number;
|
||||
expected?: number;
|
||||
adherence_percent: number | null;
|
||||
is_prn?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionNote {
|
||||
id: string;
|
||||
session_id: string;
|
||||
step_index?: number;
|
||||
note: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token?: string;
|
||||
error?: string;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
}
|
||||
Reference in New Issue
Block a user