mirror of
https://github.com/escalante29/healthy-fit.git
synced 2026-03-21 10:48:46 +01:00
Add supplements, kettlebell, calendar, push notifications, and PWA support
- Supplement tracking: CRUD endpoints, /today, /logs, Supplements page - Kettlebell workouts: session tracking, analytics endpoint, ActiveSession page - Calendar module: events CRUD, calendar components - Push notifications: VAPID keys, PushSubscription model, APScheduler reminders, service worker with push/notificationclick handlers, Profile notifications UI - PWA: vite-plugin-pwa, manifest, icons, service worker generation - Frontend: TypeScript types, API modules, ConfirmModal, toast notifications - Auth fixes: password hashing, nutrition endpoint auth - CLAUDE.md: project documentation and development guide Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
frontend/dev-dist/registerSW.js
Normal file
1
frontend/dev-dist/registerSW.js
Normal file
@@ -0,0 +1 @@
|
||||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||
@@ -4,7 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<meta name="theme-color" content="#556B2F" />
|
||||
<meta name="description" content="AI-powered health & fitness tracker" />
|
||||
<title>HealthyFit</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
4236
frontend/package-lock.json
generated
4236
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,10 +14,12 @@
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.27.0",
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"recharts": "^3.6.0"
|
||||
"recharts": "^3.6.0",
|
||||
"sonner": "^2.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
@@ -35,6 +37,7 @@
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.4"
|
||||
"vite": "^7.2.4",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
frontend/public/icons/icon-192.png
Normal file
BIN
frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 547 B |
BIN
frontend/public/icons/icon-512.png
Normal file
BIN
frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
39
frontend/public/push-sw.js
Normal file
39
frontend/public/push-sw.js
Normal file
@@ -0,0 +1,39 @@
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('[SW] push received', event.data?.text());
|
||||
if (!event.data) return;
|
||||
|
||||
let title = 'HealthyFit';
|
||||
let body = 'You have a new notification';
|
||||
let url = '/';
|
||||
try {
|
||||
const data = event.data.json();
|
||||
title = data.title;
|
||||
body = data.body;
|
||||
url = data.url ?? '/';
|
||||
} catch {
|
||||
body = event.data.text();
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/icon-192.png',
|
||||
data: { url },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
const url = event.notification.data?.url ?? '/';
|
||||
event.waitUntil(
|
||||
self.clients
|
||||
.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then((clients) => {
|
||||
const existing = clients.find((c) => c.url.includes(url));
|
||||
if (existing) return existing.focus();
|
||||
return self.clients.openWindow(url);
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -1,12 +1,18 @@
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import { ThemeProvider } from './context/ThemeContext';
|
||||
import { Toaster } from 'sonner';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Nutrition from './pages/Nutrition';
|
||||
import Health from './pages/Health';
|
||||
import Plans from './pages/Plans';
|
||||
import Profile from './pages/Profile';
|
||||
import Kettlebell from './pages/Kettlebell';
|
||||
import ActiveSession from './pages/ActiveSession';
|
||||
import Supplements from './pages/Supplements';
|
||||
import KettlebellAnalytics from './pages/KettlebellAnalytics';
|
||||
import CalendarPage from './pages/Calendar';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import AppLayout from './components/Layout/AppLayout';
|
||||
|
||||
@@ -15,6 +21,7 @@ function App() {
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Toaster richColors position="top-right" />
|
||||
<div className="min-h-screen font-sans bg-base text-content transition-colors duration-200">
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
@@ -55,6 +62,42 @@ function App() {
|
||||
</AppLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/kettlebell" element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout>
|
||||
<Kettlebell />
|
||||
</AppLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/supplements" element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout>
|
||||
<Supplements />
|
||||
</AppLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/kettlebell/analytics" element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout>
|
||||
<KettlebellAnalytics />
|
||||
</AppLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/calendar" element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout>
|
||||
<CalendarPage />
|
||||
</AppLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* Full-screen active session — no AppLayout/sidebar */}
|
||||
<Route path="/kettlebell/session/:id" element={
|
||||
<ProtectedRoute>
|
||||
<ActiveSession />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
|
||||
26
frontend/src/api/calendar.ts
Normal file
26
frontend/src/api/calendar.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import client from './client';
|
||||
import type { DayMeta, DailyNote, CalendarEvent, DayDetail } from '../types/calendar';
|
||||
|
||||
export const getMonthSummary = (year: number, month: number) =>
|
||||
client.get<DayMeta[]>('/calendar/month', { params: { year, month } }).then(r => r.data);
|
||||
|
||||
export const getDayDetail = (date: string) =>
|
||||
client.get<DayDetail>('/calendar/day', { params: { date } }).then(r => r.data);
|
||||
|
||||
export const getNote = (date: string) =>
|
||||
client.get<DailyNote>(`/calendar/notes/${date}`).then(r => r.data);
|
||||
|
||||
export const upsertNote = (date: string, data: { content: string; mood?: string; energy_level?: number }) =>
|
||||
client.put<DailyNote>(`/calendar/notes/${date}`, data).then(r => r.data);
|
||||
|
||||
export const getEvents = (start: string, end: string) =>
|
||||
client.get<CalendarEvent[]>('/calendar/events', { params: { start, end } }).then(r => r.data);
|
||||
|
||||
export const createEvent = (data: Omit<CalendarEvent, 'id' | 'created_at'>) =>
|
||||
client.post<CalendarEvent>('/calendar/events', data).then(r => r.data);
|
||||
|
||||
export const updateEvent = (id: number, data: Partial<Omit<CalendarEvent, 'id' | 'date' | 'created_at'>>) =>
|
||||
client.put<CalendarEvent>(`/calendar/events/${id}`, data).then(r => r.data);
|
||||
|
||||
export const deleteEvent = (id: number) =>
|
||||
client.delete(`/calendar/events/${id}`);
|
||||
14
frontend/src/api/health.ts
Normal file
14
frontend/src/api/health.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import client from './client';
|
||||
import type { HealthGoal, HealthGoalCreate, HealthMetric, HealthMetricCreate } from '../types/health';
|
||||
|
||||
export const getMetrics = (limit = 100) =>
|
||||
client.get<HealthMetric[]>('/health/metrics', { params: { limit } }).then(r => r.data);
|
||||
|
||||
export const createMetric = (data: HealthMetricCreate) =>
|
||||
client.post<HealthMetric>('/health/metrics', data).then(r => r.data);
|
||||
|
||||
export const getGoals = () =>
|
||||
client.get<HealthGoal[]>('/health/goals').then(r => r.data);
|
||||
|
||||
export const createGoal = (data: HealthGoalCreate) =>
|
||||
client.post<HealthGoal>('/health/goals', data).then(r => r.data);
|
||||
51
frontend/src/api/kettlebell.ts
Normal file
51
frontend/src/api/kettlebell.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import client from './client';
|
||||
import type { KettlebellSession, KettlebellSetLog } from '../types/kettlebell';
|
||||
|
||||
export interface GenerateSessionRequest {
|
||||
focus: string;
|
||||
duration_minutes: number;
|
||||
available_weights: number[];
|
||||
}
|
||||
|
||||
export interface LogSetRequest {
|
||||
exercise_order: number;
|
||||
set_number: number;
|
||||
actual_reps: number;
|
||||
actual_weight_kg: number;
|
||||
actual_duration_seconds: number;
|
||||
perceived_effort: number;
|
||||
}
|
||||
|
||||
export interface CompleteSessionRequest {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export const generateSession = (data: GenerateSessionRequest) =>
|
||||
client.post<KettlebellSession>('/kettlebell/generate', data).then(r => r.data);
|
||||
|
||||
export const getSessions = () =>
|
||||
client.get<KettlebellSession[]>('/kettlebell/').then(r => r.data);
|
||||
|
||||
export const getSession = (id: number) =>
|
||||
client.get<KettlebellSession>(`/kettlebell/${id}`).then(r => r.data);
|
||||
|
||||
export const startSession = (id: number) =>
|
||||
client.patch<KettlebellSession>(`/kettlebell/${id}/start`).then(r => r.data);
|
||||
|
||||
export const logSet = (id: number, data: LogSetRequest) =>
|
||||
client.post<KettlebellSetLog>(`/kettlebell/${id}/sets`, data).then(r => r.data);
|
||||
|
||||
export const getSets = (id: number) =>
|
||||
client.get<KettlebellSetLog[]>(`/kettlebell/${id}/sets`).then(r => r.data);
|
||||
|
||||
export const completeSession = (id: number, data: CompleteSessionRequest = {}) =>
|
||||
client.patch<KettlebellSession>(`/kettlebell/${id}/complete`, data).then(r => r.data);
|
||||
|
||||
export const retrySession = (id: number) =>
|
||||
client.post<KettlebellSession>(`/kettlebell/${id}/retry`).then(r => r.data);
|
||||
|
||||
export const abandonSession = (id: number) =>
|
||||
client.patch<KettlebellSession>(`/kettlebell/${id}/abandon`).then(r => r.data);
|
||||
|
||||
export const deleteSession = (id: number) =>
|
||||
client.delete(`/kettlebell/${id}`);
|
||||
27
frontend/src/api/nutrition.ts
Normal file
27
frontend/src/api/nutrition.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import client from './client';
|
||||
import type { FoodLog, FoodLogCreate, NutritionalInfo, NutritionSummary } from '../types/nutrition';
|
||||
|
||||
export const analyzeFoodText = (description: string) =>
|
||||
client.post<NutritionalInfo>('/nutrition/analyze', { description }).then(r => r.data);
|
||||
|
||||
export const analyzeFoodImage = (file: File, description = '') => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (description) formData.append('description', description);
|
||||
return client
|
||||
.post<NutritionalInfo>('/nutrition/analyze/image', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
.then(r => r.data);
|
||||
};
|
||||
|
||||
export const logFood = (data: FoodLogCreate) =>
|
||||
client.post<FoodLog>('/nutrition/log', data).then(r => r.data);
|
||||
|
||||
export const getLogs = (skip = 0, limit = 100) =>
|
||||
client.get<FoodLog[]>('/nutrition/logs', { params: { skip, limit } }).then(r => r.data);
|
||||
|
||||
export const getNutritionSummary = (date?: string) =>
|
||||
client
|
||||
.get<NutritionSummary>('/nutrition/summary', { params: date ? { date } : undefined })
|
||||
.then(r => r.data);
|
||||
8
frontend/src/api/plans.ts
Normal file
8
frontend/src/api/plans.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import client from './client';
|
||||
import type { Plan, PlanRequest } from '../types/plans';
|
||||
|
||||
export const getPlans = () =>
|
||||
client.get<Plan[]>('/plans/').then(r => r.data);
|
||||
|
||||
export const generatePlan = (data: PlanRequest) =>
|
||||
client.post<Plan>('/plans/generate', data).then(r => r.data);
|
||||
32
frontend/src/api/push.ts
Normal file
32
frontend/src/api/push.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import client from './client';
|
||||
|
||||
export interface PushSubscribeRequest {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
reminder_hour: number;
|
||||
reminder_minute: number;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export const getVapidPublicKey = (): Promise<string> =>
|
||||
client.get<{ public_key: string }>('/push/vapid-public-key').then((r) => r.data.public_key);
|
||||
|
||||
export const subscribePush = (data: PushSubscribeRequest): Promise<void> =>
|
||||
client.post('/push/subscribe', data).then((r) => r.data);
|
||||
|
||||
export const unsubscribePush = (endpoint: string): Promise<void> =>
|
||||
client.delete('/push/unsubscribe', { data: { endpoint } }).then((r) => r.data);
|
||||
|
||||
export const sendTestNotification = (): Promise<{ sent: number }> =>
|
||||
client.post('/push/test').then((r) => r.data);
|
||||
|
||||
export function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = atob(base64);
|
||||
const output = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
output[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
32
frontend/src/api/supplements.ts
Normal file
32
frontend/src/api/supplements.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import client from './client';
|
||||
import type {
|
||||
Supplement,
|
||||
SupplementCreate,
|
||||
SupplementLog,
|
||||
SupplementUpdate,
|
||||
SupplementWithStatus,
|
||||
} from '../types/supplement';
|
||||
|
||||
export const getSupplements = () =>
|
||||
client.get<Supplement[]>('/supplements/').then(r => r.data);
|
||||
|
||||
export const createSupplement = (data: SupplementCreate) =>
|
||||
client.post<Supplement>('/supplements/', data).then(r => r.data);
|
||||
|
||||
export const updateSupplement = (id: number, data: SupplementUpdate) =>
|
||||
client.put<Supplement>(`/supplements/${id}`, data).then(r => r.data);
|
||||
|
||||
export const deleteSupplement = (id: number) =>
|
||||
client.delete(`/supplements/${id}`);
|
||||
|
||||
export const logSupplement = (id: number, data: { dose_taken?: number; notes?: string } = {}) =>
|
||||
client.post<SupplementLog>(`/supplements/${id}/log`, data).then(r => r.data);
|
||||
|
||||
export const getSupplementLogs = (params?: {
|
||||
supplement_id?: number;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}) => client.get<SupplementLog[]>('/supplements/logs', { params }).then(r => r.data);
|
||||
|
||||
export const getTodaySupplements = () =>
|
||||
client.get<SupplementWithStatus[]>('/supplements/today').then(r => r.data);
|
||||
8
frontend/src/api/users.ts
Normal file
8
frontend/src/api/users.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import client from './client';
|
||||
import type { User, UserUpdate } from '../types/user';
|
||||
|
||||
export const getMe = () =>
|
||||
client.get<User>('/users/me').then(r => r.data);
|
||||
|
||||
export const updateMe = (data: UserUpdate) =>
|
||||
client.put<User>('/users/me', data).then(r => r.data);
|
||||
47
frontend/src/components/ConfirmModal.tsx
Normal file
47
frontend/src/components/ConfirmModal.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
interface ConfirmModalProps {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
destructive?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
destructive = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center px-4">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onCancel} />
|
||||
<div className="relative bg-surface border border-border rounded-2xl shadow-xl w-full max-w-sm p-6 flex flex-col gap-4">
|
||||
<h2 className="text-lg font-bold text-content">{title}</h2>
|
||||
<p className="text-sm text-content-muted">{message}</p>
|
||||
<div className="flex gap-3 justify-end mt-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 rounded-xl border border-border text-content text-sm hover:bg-base transition-colors"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-semibold transition-colors ${
|
||||
destructive
|
||||
? 'bg-red-600 hover:bg-red-500 text-white'
|
||||
: 'bg-primary hover:bg-primary/90 text-white'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,10 +14,13 @@ import {
|
||||
Utensils,
|
||||
Heart,
|
||||
Calendar,
|
||||
CalendarDays,
|
||||
User,
|
||||
Moon,
|
||||
Sun,
|
||||
LogOut,
|
||||
Dumbbell,
|
||||
Pill,
|
||||
} from 'lucide-react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useTheme } from '../../context/ThemeContext'
|
||||
@@ -34,6 +37,9 @@ export function AppSidebar() {
|
||||
{ to: "/", icon: LayoutDashboard, label: "Dashboard" },
|
||||
{ to: "/nutrition", icon: Utensils, label: "Nutrition" },
|
||||
{ to: "/health", icon: Heart, label: "Health" },
|
||||
{ to: "/kettlebell", icon: Dumbbell, label: "Kettlebell" },
|
||||
{ to: "/supplements", icon: Pill, label: "Supplements" },
|
||||
{ to: "/calendar", icon: CalendarDays, label: "Calendar" },
|
||||
{ to: "/plans", icon: Calendar, label: "Plans" },
|
||||
{ to: "/profile", icon: User, label: "Profile" },
|
||||
]
|
||||
|
||||
173
frontend/src/components/calendar/CalendarEventForm.tsx
Normal file
173
frontend/src/components/calendar/CalendarEventForm.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import type { CalendarEvent } from '../../types/calendar';
|
||||
import { createEvent, updateEvent } from '../../api/calendar';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const COLOR_SWATCHES = [
|
||||
{ label: 'Blue', value: 'blue', cls: 'bg-blue-500' },
|
||||
{ label: 'Green', value: 'green', cls: 'bg-green-500' },
|
||||
{ label: 'Red', value: 'red', cls: 'bg-red-500' },
|
||||
{ label: 'Purple', value: 'purple', cls: 'bg-purple-500' },
|
||||
{ label: 'Orange', value: 'orange', cls: 'bg-orange-500' },
|
||||
{ label: 'Pink', value: 'pink', cls: 'bg-pink-500' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
date: string;
|
||||
existing?: CalendarEvent;
|
||||
defaultType?: CalendarEvent['event_type'];
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function CalendarEventForm({ date, existing, defaultType = 'general', onSuccess, onCancel }: Props) {
|
||||
const [title, setTitle] = useState(existing?.title ?? '');
|
||||
const [description, setDescription] = useState(existing?.description ?? '');
|
||||
const [eventType, setEventType] = useState<CalendarEvent['event_type']>(existing?.event_type ?? defaultType);
|
||||
const [color, setColor] = useState(existing?.color ?? '');
|
||||
const [startTime, setStartTime] = useState(existing?.start_time ?? '');
|
||||
const [isCompleted, setIsCompleted] = useState(existing?.is_completed ?? false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) {
|
||||
toast.error('Title is required');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (existing?.id) {
|
||||
await updateEvent(existing.id, {
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
event_type: eventType,
|
||||
color: color || undefined,
|
||||
start_time: startTime || undefined,
|
||||
is_completed: isCompleted,
|
||||
});
|
||||
toast.success('Event updated');
|
||||
} else {
|
||||
await createEvent({
|
||||
date,
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
event_type: eventType,
|
||||
color: color || undefined,
|
||||
start_time: startTime || undefined,
|
||||
is_completed: isCompleted,
|
||||
});
|
||||
toast.success('Event created');
|
||||
}
|
||||
onSuccess();
|
||||
} catch {
|
||||
toast.error('Failed to save event');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">
|
||||
Title <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
className="w-full rounded-md border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="Event title"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full rounded-md border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">Type</label>
|
||||
<select
|
||||
value={eventType}
|
||||
onChange={e => setEventType(e.target.value as CalendarEvent['event_type'])}
|
||||
className="w-full rounded-md border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="general">General</option>
|
||||
<option value="workout">Workout</option>
|
||||
<option value="supplement">Supplement</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">Start Time</label>
|
||||
<input
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={e => setStartTime(e.target.value)}
|
||||
className="w-full rounded-md border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">Color</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setColor('')}
|
||||
className={`w-7 h-7 rounded-full border-2 bg-zinc-200 dark:bg-zinc-600 ${!color ? 'border-primary' : 'border-transparent'}`}
|
||||
title="None"
|
||||
/>
|
||||
{COLOR_SWATCHES.map(s => (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
onClick={() => setColor(s.value)}
|
||||
className={`w-7 h-7 rounded-full border-2 ${s.cls} ${color === s.value ? 'border-primary' : 'border-transparent'}`}
|
||||
title={s.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="is-completed"
|
||||
type="checkbox"
|
||||
checked={isCompleted}
|
||||
onChange={e => setIsCompleted(e.target.checked)}
|
||||
className="rounded border-zinc-300 dark:border-zinc-600 text-primary focus:ring-primary"
|
||||
/>
|
||||
<label htmlFor="is-completed" className="text-sm text-zinc-700 dark:text-zinc-300">Mark as completed</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{submitting ? 'Saving...' : existing ? 'Update' : 'Create'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 rounded-md border border-zinc-300 dark:border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
420
frontend/src/components/calendar/DayModal.tsx
Normal file
420
frontend/src/components/calendar/DayModal.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { X, Dumbbell, Pill, FileText, Plus, Pencil, Trash2, CheckCircle2, Circle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { DayDetail, CalendarEvent as CalEvent } from '../../types/calendar';
|
||||
import { upsertNote, deleteEvent } from '../../api/calendar';
|
||||
import CalendarEventForm from './CalendarEventForm';
|
||||
|
||||
const MOOD_OPTIONS = [
|
||||
{ value: 'great', emoji: '😄', label: 'Great' },
|
||||
{ value: 'good', emoji: '🙂', label: 'Good' },
|
||||
{ value: 'okay', emoji: '😐', label: 'Okay' },
|
||||
{ value: 'bad', emoji: '😕', label: 'Bad' },
|
||||
{ value: 'awful', emoji: '😞', label: 'Awful' },
|
||||
];
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
return new Date(year, month - 1, day).toLocaleDateString('en-US', {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function colorClass(color?: string): string {
|
||||
const map: Record<string, string> = {
|
||||
blue: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
green: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||
red: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
||||
purple: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
orange: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
pink: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300',
|
||||
};
|
||||
return color && map[color] ? map[color] : 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300';
|
||||
}
|
||||
|
||||
// ── Workout Tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
function WorkoutTab({ detail, onRefresh }: { detail: DayDetail; onRefresh: () => void }) {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editEvent, setEditEvent] = useState<CalEvent | null>(null);
|
||||
|
||||
const workoutEvents = detail.events.filter(e => e.event_type === 'workout');
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteEvent(id);
|
||||
toast.success('Event deleted');
|
||||
onRefresh();
|
||||
} catch {
|
||||
toast.error('Failed to delete event');
|
||||
}
|
||||
};
|
||||
|
||||
if (showForm || editEvent) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-zinc-500 dark:text-zinc-400 mb-3">
|
||||
{editEvent ? 'Edit Event' : 'Add Workout Event'}
|
||||
</h3>
|
||||
<CalendarEventForm
|
||||
date={detail.date}
|
||||
existing={editEvent ?? undefined}
|
||||
defaultType="workout"
|
||||
onSuccess={() => { setShowForm(false); setEditEvent(null); onRefresh(); }}
|
||||
onCancel={() => { setShowForm(false); setEditEvent(null); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Kettlebell sessions */}
|
||||
{detail.kettlebell_sessions.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 mb-2">
|
||||
Kettlebell Sessions
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{detail.kettlebell_sessions.map(kb => (
|
||||
<div key={kb.id} className="rounded-lg border border-zinc-200 dark:border-zinc-700 p-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{kb.title}</p>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
|
||||
{kb.focus} · {kb.total_duration_min} min · {kb.difficulty}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
kb.status === 'completed'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400'
|
||||
}`}>
|
||||
{kb.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Workout events */}
|
||||
{workoutEvents.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 mb-2">
|
||||
Workout Events
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{workoutEvents.map(ev => (
|
||||
<div key={ev.id} className={`rounded-lg p-3 ${colorClass(ev.color)}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{ev.is_completed ? <CheckCircle2 size={14} /> : <Circle size={14} />}
|
||||
<p className="text-sm font-medium truncate">{ev.title}</p>
|
||||
</div>
|
||||
{ev.description && (
|
||||
<p className="text-xs mt-0.5 opacity-75 truncate">{ev.description}</p>
|
||||
)}
|
||||
{ev.start_time && (
|
||||
<p className="text-xs mt-0.5 opacity-60">{ev.start_time}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<button onClick={() => setEditEvent(ev)} className="p-1 rounded hover:opacity-75 transition-opacity">
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button onClick={() => ev.id && handleDelete(ev.id)} className="p-1 rounded hover:opacity-75 transition-opacity">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.kettlebell_sessions.length === 0 && workoutEvents.length === 0 && (
|
||||
<p className="text-sm text-zinc-400 dark:text-zinc-500 text-center py-6">No workouts logged for this day.</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Plus size={15} /> Add workout event
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Supplements Tab ───────────────────────────────────────────────────────────
|
||||
|
||||
function SupplementsTab({ detail }: { detail: DayDetail }) {
|
||||
const taken = detail.supplements.filter(s => s.taken_today);
|
||||
const notTaken = detail.supplements.filter(s => !s.taken_today);
|
||||
const compliance = detail.supplements.length > 0
|
||||
? Math.round((taken.length / detail.supplements.length) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{compliance !== null && (
|
||||
<div className="rounded-lg bg-zinc-50 dark:bg-zinc-800/50 p-3 flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between text-xs text-zinc-500 dark:text-zinc-400 mb-1">
|
||||
<span>Compliance</span>
|
||||
<span>{taken.length}/{detail.supplements.length}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-zinc-200 dark:bg-zinc-700 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${compliance >= 80 ? 'bg-green-500' : compliance >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
|
||||
style={{ width: `${compliance}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-zinc-900 dark:text-zinc-100">{compliance}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.supplements.length === 0 && (
|
||||
<p className="text-sm text-zinc-400 dark:text-zinc-500 text-center py-6">No active supplements.</p>
|
||||
)}
|
||||
|
||||
{taken.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 mb-2">Taken</h3>
|
||||
<div className="space-y-1.5">
|
||||
{taken.map(s => (
|
||||
<div key={s.id} className="flex items-center gap-2 rounded-md bg-green-50 dark:bg-green-900/20 px-3 py-2">
|
||||
<CheckCircle2 size={15} className="text-green-500 shrink-0" />
|
||||
<span className="text-sm text-zinc-900 dark:text-zinc-100">{s.name}</span>
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400 ml-auto">{s.dosage} {s.unit}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notTaken.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 mb-2">Not Taken</h3>
|
||||
<div className="space-y-1.5">
|
||||
{notTaken.map(s => (
|
||||
<div key={s.id} className="flex items-center gap-2 rounded-md bg-zinc-50 dark:bg-zinc-800/50 px-3 py-2">
|
||||
<Circle size={15} className="text-zinc-400 shrink-0" />
|
||||
<span className="text-sm text-zinc-500 dark:text-zinc-400">{s.name}</span>
|
||||
<span className="text-xs text-zinc-400 ml-auto">{s.dosage} {s.unit}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Notes Tab ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function NotesTab({ detail, onRefresh }: { detail: DayDetail; onRefresh: () => void }) {
|
||||
const [content, setContent] = useState(detail.note?.content ?? '');
|
||||
const [mood, setMood] = useState(detail.note?.mood ?? '');
|
||||
const [energyLevel, setEnergyLevel] = useState(detail.note?.energy_level ?? 5);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const save = useCallback(async (c: string, m: string, e: number) => {
|
||||
setSaveStatus('saving');
|
||||
try {
|
||||
await upsertNote(detail.date, {
|
||||
content: c,
|
||||
mood: m || undefined,
|
||||
energy_level: e,
|
||||
});
|
||||
setSaveStatus('saved');
|
||||
onRefresh();
|
||||
} catch {
|
||||
setSaveStatus('idle');
|
||||
toast.error('Failed to save note');
|
||||
}
|
||||
}, [detail.date, onRefresh]);
|
||||
|
||||
const scheduleAutosave = (c: string, m: string, e: number) => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => save(c, m, e), 1500);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleContentChange = (val: string) => {
|
||||
if (val.length > 10000) return;
|
||||
setContent(val);
|
||||
setSaveStatus('idle');
|
||||
scheduleAutosave(val, mood, energyLevel);
|
||||
};
|
||||
|
||||
const handleMoodChange = (val: string) => {
|
||||
const newMood = mood === val ? '' : val;
|
||||
setMood(newMood);
|
||||
setSaveStatus('idle');
|
||||
scheduleAutosave(content, newMood, energyLevel);
|
||||
};
|
||||
|
||||
const handleEnergyChange = (val: number) => {
|
||||
setEnergyLevel(val);
|
||||
setSaveStatus('idle');
|
||||
scheduleAutosave(content, mood, val);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Notes</label>
|
||||
<span className="text-xs text-zinc-400">
|
||||
{saveStatus === 'saving' && 'Saving...'}
|
||||
{saveStatus === 'saved' && <span className="text-green-500">Saved</span>}
|
||||
{saveStatus === 'idle' && `${content.length}/10000`}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={e => handleContentChange(e.target.value)}
|
||||
rows={6}
|
||||
maxLength={10000}
|
||||
className="w-full rounded-md border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
placeholder="How did your day go? Any notes about your health, workouts, or wellbeing..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2 block">Mood</label>
|
||||
<div className="flex gap-2">
|
||||
{MOOD_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => handleMoodChange(opt.value)}
|
||||
className={`flex flex-col items-center gap-0.5 rounded-lg p-2 text-xs transition-colors border-2 ${
|
||||
mood === opt.value
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-transparent hover:border-zinc-300 dark:hover:border-zinc-600'
|
||||
}`}
|
||||
title={opt.label}
|
||||
>
|
||||
<span className="text-xl">{opt.emoji}</span>
|
||||
<span className="text-zinc-500 dark:text-zinc-400">{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Energy Level</label>
|
||||
<span className="text-sm font-bold text-primary">{energyLevel}/10</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={10}
|
||||
value={energyLevel}
|
||||
onChange={e => handleEnergyChange(Number(e.target.value))}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-zinc-400 mt-1">
|
||||
<span>Low</span><span>High</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── DayModal ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type Tab = 'workout' | 'supplements' | 'notes';
|
||||
|
||||
interface Props {
|
||||
date: string;
|
||||
detail: DayDetail | null;
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export default function DayModal({ date, detail, loading, onClose, onRefresh }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('notes');
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'workout', label: 'Workout', icon: <Dumbbell size={15} /> },
|
||||
{ id: 'supplements', label: 'Supplements', icon: <Pill size={15} /> },
|
||||
{ id: 'notes', label: 'Notes', icon: <FileText size={15} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
|
||||
{/* Panel */}
|
||||
<div className="relative z-10 w-full sm:max-w-lg sm:mx-4 bg-white dark:bg-zinc-900 rounded-t-2xl sm:rounded-2xl shadow-2xl max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-200 dark:border-zinc-800">
|
||||
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{formatDate(date)}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors rounded-md p-1"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-zinc-200 dark:border-zinc-800 px-2">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-1.5 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
)}
|
||||
{!loading && detail && (
|
||||
<>
|
||||
{activeTab === 'workout' && <WorkoutTab detail={detail} onRefresh={onRefresh} />}
|
||||
{activeTab === 'supplements' && <SupplementsTab detail={detail} />}
|
||||
{activeTab === 'notes' && <NotesTab detail={detail} onRefresh={onRefresh} />}
|
||||
</>
|
||||
)}
|
||||
{!loading && !detail && (
|
||||
<p className="text-sm text-zinc-400 text-center py-12">Failed to load day details.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
frontend/src/components/kettlebell/ElapsedTimer.tsx
Normal file
55
frontend/src/components/kettlebell/ElapsedTimer.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
interface ElapsedTimerProps {
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
export function ElapsedTimer({ seconds }: ElapsedTimerProps) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return (
|
||||
<span className="font-mono tabular-nums">
|
||||
{h > 0 ? `${pad(h)}:` : ''}{pad(m)}:{pad(s)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ElapsedTimerCircle({ seconds }: ElapsedTimerProps) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const radius = 70;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="relative w-48 h-48">
|
||||
<svg className="w-full h-full -rotate-90" viewBox="0 0 160 160">
|
||||
<circle
|
||||
cx="80" cy="80" r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
className="text-zinc-200 dark:text-zinc-700"
|
||||
/>
|
||||
<circle
|
||||
cx="80" cy="80" r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={circumference * 0.25}
|
||||
className="text-primary"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-4xl font-mono font-bold tabular-nums text-content">
|
||||
{h > 0 ? `${pad(h)}:` : ''}{pad(m)}:{pad(s)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
frontend/src/components/kettlebell/ProgressBar.tsx
Normal file
14
frontend/src/components/kettlebell/ProgressBar.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
interface ProgressBarProps {
|
||||
exerciseIdx: number;
|
||||
totalExercises: number;
|
||||
setIdx: number;
|
||||
totalSets: number;
|
||||
}
|
||||
|
||||
export function ProgressBar({ exerciseIdx, totalExercises, setIdx, totalSets }: ProgressBarProps) {
|
||||
return (
|
||||
<div className="text-sm text-content-muted text-center">
|
||||
Exercise {exerciseIdx + 1}/{totalExercises} · Set {setIdx + 1}/{totalSets}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/kettlebell/RestCountdown.tsx
Normal file
46
frontend/src/components/kettlebell/RestCountdown.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
interface RestCountdownProps {
|
||||
remaining: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function RestCountdown({ remaining, total }: RestCountdownProps) {
|
||||
const radius = 54;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const progress = total > 0 ? remaining / total : 0;
|
||||
const dashOffset = circumference * (1 - progress);
|
||||
|
||||
const m = Math.floor(remaining / 60);
|
||||
const s = remaining % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="relative w-36 h-36">
|
||||
<svg className="w-full h-full -rotate-90" viewBox="0 0 120 120">
|
||||
<circle
|
||||
cx="60" cy="60" r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
className="text-zinc-200 dark:text-zinc-700"
|
||||
/>
|
||||
<circle
|
||||
cx="60" cy="60" r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
className="text-primary transition-all duration-1000"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-3xl font-mono font-bold tabular-nums text-content">
|
||||
{pad(m)}:{pad(s)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
frontend/src/components/kettlebell/SessionSummary.tsx
Normal file
36
frontend/src/components/kettlebell/SessionSummary.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { KettlebellSetLog } from '../../types/kettlebell';
|
||||
import { ElapsedTimer } from './ElapsedTimer';
|
||||
|
||||
interface SessionSummaryProps {
|
||||
totalElapsed: number;
|
||||
logged: KettlebellSetLog[];
|
||||
}
|
||||
|
||||
export function SessionSummary({ totalElapsed, logged }: SessionSummaryProps) {
|
||||
const avgRpe = logged.length > 0
|
||||
? Math.round(logged.reduce((sum, s) => sum + s.perceived_effort, 0) / logged.length * 10) / 10
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-8 py-12 px-6">
|
||||
<div className="text-6xl">🎉</div>
|
||||
<h1 className="text-3xl font-bold text-content">Workout Complete!</h1>
|
||||
<div className="grid grid-cols-3 gap-6 w-full max-w-sm">
|
||||
<div className="bg-surface border border-border rounded-2xl p-4 text-center">
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
<ElapsedTimer seconds={totalElapsed} />
|
||||
</div>
|
||||
<div className="text-xs text-content-muted mt-1">Total Time</div>
|
||||
</div>
|
||||
<div className="bg-surface border border-border rounded-2xl p-4 text-center">
|
||||
<div className="text-2xl font-bold text-primary">{logged.length}</div>
|
||||
<div className="text-xs text-content-muted mt-1">Sets Done</div>
|
||||
</div>
|
||||
<div className="bg-surface border border-border rounded-2xl p-4 text-center">
|
||||
<div className="text-2xl font-bold text-primary">{avgRpe}</div>
|
||||
<div className="text-xs text-content-muted mt-1">Avg RPE</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
frontend/src/components/kettlebell/SetLogger.tsx
Normal file
101
frontend/src/components/kettlebell/SetLogger.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { ExerciseBlock } from '../../types/kettlebell';
|
||||
|
||||
interface SetLoggerProps {
|
||||
exercise: ExerciseBlock;
|
||||
reps: number;
|
||||
weightKg: number;
|
||||
effort: number;
|
||||
onRepsChange: (v: number) => void;
|
||||
onWeightChange: (v: number) => void;
|
||||
onEffortChange: (v: number) => void;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
function Stepper({ label, value, onChange, step = 1, min = 0 }: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
step?: number;
|
||||
min?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-content-muted w-24">{label}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(Math.max(min, value - step))}
|
||||
className="w-10 h-10 rounded-full bg-surface border border-border text-xl font-bold text-content flex items-center justify-center active:scale-95"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-14 text-center text-xl font-bold text-content tabular-nums">
|
||||
{value}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(value + step)}
|
||||
className="w-10 h-10 rounded-full bg-surface border border-border text-xl font-bold text-content flex items-center justify-center active:scale-95"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RpeDots({ value, onChange }: { value: number; onChange: (v: number) => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-content-muted w-24">Effort</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{Array.from({ length: 10 }, (_, i) => i + 1).map((dot) => (
|
||||
<button
|
||||
key={dot}
|
||||
type="button"
|
||||
onClick={() => onChange(dot)}
|
||||
className={`w-6 h-6 rounded-full transition-colors ${dot <= value
|
||||
? 'bg-primary'
|
||||
: 'bg-zinc-200 dark:bg-zinc-700'
|
||||
}`}
|
||||
aria-label={`RPE ${dot}`}
|
||||
/>
|
||||
))}
|
||||
<span className="ml-2 text-sm text-content-muted tabular-nums">{value}/10</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetLogger({
|
||||
exercise,
|
||||
reps,
|
||||
weightKg,
|
||||
effort,
|
||||
onRepsChange,
|
||||
onWeightChange,
|
||||
onEffortChange,
|
||||
onComplete,
|
||||
}: SetLoggerProps) {
|
||||
const isTimed = exercise.reps === 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 px-6 py-4">
|
||||
{isTimed ? (
|
||||
<Stepper label="Duration (s)" value={reps} onChange={onRepsChange} step={5} />
|
||||
) : (
|
||||
<Stepper label="Reps done" value={reps} onChange={onRepsChange} />
|
||||
)}
|
||||
<Stepper label="Weight (kg)" value={weightKg} onChange={onWeightChange} step={0.5} />
|
||||
<RpeDots value={effort} onChange={onEffortChange} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onComplete}
|
||||
className="w-full min-h-[72px] rounded-2xl bg-primary text-white text-xl font-bold active:scale-95 transition-transform mt-2"
|
||||
>
|
||||
COMPLETE SET
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
532
frontend/src/pages/ActiveSession.tsx
Normal file
532
frontend/src/pages/ActiveSession.tsx
Normal file
@@ -0,0 +1,532 @@
|
||||
import { useEffect, useReducer, useCallback, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getSession, getSets, startSession, logSet, completeSession, abandonSession } from '../api/kettlebell';
|
||||
import type { KettlebellSession, KettlebellSetLog, ExerciseBlock } from '../types/kettlebell';
|
||||
import { ElapsedTimer, ElapsedTimerCircle } from '../components/kettlebell/ElapsedTimer';
|
||||
import { RestCountdown } from '../components/kettlebell/RestCountdown';
|
||||
import { SetLogger } from '../components/kettlebell/SetLogger';
|
||||
import { ProgressBar } from '../components/kettlebell/ProgressBar';
|
||||
import { SessionSummary } from '../components/kettlebell/SessionSummary';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
|
||||
// --------------- State Machine ---------------
|
||||
|
||||
interface LastSetStats {
|
||||
duration: number;
|
||||
restTaken: number | null;
|
||||
reps: number;
|
||||
weightKg: number;
|
||||
effort: number;
|
||||
}
|
||||
|
||||
type SessionPhase =
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'planning'; session: KettlebellSession }
|
||||
| { phase: 'active'; session: KettlebellSession; exerciseIdx: number; setIdx: number; elapsed: number; setElapsed: number; lastSetStats: LastSetStats | null; logged: KettlebellSetLog[]; reps: number; weightKg: number; effort: number }
|
||||
| { phase: 'resting'; session: KettlebellSession; exerciseIdx: number; nextSetIdx: number; restRemaining: number; totalRest: number; elapsed: number; pendingSet: { duration: number; reps: number; weightKg: number; effort: number }; logged: KettlebellSetLog[] }
|
||||
| { phase: 'complete'; session: KettlebellSession; totalElapsed: number; logged: KettlebellSetLog[] };
|
||||
|
||||
type Action =
|
||||
| { type: 'LOAD'; session: KettlebellSession; existingLogs: KettlebellSetLog[] }
|
||||
| { type: 'START' }
|
||||
| { type: 'TICK_ELAPSED' }
|
||||
| { type: 'TICK_REST' }
|
||||
| { type: 'SET_REPS'; value: number }
|
||||
| { type: 'SET_WEIGHT'; value: number }
|
||||
| { type: 'SET_EFFORT'; value: number }
|
||||
| { type: 'COMPLETE_SET'; log: KettlebellSetLog }
|
||||
| { type: 'SKIP_REST' }
|
||||
| { type: 'FINISH' };
|
||||
|
||||
function getExercises(session: KettlebellSession): ExerciseBlock[] {
|
||||
return session.exercises?.exercises ?? [];
|
||||
}
|
||||
|
||||
function initialSetValues(exercise: ExerciseBlock) {
|
||||
return {
|
||||
reps: exercise.reps > 0 ? exercise.reps : exercise.duration_seconds,
|
||||
weightKg: exercise.weight_kg,
|
||||
effort: 5,
|
||||
};
|
||||
}
|
||||
|
||||
function reducer(state: SessionPhase, action: Action): SessionPhase {
|
||||
switch (action.type) {
|
||||
case 'LOAD': {
|
||||
const { session, existingLogs } = action;
|
||||
if (session.status === 'completed') {
|
||||
const elapsed = session.started_at && session.completed_at
|
||||
? Math.round((new Date(session.completed_at).getTime() - new Date(session.started_at).getTime()) / 1000)
|
||||
: 0;
|
||||
return { phase: 'complete', session, totalElapsed: elapsed, logged: existingLogs };
|
||||
}
|
||||
if (session.status === 'in_progress') {
|
||||
const exercises = getExercises(session);
|
||||
const elapsed = session.started_at
|
||||
? Math.round((Date.now() - new Date(session.started_at).getTime()) / 1000)
|
||||
: 0;
|
||||
// Rehydrate: find next incomplete set
|
||||
let exerciseIdx = 0;
|
||||
let setIdx = 0;
|
||||
for (let ei = 0; ei < exercises.length; ei++) {
|
||||
for (let si = 0; si < exercises[ei].sets; si++) {
|
||||
const done = existingLogs.some(l => l.exercise_order === exercises[ei].order && l.set_number === si + 1);
|
||||
if (!done) {
|
||||
exerciseIdx = ei;
|
||||
setIdx = si;
|
||||
const ex = exercises[ei];
|
||||
return {
|
||||
phase: 'active', session, exerciseIdx, setIdx, elapsed, setElapsed: 0, lastSetStats: null, logged: existingLogs,
|
||||
...initialSetValues(ex),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
// All sets done but session not completed
|
||||
return { phase: 'complete', session, totalElapsed: elapsed, logged: existingLogs };
|
||||
}
|
||||
return { phase: 'planning', session };
|
||||
}
|
||||
case 'START': {
|
||||
if (state.phase !== 'planning') return state;
|
||||
const exercises = getExercises(state.session);
|
||||
if (exercises.length === 0) return state;
|
||||
const ex = exercises[0];
|
||||
return {
|
||||
phase: 'active',
|
||||
session: { ...state.session, status: 'in_progress' },
|
||||
exerciseIdx: 0,
|
||||
setIdx: 0,
|
||||
elapsed: 0,
|
||||
setElapsed: 0,
|
||||
lastSetStats: null,
|
||||
logged: [],
|
||||
...initialSetValues(ex),
|
||||
};
|
||||
}
|
||||
case 'TICK_ELAPSED': {
|
||||
if (state.phase === 'active') return { ...state, elapsed: state.elapsed + 1, setElapsed: state.setElapsed + 1 };
|
||||
if (state.phase === 'resting') return { ...state, elapsed: state.elapsed + 1 };
|
||||
return state;
|
||||
}
|
||||
case 'TICK_REST': {
|
||||
if (state.phase !== 'resting') return state;
|
||||
if (state.restRemaining <= 1) {
|
||||
// Auto-advance to next set
|
||||
return advanceAfterRest(state);
|
||||
}
|
||||
return { ...state, restRemaining: state.restRemaining - 1 };
|
||||
}
|
||||
case 'SET_REPS': {
|
||||
if (state.phase !== 'active') return state;
|
||||
return { ...state, reps: action.value };
|
||||
}
|
||||
case 'SET_WEIGHT': {
|
||||
if (state.phase !== 'active') return state;
|
||||
return { ...state, weightKg: action.value };
|
||||
}
|
||||
case 'SET_EFFORT': {
|
||||
if (state.phase !== 'active') return state;
|
||||
return { ...state, effort: action.value };
|
||||
}
|
||||
case 'COMPLETE_SET': {
|
||||
if (state.phase !== 'active') return state;
|
||||
const newLogged = [...state.logged, action.log];
|
||||
const exercises = getExercises(state.session);
|
||||
const exercise = exercises[state.exerciseIdx];
|
||||
const totalRest = exercise.rest_seconds;
|
||||
|
||||
// Check if there's a next set or exercise
|
||||
const hasNextSet = state.setIdx + 1 < exercise.sets;
|
||||
const hasNextExercise = state.exerciseIdx + 1 < exercises.length;
|
||||
|
||||
if (!hasNextSet && !hasNextExercise) {
|
||||
// Last set of last exercise — go to complete
|
||||
return {
|
||||
phase: 'complete',
|
||||
session: state.session,
|
||||
totalElapsed: state.elapsed,
|
||||
logged: newLogged,
|
||||
};
|
||||
}
|
||||
|
||||
const pendingSet = { duration: state.setElapsed, reps: state.reps, weightKg: state.weightKg, effort: state.effort };
|
||||
|
||||
if (totalRest > 0) {
|
||||
return {
|
||||
phase: 'resting',
|
||||
session: state.session,
|
||||
exerciseIdx: state.exerciseIdx,
|
||||
nextSetIdx: state.setIdx + 1,
|
||||
restRemaining: totalRest,
|
||||
totalRest,
|
||||
elapsed: state.elapsed,
|
||||
pendingSet,
|
||||
logged: newLogged,
|
||||
};
|
||||
}
|
||||
return advanceFromActive(state, newLogged, { ...pendingSet, restTaken: null });
|
||||
}
|
||||
case 'SKIP_REST': {
|
||||
if (state.phase !== 'resting') return state;
|
||||
return advanceAfterRest(state);
|
||||
}
|
||||
case 'FINISH':
|
||||
return state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function advanceFromActive(state: Extract<SessionPhase, { phase: 'active' }>, newLogged: KettlebellSetLog[], lastSetStats: LastSetStats): SessionPhase {
|
||||
const exercises = getExercises(state.session);
|
||||
const exercise = exercises[state.exerciseIdx];
|
||||
if (state.setIdx + 1 < exercise.sets) {
|
||||
return { ...state, setIdx: state.setIdx + 1, setElapsed: 0, lastSetStats, logged: newLogged, ...initialSetValues(exercise) };
|
||||
}
|
||||
if (state.exerciseIdx + 1 < exercises.length) {
|
||||
const nextEx = exercises[state.exerciseIdx + 1];
|
||||
return { ...state, exerciseIdx: state.exerciseIdx + 1, setIdx: 0, setElapsed: 0, lastSetStats, logged: newLogged, ...initialSetValues(nextEx) };
|
||||
}
|
||||
return { phase: 'complete', session: state.session, totalElapsed: state.elapsed, logged: newLogged };
|
||||
}
|
||||
|
||||
function advanceAfterRest(state: Extract<SessionPhase, { phase: 'resting' }>): SessionPhase {
|
||||
const exercises = getExercises(state.session);
|
||||
const exercise = exercises[state.exerciseIdx];
|
||||
const restTaken = state.totalRest - state.restRemaining;
|
||||
const lastSetStats: LastSetStats = { ...state.pendingSet, restTaken };
|
||||
if (state.nextSetIdx < exercise.sets) {
|
||||
return {
|
||||
phase: 'active',
|
||||
session: state.session,
|
||||
exerciseIdx: state.exerciseIdx,
|
||||
setIdx: state.nextSetIdx,
|
||||
elapsed: state.elapsed,
|
||||
setElapsed: 0,
|
||||
lastSetStats,
|
||||
logged: state.logged,
|
||||
...initialSetValues(exercise),
|
||||
};
|
||||
}
|
||||
if (state.exerciseIdx + 1 < exercises.length) {
|
||||
const nextEx = exercises[state.exerciseIdx + 1];
|
||||
return {
|
||||
phase: 'active',
|
||||
session: state.session,
|
||||
exerciseIdx: state.exerciseIdx + 1,
|
||||
setIdx: 0,
|
||||
elapsed: state.elapsed,
|
||||
setElapsed: 0,
|
||||
lastSetStats,
|
||||
logged: state.logged,
|
||||
...initialSetValues(nextEx),
|
||||
};
|
||||
}
|
||||
return { phase: 'complete', session: state.session, totalElapsed: state.elapsed, logged: state.logged };
|
||||
}
|
||||
|
||||
// --------------- Component ---------------
|
||||
|
||||
export default function ActiveSession() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [state, dispatch] = useReducer(reducer, { phase: 'loading' });
|
||||
|
||||
// Load session on mount
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
Promise.all([getSession(Number(id)), getSets(Number(id))])
|
||||
.then(([session, logs]) => dispatch({ type: 'LOAD', session, existingLogs: logs }))
|
||||
.catch(() => navigate('/kettlebell'));
|
||||
}, [id, navigate]);
|
||||
|
||||
// Elapsed timer
|
||||
useEffect(() => {
|
||||
if (state.phase !== 'active' && state.phase !== 'resting') return;
|
||||
const interval = setInterval(() => dispatch({ type: 'TICK_ELAPSED' }), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [state.phase]);
|
||||
|
||||
// Rest countdown
|
||||
useEffect(() => {
|
||||
if (state.phase !== 'resting') return;
|
||||
const interval = setInterval(() => dispatch({ type: 'TICK_REST' }), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [state.phase]);
|
||||
|
||||
// Start session on server
|
||||
const handleStart = useCallback(async () => {
|
||||
if (!id || state.phase !== 'planning') return;
|
||||
await startSession(Number(id));
|
||||
dispatch({ type: 'START' });
|
||||
}, [id, state.phase]);
|
||||
|
||||
// Complete set: log to server, then advance
|
||||
const handleCompleteSet = useCallback(async () => {
|
||||
if (state.phase !== 'active' || !id) return;
|
||||
const exercises = getExercises(state.session);
|
||||
const exercise = exercises[state.exerciseIdx];
|
||||
const isTimed = exercise.reps === 0;
|
||||
try {
|
||||
const log = await logSet(Number(id), {
|
||||
exercise_order: exercise.order,
|
||||
set_number: state.setIdx + 1,
|
||||
actual_reps: isTimed ? 0 : state.reps,
|
||||
actual_weight_kg: state.weightKg,
|
||||
actual_duration_seconds: isTimed ? state.reps : 0,
|
||||
perceived_effort: state.effort,
|
||||
});
|
||||
dispatch({ type: 'COMPLETE_SET', log });
|
||||
|
||||
// Check if this was the last set of last exercise
|
||||
const hasNextSet = state.setIdx + 1 < exercise.sets;
|
||||
const hasNextExercise = state.exerciseIdx + 1 < exercises.length;
|
||||
if (!hasNextSet && !hasNextExercise) {
|
||||
await completeSession(Number(id));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to log set', err);
|
||||
alert('Failed to log set. Please try again.');
|
||||
}
|
||||
}, [id, state]);
|
||||
|
||||
const [showAbandonModal, setShowAbandonModal] = useState(false);
|
||||
|
||||
const handleAbandon = useCallback(async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await abandonSession(Number(id));
|
||||
navigate('/kettlebell');
|
||||
} catch (err) {
|
||||
console.error('Failed to abandon session', err);
|
||||
}
|
||||
}, [id, navigate]);
|
||||
|
||||
if (state.phase === 'loading') {
|
||||
return (
|
||||
<div className="min-h-screen bg-base flex items-center justify-center">
|
||||
<p className="text-content-muted">Loading session...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase === 'planning') {
|
||||
const exercises = getExercises(state.session);
|
||||
return (
|
||||
<div className="min-h-screen bg-base flex flex-col">
|
||||
<div className="flex items-center gap-4 px-4 py-4 border-b border-border">
|
||||
<button onClick={() => navigate('/kettlebell')} className="text-content-muted hover:text-content">
|
||||
← Back
|
||||
</button>
|
||||
<h1 className="font-bold text-content flex-1 truncate">{state.session.title}</h1>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-4">
|
||||
<p className="text-content-muted text-sm capitalize">
|
||||
{state.session.focus} · {state.session.difficulty} · {state.session.total_duration_min} min
|
||||
</p>
|
||||
{exercises.map((ex, i) => (
|
||||
<div key={i} className="bg-surface border border-border rounded-xl p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="font-bold text-content">{ex.order}. {ex.name}</p>
|
||||
<p className="text-sm text-content-muted">{ex.description}</p>
|
||||
</div>
|
||||
<div className="text-sm text-content-muted text-right ml-4 shrink-0">
|
||||
<p>{ex.sets} × {ex.reps > 0 ? `${ex.reps} reps` : `${ex.duration_seconds}s`}</p>
|
||||
<p>{ex.weight_kg} kg</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-primary mt-2 italic">"{ex.coaching_tip}"</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-4 pb-8 pt-4 border-t border-border">
|
||||
<button
|
||||
onClick={handleStart}
|
||||
className="w-full min-h-[72px] rounded-2xl bg-primary text-white text-xl font-bold active:scale-95 transition-transform"
|
||||
>
|
||||
START WORKOUT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase === 'active') {
|
||||
const exercises = getExercises(state.session);
|
||||
const exercise = exercises[state.exerciseIdx];
|
||||
return (
|
||||
<div className="min-h-screen bg-base flex flex-col">
|
||||
{showAbandonModal && (
|
||||
<ConfirmModal
|
||||
title="Cancel Session"
|
||||
message="Cancel this session? Progress logged so far will be saved but the session will be marked as abandoned."
|
||||
confirmLabel="Cancel Session"
|
||||
destructive
|
||||
onConfirm={handleAbandon}
|
||||
onCancel={() => setShowAbandonModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sticky top bar */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-surface sticky top-0 z-10">
|
||||
<button onClick={() => navigate('/kettlebell')} className="text-content-muted hover:text-content text-sm">
|
||||
← Back
|
||||
</button>
|
||||
<ProgressBar
|
||||
exerciseIdx={state.exerciseIdx}
|
||||
totalExercises={exercises.length}
|
||||
setIdx={state.setIdx}
|
||||
totalSets={exercise.sets}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Exercise info */}
|
||||
<div className="px-6 py-6 border-b border-border">
|
||||
<h2 className="text-2xl font-bold text-content uppercase">{exercise.name}</h2>
|
||||
<p className="text-content-muted mt-1">
|
||||
Set {state.setIdx + 1} of {exercise.sets} · {exercise.weight_kg} kg · {exercise.reps > 0 ? `${exercise.reps} reps` : `${exercise.duration_seconds}s`}
|
||||
</p>
|
||||
<p className="text-sm text-primary italic mt-2">"{exercise.coaching_tip}"</p>
|
||||
</div>
|
||||
|
||||
{/* Timer row: last set card | current set circle | total */}
|
||||
<div className="flex items-center justify-between px-4 py-4 gap-3">
|
||||
{/* Last set card */}
|
||||
<div className="flex-1 flex flex-col gap-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-emerald-400">Last Set</span>
|
||||
<span className="text-xl font-mono font-bold tabular-nums text-content">
|
||||
{state.lastSetStats !== null
|
||||
? <ElapsedTimer seconds={state.lastSetStats.duration} />
|
||||
: <span className="text-content-muted text-base">—</span>}
|
||||
</span>
|
||||
{state.lastSetStats !== null && (
|
||||
<div className="bg-surface border border-border rounded-xl px-2.5 py-2 flex flex-col gap-1">
|
||||
{state.lastSetStats.restTaken !== null && (
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-content-muted">Rest</span>
|
||||
<span className="text-content font-medium tabular-nums"><ElapsedTimer seconds={state.lastSetStats.restTaken} /></span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-content-muted">Reps</span>
|
||||
<span className="text-content font-medium">{state.lastSetStats.reps}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-content-muted">Weight</span>
|
||||
<span className="text-content font-medium">{state.lastSetStats.weightKg} kg</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-content-muted">Effort</span>
|
||||
<span className="text-content font-medium">{state.lastSetStats.effort}/10</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ElapsedTimerCircle seconds={state.setElapsed} />
|
||||
|
||||
{/* Total */}
|
||||
<div className="flex-1 flex flex-col items-end gap-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-amber-400">Total</span>
|
||||
<span className="text-xl font-mono font-bold tabular-nums text-content">
|
||||
<ElapsedTimer seconds={state.elapsed} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Set logger */}
|
||||
<div className="flex-1">
|
||||
<SetLogger
|
||||
exercise={exercise}
|
||||
reps={state.reps}
|
||||
weightKg={state.weightKg}
|
||||
effort={state.effort}
|
||||
onRepsChange={value => dispatch({ type: 'SET_REPS', value })}
|
||||
onWeightChange={value => dispatch({ type: 'SET_WEIGHT', value })}
|
||||
onEffortChange={value => dispatch({ type: 'SET_EFFORT', value })}
|
||||
onComplete={handleCompleteSet}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cancel session */}
|
||||
<div className="px-6 pb-6 flex justify-center">
|
||||
<button
|
||||
onClick={() => setShowAbandonModal(true)}
|
||||
className="text-sm text-content-muted hover:text-red-500 transition-colors"
|
||||
>
|
||||
Cancel Session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase === 'resting') {
|
||||
const exercises = getExercises(state.session);
|
||||
const exercise = exercises[state.exerciseIdx];
|
||||
const isLastSet = state.nextSetIdx >= exercise.sets;
|
||||
const nextExercise = isLastSet ? exercises[state.exerciseIdx + 1] : null;
|
||||
const nextLabel = isLastSet && nextExercise
|
||||
? `Next: ${nextExercise.name} · Set 1/${nextExercise.sets} · ${nextExercise.weight_kg}kg`
|
||||
: `Next: Set ${state.nextSetIdx + 1}/${exercise.sets} · ${exercise.name} · ${exercise.weight_kg}kg`;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base flex flex-col items-center justify-center gap-8 px-6">
|
||||
{showAbandonModal && (
|
||||
<ConfirmModal
|
||||
title="Cancel Session"
|
||||
message="Cancel this session? Progress logged so far will be saved but the session will be marked as abandoned."
|
||||
confirmLabel="Cancel Session"
|
||||
destructive
|
||||
onConfirm={handleAbandon}
|
||||
onCancel={() => setShowAbandonModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between w-full max-w-sm">
|
||||
<button onClick={() => navigate('/kettlebell')} className="text-content-muted hover:text-content text-sm">
|
||||
← Back
|
||||
</button>
|
||||
<div className="text-lg font-mono font-bold text-content">
|
||||
<ElapsedTimer seconds={state.elapsed} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl font-bold text-content">REST</h2>
|
||||
<RestCountdown remaining={state.restRemaining} total={state.totalRest} />
|
||||
<p className="text-content-muted text-sm text-center">{nextLabel}</p>
|
||||
|
||||
<button
|
||||
onClick={() => dispatch({ type: 'SKIP_REST' })}
|
||||
className="px-8 py-3 rounded-xl border border-border text-content hover:bg-surface transition-colors"
|
||||
>
|
||||
SKIP REST
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowAbandonModal(true)}
|
||||
className="text-sm text-content-muted hover:text-red-500 transition-colors"
|
||||
>
|
||||
Cancel Session
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.phase === 'complete') {
|
||||
return (
|
||||
<div className="min-h-screen bg-base flex flex-col">
|
||||
<div className="flex items-center gap-4 px-4 py-4 border-b border-border">
|
||||
<button onClick={() => navigate('/kettlebell')} className="text-content-muted hover:text-content">
|
||||
← Back to Kettlebell
|
||||
</button>
|
||||
</div>
|
||||
<SessionSummary totalElapsed={state.totalElapsed} logged={state.logged} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
251
frontend/src/pages/Calendar.tsx
Normal file
251
frontend/src/pages/Calendar.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { DayPicker } from 'react-day-picker';
|
||||
import 'react-day-picker/style.css';
|
||||
import { CalendarDays, Dumbbell, Pill, FileText, ChevronLeft, ChevronRight, Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { DayMeta, DayDetail } from '../types/calendar';
|
||||
import { getMonthSummary, getDayDetail } from '../api/calendar';
|
||||
import DayModal from '../components/calendar/DayModal';
|
||||
|
||||
function pad(n: number) { return String(n).padStart(2, '0'); }
|
||||
function toDateStr(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
function MonthStats({ meta }: { meta: DayMeta[] }) {
|
||||
const workoutDays = meta.filter(d => d.has_workout).length;
|
||||
const noteDays = meta.filter(d => d.has_note).length;
|
||||
const daysWithSupp = meta.filter(d => d.supplement_compliance !== null);
|
||||
const avgCompliance = daysWithSupp.length > 0
|
||||
? Math.round(daysWithSupp.reduce((a, d) => a + (d.supplement_compliance ?? 0), 0) / daysWithSupp.length * 100)
|
||||
: null;
|
||||
|
||||
const stats = [
|
||||
{ icon: <Dumbbell size={16} />, label: 'Workout Days', value: workoutDays, color: 'text-green-600 dark:text-green-400' },
|
||||
{ icon: <Pill size={16} />, label: 'Avg Compliance', value: avgCompliance !== null ? `${avgCompliance}%` : '—', color: 'text-blue-600 dark:text-blue-400' },
|
||||
{ icon: <FileText size={16} />, label: 'Days with Notes', value: noteDays, color: 'text-yellow-600 dark:text-yellow-400' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||
{stats.map(s => (
|
||||
<div key={s.label} className="bg-surface rounded-xl border border-border p-3 flex flex-col items-center gap-1">
|
||||
<span className={s.color}>{s.icon}</span>
|
||||
<span className="text-lg font-bold text-zinc-900 dark:text-zinc-100">{s.value}</span>
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400 text-center leading-tight">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Calendar() {
|
||||
const [month, setMonth] = useState(() => new Date());
|
||||
const [monthMeta, setMonthMeta] = useState<DayMeta[]>([]);
|
||||
const [metaLoading, setMetaLoading] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
const [dayDetail, setDayDetail] = useState<DayDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
const metaByDate = Object.fromEntries(monthMeta.map(m => [m.date, m]));
|
||||
|
||||
const fetchMonth = useCallback(async (m: Date) => {
|
||||
setMetaLoading(true);
|
||||
try {
|
||||
const data = await getMonthSummary(m.getFullYear(), m.getMonth() + 1);
|
||||
setMonthMeta(data);
|
||||
} catch {
|
||||
toast.error('Failed to load calendar data');
|
||||
} finally {
|
||||
setMetaLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMonth(month);
|
||||
}, [month, fetchMonth]);
|
||||
|
||||
const openDay = async (date: string) => {
|
||||
setSelectedDate(date);
|
||||
setDayDetail(null);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const data = await getDayDetail(date);
|
||||
setDayDetail(data);
|
||||
} catch {
|
||||
toast.error('Failed to load day details');
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDayClick = (day: Date) => {
|
||||
openDay(toDateStr(day));
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setSelectedDate(null);
|
||||
setDayDetail(null);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (selectedDate) openDay(selectedDate);
|
||||
fetchMonth(month);
|
||||
};
|
||||
|
||||
// Build modifiers from monthMeta
|
||||
const workoutDates = monthMeta.filter(d => d.has_workout).map(d => new Date(d.date + 'T00:00:00'));
|
||||
const suppGoodDates = monthMeta
|
||||
.filter(d => d.supplement_compliance !== null && d.supplement_compliance >= 0.8)
|
||||
.map(d => new Date(d.date + 'T00:00:00'));
|
||||
const noteDates = monthMeta.filter(d => d.has_note).map(d => new Date(d.date + 'T00:00:00'));
|
||||
const calorieDates = monthMeta.filter(d => d.calorie_total && d.calorie_total > 0).map(d => new Date(d.date + 'T00:00:00'));
|
||||
|
||||
return (
|
||||
<div className="w-full animate-fade-in">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
|
||||
<CalendarDays size={24} className="text-primary" /> Calendar
|
||||
</h1>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">Track your health journey day by day.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MonthStats meta={monthMeta} />
|
||||
|
||||
<div className="bg-surface rounded-2xl border border-border shadow-sm p-4 sm:p-6">
|
||||
{metaLoading && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DayPicker
|
||||
mode="single"
|
||||
month={month}
|
||||
onMonthChange={setMonth}
|
||||
onDayClick={handleDayClick}
|
||||
modifiers={{
|
||||
hasWorkout: workoutDates,
|
||||
suppGood: suppGoodDates,
|
||||
hasNote: noteDates,
|
||||
hasCalories: calorieDates,
|
||||
}}
|
||||
modifiersClassNames={{
|
||||
hasWorkout: 'rdp-cal-workout',
|
||||
suppGood: 'rdp-cal-supp',
|
||||
hasNote: 'rdp-cal-note',
|
||||
hasCalories: 'rdp-cal-calories',
|
||||
}}
|
||||
classNames={{
|
||||
root: 'rdp-cal-root w-full',
|
||||
months: 'w-full',
|
||||
month: 'w-full',
|
||||
month_grid: 'w-full',
|
||||
month_caption: 'text-zinc-900 dark:text-zinc-100 font-semibold text-xl mb-4',
|
||||
nav: 'text-zinc-500 dark:text-zinc-400',
|
||||
day: 'rdp-cal-day text-zinc-900 dark:text-zinc-100 hover:bg-primary/10 rounded-xl transition-colors cursor-pointer relative',
|
||||
selected: '!bg-primary !text-white rounded-xl',
|
||||
today: 'font-bold text-primary',
|
||||
outside: 'opacity-30',
|
||||
weekday: 'text-zinc-500 dark:text-zinc-400 text-sm font-medium',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-4 mt-4 pt-4 border-t border-border">
|
||||
<div className="flex items-center gap-1.5 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-green-500 inline-block" />
|
||||
Workout
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-blue-500 inline-block" />
|
||||
Supplements ≥80%
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-yellow-400 inline-block" />
|
||||
Note
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
/* Make the grid fill the full container width */
|
||||
.rdp-cal-root,
|
||||
.rdp-cal-root .rdp-months,
|
||||
.rdp-cal-root .rdp-month,
|
||||
.rdp-cal-root .rdp-month_grid {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
/* Table layout: fixed forces equal column distribution */
|
||||
.rdp-cal-root .rdp-month_grid {
|
||||
table-layout: fixed !important;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
/* Equal-width columns */
|
||||
.rdp-cal-root .rdp-weekday,
|
||||
.rdp-cal-root .rdp-day {
|
||||
width: calc(100% / 7);
|
||||
text-align: center;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
/* Day button fills the cell, square aspect ratio */
|
||||
.rdp-cal-root .rdp-day_button {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
aspect-ratio: 1 / 1;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
font-size: 1.05rem;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Weekday header font */
|
||||
.rdp-cal-root .rdp-weekday {
|
||||
font-size: 0.875rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Indicator dots sit on the button itself */
|
||||
.rdp-cal-workout .rdp-day_button,
|
||||
.rdp-cal-supp .rdp-day_button,
|
||||
.rdp-cal-note .rdp-day_button {
|
||||
position: relative;
|
||||
}
|
||||
.rdp-cal-workout .rdp-day_button::after,
|
||||
.rdp-cal-supp .rdp-day_button::after,
|
||||
.rdp-cal-note .rdp-day_button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.rdp-cal-workout .rdp-day_button::after { background: #22c55e; }
|
||||
.rdp-cal-supp .rdp-day_button::after { background: #3b82f6; left: calc(50% + 8px); }
|
||||
.rdp-cal-note .rdp-day_button::after { background: #facc15; left: calc(50% - 8px); }
|
||||
`}</style>
|
||||
|
||||
{selectedDate && (
|
||||
<DayModal
|
||||
date={selectedDate}
|
||||
detail={dayDetail}
|
||||
loading={detailLoading}
|
||||
onClose={handleModalClose}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,32 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import client from '../api/client';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
BarChart, Bar, PieChart, Pie, Cell, Legend
|
||||
PieChart, Pie, Cell, Legend
|
||||
} from 'recharts';
|
||||
import { Activity, Flame, Footprints, Scale, Utensils } from 'lucide-react';
|
||||
import type { FoodLog } from '../types/nutrition';
|
||||
import type { HealthMetric } from '../types/health';
|
||||
import type { NutritionSummary } from '../types/nutrition';
|
||||
import { getNutritionSummary } from '../api/nutrition';
|
||||
|
||||
const Dashboard = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [metrics, setMetrics] = useState([]);
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [metrics, setMetrics] = useState<HealthMetric[]>([]);
|
||||
const [logs, setLogs] = useState<FoodLog[]>([]);
|
||||
const [summary, setSummary] = useState<NutritionSummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [metricsRes, logsRes] = await Promise.all([
|
||||
const [metricsRes, logsRes, summaryData] = await Promise.all([
|
||||
client.get('/health/metrics'),
|
||||
client.get('/nutrition/logs')
|
||||
client.get('/nutrition/logs'),
|
||||
getNutritionSummary(),
|
||||
]);
|
||||
setMetrics(metricsRes.data);
|
||||
setLogs(logsRes.data);
|
||||
setSummary(summaryData);
|
||||
} catch (error) {
|
||||
console.error("Failed to load dashboard data", error);
|
||||
} finally {
|
||||
@@ -171,7 +178,7 @@ const Dashboard = () => {
|
||||
paddingAngle={5}
|
||||
dataKey="value"
|
||||
>
|
||||
{activeMacroData.map((entry, index) => (
|
||||
{activeMacroData.map((_entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
@@ -191,6 +198,41 @@ const Dashboard = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Macro Targets Progress */}
|
||||
{summary && (summary.target_calories || summary.target_protein || summary.target_carbs || summary.target_fat) && (
|
||||
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-content mb-6 flex items-center gap-2">
|
||||
<Flame size={18} className="text-primary" />
|
||||
Today's Macro Targets
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{[
|
||||
{ label: 'Calories', current: Math.round(summary.total_calories), target: summary.target_calories, unit: 'kcal', color: 'bg-orange-500' },
|
||||
{ label: 'Protein', current: Math.round(summary.total_protein), target: summary.target_protein, unit: 'g', color: 'bg-red-500' },
|
||||
{ label: 'Carbs', current: Math.round(summary.total_carbs), target: summary.target_carbs, unit: 'g', color: 'bg-amber-500' },
|
||||
{ label: 'Fat', current: Math.round(summary.total_fats), target: summary.target_fat, unit: 'g', color: 'bg-blue-500' },
|
||||
].filter(m => m.target).map(macro => {
|
||||
const pct = Math.min(100, Math.round(((macro.current) / (macro.target!)) * 100));
|
||||
return (
|
||||
<div key={macro.label}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="font-medium text-content">{macro.label}</span>
|
||||
<span className="text-content-muted">{macro.current} / {macro.target} {macro.unit}</span>
|
||||
</div>
|
||||
<div className="w-full bg-base rounded-full h-2.5 overflow-hidden border border-border">
|
||||
<div
|
||||
className={`h-full rounded-full ${macro.color} transition-all duration-500`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-content-muted mt-1">{pct}%</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-content mb-4">Recent Food Logs</h3>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useMemo, FormEvent, ChangeEvent } from 'react';
|
||||
import { useState, useEffect, useMemo, FormEvent } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import client from '../api/client';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { Field, Label, Fieldset, Legend } from '../components/catalyst/fieldset';
|
||||
import { Field, Label } from '../components/catalyst/fieldset';
|
||||
import { Input } from '../components/catalyst/input';
|
||||
import { Select } from '../components/catalyst/select';
|
||||
import { Button } from '../components/catalyst/button';
|
||||
@@ -56,7 +57,7 @@ const Health = () => {
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Failed to add metric');
|
||||
toast.error('Failed to add metric');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -74,7 +75,7 @@ const Health = () => {
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Failed to add goal');
|
||||
toast.error('Failed to add goal');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
270
frontend/src/pages/Kettlebell.tsx
Normal file
270
frontend/src/pages/Kettlebell.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import { useState, useEffect, FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { generateSession, getSessions, deleteSession, retrySession } from '../api/kettlebell';
|
||||
import type { KettlebellSession } from '../types/kettlebell';
|
||||
import { Heading, Subheading } from '../components/catalyst/heading';
|
||||
import { Button } from '../components/catalyst/button';
|
||||
import { Field, Label } from '../components/catalyst/fieldset';
|
||||
import { Input } from '../components/catalyst/input';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
import { BarChart2 } from 'lucide-react';
|
||||
|
||||
const FOCUS_OPTIONS = ['strength', 'conditioning', 'fat loss', 'mobility', 'endurance'];
|
||||
const DURATION_OPTIONS = [20, 30, 45, 60];
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
generated: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300',
|
||||
in_progress: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200',
|
||||
completed: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200',
|
||||
abandoned: 'bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-300',
|
||||
};
|
||||
|
||||
export default function Kettlebell() {
|
||||
const navigate = useNavigate();
|
||||
const [sessions, setSessions] = useState<KettlebellSession[]>([]);
|
||||
const [selected, setSelected] = useState<KettlebellSession | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<KettlebellSession | null>(null);
|
||||
|
||||
const [focus, setFocus] = useState('strength');
|
||||
const [duration, setDuration] = useState(30);
|
||||
const [weightsInput, setWeightsInput] = useState('16, 24, 32');
|
||||
|
||||
useEffect(() => {
|
||||
getSessions().then(data => {
|
||||
setSessions(data);
|
||||
if (data.length > 0) setSelected(data[0]);
|
||||
}).catch(console.error);
|
||||
}, []);
|
||||
|
||||
const handleRetry = async (id: number) => {
|
||||
try {
|
||||
const newSession = await retrySession(id);
|
||||
setSessions(prev => [newSession, ...prev]);
|
||||
setSelected(newSession);
|
||||
navigate(`/kettlebell/session/${newSession.id}`);
|
||||
} catch {
|
||||
toast.error('Failed to create retry session.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) return;
|
||||
const id = deleteTarget.id;
|
||||
setDeleteTarget(null);
|
||||
try {
|
||||
await deleteSession(id);
|
||||
setSessions(prev => prev.filter(s => s.id !== id));
|
||||
if (selected?.id === id) setSelected(null);
|
||||
} catch {
|
||||
// silently ignore — could add toast here in future
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
const available_weights = weightsInput
|
||||
.split(',')
|
||||
.map(s => parseFloat(s.trim()))
|
||||
.filter(n => !isNaN(n) && n > 0);
|
||||
const session = await generateSession({ focus, duration_minutes: duration, available_weights });
|
||||
setSessions(prev => [session, ...prev]);
|
||||
setSelected(session);
|
||||
} catch {
|
||||
toast.error('Failed to generate session');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exercises = selected?.exercises?.exercises ?? [];
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{deleteTarget && (
|
||||
<ConfirmModal
|
||||
title="Delete Session"
|
||||
message={`Delete "${deleteTarget.title}"? This cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<Heading>Kettlebell</Heading>
|
||||
<Button outline onClick={() => navigate('/kettlebell/analytics')}>
|
||||
<BarChart2 size={16} /> Analytics
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Left Column */}
|
||||
<div className="space-y-6 lg:col-span-1">
|
||||
<div className="bg-surface p-6 rounded-2xl shadow-sm border border-border">
|
||||
<Subheading className="mb-6 text-primary">Generate Session</Subheading>
|
||||
<form onSubmit={handleGenerate} className="space-y-4">
|
||||
<Field>
|
||||
<Label>Focus</Label>
|
||||
<select
|
||||
value={focus}
|
||||
onChange={e => setFocus(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-base text-content px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
{FOCUS_OPTIONS.map(f => (
|
||||
<option key={f} value={f}>{f.charAt(0).toUpperCase() + f.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label>Duration (minutes)</Label>
|
||||
<div className="flex gap-2">
|
||||
{DURATION_OPTIONS.map(d => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
onClick={() => setDuration(d)}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-medium border transition-colors ${duration === d
|
||||
? 'bg-primary text-white border-primary'
|
||||
: 'bg-base border-border text-content hover:bg-base/80'
|
||||
}`}
|
||||
>
|
||||
{d}m
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label>Available Weights (kg, comma-separated)</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="e.g. 16, 24, 32"
|
||||
value={weightsInput}
|
||||
onChange={e => setWeightsInput(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Button type="submit" color="dark/zinc" disabled={loading} className="w-full">
|
||||
{loading ? 'Generating...' : 'Generate Session'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface p-6 rounded-2xl shadow-sm border border-border h-96 overflow-y-auto">
|
||||
<Subheading className="mb-4">History</Subheading>
|
||||
<div className="space-y-3">
|
||||
{sessions.map(s => (
|
||||
<div
|
||||
key={s.id}
|
||||
onClick={() => setSelected(s)}
|
||||
className={`p-4 rounded-xl cursor-pointer transition-colors border-l-4 ${selected?.id === s.id
|
||||
? 'bg-base border-primary'
|
||||
: 'bg-base/50 border-border hover:bg-base'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-bold text-content truncate">{s.title}</p>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${statusColors[s.status] || statusColors.generated}`}>
|
||||
{s.status}
|
||||
</span>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setDeleteTarget(s); }}
|
||||
className="text-content-muted hover:text-red-500 transition-colors text-xs leading-none"
|
||||
title="Delete session"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-content-muted mt-1">
|
||||
{s.focus} · {s.total_duration_min}min · {new Date(s.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{sessions.length === 0 && <p className="text-content-muted">No sessions yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="lg:col-span-2">
|
||||
{selected ? (
|
||||
<div className="bg-surface p-8 rounded-2xl shadow-sm border border-border min-h-[600px] flex flex-col">
|
||||
<div className="flex justify-between items-start mb-6 border-b border-border pb-4">
|
||||
<div>
|
||||
<Heading className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-orange-400">
|
||||
{selected.title}
|
||||
</Heading>
|
||||
<p className="text-content-muted mt-1 capitalize">
|
||||
{selected.focus} · {selected.difficulty} · {selected.total_duration_min} min
|
||||
</p>
|
||||
</div>
|
||||
<span className={`text-xs px-3 py-1 rounded-full border border-border ${statusColors[selected.status] || statusColors.generated}`}>
|
||||
{selected.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 flex-1">
|
||||
{exercises.map((ex, i) => (
|
||||
<div key={i} className="p-4 bg-base rounded-xl border border-border">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="font-bold text-content">{ex.order}. {ex.name}</p>
|
||||
<p className="text-sm text-content-muted mt-0.5">{ex.description}</p>
|
||||
</div>
|
||||
<div className="text-right text-sm text-content-muted shrink-0 ml-4">
|
||||
<p>{ex.sets} × {ex.reps > 0 ? `${ex.reps} reps` : `${ex.duration_seconds}s`}</p>
|
||||
<p>{ex.weight_kg} kg</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-primary mt-2 italic">"{ex.coaching_tip}"</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected.notes && (
|
||||
<div className="mt-4 p-4 bg-base/50 rounded-xl border border-border">
|
||||
<p className="text-sm text-content-muted">{selected.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selected.status === 'generated' || selected.status === 'in_progress') && (
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
color="dark/zinc"
|
||||
className="w-full"
|
||||
onClick={() => navigate(`/kettlebell/session/${selected.id}`)}
|
||||
>
|
||||
{selected.status === 'in_progress' ? 'Resume Workout' : 'Start Workout'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{selected.status === 'abandoned' && (
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
color="dark/zinc"
|
||||
className="w-full"
|
||||
onClick={() => handleRetry(selected.id)}
|
||||
>
|
||||
Retry Session
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-surface p-8 rounded-2xl shadow-sm border border-border h-full flex flex-col items-center justify-center text-center">
|
||||
<div className="text-6xl mb-4">🏋️</div>
|
||||
<Heading level={2} className="mb-2">Ready to Train?</Heading>
|
||||
<p className="text-content-muted max-w-md">
|
||||
Generate an AI-powered kettlebell session tailored to your profile and goals.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
242
frontend/src/pages/KettlebellAnalytics.tsx
Normal file
242
frontend/src/pages/KettlebellAnalytics.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
BarChart, Bar, LineChart, Line, AreaChart, Area,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
||||
} from 'recharts';
|
||||
import { TrendingUp, Trophy, Zap, BarChart2, ChevronLeft } from 'lucide-react';
|
||||
import { Heading, Subheading } from '../components/catalyst/heading';
|
||||
import { Select } from '../components/catalyst/select';
|
||||
import { Button } from '../components/catalyst/button';
|
||||
import client from '../api/client';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface ExerciseProgression {
|
||||
date: string;
|
||||
max_weight: number;
|
||||
avg_rpe: number;
|
||||
total_volume: number;
|
||||
}
|
||||
|
||||
interface PersonalRecord {
|
||||
exercise_name: string;
|
||||
max_weight: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface WeeklyVolume {
|
||||
week: string;
|
||||
sessions: number;
|
||||
total_volume: number;
|
||||
}
|
||||
|
||||
interface AnalyticsData {
|
||||
weekly_sessions: WeeklyVolume[];
|
||||
exercise_progressions: Record<string, ExerciseProgression[]>;
|
||||
personal_records: PersonalRecord[];
|
||||
avg_rpe_trend: { date: string; avg_rpe: number; session_title: string }[];
|
||||
}
|
||||
|
||||
const KettlebellAnalytics = () => {
|
||||
const [data, setData] = useState<AnalyticsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedExercise, setSelectedExercise] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAnalytics = async () => {
|
||||
try {
|
||||
const res = await client.get('/kettlebell/analytics');
|
||||
setData(res.data);
|
||||
const exercises = Object.keys(res.data.exercise_progressions);
|
||||
if (exercises.length > 0) setSelectedExercise(exercises[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('Failed to load analytics');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchAnalytics();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-center text-content-muted">Loading analytics...</div>;
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const hasData = data.weekly_sessions.length > 0;
|
||||
const exerciseNames = Object.keys(data.exercise_progressions);
|
||||
const progressionData = selectedExercise ? data.exercise_progressions[selectedExercise] ?? [] : [];
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto space-y-8 animate-fade-in">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button outline onClick={() => navigate('/kettlebell')}>
|
||||
<ChevronLeft size={16} /> Back
|
||||
</Button>
|
||||
<div>
|
||||
<Heading>Workout Analytics</Heading>
|
||||
<p className="text-content-muted">Performance insights from your completed sessions.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasData ? (
|
||||
<div className="text-center py-24 text-content-muted">
|
||||
<BarChart2 size={64} className="mx-auto mb-4 opacity-30" />
|
||||
<p className="text-xl font-semibold">No completed sessions yet</p>
|
||||
<p className="text-sm mt-2">Complete some kettlebell sessions to see your analytics here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-surface p-5 rounded-2xl border border-border shadow-sm text-center">
|
||||
<p className="text-2xl font-bold text-content">{data.weekly_sessions.reduce((a, w) => a + w.sessions, 0)}</p>
|
||||
<p className="text-sm text-content-muted mt-1">Total Sessions</p>
|
||||
</div>
|
||||
<div className="bg-surface p-5 rounded-2xl border border-border shadow-sm text-center">
|
||||
<p className="text-2xl font-bold text-content">{data.personal_records.length}</p>
|
||||
<p className="text-sm text-content-muted mt-1">Exercises Tracked</p>
|
||||
</div>
|
||||
<div className="bg-surface p-5 rounded-2xl border border-border shadow-sm text-center">
|
||||
<p className="text-2xl font-bold text-content">
|
||||
{data.avg_rpe_trend.length > 0
|
||||
? (data.avg_rpe_trend.reduce((a, r) => a + r.avg_rpe, 0) / data.avg_rpe_trend.length).toFixed(1)
|
||||
: '--'}
|
||||
</p>
|
||||
<p className="text-sm text-content-muted mt-1">Avg RPE</p>
|
||||
</div>
|
||||
<div className="bg-surface p-5 rounded-2xl border border-border shadow-sm text-center">
|
||||
<p className="text-2xl font-bold text-content">
|
||||
{Math.round(data.weekly_sessions.reduce((a, w) => a + w.total_volume, 0))} kg
|
||||
</p>
|
||||
<p className="text-sm text-content-muted mt-1">Total Volume</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Sessions per Week */}
|
||||
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm">
|
||||
<Subheading className="mb-6 flex items-center gap-2">
|
||||
<BarChart2 size={18} className="text-primary" /> Sessions Per Week
|
||||
</Subheading>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data.weekly_sessions}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.5} />
|
||||
<XAxis dataKey="week" axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 11 }} />
|
||||
<YAxis allowDecimals={false} axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: 'var(--color-bg-surface)', borderColor: 'var(--color-border)', borderRadius: '8px' }}
|
||||
itemStyle={{ color: 'var(--color-text-main)' }}
|
||||
/>
|
||||
<Bar dataKey="sessions" fill="var(--color-primary)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RPE Trend */}
|
||||
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm">
|
||||
<Subheading className="mb-6 flex items-center gap-2">
|
||||
<Zap size={18} className="text-primary" /> Avg RPE Trend
|
||||
</Subheading>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data.avg_rpe_trend}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.5} />
|
||||
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 11 }} />
|
||||
<YAxis domain={[1, 10]} axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: 'var(--color-bg-surface)', borderColor: 'var(--color-border)', borderRadius: '8px' }}
|
||||
itemStyle={{ color: 'var(--color-text-main)' }}
|
||||
/>
|
||||
<Line type="monotone" dataKey="avg_rpe" stroke="#F59E0B" strokeWidth={3} dot={{ r: 4, fill: '#F59E0B' }} activeDot={{ r: 6 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exercise Weight Progression */}
|
||||
{exerciseNames.length > 0 && (
|
||||
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Subheading className="flex items-center gap-2">
|
||||
<TrendingUp size={18} className="text-primary" /> Weight Progression
|
||||
</Subheading>
|
||||
<Select
|
||||
value={selectedExercise}
|
||||
onChange={e => setSelectedExercise(e.target.value)}
|
||||
className="text-sm max-w-xs"
|
||||
>
|
||||
{exerciseNames.map(name => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={progressionData}>
|
||||
<defs>
|
||||
<linearGradient id="colorWeight" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-primary)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="var(--color-primary)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.5} />
|
||||
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 11 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: 'var(--color-bg-surface)', borderColor: 'var(--color-border)', borderRadius: '8px' }}
|
||||
itemStyle={{ color: 'var(--color-text-main)' }}
|
||||
/>
|
||||
<Legend />
|
||||
<Area type="monotone" dataKey="max_weight" name="Max Weight (kg)" stroke="var(--color-primary)" strokeWidth={3} fillOpacity={1} fill="url(#colorWeight)" />
|
||||
<Area type="monotone" dataKey="total_volume" name="Volume (kg)" stroke="#10B981" strokeWidth={2} fill="none" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Personal Records Table */}
|
||||
{data.personal_records.length > 0 && (
|
||||
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm">
|
||||
<Subheading className="mb-6 flex items-center gap-2">
|
||||
<Trophy size={18} className="text-primary" /> Personal Records
|
||||
</Subheading>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-3 px-4 text-content-muted font-medium">Exercise</th>
|
||||
<th className="text-right py-3 px-4 text-content-muted font-medium">Max Weight</th>
|
||||
<th className="text-right py-3 px-4 text-content-muted font-medium">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.personal_records.map((pr, i) => (
|
||||
<tr key={pr.exercise_name} className={`border-b border-border/50 hover:bg-base/50 transition-colors ${i === 0 ? 'text-primary font-semibold' : ''}`}>
|
||||
<td className="py-3 px-4 text-content">
|
||||
{i === 0 && <Trophy size={14} className="inline mr-2 text-yellow-500" />}
|
||||
{pr.exercise_name}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right font-mono">{pr.max_weight} kg</td>
|
||||
<td className="py-3 px-4 text-right text-content-muted">{pr.date}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KettlebellAnalytics;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef, FormEvent, ChangeEvent } from 'react';
|
||||
import { useState, useRef, ChangeEvent } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import client from '../api/client';
|
||||
import {
|
||||
Upload,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
Info,
|
||||
Utensils
|
||||
} from 'lucide-react';
|
||||
import { Field, Label } from '../components/catalyst/fieldset';
|
||||
import { Field } from '../components/catalyst/fieldset';
|
||||
import { Textarea } from '../components/catalyst/textarea';
|
||||
import { Button } from '../components/catalyst/button';
|
||||
import { Heading } from '../components/catalyst/heading';
|
||||
@@ -78,7 +79,7 @@ const Nutrition = () => {
|
||||
setShowReasoning(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Failed to analyze. Please try again.');
|
||||
toast.error('Failed to analyze. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -91,10 +92,10 @@ const Nutrition = () => {
|
||||
setAnalysis(null);
|
||||
setDescription('');
|
||||
clearFile();
|
||||
alert('Meal logged successfully!');
|
||||
toast.success('Meal logged successfully!');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Failed to save log.');
|
||||
toast.error('Failed to save log.');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, FormEvent } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import client from '../api/client';
|
||||
import { Field, Label } from '../components/catalyst/fieldset';
|
||||
import { Input } from '../components/catalyst/input';
|
||||
@@ -52,7 +53,7 @@ const Plans = () => {
|
||||
setUserDetails('');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Failed to generate plan');
|
||||
toast.error('Failed to generate plan');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState, useEffect, useContext, FormEvent, ChangeEvent } from 'react';
|
||||
import { AuthContext } from '../context/AuthContext';
|
||||
import { User, Ruler, Weight, Activity, Save, AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { User, Ruler, Weight, Activity, Save, Target, Bell } from 'lucide-react';
|
||||
import { Field, Label, Fieldset, Legend } from '../components/catalyst/fieldset';
|
||||
import { Input } from '../components/catalyst/input';
|
||||
import { Select } from '../components/catalyst/select';
|
||||
import { Button } from '../components/catalyst/button';
|
||||
import { Heading } from '../components/catalyst/heading';
|
||||
import { getVapidPublicKey, subscribePush, unsubscribePush, sendTestNotification, urlBase64ToUint8Array } from '../api/push';
|
||||
|
||||
interface FormData {
|
||||
firstname: string;
|
||||
@@ -22,9 +24,21 @@ interface FormData {
|
||||
const Profile = () => {
|
||||
const { user, updateUser } = useContext(AuthContext);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
const [targets, setTargets] = useState({
|
||||
target_calories: '',
|
||||
target_protein: '',
|
||||
target_carbs: '',
|
||||
target_fat: '',
|
||||
});
|
||||
|
||||
const [unitSystem, setUnitSystem] = useState('metric'); // 'metric' or 'imperial'
|
||||
|
||||
// Push notification state
|
||||
const [notifEnabled, setNotifEnabled] = useState(false);
|
||||
const [notifLoading, setNotifLoading] = useState(false);
|
||||
const [reminderHour, setReminderHour] = useState(9);
|
||||
const [reminderMinute, setReminderMinute] = useState(0);
|
||||
const [currentSub, setCurrentSub] = useState<PushSubscription | null>(null);
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
firstname: '',
|
||||
lastname: '',
|
||||
@@ -41,6 +55,12 @@ const Profile = () => {
|
||||
if (user) {
|
||||
const prefs = user.unit_preference || 'metric';
|
||||
setUnitSystem(prefs);
|
||||
setTargets({
|
||||
target_calories: user.target_calories?.toString() || '',
|
||||
target_protein: user.target_protein?.toString() || '',
|
||||
target_carbs: user.target_carbs?.toString() || '',
|
||||
target_fat: user.target_fat?.toString() || '',
|
||||
});
|
||||
|
||||
const h_cm = user.height || '';
|
||||
const w_kg = user.weight || '';
|
||||
@@ -70,6 +90,86 @@ const Profile = () => {
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
navigator.serviceWorker.ready
|
||||
.then((reg) => reg.pushManager.getSubscription())
|
||||
.then((sub) => {
|
||||
if (sub) {
|
||||
setCurrentSub(sub);
|
||||
setNotifEnabled(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEnableNotifications = async () => {
|
||||
setNotifLoading(true);
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
toast.error('Notification permission denied');
|
||||
return;
|
||||
}
|
||||
const publicKey = await getVapidPublicKey();
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
});
|
||||
const json = sub.toJSON();
|
||||
await subscribePush({
|
||||
endpoint: json.endpoint!,
|
||||
keys: { p256dh: json.keys!.p256dh, auth: json.keys!.auth },
|
||||
reminder_hour: reminderHour,
|
||||
reminder_minute: reminderMinute,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
});
|
||||
setCurrentSub(sub);
|
||||
setNotifEnabled(true);
|
||||
toast.success('Notifications enabled!');
|
||||
} catch (err) {
|
||||
console.error('Push subscribe error:', err);
|
||||
toast.error('Failed to enable notifications');
|
||||
} finally {
|
||||
setNotifLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisableNotifications = async () => {
|
||||
setNotifLoading(true);
|
||||
try {
|
||||
if (currentSub) {
|
||||
await unsubscribePush(currentSub.endpoint);
|
||||
await currentSub.unsubscribe();
|
||||
}
|
||||
setCurrentSub(null);
|
||||
setNotifEnabled(false);
|
||||
toast.success('Notifications disabled');
|
||||
} catch {
|
||||
toast.error('Failed to disable notifications');
|
||||
} finally {
|
||||
setNotifLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateReminderTime = async () => {
|
||||
if (!currentSub) return;
|
||||
try {
|
||||
const json = currentSub.toJSON();
|
||||
await subscribePush({
|
||||
endpoint: json.endpoint!,
|
||||
keys: { p256dh: json.keys!.p256dh, auth: json.keys!.auth },
|
||||
reminder_hour: reminderHour,
|
||||
reminder_minute: reminderMinute,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
});
|
||||
toast.success('Reminder time updated');
|
||||
} catch {
|
||||
toast.error('Failed to update reminder time');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnitChange = (system: string) => {
|
||||
setUnitSystem(system);
|
||||
};
|
||||
@@ -108,9 +208,8 @@ const Profile = () => {
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setMessage({ type: '', text: '' });
|
||||
|
||||
const payload = {
|
||||
const payload: Record<string, unknown> = {
|
||||
firstname: formData.firstname,
|
||||
lastname: formData.lastname,
|
||||
age: parseInt(formData.age),
|
||||
@@ -119,12 +218,16 @@ const Profile = () => {
|
||||
height: parseFloat(String(formData.height_cm)),
|
||||
weight: parseFloat(String(formData.weight_kg)),
|
||||
};
|
||||
if (targets.target_calories) payload.target_calories = parseFloat(targets.target_calories);
|
||||
if (targets.target_protein) payload.target_protein = parseFloat(targets.target_protein);
|
||||
if (targets.target_carbs) payload.target_carbs = parseFloat(targets.target_carbs);
|
||||
if (targets.target_fat) payload.target_fat = parseFloat(targets.target_fat);
|
||||
|
||||
const success = await updateUser(payload);
|
||||
if (success) {
|
||||
setMessage({ type: 'success', text: 'Profile updated successfully!' });
|
||||
toast.success('Profile updated successfully!');
|
||||
} else {
|
||||
setMessage({ type: 'error', text: 'Failed to update profile.' });
|
||||
toast.error('Failed to update profile.');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -137,14 +240,6 @@ const Profile = () => {
|
||||
</header>
|
||||
|
||||
<div className="bg-surface p-8 rounded-2xl shadow-sm border border-border">
|
||||
{message.text && (
|
||||
<div className={`p-4 mb-6 rounded-lg flex items-center gap-3 ${message.type === 'success' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
<AlertCircle size={20} />
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Personal Info */}
|
||||
<Fieldset>
|
||||
@@ -298,6 +393,122 @@ const Profile = () => {
|
||||
</div>
|
||||
</Fieldset>
|
||||
|
||||
<div className="border-t border-border my-6"></div>
|
||||
|
||||
{/* Nutrition Targets */}
|
||||
<Fieldset>
|
||||
<Legend className="flex items-center gap-2">
|
||||
<Target className="text-primary" size={24} />
|
||||
Daily Nutrition Targets
|
||||
</Legend>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mt-6">
|
||||
<Field>
|
||||
<Label>Calories (kcal)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={targets.target_calories}
|
||||
onChange={(e) => setTargets(t => ({ ...t, target_calories: e.target.value }))}
|
||||
placeholder="2000"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label>Protein (g)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={targets.target_protein}
|
||||
onChange={(e) => setTargets(t => ({ ...t, target_protein: e.target.value }))}
|
||||
placeholder="150"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label>Carbs (g)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={targets.target_carbs}
|
||||
onChange={(e) => setTargets(t => ({ ...t, target_carbs: e.target.value }))}
|
||||
placeholder="200"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label>Fat (g)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={targets.target_fat}
|
||||
onChange={(e) => setTargets(t => ({ ...t, target_fat: e.target.value }))}
|
||||
placeholder="65"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Fieldset>
|
||||
|
||||
<div className="border-t border-border my-6"></div>
|
||||
|
||||
{/* Notifications */}
|
||||
{'serviceWorker' in navigator && 'PushManager' in window && (
|
||||
<Fieldset>
|
||||
<Legend className="flex items-center gap-2">
|
||||
<Bell className="text-primary" size={24} />
|
||||
Notifications
|
||||
</Legend>
|
||||
<p className="text-content-muted text-sm mt-1">
|
||||
Receive daily reminders to log your supplements.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-6">
|
||||
<div className="flex items-center justify-between max-w-sm">
|
||||
<Label>Enable supplement reminders</Label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={notifLoading}
|
||||
onClick={notifEnabled ? handleDisableNotifications : handleEnableNotifications}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none disabled:opacity-50 ${notifEnabled ? 'bg-primary' : 'bg-zinc-300 dark:bg-zinc-600'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${notifEnabled ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{notifEnabled && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 max-w-xs">
|
||||
<Field>
|
||||
<Label>Hour (0–23)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={23}
|
||||
value={reminderHour}
|
||||
onChange={(e) => setReminderHour(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label>Minute</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
value={reminderMinute}
|
||||
onChange={(e) => setReminderMinute(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button type="button" color="dark/zinc" onClick={handleUpdateReminderTime}>
|
||||
Update reminder time
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
outline
|
||||
onClick={() => sendTestNotification().then(() => toast.success('Test notification sent!')).catch(() => toast.error('Failed to send test'))}
|
||||
>
|
||||
Send test
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Fieldset>
|
||||
)}
|
||||
|
||||
<div className="pt-4 flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
343
frontend/src/pages/Supplements.tsx
Normal file
343
frontend/src/pages/Supplements.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
import { useState, useEffect, FormEvent } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { CheckCircle2, Circle, Plus, Pill, Flame, Trash2, X } from 'lucide-react';
|
||||
import { Heading } from '../components/catalyst/heading';
|
||||
import { Button } from '../components/catalyst/button';
|
||||
import { Field, Label } from '../components/catalyst/fieldset';
|
||||
import { Input } from '../components/catalyst/input';
|
||||
import { Select } from '../components/catalyst/select';
|
||||
import {
|
||||
getTodaySupplements,
|
||||
createSupplement,
|
||||
deleteSupplement,
|
||||
logSupplement,
|
||||
} from '../api/supplements';
|
||||
import type { SupplementCreate, SupplementWithStatus } from '../types/supplement';
|
||||
|
||||
const UNITS = ['mg', 'mcg', 'IU', 'g', 'ml', 'capsule', 'tablet'];
|
||||
const FREQUENCIES = ['daily', 'weekly', 'as_needed'];
|
||||
|
||||
const DEFAULT_FORM: SupplementCreate = {
|
||||
name: '',
|
||||
dosage: 0,
|
||||
unit: 'mg',
|
||||
frequency: 'daily',
|
||||
scheduled_times: [],
|
||||
notes: '',
|
||||
};
|
||||
|
||||
const Supplements = () => {
|
||||
const [supplements, setSupplements] = useState<SupplementWithStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState<SupplementCreate>(DEFAULT_FORM);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [timeInput, setTimeInput] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'today' | 'manage'>('today');
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const data = await getTodaySupplements();
|
||||
setSupplements(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('Failed to load supplements');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleToggle = async (s: SupplementWithStatus) => {
|
||||
if (s.taken_today) return; // can't untake
|
||||
try {
|
||||
await logSupplement(s.id);
|
||||
setSupplements(prev =>
|
||||
prev.map(item => item.id === s.id ? { ...item, taken_today: true, streak: item.streak + 1 } : item)
|
||||
);
|
||||
toast.success(`${s.name} logged!`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('Failed to log supplement');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTime = () => {
|
||||
if (!timeInput) return;
|
||||
setForm(f => ({ ...f, scheduled_times: [...f.scheduled_times, timeInput] }));
|
||||
setTimeInput('');
|
||||
};
|
||||
|
||||
const handleRemoveTime = (t: string) => {
|
||||
setForm(f => ({ ...f, scheduled_times: f.scheduled_times.filter(x => x !== t) }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.name || form.dosage <= 0) {
|
||||
toast.error('Please fill in name and dosage');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createSupplement(form);
|
||||
toast.success(`${form.name} added!`);
|
||||
setForm(DEFAULT_FORM);
|
||||
setShowForm(false);
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('Failed to add supplement');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number, name: string) => {
|
||||
try {
|
||||
await deleteSupplement(id);
|
||||
toast.success(`${name} removed`);
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('Failed to remove supplement');
|
||||
}
|
||||
};
|
||||
|
||||
const taken = supplements.filter(s => s.taken_today).length;
|
||||
const total = supplements.length;
|
||||
const progress = total > 0 ? Math.round((taken / total) * 100) : 0;
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-center text-content-muted">Loading supplements...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-8 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Heading>Supplements</Heading>
|
||||
<p className="text-content-muted">Track your daily supplement intake.</p>
|
||||
</div>
|
||||
<Button color="dark/zinc" onClick={() => setShowForm(!showForm)}>
|
||||
<Plus size={16} /> Add Supplement
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Add Supplement Form */}
|
||||
{showForm && (
|
||||
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Heading>New Supplement</Heading>
|
||||
<button onClick={() => setShowForm(false)} className="text-content-muted hover:text-content">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Field>
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Vitamin D3"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field>
|
||||
<Label>Dosage</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.dosage || ''}
|
||||
onChange={e => setForm(f => ({ ...f, dosage: parseFloat(e.target.value) || 0 }))}
|
||||
placeholder="5000"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label>Unit</Label>
|
||||
<Select value={form.unit} onChange={e => setForm(f => ({ ...f, unit: e.target.value }))}>
|
||||
{UNITS.map(u => <option key={u} value={u}>{u}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<Label>Frequency</Label>
|
||||
<Select value={form.frequency} onChange={e => setForm(f => ({ ...f, frequency: e.target.value }))}>
|
||||
{FREQUENCIES.map(freq => (
|
||||
<option key={freq} value={freq}>{freq.replace('_', ' ')}</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label>Notes (optional)</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={form.notes || ''}
|
||||
onChange={e => setForm(f => ({ ...f, notes: e.target.value }))}
|
||||
placeholder="Take with food"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* Scheduled Times */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-content mb-1">Scheduled Times</p>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
type="time"
|
||||
value={timeInput}
|
||||
onChange={e => setTimeInput(e.target.value)}
|
||||
className="w-36"
|
||||
/>
|
||||
<Button type="button" outline onClick={handleAddTime}>Add Time</Button>
|
||||
</div>
|
||||
{form.scheduled_times.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{form.scheduled_times.map(t => (
|
||||
<span key={t} className="flex items-center gap-1 px-3 py-1 bg-primary/10 text-primary text-sm rounded-full">
|
||||
{t}
|
||||
<button type="button" onClick={() => handleRemoveTime(t)} className="hover:text-red-500">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" color="dark/zinc" disabled={submitting}>
|
||||
{submitting ? 'Adding...' : 'Add Supplement'}
|
||||
</Button>
|
||||
<Button type="button" outline onClick={() => setShowForm(false)}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 bg-surface p-1 rounded-xl border border-border w-fit">
|
||||
{(['today', 'manage'] as const).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-5 py-2 rounded-lg text-sm font-medium transition-all capitalize ${
|
||||
activeTab === tab
|
||||
? 'bg-primary text-white shadow-sm'
|
||||
: 'text-content-muted hover:text-content'
|
||||
}`}
|
||||
>
|
||||
{tab === 'today' ? "Today's Checklist" : 'Manage'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'today' && (
|
||||
<div className="space-y-6">
|
||||
{/* Daily Progress */}
|
||||
{total > 0 && (
|
||||
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="font-semibold text-content">Daily Progress</span>
|
||||
<span className="text-sm font-medium text-content-muted">{taken} / {total} taken</span>
|
||||
</div>
|
||||
<div className="w-full bg-base rounded-full h-3 overflow-hidden border border-border">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-content-muted mt-2">{progress}% complete</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Supplement Checklist */}
|
||||
{supplements.length === 0 ? (
|
||||
<div className="text-center py-16 text-content-muted">
|
||||
<Pill size={48} className="mx-auto mb-4 opacity-30" />
|
||||
<p className="text-lg font-medium">No supplements yet</p>
|
||||
<p className="text-sm">Click "Add Supplement" to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{supplements.map(s => (
|
||||
<div
|
||||
key={s.id}
|
||||
onClick={() => handleToggle(s)}
|
||||
className={`flex items-center gap-4 p-4 rounded-2xl border cursor-pointer transition-all duration-200 ${
|
||||
s.taken_today
|
||||
? 'bg-primary/5 border-primary/30 opacity-75'
|
||||
: 'bg-surface border-border hover:border-primary/50 hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className={`flex-shrink-0 ${s.taken_today ? 'text-primary' : 'text-content-muted'}`}>
|
||||
{s.taken_today ? <CheckCircle2 size={28} /> : <Circle size={28} />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`font-semibold text-content ${s.taken_today ? 'line-through opacity-60' : ''}`}>
|
||||
{s.name}
|
||||
</p>
|
||||
{s.streak > 1 && (
|
||||
<span className="flex items-center gap-1 text-xs text-orange-500 font-medium">
|
||||
<Flame size={12} /> {s.streak}d
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-content-muted">
|
||||
{s.dosage} {s.unit}
|
||||
{s.scheduled_times?.length > 0 && ` · ${s.scheduled_times.join(', ')}`}
|
||||
</p>
|
||||
</div>
|
||||
{s.taken_today && (
|
||||
<span className="text-xs font-medium text-primary bg-primary/10 px-2 py-1 rounded-full">
|
||||
Done
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'manage' && (
|
||||
<div className="space-y-3">
|
||||
{supplements.length === 0 ? (
|
||||
<div className="text-center py-16 text-content-muted">
|
||||
<Pill size={48} className="mx-auto mb-4 opacity-30" />
|
||||
<p>No supplements added yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
supplements.map(s => (
|
||||
<div key={s.id} className="flex items-center justify-between p-4 bg-surface rounded-2xl border border-border">
|
||||
<div>
|
||||
<p className="font-semibold text-content">{s.name}</p>
|
||||
<p className="text-sm text-content-muted">
|
||||
{s.dosage} {s.unit} · {s.frequency}
|
||||
{s.notes && ` · ${s.notes}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(s.id, s.name)}
|
||||
className="p-2 text-content-muted hover:text-red-500 transition-colors rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Supplements;
|
||||
51
frontend/src/sw.ts
Normal file
51
frontend/src/sw.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/// <reference lib="webworker" />
|
||||
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
// self.__WB_MANIFEST is replaced by vite-plugin-pwa with the actual precache manifest array
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
cleanupOutdatedCaches();
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
|
||||
|
||||
self.addEventListener('push', (event: PushEvent) => {
|
||||
console.log('[SW] push received', event.data?.text());
|
||||
if (!event.data) return;
|
||||
|
||||
let title = 'HealthyFit';
|
||||
let body = 'You have a new notification';
|
||||
let url = '/';
|
||||
try {
|
||||
const data = event.data.json() as { title: string; body: string; url?: string };
|
||||
title = data.title;
|
||||
body = data.body;
|
||||
url = data.url ?? '/';
|
||||
} catch {
|
||||
body = event.data.text();
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/icon-192.png',
|
||||
data: { url },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event: NotificationEvent) => {
|
||||
event.notification.close();
|
||||
const url = (event.notification.data as { url: string }).url;
|
||||
event.waitUntil(
|
||||
self.clients
|
||||
.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then((clients) => {
|
||||
const existing = clients.find((c) => c.url.includes(url));
|
||||
if (existing) return existing.focus();
|
||||
return self.clients.openWindow(url);
|
||||
})
|
||||
);
|
||||
});
|
||||
49
frontend/src/types/calendar.ts
Normal file
49
frontend/src/types/calendar.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { SupplementWithStatus } from './supplement';
|
||||
|
||||
export interface DayMeta {
|
||||
date: string; // "YYYY-MM-DD"
|
||||
has_note: boolean;
|
||||
event_count: number;
|
||||
supplement_compliance: number | null; // 0.0–1.0
|
||||
has_workout: boolean;
|
||||
calorie_total: number | null;
|
||||
}
|
||||
|
||||
export interface DailyNote {
|
||||
id?: number;
|
||||
date: string;
|
||||
content: string;
|
||||
mood?: string;
|
||||
energy_level?: number;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
id?: number;
|
||||
date: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
event_type: 'workout' | 'supplement' | 'general';
|
||||
color?: string;
|
||||
start_time?: string;
|
||||
is_completed: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface KettlebellSessionSummary {
|
||||
id: number;
|
||||
title: string;
|
||||
focus: string;
|
||||
total_duration_min: number;
|
||||
difficulty: string;
|
||||
status: string;
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
export interface DayDetail {
|
||||
date: string;
|
||||
note: DailyNote | null;
|
||||
events: CalendarEvent[];
|
||||
supplements: SupplementWithStatus[];
|
||||
kettlebell_sessions: KettlebellSessionSummary[];
|
||||
}
|
||||
27
frontend/src/types/health.ts
Normal file
27
frontend/src/types/health.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface HealthMetric {
|
||||
id: number;
|
||||
metric_type: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface HealthGoal {
|
||||
id: number;
|
||||
goal_type: string;
|
||||
target_value: number;
|
||||
target_date?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface HealthMetricCreate {
|
||||
metric_type: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface HealthGoalCreate {
|
||||
goal_type: string;
|
||||
target_value: number;
|
||||
target_date?: string | null;
|
||||
}
|
||||
38
frontend/src/types/kettlebell.ts
Normal file
38
frontend/src/types/kettlebell.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export interface ExerciseBlock {
|
||||
order: number;
|
||||
name: string;
|
||||
description: string;
|
||||
sets: number;
|
||||
reps: number;
|
||||
duration_seconds: number; // reps=0 → timed, duration_seconds=0 → rep-based
|
||||
weight_kg: number;
|
||||
rest_seconds: number;
|
||||
coaching_tip: string;
|
||||
}
|
||||
|
||||
export interface KettlebellSession {
|
||||
id: number;
|
||||
user_id: number;
|
||||
title: string;
|
||||
focus: string;
|
||||
exercises: { exercises: ExerciseBlock[] };
|
||||
total_duration_min: number;
|
||||
difficulty: string;
|
||||
notes: string;
|
||||
status: 'generated' | 'in_progress' | 'completed' | 'abandoned';
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface KettlebellSetLog {
|
||||
id: number;
|
||||
session_id: number;
|
||||
exercise_order: number;
|
||||
set_number: number;
|
||||
actual_reps: number;
|
||||
actual_weight_kg: number;
|
||||
actual_duration_seconds: number;
|
||||
perceived_effort: number;
|
||||
completed_at: string;
|
||||
}
|
||||
41
frontend/src/types/nutrition.ts
Normal file
41
frontend/src/types/nutrition.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export interface NutritionalInfo {
|
||||
name: string;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbs: number;
|
||||
fats: number;
|
||||
reasoning?: string;
|
||||
}
|
||||
|
||||
export interface FoodLog {
|
||||
id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbs: number;
|
||||
fats: number;
|
||||
image_url?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface FoodLogCreate {
|
||||
name: string;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbs: number;
|
||||
fats: number;
|
||||
}
|
||||
|
||||
export interface NutritionSummary {
|
||||
date: string;
|
||||
total_calories: number;
|
||||
total_protein: number;
|
||||
total_carbs: number;
|
||||
total_fats: number;
|
||||
log_count: number;
|
||||
target_calories?: number;
|
||||
target_protein?: number;
|
||||
target_carbs?: number;
|
||||
target_fat?: number;
|
||||
}
|
||||
18
frontend/src/types/plans.ts
Normal file
18
frontend/src/types/plans.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface Plan {
|
||||
id: number;
|
||||
goal: string;
|
||||
created_at: string;
|
||||
content: string;
|
||||
structured_content?: {
|
||||
title?: string;
|
||||
summary?: string;
|
||||
diet_plan?: string[];
|
||||
exercise_plan?: string[];
|
||||
tips?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlanRequest {
|
||||
goal: string;
|
||||
user_details: string;
|
||||
}
|
||||
45
frontend/src/types/supplement.ts
Normal file
45
frontend/src/types/supplement.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export interface Supplement {
|
||||
id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
dosage: number;
|
||||
unit: string;
|
||||
frequency: string;
|
||||
scheduled_times: string[];
|
||||
notes?: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SupplementWithStatus extends Supplement {
|
||||
taken_today: boolean;
|
||||
streak: number;
|
||||
}
|
||||
|
||||
export interface SupplementLog {
|
||||
id: number;
|
||||
user_id: number;
|
||||
supplement_id: number;
|
||||
taken_at: string;
|
||||
dose_taken?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface SupplementCreate {
|
||||
name: string;
|
||||
dosage: number;
|
||||
unit: string;
|
||||
frequency: string;
|
||||
scheduled_times: string[];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface SupplementUpdate {
|
||||
name?: string;
|
||||
dosage?: number;
|
||||
unit?: string;
|
||||
frequency?: string;
|
||||
scheduled_times?: string[];
|
||||
notes?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
33
frontend/src/types/user.ts
Normal file
33
frontend/src/types/user.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string;
|
||||
firstname?: string;
|
||||
lastname?: string;
|
||||
age?: number;
|
||||
gender?: string;
|
||||
height?: number;
|
||||
weight?: number;
|
||||
unit_preference: string;
|
||||
target_calories?: number;
|
||||
target_protein?: number;
|
||||
target_carbs?: number;
|
||||
target_fat?: number;
|
||||
}
|
||||
|
||||
export interface UserUpdate {
|
||||
email?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
firstname?: string;
|
||||
lastname?: string;
|
||||
age?: number;
|
||||
gender?: string;
|
||||
height?: number;
|
||||
weight?: number;
|
||||
unit_preference?: string;
|
||||
target_calories?: number;
|
||||
target_protein?: number;
|
||||
target_carbs?: number;
|
||||
target_fat?: number;
|
||||
}
|
||||
@@ -2,11 +2,34 @@ import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import path from 'path';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'sw.ts',
|
||||
manifest: {
|
||||
name: 'HealthyFit',
|
||||
short_name: 'HealthyFit',
|
||||
description: 'AI-powered health & fitness tracker',
|
||||
theme_color: '#556B2F',
|
||||
background_color: '#FDFBF7',
|
||||
display: 'standalone',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
|
||||
],
|
||||
},
|
||||
injectManifest: {
|
||||
globPatterns: [], // no precaching — push handling only
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user