Fix medication system and rename to Synculous.

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

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

View File

@@ -0,0 +1,265 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import api from '@/lib/api';
interface UseSwipeOptions {
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
onSwipeUp?: () => void;
onSwipeDown?: () => void;
threshold?: number;
}
export function useSwipe({
onSwipeLeft,
onSwipeRight,
onSwipeUp,
onSwipeDown,
threshold = 50,
}: UseSwipeOptions) {
const touchStart = useRef<{ x: number; y: number } | null>(null);
const touchEnd = useRef<{ x: number; y: number } | null>(null);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}, []);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
touchEnd.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}, []);
const handleTouchEnd = useCallback(() => {
if (!touchStart.current || !touchEnd.current) return;
const diffX = touchEnd.current.x - touchStart.current.x;
const diffY = touchEnd.current.y - touchStart.current.y;
if (Math.abs(diffX) > Math.abs(diffY)) {
if (Math.abs(diffX) > threshold) {
if (diffX > 0 && onSwipeRight) {
onSwipeRight();
} else if (diffX < 0 && onSwipeLeft) {
onSwipeLeft();
}
}
} else {
if (Math.abs(diffY) > threshold) {
if (diffY > 0 && onSwipeDown) {
onSwipeDown();
} else if (diffY < 0 && onSwipeUp) {
onSwipeUp();
}
}
}
touchStart.current = null;
touchEnd.current = null;
}, [onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown, threshold]);
return {
handleTouchStart,
handleTouchMove,
handleTouchEnd,
};
}
export function useTimer(initialMinutes: number = 0) {
const [seconds, setSeconds] = useState(initialMinutes * 60);
const [isRunning, setIsRunning] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (isRunning && !isPaused && seconds > 0) {
intervalRef.current = setInterval(() => {
setSeconds((s) => Math.max(0, s - 1));
}, 1000);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [isRunning, isPaused, seconds]);
const start = useCallback(() => {
setIsRunning(true);
setIsPaused(false);
}, []);
const pause = useCallback(() => {
setIsPaused(true);
}, []);
const resume = useCallback(() => {
setIsPaused(false);
}, []);
const reset = useCallback((minutes?: number) => {
setSeconds((minutes ?? initialMinutes) * 60);
setIsRunning(false);
setIsPaused(false);
}, [initialMinutes]);
const formatTime = useCallback((totalSeconds: number) => {
const mins = Math.floor(totalSeconds / 60);
const secs = totalSeconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}, []);
return {
seconds,
isRunning,
isPaused,
start,
pause,
resume,
reset,
formattedTime: formatTime(seconds),
};
}
export function useActiveSession() {
const [session, setSession] = useState<{
id: string;
routineId: string;
routineName: string;
routineIcon?: string;
status: string;
currentStepIndex: number;
} | null>(null);
const [currentStep, setCurrentStep] = useState<{
id: string;
name: string;
instructions?: string;
stepType: string;
durationMinutes?: number;
position: number;
} | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchActiveSession = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await api.sessions.getActive();
if (data.session && data.current_step) {
setSession({
id: data.session.id,
routineId: data.session.routine_id,
routineName: data.routine.name,
routineIcon: data.routine.icon,
status: data.session.status,
currentStepIndex: data.session.current_step_index,
});
setCurrentStep({
id: data.current_step.id,
name: data.current_step.name,
instructions: data.current_step.instructions,
stepType: data.current_step.step_type,
durationMinutes: data.current_step.duration_minutes,
position: data.current_step.position,
});
}
} catch (err) {
if ((err as Error).message !== 'no active session') {
setError((err as Error).message);
}
} finally {
setIsLoading(false);
}
}, []);
const completeStep = useCallback(async () => {
if (!session) return;
try {
const result = await api.sessions.completeStep(session.id, currentStep!.id);
if (result.next_step) {
setCurrentStep({
id: result.next_step.id,
name: result.next_step.name,
instructions: result.next_step.instructions,
stepType: result.next_step.step_type,
durationMinutes: result.next_step.duration_minutes,
position: result.next_step.position,
});
setSession((s) => s ? { ...s, currentStepIndex: result.session.current_step_index! } : null);
} else {
setSession(null);
setCurrentStep(null);
}
} catch (err) {
setError((err as Error).message);
}
}, [session, currentStep]);
const skipStep = useCallback(async () => {
if (!session) return;
try {
const result = await api.sessions.skipStep(session.id, currentStep!.id);
if (result.next_step) {
setCurrentStep({
id: result.next_step.id,
name: result.next_step.name,
instructions: result.next_step.instructions,
stepType: result.next_step.step_type,
durationMinutes: result.next_step.duration_minutes,
position: result.next_step.position,
});
setSession((s) => s ? { ...s, currentStepIndex: result.session.current_step_index! } : null);
} else {
setSession(null);
setCurrentStep(null);
}
} catch (err) {
setError((err as Error).message);
}
}, [session, currentStep]);
const pause = useCallback(async () => {
if (!session) return;
try {
await api.sessions.pause(session.id);
setSession((s) => s ? { ...s, status: 'paused' } : null);
} catch (err) {
setError((err as Error).message);
}
}, [session]);
const resume = useCallback(async () => {
if (!session) return;
try {
await api.sessions.resume(session.id);
setSession((s) => s ? { ...s, status: 'active' } : null);
} catch (err) {
setError((err as Error).message);
}
}, [session]);
const cancel = useCallback(async () => {
if (!session) return;
try {
await api.sessions.cancel(session.id);
setSession(null);
setCurrentStep(null);
} catch (err) {
setError((err as Error).message);
}
}, [session]);
return {
session,
currentStep,
isLoading,
error,
fetchActiveSession,
completeStep,
skipStep,
pause,
resume,
cancel,
};
}