mirror of
https://github.com/escalante29/healthy-fit.git
synced 2026-03-21 15:08: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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user