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