Set logging is now a united. Sporadic set table removed.

This commit is contained in:
AG
2025-12-05 08:55:59 +02:00
parent a632de65ea
commit 41d1d0f16a
19 changed files with 1129 additions and 1232 deletions

34
App.tsx
View File

@@ -8,9 +8,8 @@ import AICoach from './components/AICoach';
import Plans from './components/Plans';
import Login from './components/Login';
import Profile from './components/Profile';
import { TabView, WorkoutSession, WorkoutSet, WorkoutPlan, User, Language, SporadicSet } from './types';
import { TabView, WorkoutSession, WorkoutSet, WorkoutPlan, User, Language } from './types';
import { getSessions, saveSession, deleteSession, getPlans, getActiveSession, updateActiveSession, deleteActiveSession, updateSetInActiveSession, deleteSetFromActiveSession } from './services/storage';
import { getSporadicSets, updateSporadicSet, deleteSporadicSet } from './services/sporadicSets';
import { getCurrentUserProfile, getMe } from './services/auth';
import { getSystemLanguage } from './services/i18n';
import { logWeight } from './services/weight';
@@ -25,7 +24,7 @@ function App() {
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
const [activeSession, setActiveSession] = useState<WorkoutSession | null>(null);
const [activePlan, setActivePlan] = useState<WorkoutPlan | null>(null);
const [sporadicSets, setSporadicSets] = useState<SporadicSet[]>([]);
useEffect(() => {
// Set initial language
@@ -68,13 +67,11 @@ function App() {
// Load plans
const p = await getPlans(currentUser.id);
setPlans(p);
// Load sporadic sets
const sporadicSets = await getSporadicSets();
setSporadicSets(sporadicSets);
} else {
setSessions([]);
setPlans([]);
setSporadicSets([]);
}
};
loadSessions();
@@ -112,6 +109,7 @@ function App() {
const newSession: WorkoutSession = {
id: generateId(),
startTime: Date.now(),
type: 'STANDARD',
userBodyWeight: currentWeight,
sets: [],
planId: plan?.id,
@@ -198,24 +196,7 @@ function App() {
setSessions(prev => prev.filter(s => s.id !== sessionId));
};
const handleSporadicSetAdded = async () => {
const sets = await getSporadicSets();
setSporadicSets(sets);
};
const handleUpdateSporadicSet = async (set: SporadicSet) => {
const updated = await updateSporadicSet(set.id, set);
if (updated) {
setSporadicSets(prev => prev.map(s => s.id === set.id ? updated : s));
}
};
const handleDeleteSporadicSet = async (id: string) => {
const success = await deleteSporadicSet(id);
if (success) {
setSporadicSets(prev => prev.filter(s => s.id !== id));
}
};
if (!currentUser) {
return <Login onLogin={handleLogin} language={language} onLanguageChange={handleLanguageChange} />;
@@ -236,14 +217,12 @@ function App() {
userWeight={currentUser.profile?.weight}
activeSession={activeSession}
activePlan={activePlan}
sporadicSets={sporadicSets}
onSessionStart={handleStartSession}
onSessionEnd={handleEndSession}
onSessionQuit={handleQuitSession}
onSetAdded={handleAddSet}
onRemoveSet={handleRemoveSetFromActive}
onUpdateSet={handleUpdateSetInActive}
onSporadicSetAdded={handleSporadicSetAdded}
lang={language}
/>
)}
@@ -253,11 +232,8 @@ function App() {
{currentTab === 'HISTORY' && (
<History
sessions={sessions}
sporadicSets={sporadicSets}
onUpdateSession={handleUpdateSession}
onDeleteSession={handleDeleteSession}
onUpdateSporadicSet={handleUpdateSporadicSet}
onDeleteSporadicSet={handleDeleteSporadicSet}
lang={language}
/>
)}

View File

@@ -1,24 +1,20 @@
import React, { useState } from 'react';
import { Calendar, Clock, TrendingUp, Gauge, Pencil, Trash2, X, Save, ArrowRight, ArrowUp, Timer, Activity, Dumbbell, Percent } from 'lucide-react';
import { WorkoutSession, ExerciseType, WorkoutSet, Language, SporadicSet } from '../types';
import { WorkoutSession, ExerciseType, WorkoutSet, Language } from '../types';
import { t } from '../services/i18n';
interface HistoryProps {
sessions: WorkoutSession[];
sporadicSets?: SporadicSet[];
onUpdateSession?: (session: WorkoutSession) => void;
onDeleteSession?: (sessionId: string) => void;
onUpdateSporadicSet?: (set: SporadicSet) => void;
onDeleteSporadicSet?: (setId: string) => void;
lang: Language;
}
const History: React.FC<HistoryProps> = ({ sessions, sporadicSets, onUpdateSession, onDeleteSession, onUpdateSporadicSet, onDeleteSporadicSet, lang }) => {
const History: React.FC<HistoryProps> = ({ sessions, onUpdateSession, onDeleteSession, lang }) => {
const [editingSession, setEditingSession] = useState<WorkoutSession | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [editingSporadicSet, setEditingSporadicSet] = useState<SporadicSet | null>(null);
const [deletingSporadicId, setDeletingSporadicId] = useState<string | null>(null);
const calculateSessionWork = (session: WorkoutSession) => {
const bw = session.userBodyWeight || 70;
@@ -93,26 +89,9 @@ const History: React.FC<HistoryProps> = ({ sessions, sporadicSets, onUpdateSessi
}
}
const handleSaveSporadicEdit = () => {
if (editingSporadicSet && onUpdateSporadicSet) {
onUpdateSporadicSet(editingSporadicSet);
setEditingSporadicSet(null);
}
};
const handleUpdateSporadicField = (field: keyof SporadicSet, value: number) => {
if (!editingSporadicSet) return;
setEditingSporadicSet({ ...editingSporadicSet, [field]: value });
};
const handleConfirmDeleteSporadic = () => {
if (deletingSporadicId && onDeleteSporadicSet) {
onDeleteSporadicSet(deletingSporadicId);
setDeletingSporadicId(null);
}
};
if (sessions.length === 0 && (!sporadicSets || sporadicSets.length === 0)) {
if (sessions.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-on-surface-variant p-8 text-center">
<Clock size={48} className="mb-4 opacity-50" />
@@ -128,7 +107,8 @@ const History: React.FC<HistoryProps> = ({ sessions, sporadicSets, onUpdateSessi
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4 pb-20">
{sessions.map((session) => {
{/* Regular Workout Sessions */}
{sessions.filter(s => s.type === 'STANDARD').map((session) => {
const totalWork = calculateSessionWork(session);
return (
@@ -198,30 +178,35 @@ const History: React.FC<HistoryProps> = ({ sessions, sporadicSets, onUpdateSessi
)
})}
{/* Sporadic Sets Section */}
{sporadicSets && sporadicSets.length > 0 && (
{/* Quick Log Sessions */}
{sessions.filter(s => s.type === 'QUICK_LOG').length > 0 && (
<div className="mt-8">
<h3 className="text-xl font-medium text-on-surface mb-4 px-2">{t('sporadic_sets_title', lang)}</h3>
<h3 className="text-xl font-medium text-on-surface mb-4 px-2">{t('quick_log', lang)}</h3>
{Object.entries(
sporadicSets.reduce((groups: Record<string, SporadicSet[]>, set) => {
const date = new Date(set.timestamp).toISOString().split('T')[0];
if (!groups[date]) groups[date] = [];
groups[date].push(set);
return groups;
}, {})
sessions
.filter(s => s.type === 'QUICK_LOG')
.reduce((groups: Record<string, WorkoutSession[]>, session) => {
const date = new Date(session.startTime).toISOString().split('T')[0];
if (!groups[date]) groups[date] = [];
groups[date].push(session);
return groups;
}, {})
)
.sort(([a], [b]) => b.localeCompare(a))
.map(([date, sets]) => (
.map(([date, daySessions]) => (
<div key={date} className="mb-4">
<div className="text-sm text-on-surface-variant px-2 mb-2 font-medium">{date}</div>
<div className="space-y-2">
{(sets as SporadicSet[]).map(set => (
{daySessions.flatMap(session => session.sets).map((set, idx) => (
<div
key={set.id}
className="bg-surface-container-low rounded-xl p-4 border border-outline-variant/10 flex justify-between items-center"
>
<div className="flex-1">
<div className="font-medium text-on-surface">{set.exerciseName}</div>
<div className="font-medium text-on-surface">
{set.exerciseName}
{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}
</div>
<div className="text-sm text-on-surface-variant mt-1">
{set.type === ExerciseType.STRENGTH && `${set.weight || 0}kg x ${set.reps || 0}`}
{set.type === ExerciseType.BODYWEIGHT && `${set.weight ? `+${set.weight}kg` : 'BW'} x ${set.reps || 0}`}
@@ -237,13 +222,26 @@ const History: React.FC<HistoryProps> = ({ sessions, sporadicSets, onUpdateSessi
</div>
<div className="flex gap-1">
<button
onClick={() => setEditingSporadicSet(JSON.parse(JSON.stringify(set)))}
onClick={() => {
// Find the session this set belongs to and open edit mode
const parentSession = daySessions.find(s => s.sets.some(st => st.id === set.id));
if (parentSession) {
setEditingSession(JSON.parse(JSON.stringify(parentSession)));
}
}}
className="p-2 text-on-surface-variant hover:text-primary hover:bg-surface-container-high rounded-full transition-colors"
>
<Pencil size={18} />
</button>
<button
onClick={() => setDeletingSporadicId(set.id)}
onClick={() => {
// Find the session and set up for deletion
const parentSession = daySessions.find(s => s.sets.some(st => st.id === set.id));
if (parentSession) {
setEditingSession(JSON.parse(JSON.stringify(parentSession)));
setDeletingId(set.id); // Use set ID for deletion
}
}}
className="p-2 text-on-surface-variant hover:text-error hover:bg-surface-container-high rounded-full transition-colors"
>
<Trash2 size={18} />
@@ -256,6 +254,7 @@ const History: React.FC<HistoryProps> = ({ sessions, sporadicSets, onUpdateSessi
))}
</div>
)}
</div>
{/* DELETE CONFIRMATION DIALOG (MD3) */}
@@ -336,7 +335,7 @@ const History: React.FC<HistoryProps> = ({ sessions, sporadicSets, onUpdateSessi
<div className="flex justify-between items-center border-b border-outline-variant pb-2">
<div className="flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-secondary-container text-on-secondary-container text-xs font-bold flex items-center justify-center">{idx + 1}</span>
<span className="font-medium text-on-surface text-sm">{set.exerciseName}</span>
<span className="font-medium text-on-surface text-sm">{set.exerciseName}{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase(), lang)}</span>}</span>
</div>
<button
onClick={() => handleDeleteSet(set.id)}
@@ -422,29 +421,7 @@ const History: React.FC<HistoryProps> = ({ sessions, sporadicSets, onUpdateSessi
</div>
)}
{/* Sporadic Set Delete Confirmation */}
{deletingSporadicId && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-surface-container w-full max-w-xs rounded-[28px] p-6 shadow-elevation-3">
<h3 className="text-xl font-normal text-on-surface mb-2">{t('delete', lang)}</h3>
<p className="text-sm text-on-surface-variant mb-8">{t('delete_confirm', lang)}</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setDeletingSporadicId(null)}
className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5"
>
{t('cancel', lang)}
</button>
<button
onClick={handleConfirmDeleteSporadic}
className="px-4 py-2 rounded-full bg-error-container text-on-error-container font-medium"
>
{t('delete', lang)}
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -5,6 +5,7 @@ import { t } from '../../services/i18n';
import FilledInput from '../FilledInput';
import ExerciseModal from '../ExerciseModal';
import { useTracker } from './useTracker';
import SetLogger from './SetLogger';
interface ActiveSessionViewProps {
tracker: ReturnType<typeof useTracker>;
@@ -178,237 +179,11 @@ const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ tracker, activeSe
<div className="flex-1 overflow-y-auto p-4 pb-32 space-y-6">
<div className="relative">
<FilledInput
label={t('select_exercise', lang)}
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
setShowSuggestions(true);
}}
onFocus={() => {
setSearchQuery('');
setShowSuggestions(true);
}}
onBlur={() => setTimeout(() => setShowSuggestions(false), 100)} // Delay hiding to allow click
icon={<Dumbbell size={10} />}
autoComplete="off"
type="text"
/>
<button
onClick={() => setIsCreating(true)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-primary hover:bg-primary-container/20 rounded-full z-10"
>
<Plus size={24} />
</button>
{showSuggestions && (
<div className="absolute top-full left-0 w-full bg-surface-container rounded-xl shadow-elevation-3 overflow-hidden z-20 mt-1 max-h-60 overflow-y-auto animate-in fade-in slide-in-from-top-2">
{filteredExercises.length > 0 ? (
filteredExercises.map(ex => (
<button
key={ex.id}
onMouseDown={(e) => {
e.preventDefault(); // Prevent input blur
setSelectedExercise(ex);
setSearchQuery(ex.name);
setShowSuggestions(false);
}}
className="w-full text-left px-4 py-3 text-on-surface hover:bg-surface-container-high transition-colors text-lg"
>
{ex.name}
</button>
))
) : (
<div className="px-4 py-3 text-on-surface-variant text-lg">{t('no_exercises_found', lang)}</div>
)}
</div>
)}
</div>
{selectedExercise && (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-300 space-y-6">
{/* Unilateral Exercise Toggle */}
{selectedExercise.isUnilateral && (
<div className="flex items-center gap-3 px-2 py-3 bg-surface-container rounded-xl">
<input
type="checkbox"
id="sameValuesBothSides"
checked={tracker.sameValuesBothSides}
onChange={(e) => tracker.handleToggleSameValues(e.target.checked)}
className="w-5 h-5 rounded border-2 border-outline bg-surface-container-high checked:bg-primary checked:border-primary cursor-pointer"
/>
<label htmlFor="sameValuesBothSides" className="text-sm text-on-surface cursor-pointer flex-1">
{t('same_values_both_sides', lang)}
</label>
</div>
)}
{/* Input Forms */}
{selectedExercise.isUnilateral && !tracker.sameValuesBothSides ? (
/* Separate Left/Right Inputs */
<div className="space-y-4">
{/* Left Side */}
<div className="space-y-2">
<div className="text-sm font-medium text-primary flex items-center gap-2 px-2">
<span className="w-6 h-6 rounded-full bg-primary-container text-on-primary-container flex items-center justify-center text-xs font-bold">L</span>
{t('left', lang)}
</div>
<div className="grid grid-cols-2 gap-4">
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STATIC) && (
<FilledInput
label={selectedExercise.type === ExerciseType.BODYWEIGHT ? t('add_weight', lang) : t('weight_kg', lang)}
value={tracker.weightLeft}
step="0.1"
onChange={(e: any) => tracker.setWeightLeft(e.target.value)}
icon={<Scale size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.PLYOMETRIC) && (
<FilledInput
label={t('reps', lang)}
value={tracker.repsLeft}
onChange={(e: any) => tracker.setRepsLeft(e.target.value)}
icon={<Activity size={10} />}
type="number"
/>
)}
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.STATIC) && (
<FilledInput
label={t('time_sec', lang)}
value={tracker.durationLeft}
onChange={(e: any) => tracker.setDurationLeft(e.target.value)}
icon={<TimerIcon size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.LONG_JUMP) && (
<FilledInput
label={t('dist_m', lang)}
value={tracker.distanceLeft}
onChange={(e: any) => tracker.setDistanceLeft(e.target.value)}
icon={<ArrowRight size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.HIGH_JUMP) && (
<FilledInput
label={t('height_cm', lang)}
value={tracker.heightLeft}
onChange={(e: any) => tracker.setHeightLeft(e.target.value)}
icon={<ArrowUp size={10} />}
/>
)}
</div>
</div>
{/* Right Side */}
<div className="space-y-2">
<div className="text-sm font-medium text-secondary flex items-center gap-2 px-2">
<span className="w-6 h-6 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center text-xs font-bold">R</span>
{t('right', lang)}
</div>
<div className="grid grid-cols-2 gap-4">
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STATIC) && (
<FilledInput
label={selectedExercise.type === ExerciseType.BODYWEIGHT ? t('add_weight', lang) : t('weight_kg', lang)}
value={tracker.weightRight}
step="0.1"
onChange={(e: any) => tracker.setWeightRight(e.target.value)}
icon={<Scale size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.PLYOMETRIC) && (
<FilledInput
label={t('reps', lang)}
value={tracker.repsRight}
onChange={(e: any) => tracker.setRepsRight(e.target.value)}
icon={<Activity size={10} />}
type="number"
/>
)}
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.STATIC) && (
<FilledInput
label={t('time_sec', lang)}
value={tracker.durationRight}
onChange={(e: any) => tracker.setDurationRight(e.target.value)}
icon={<TimerIcon size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.LONG_JUMP) && (
<FilledInput
label={t('dist_m', lang)}
value={tracker.distanceRight}
onChange={(e: any) => tracker.setDistanceRight(e.target.value)}
icon={<ArrowRight size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.HIGH_JUMP) && (
<FilledInput
label={t('height_cm', lang)}
value={tracker.heightRight}
onChange={(e: any) => tracker.setHeightRight(e.target.value)}
icon={<ArrowUp size={10} />}
/>
)}
</div>
</div>
</div>
) : (
/* Single Input Form (for bilateral or unilateral with same values) */
<div className="grid grid-cols-2 gap-4">
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STATIC) && (
<FilledInput
label={selectedExercise.type === ExerciseType.BODYWEIGHT ? t('add_weight', lang) : t('weight_kg', lang)}
value={weight}
step="0.1"
onChange={(e: any) => setWeight(e.target.value)}
icon={<Scale size={10} />}
autoFocus={activePlan && !isPlanFinished && activePlan.steps[currentStepIndex]?.isWeighted && (selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STRENGTH)}
/>
)}
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.PLYOMETRIC) && (
<FilledInput
label={t('reps', lang)}
value={reps}
onChange={(e: any) => setReps(e.target.value)}
icon={<Activity size={10} />}
type="number"
/>
)}
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.STATIC) && (
<FilledInput
label={t('time_sec', lang)}
value={duration}
onChange={(e: any) => setDuration(e.target.value)}
icon={<TimerIcon size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.LONG_JUMP) && (
<FilledInput
label={t('dist_m', lang)}
value={distance}
onChange={(e: any) => setDistance(e.target.value)}
icon={<ArrowRight size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.HIGH_JUMP) && (
<FilledInput
label={t('height_cm', lang)}
value={height}
onChange={(e: any) => setHeight(e.target.value)}
icon={<ArrowUp size={10} />}
/>
)}
</div>
)}
<button
onClick={handleAddSet}
className="w-full h-14 bg-primary-container text-on-primary-container font-medium text-lg rounded-full shadow-elevation-2 hover:shadow-elevation-3 active:scale-[0.98] transition-all flex items-center justify-center gap-2"
>
<CheckCircle size={24} />
<span>{t('log_set', lang)}</span>
</button>
</div>
)}
<SetLogger
tracker={tracker}
lang={lang}
onLogSet={handleAddSet}
/>
{activeSession.sets.length > 0 && (
<div className="pt-4">
@@ -425,7 +200,7 @@ const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ tracker, activeSe
</div>
{isEditing ? (
<div className="flex-1">
<div className="text-base font-medium text-on-surface mb-2">{set.exerciseName}</div>
<div className="text-base font-medium text-on-surface mb-2">{set.exerciseName}{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}</div>
<div className="grid grid-cols-2 gap-2">
{set.weight !== undefined && (
<input
@@ -479,7 +254,7 @@ const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ tracker, activeSe
</div>
) : (
<div>
<div className="text-base font-medium text-on-surface">{set.exerciseName}</div>
<div className="text-base font-medium text-on-surface">{set.exerciseName}{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}</div>
<div className="text-sm text-on-surface-variant">
{set.type === ExerciseType.STRENGTH &&
`${set.weight ? `${set.weight}kg` : ''} ${set.reps ? `x ${set.reps}` : ''}`.trim()

View File

@@ -0,0 +1,179 @@
import React from 'react';
import { Dumbbell, Scale, Activity, Timer as TimerIcon, ArrowRight, ArrowUp, Plus, CheckCircle } from 'lucide-react';
import { ExerciseType, Language } from '../../types';
import { t } from '../../services/i18n';
import FilledInput from '../FilledInput';
import { useTracker } from './useTracker';
interface SetLoggerProps {
tracker: ReturnType<typeof useTracker>;
lang: Language;
onLogSet: () => void;
isSporadic?: boolean;
}
const SetLogger: React.FC<SetLoggerProps> = ({ tracker, lang, onLogSet, isSporadic = false }) => {
const {
searchQuery,
setSearchQuery,
setShowSuggestions,
showSuggestions,
filteredExercises,
setSelectedExercise,
selectedExercise,
weight,
setWeight,
reps,
setReps,
duration,
setDuration,
distance,
setDistance,
height,
setHeight,
setIsCreating,
sporadicSuccess,
activePlan,
currentStepIndex,
unilateralSide,
setUnilateralSide
} = tracker;
const isPlanFinished = activePlan && currentStepIndex >= activePlan.steps.length;
return (
<div className="space-y-6">
{/* Exercise Selection */}
<div className="relative">
<FilledInput
label={t('select_exercise', lang)}
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
setShowSuggestions(true);
}}
onFocus={() => {
setSearchQuery('');
setShowSuggestions(true);
}}
onBlur={() => setTimeout(() => setShowSuggestions(false), 100)}
icon={<Dumbbell size={10} />}
autoComplete="off"
type="text"
/>
<button
onClick={() => setIsCreating(true)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-primary hover:bg-primary-container/20 rounded-full z-10"
>
<Plus size={24} />
</button>
{showSuggestions && (
<div className="absolute top-full left-0 w-full bg-surface-container rounded-xl shadow-elevation-3 overflow-hidden z-20 mt-1 max-h-60 overflow-y-auto animate-in fade-in slide-in-from-top-2">
{filteredExercises.length > 0 ? (
filteredExercises.map(ex => (
<button
key={ex.id}
onMouseDown={(e) => {
e.preventDefault();
setSelectedExercise(ex);
setSearchQuery(ex.name);
setShowSuggestions(false);
}}
className="w-full text-left px-4 py-3 text-on-surface hover:bg-surface-container-high transition-colors text-lg"
>
{ex.name}
</button>
))
) : (
<div className="px-4 py-3 text-on-surface-variant text-lg">{t('no_exercises_found', lang)}</div>
)}
</div>
)}
</div>
{selectedExercise && (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-300 space-y-6">
{/* Unilateral Exercise Toggle */}
{selectedExercise.isUnilateral && (
<div className="flex items-center gap-2 bg-surface-container rounded-full p-1">
<button
onClick={() => setUnilateralSide('LEFT')}
className={`w-full text-center px-4 py-2 rounded-full text-sm font-medium transition-colors ${unilateralSide === 'LEFT' ? 'bg-primary-container text-on-primary-container' : 'text-on-surface-variant hover:bg-surface-container-high'
}`}
>
{t('left', lang)}
</button>
<button
onClick={() => setUnilateralSide('RIGHT')}
className={`w-full text-center px-4 py-2 rounded-full text-sm font-medium transition-colors ${unilateralSide === 'RIGHT' ? 'bg-secondary-container text-on-secondary-container' : 'text-on-surface-variant hover:bg-surface-container-high'
}`}
>
{t('right', lang)}
</button>
</div>
)}
{/* Input Forms */}
<div className="grid grid-cols-2 gap-4">
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STATIC) && (
<FilledInput
label={selectedExercise.type === ExerciseType.BODYWEIGHT ? t('add_weight', lang) : t('weight_kg', lang)}
value={weight}
step="0.1"
onChange={(e: any) => setWeight(e.target.value)}
icon={<Scale size={10} />}
autoFocus={!isSporadic && activePlan && !isPlanFinished && activePlan.steps[currentStepIndex]?.isWeighted && (selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STRENGTH)}
/>
)}
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.PLYOMETRIC) && (
<FilledInput
label={t('reps', lang)}
value={reps}
onChange={(e: any) => setReps(e.target.value)}
icon={<Activity size={10} />}
type="number"
/>
)}
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.STATIC) && (
<FilledInput
label={t('time_sec', lang)}
value={duration}
onChange={(e: any) => setDuration(e.target.value)}
icon={<TimerIcon size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.LONG_JUMP) && (
<FilledInput
label={t('dist_m', lang)}
value={distance}
onChange={(e: any) => setDistance(e.target.value)}
icon={<ArrowRight size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.HIGH_JUMP) && (
<FilledInput
label={t('height_cm', lang)}
value={height}
onChange={(e: any) => setHeight(e.target.value)}
icon={<ArrowUp size={10} />}
/>
)}
</div>
<button
onClick={onLogSet}
className={`w-full h-14 font-medium text-lg rounded-full shadow-elevation-2 hover:shadow-elevation-3 active:scale-[0.98] transition-all flex items-center justify-center gap-2 ${isSporadic && sporadicSuccess
? 'bg-green-500 text-white'
: 'bg-primary-container text-on-primary-container'
}`}
>
{isSporadic && sporadicSuccess ? <CheckCircle size={24} /> : (isSporadic ? <Plus size={24} /> : <CheckCircle size={24} />)}
<span>{isSporadic && sporadicSuccess ? t('saved', lang) : t('log_set', lang)}</span>
</button>
</div>
)}
</div>
);
};
export default SetLogger;

View File

@@ -1,58 +1,45 @@
import React, { useState, useEffect } from 'react';
import { Dumbbell, Scale, Activity, Timer as TimerIcon, ArrowRight, ArrowUp, Plus, CheckCircle, Edit, Trash2 } from 'lucide-react';
import { ExerciseType, Language, SporadicSet } from '../../types';
import { CheckCircle, Plus, Pencil, Trash2, X, Save } from 'lucide-react';
import { Language, WorkoutSet } from '../../types';
import { t } from '../../services/i18n';
import FilledInput from '../FilledInput';
import ExerciseModal from '../ExerciseModal';
import { useTracker } from './useTracker';
import SetLogger from './SetLogger';
interface SporadicViewProps {
tracker: ReturnType<typeof useTracker>;
lang: Language;
sporadicSets?: SporadicSet[];
}
const SporadicView: React.FC<SporadicViewProps> = ({ tracker, lang, sporadicSets }) => {
const SporadicView: React.FC<SporadicViewProps> = ({ tracker, lang }) => {
const {
searchQuery,
setSearchQuery,
setShowSuggestions,
showSuggestions,
filteredExercises,
setSelectedExercise,
selectedExercise,
weight,
setWeight,
reps,
setReps,
duration,
setDuration,
distance,
setDistance,
height,
setHeight,
handleLogSporadicSet,
sporadicSuccess,
setIsSporadicMode,
isCreating,
setIsCreating,
handleCreateExercise,
exercises,
resetForm
resetForm,
quickLogSession,
selectedExercise,
loadQuickLogSession
} = tracker;
const [todaysSets, setTodaysSets] = useState<SporadicSet[]>([]);
const [todaysSets, setTodaysSets] = useState<WorkoutSet[]>([]);
const [editingSetId, setEditingSetId] = useState<string | null>(null);
const [editingSet, setEditingSet] = useState<WorkoutSet | null>(null);
const [deletingSetId, setDeletingSetId] = useState<string | null>(null);
useEffect(() => {
if (sporadicSets) {
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
const todayS = sporadicSets.filter(s => s.timestamp >= startOfDay.getTime());
setTodaysSets(todayS.sort((a, b) => b.timestamp - a.timestamp));
if (quickLogSession && quickLogSession.sets) {
// Sets are already ordered by timestamp desc in the backend query, but let's ensure
setTodaysSets([...quickLogSession.sets].sort((a, b) => b.timestamp - a.timestamp));
} else {
setTodaysSets([]);
}
}, [sporadicSets]);
}, [quickLogSession]);
const renderSetMetrics = (set: SporadicSet) => {
const renderSetMetrics = (set: WorkoutSet) => {
const metrics: string[] = [];
if (set.weight) metrics.push(`${set.weight} ${t('weight_kg', lang)}`);
if (set.reps) metrics.push(`${set.reps} ${t('reps', lang)}`);
@@ -93,254 +80,45 @@ const SporadicView: React.FC<SporadicViewProps> = ({ tracker, lang, sporadicSets
</div>
<div className="flex-1 overflow-y-auto p-4 pb-32 space-y-6">
{/* Exercise Selection */}
<div className="relative">
<FilledInput
label={t('select_exercise', lang)}
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
setShowSuggestions(true);
}}
onFocus={() => {
setSearchQuery('');
setShowSuggestions(true);
}}
onBlur={() => setTimeout(() => setShowSuggestions(false), 100)}
icon={<Dumbbell size={10} />}
autoComplete="off"
type="text"
/>
<button
onClick={() => setIsCreating(true)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-primary hover:bg-primary-container/20 rounded-full z-10"
>
<Plus size={24} />
</button>
{showSuggestions && (
<div className="absolute top-full left-0 w-full bg-surface-container rounded-xl shadow-elevation-3 overflow-hidden z-20 mt-1 max-h-60 overflow-y-auto animate-in fade-in slide-in-from-top-2">
{filteredExercises.length > 0 ? (
filteredExercises.map(ex => (
<button
key={ex.id}
onMouseDown={(e) => {
e.preventDefault();
setSelectedExercise(ex);
setSearchQuery(ex.name);
setShowSuggestions(false);
}}
className="w-full text-left px-4 py-3 text-on-surface hover:bg-surface-container-high transition-colors text-lg"
>
{ex.name}
</button>
))
) : (
<div className="px-4 py-3 text-on-surface-variant text-lg">{t('no_exercises_found', lang)}</div>
)}
</div>
)}
</div>
{selectedExercise && (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-300 space-y-6">
{/* Unilateral Exercise Toggle */}
{selectedExercise.isUnilateral && (
<div className="flex items-center gap-3 px-2 py-3 bg-surface-container rounded-xl">
<input
type="checkbox"
id="sameValuesBothSidesSporadic"
checked={tracker.sameValuesBothSides}
onChange={(e) => tracker.handleToggleSameValues(e.target.checked)}
className="w-5 h-5 rounded border-2 border-outline bg-surface-container-high checked:bg-primary checked:border-primary cursor-pointer"
/>
<label htmlFor="sameValuesBothSidesSporadic" className="text-sm text-on-surface cursor-pointer flex-1">
{t('same_values_both_sides', lang)}
</label>
</div>
)}
{/* Input Forms */}
{selectedExercise.isUnilateral && !tracker.sameValuesBothSides ? (
/* Separate Left/Right Inputs */
<div className="space-y-4">
{/* Left Side */}
<div className="space-y-2">
<div className="text-sm font-medium text-primary flex items-center gap-2 px-2">
<span className="w-6 h-6 rounded-full bg-primary-container text-on-primary-container flex items-center justify-center text-xs font-bold">L</span>
{t('left', lang)}
</div>
<div className="grid grid-cols-2 gap-4">
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STATIC) && (
<FilledInput
label={selectedExercise.type === ExerciseType.BODYWEIGHT ? t('add_weight', lang) : t('weight_kg', lang)}
value={tracker.weightLeft}
step="0.1"
onChange={(e: any) => tracker.setWeightLeft(e.target.value)}
icon={<Scale size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.PLYOMETRIC) && (
<FilledInput
label={t('reps', lang)}
value={tracker.repsLeft}
onChange={(e: any) => tracker.setRepsLeft(e.target.value)}
icon={<Activity size={10} />}
type="number"
/>
)}
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.STATIC) && (
<FilledInput
label={t('time_sec', lang)}
value={tracker.durationLeft}
onChange={(e: any) => tracker.setDurationLeft(e.target.value)}
icon={<TimerIcon size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.LONG_JUMP) && (
<FilledInput
label={t('dist_m', lang)}
value={tracker.distanceLeft}
onChange={(e: any) => tracker.setDistanceLeft(e.target.value)}
icon={<ArrowRight size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.HIGH_JUMP) && (
<FilledInput
label={t('height_cm', lang)}
value={tracker.heightLeft}
onChange={(e: any) => tracker.setHeightLeft(e.target.value)}
icon={<ArrowUp size={10} />}
/>
)}
</div>
</div>
{/* Right Side */}
<div className="space-y-2">
<div className="text-sm font-medium text-secondary flex items-center gap-2 px-2">
<span className="w-6 h-6 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center text-xs font-bold">R</span>
{t('right', lang)}
</div>
<div className="grid grid-cols-2 gap-4">
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STATIC) && (
<FilledInput
label={selectedExercise.type === ExerciseType.BODYWEIGHT ? t('add_weight', lang) : t('weight_kg', lang)}
value={tracker.weightRight}
step="0.1"
onChange={(e: any) => tracker.setWeightRight(e.target.value)}
icon={<Scale size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.PLYOMETRIC) && (
<FilledInput
label={t('reps', lang)}
value={tracker.repsRight}
onChange={(e: any) => tracker.setRepsRight(e.target.value)}
icon={<Activity size={10} />}
type="number"
/>
)}
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.STATIC) && (
<FilledInput
label={t('time_sec', lang)}
value={tracker.durationRight}
onChange={(e: any) => tracker.setDurationRight(e.target.value)}
icon={<TimerIcon size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.LONG_JUMP) && (
<FilledInput
label={t('dist_m', lang)}
value={tracker.distanceRight}
onChange={(e: any) => tracker.setDistanceRight(e.target.value)}
icon={<ArrowRight size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.HIGH_JUMP) && (
<FilledInput
label={t('height_cm', lang)}
value={tracker.heightRight}
onChange={(e: any) => tracker.setHeightRight(e.target.value)}
icon={<ArrowUp size={10} />}
/>
)}
</div>
</div>
</div>
) : (
/* Single Input Form (for bilateral or unilateral with same values) */
<div className="grid grid-cols-2 gap-4">
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STATIC) && (
<FilledInput
label={selectedExercise.type === ExerciseType.BODYWEIGHT ? t('add_weight', lang) : t('weight_kg', lang)}
value={weight}
step="0.1"
onChange={(e: any) => setWeight(e.target.value)}
icon={<Scale size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.PLYOMETRIC) && (
<FilledInput
label={t('reps', lang)}
value={reps}
onChange={(e: any) => setReps(e.target.value)}
icon={<Activity size={10} />}
type="number"
/>
)}
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.STATIC) && (
<FilledInput
label={t('time_sec', lang)}
value={duration}
onChange={(e: any) => setDuration(e.target.value)}
icon={<TimerIcon size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.LONG_JUMP) && (
<FilledInput
label={t('dist_m', lang)}
value={distance}
onChange={(e: any) => setDistance(e.target.value)}
icon={<ArrowRight size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.HIGH_JUMP) && (
<FilledInput
label={t('height_cm', lang)}
value={height}
onChange={(e: any) => setHeight(e.target.value)}
icon={<ArrowUp size={10} />}
/>
)}
</div>
)}
<button
onClick={handleLogSporadicSet}
className={`w-full h-14 font-medium text-lg rounded-full shadow-elevation-2 hover:shadow-elevation-3 active:scale-[0.98] transition-all flex items-center justify-center gap-2 ${sporadicSuccess
? 'bg-green-500 text-white'
: 'bg-primary-container text-on-primary-container'
}`}
>
{sporadicSuccess ? <CheckCircle size={24} /> : <Plus size={24} />}
<span>{sporadicSuccess ? t('saved', lang) : t('log_set', lang)}</span>
</button>
</div>
)}
<SetLogger
tracker={tracker}
lang={lang}
onLogSet={handleLogSporadicSet}
isSporadic={true}
/>
{/* History Section */}
{todaysSets.length > 0 && (
<div className="mt-6">
<h3 className="text-title-medium font-medium mb-3">{t('history_section', lang)}</h3>
<div className="space-y-2">
{todaysSets.map(set => (
{todaysSets.map((set, idx) => (
<div key={set.id} className="bg-surface-container rounded-lg p-3 flex items-center justify-between shadow-elevation-1 animate-in fade-in">
<div>
<p className="font-medium text-on-surface">{set.exerciseName}</p>
<p className="text-sm text-on-surface-variant">{renderSetMetrics(set)}</p>
<div className="flex items-center gap-4 flex-1">
<div className="w-8 h-8 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center text-xs font-bold">
{todaysSets.length - idx}
</div>
<div>
<p className="font-medium text-on-surface">{set.exerciseName}{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}</p>
<p className="text-sm text-on-surface-variant">{renderSetMetrics(set)}</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Edit and Delete buttons can be added here in the future */}
<button
onClick={() => {
setEditingSetId(set.id);
setEditingSet(JSON.parse(JSON.stringify(set)));
}}
className="p-2 text-on-surface-variant hover:text-primary hover:bg-surface-container-high rounded-full transition-colors"
>
<Pencil size={18} />
</button>
<button
onClick={() => setDeletingSetId(set.id)}
className="p-2 text-on-surface-variant hover:text-error hover:bg-surface-container-high rounded-full transition-colors"
>
<Trash2 size={18} />
</button>
</div>
</div>
))}
@@ -358,6 +136,149 @@ const SporadicView: React.FC<SporadicViewProps> = ({ tracker, lang, sporadicSets
existingExercises={exercises}
/>
)}
{/* Edit Set Modal */}
{editingSetId && editingSet && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-surface-container w-full max-w-md rounded-[28px] p-6 shadow-elevation-3 max-h-[80vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-normal text-on-surface">{t('edit', lang)}</h3>
<button
onClick={() => {
setEditingSetId(null);
setEditingSet(null);
}}
className="p-2 hover:bg-surface-container-high rounded-full transition-colors"
>
<X size={20} />
</button>
</div>
<div className="space-y-4">
{(editingSet.type === 'STRENGTH' || editingSet.type === 'BODYWEIGHT') && (
<>
<div>
<label className="text-sm text-on-surface-variant">{t('weight_kg', lang)}</label>
<input
type="number"
step="0.1"
value={editingSet.weight || ''}
onChange={(e) => setEditingSet({ ...editingSet, weight: parseFloat(e.target.value) || 0 })}
className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="text-sm text-on-surface-variant">{t('reps', lang)}</label>
<input
type="number"
value={editingSet.reps || ''}
onChange={(e) => setEditingSet({ ...editingSet, reps: parseInt(e.target.value) || 0 })}
className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</>
)}
{(editingSet.type === 'CARDIO' || editingSet.type === 'STATIC') && (
<div>
<label className="text-sm text-on-surface-variant">{t('time_sec', lang)}</label>
<input
type="number"
value={editingSet.durationSeconds || ''}
onChange={(e) => setEditingSet({ ...editingSet, durationSeconds: parseInt(e.target.value) || 0 })}
className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
)}
{editingSet.type === 'CARDIO' && (
<div>
<label className="text-sm text-on-surface-variant">{t('dist_m', lang)}</label>
<input
type="number"
step="0.1"
value={editingSet.distanceMeters || ''}
onChange={(e) => setEditingSet({ ...editingSet, distanceMeters: parseFloat(e.target.value) || 0 })}
className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
)}
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={() => {
setEditingSetId(null);
setEditingSet(null);
}}
className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5"
>
{t('cancel', lang)}
</button>
<button
onClick={async () => {
try {
const response = await fetch(`/api/sessions/active/set/${editingSetId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify(editingSet)
});
if (response.ok) {
await loadQuickLogSession();
setEditingSetId(null);
setEditingSet(null);
}
} catch (error) {
console.error('Failed to update set:', error);
}
}}
className="px-4 py-2 rounded-full bg-primary text-on-primary font-medium flex items-center gap-2"
>
<Save size={18} />
{t('save', lang)}
</button>
</div>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{deletingSetId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-surface-container w-full max-w-xs rounded-[28px] p-6 shadow-elevation-3">
<h3 className="text-xl font-normal text-on-surface mb-2">{t('delete', lang)}</h3>
<p className="text-sm text-on-surface-variant mb-8">{t('delete_confirm', lang)}</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setDeletingSetId(null)}
className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5"
>
{t('cancel', lang)}
</button>
<button
onClick={async () => {
try {
const response = await fetch(`/api/sessions/active/set/${deletingSetId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
await loadQuickLogSession();
setDeletingSetId(null);
}
} catch (error) {
console.error('Failed to delete set:', error);
}
}}
className="px-4 py-2 rounded-full bg-error-container text-on-error-container font-medium"
>
{t('delete', lang)}
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { WorkoutSession, WorkoutSet, WorkoutPlan, Language, SporadicSet } from '../../types';
import { WorkoutSession, WorkoutSet, WorkoutPlan, Language } from '../../types';
import { useTracker } from './useTracker';
import IdleView from './IdleView';
import SporadicView from './SporadicView';
@@ -11,7 +11,6 @@ interface TrackerProps {
userWeight?: number;
activeSession: WorkoutSession | null;
activePlan: WorkoutPlan | null;
sporadicSets?: SporadicSet[];
onSessionStart: (plan?: WorkoutPlan, startWeight?: number) => void;
onSessionEnd: () => void;
onSessionQuit: () => void;
@@ -25,7 +24,7 @@ interface TrackerProps {
const Tracker: React.FC<TrackerProps> = (props) => {
const tracker = useTracker(props);
const { isSporadicMode } = tracker;
const { activeSession, lang, onSessionEnd, onSessionQuit, onRemoveSet, sporadicSets } = props;
const { activeSession, lang, onSessionEnd, onSessionQuit, onRemoveSet } = props;
if (activeSession) {
return (
@@ -41,7 +40,7 @@ const Tracker: React.FC<TrackerProps> = (props) => {
}
if (isSporadicMode) {
return <SporadicView tracker={tracker} lang={lang} sporadicSets={sporadicSets} />;
return <SporadicView tracker={tracker} lang={lang} />;
}
return <IdleView tracker={tracker} lang={lang} />;

View File

@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { WorkoutSession, WorkoutSet, ExerciseDef, ExerciseType, WorkoutPlan, Language } from '../../types';
import { getExercises, getLastSetForExercise, saveExercise, getPlans } from '../../services/storage';
import { api } from '../../services/api';
import { logSporadicSet } from '../../services/sporadicSets';
interface UseTrackerProps {
userId: string;
@@ -73,39 +73,13 @@ export const useTracker = ({
const [editDistance, setEditDistance] = useState<string>('');
const [editHeight, setEditHeight] = useState<string>('');
// Sporadic Set State
// Quick Log State
const [quickLogSession, setQuickLogSession] = useState<WorkoutSession | null>(null);
const [isSporadicMode, setIsSporadicMode] = useState(false);
const [sporadicSuccess, setSporadicSuccess] = useState(false);
// Unilateral Exercise State
const [sameValuesBothSides, setSameValuesBothSides] = useState(true);
const [weightLeft, setWeightLeft] = useState<string>('');
const [weightRight, setWeightRight] = useState<string>('');
const [repsLeft, setRepsLeft] = useState<string>('');
const [repsRight, setRepsRight] = useState<string>('');
const [durationLeft, setDurationLeft] = useState<string>('');
const [durationRight, setDurationRight] = useState<string>('');
const [distanceLeft, setDistanceLeft] = useState<string>('');
const [distanceRight, setDistanceRight] = useState<string>('');
const [heightLeft, setHeightLeft] = useState<string>('');
const [heightRight, setHeightRight] = useState<string>('');
const handleToggleSameValues = (checked: boolean) => {
setSameValuesBothSides(checked);
if (!checked) {
// Propagate values from single fields to left/right fields
setWeightLeft(weight);
setWeightRight(weight);
setRepsLeft(reps);
setRepsRight(reps);
setDurationLeft(duration);
setDurationRight(duration);
setDistanceLeft(distance);
setDistanceRight(distance);
setHeightLeft(height);
setHeightRight(height);
}
};
const [unilateralSide, setUnilateralSide] = useState<'LEFT' | 'RIGHT'>('LEFT');
useEffect(() => {
const loadData = async () => {
@@ -120,10 +94,34 @@ export const useTracker = ({
} else if (userWeight) {
setUserBodyWeight(userWeight.toString());
}
// Load Quick Log Session
try {
const response = await api.get('/sessions/quick-log');
if (response.success && response.session) {
setQuickLogSession(response.session);
}
} catch (error) {
console.error("Failed to load quick log session:", error);
}
};
loadData();
}, [activeSession, userId, userWeight, activePlan]);
// Function to reload Quick Log session
const loadQuickLogSession = async () => {
try {
const response = await api.get('/sessions/quick-log');
if (response.success && response.session) {
setQuickLogSession(response.session);
}
} catch (error) {
console.error("Failed to load quick log session:", error);
}
};
// Timer Logic
useEffect(() => {
let interval: number;
@@ -167,7 +165,6 @@ export const useTracker = ({
}
}, [activeSession, activePlan]);
useEffect(() => {
if (activeSession && activePlan && exercises.length > 0 && activePlan.steps.length > 0) {
if (currentStepIndex < activePlan.steps.length) {
@@ -222,6 +219,7 @@ export const useTracker = ({
updateSelection();
}, [selectedExercise, userId]);
const filteredExercises = searchQuery === ''
? exercises
: exercises.filter(ex =>
@@ -246,363 +244,126 @@ export const useTracker = ({
const handleAddSet = async () => {
if (!activeSession || !selectedExercise) return;
// For unilateral exercises, create two sets (LEFT and RIGHT)
const setData: Partial<WorkoutSet> = {
exerciseId: selectedExercise.id,
};
if (selectedExercise.isUnilateral) {
const setsToCreate: Array<Partial<WorkoutSet> & { side: 'LEFT' | 'RIGHT' }> = [];
setData.side = unilateralSide;
}
if (sameValuesBothSides) {
// Create two identical sets with LEFT and RIGHT sides
const setData: Partial<WorkoutSet> = {
exerciseId: selectedExercise.id,
};
switch (selectedExercise.type) {
case ExerciseType.STRENGTH:
if (weight) setData.weight = parseFloat(weight);
if (reps) setData.reps = parseInt(reps);
break;
case ExerciseType.BODYWEIGHT:
if (weight) setData.weight = parseFloat(weight);
if (reps) setData.reps = parseInt(reps);
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
break;
case ExerciseType.CARDIO:
if (duration) setData.durationSeconds = parseInt(duration);
if (distance) setData.distanceMeters = parseFloat(distance);
break;
case ExerciseType.STATIC:
if (duration) setData.durationSeconds = parseInt(duration);
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
break;
case ExerciseType.HIGH_JUMP:
if (height) setData.height = parseFloat(height);
break;
case ExerciseType.LONG_JUMP:
if (distance) setData.distanceMeters = parseFloat(distance);
break;
case ExerciseType.PLYOMETRIC:
if (reps) setData.reps = parseInt(reps);
break;
}
switch (selectedExercise.type) {
case ExerciseType.STRENGTH:
if (weight) setData.weight = parseFloat(weight);
if (reps) setData.reps = parseInt(reps);
break;
case ExerciseType.BODYWEIGHT:
if (weight) setData.weight = parseFloat(weight);
if (reps) setData.reps = parseInt(reps);
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
break;
case ExerciseType.CARDIO:
if (duration) setData.durationSeconds = parseInt(duration);
if (distance) setData.distanceMeters = parseFloat(distance);
break;
case ExerciseType.STATIC:
if (duration) setData.durationSeconds = parseInt(duration);
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
break;
case ExerciseType.HIGH_JUMP:
if (height) setData.height = parseFloat(height);
break;
case ExerciseType.LONG_JUMP:
if (distance) setData.distanceMeters = parseFloat(distance);
break;
case ExerciseType.PLYOMETRIC:
if (reps) setData.reps = parseInt(reps);
break;
}
try {
const response = await api.post('/sessions/active/log-set', setData);
if (response.success) {
const { newSet, activeExerciseId } = response;
onSetAdded(newSet);
setsToCreate.push({ ...setData, side: 'LEFT' });
setsToCreate.push({ ...setData, side: 'RIGHT' });
} else {
// Create separate sets for LEFT and RIGHT with different values
const leftSetData: Partial<WorkoutSet> = {
exerciseId: selectedExercise.id,
};
const rightSetData: Partial<WorkoutSet> = {
exerciseId: selectedExercise.id,
};
switch (selectedExercise.type) {
case ExerciseType.STRENGTH:
if (weightLeft) leftSetData.weight = parseFloat(weightLeft);
if (repsLeft) leftSetData.reps = parseInt(repsLeft);
if (weightRight) rightSetData.weight = parseFloat(weightRight);
if (repsRight) rightSetData.reps = parseInt(repsRight);
break;
case ExerciseType.BODYWEIGHT:
if (weightLeft) leftSetData.weight = parseFloat(weightLeft);
if (repsLeft) leftSetData.reps = parseInt(repsLeft);
leftSetData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
if (weightRight) rightSetData.weight = parseFloat(weightRight);
if (repsRight) rightSetData.reps = parseInt(repsRight);
rightSetData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
break;
case ExerciseType.CARDIO:
if (durationLeft) leftSetData.durationSeconds = parseInt(durationLeft);
if (distanceLeft) leftSetData.distanceMeters = parseFloat(distanceLeft);
if (durationRight) rightSetData.durationSeconds = parseInt(durationRight);
if (distanceRight) rightSetData.distanceMeters = parseFloat(distanceRight);
break;
case ExerciseType.STATIC:
if (durationLeft) leftSetData.durationSeconds = parseInt(durationLeft);
leftSetData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
if (durationRight) rightSetData.durationSeconds = parseInt(durationRight);
rightSetData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
break;
case ExerciseType.HIGH_JUMP:
if (heightLeft) leftSetData.height = parseFloat(heightLeft);
if (heightRight) rightSetData.height = parseFloat(heightRight);
break;
case ExerciseType.LONG_JUMP:
if (distanceLeft) leftSetData.distanceMeters = parseFloat(distanceLeft);
if (distanceRight) rightSetData.distanceMeters = parseFloat(distanceRight);
break;
case ExerciseType.PLYOMETRIC:
if (repsLeft) leftSetData.reps = parseInt(repsLeft);
if (repsRight) rightSetData.reps = parseInt(repsRight);
break;
}
setsToCreate.push({ ...leftSetData, side: 'LEFT' });
setsToCreate.push({ ...rightSetData, side: 'RIGHT' });
}
// Log both sets
try {
for (const setData of setsToCreate) {
const response = await api.post('/sessions/active/log-set', setData);
if (response.success) {
const { newSet } = response;
onSetAdded(newSet);
if (activePlan && activeExerciseId) {
const nextStepIndex = activePlan.steps.findIndex(step => step.exerciseId === activeExerciseId);
if (nextStepIndex !== -1) {
setCurrentStepIndex(nextStepIndex);
}
} else if (activePlan && !activeExerciseId) {
// Plan is finished
setCurrentStepIndex(activePlan.steps.length);
}
// Update plan progress after logging both sets
if (activePlan) {
const response = await api.post('/sessions/active/log-set', { exerciseId: selectedExercise.id });
if (response.success && response.activeExerciseId) {
const nextStepIndex = activePlan.steps.findIndex(step => step.exerciseId === response.activeExerciseId);
if (nextStepIndex !== -1) {
setCurrentStepIndex(nextStepIndex);
}
} else if (response.success && !response.activeExerciseId) {
setCurrentStepIndex(activePlan.steps.length);
}
}
} catch (error) {
console.error("Failed to log unilateral sets:", error);
}
} else {
// Regular bilateral exercise - single set
const setData: Partial<WorkoutSet> = {
exerciseId: selectedExercise.id,
};
switch (selectedExercise.type) {
case ExerciseType.STRENGTH:
if (weight) setData.weight = parseFloat(weight);
if (reps) setData.reps = parseInt(reps);
break;
case ExerciseType.BODYWEIGHT:
if (weight) setData.weight = parseFloat(weight);
if (reps) setData.reps = parseInt(reps);
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
break;
case ExerciseType.CARDIO:
if (duration) setData.durationSeconds = parseInt(duration);
if (distance) setData.distanceMeters = parseFloat(distance);
break;
case ExerciseType.STATIC:
if (duration) setData.durationSeconds = parseInt(duration);
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
break;
case ExerciseType.HIGH_JUMP:
if (height) setData.height = parseFloat(height);
break;
case ExerciseType.LONG_JUMP:
if (distance) setData.distanceMeters = parseFloat(distance);
break;
case ExerciseType.PLYOMETRIC:
if (reps) setData.reps = parseInt(reps);
break;
}
try {
const response = await api.post('/sessions/active/log-set', setData);
if (response.success) {
const { newSet, activeExerciseId } = response;
onSetAdded(newSet);
if (activePlan && activeExerciseId) {
const nextStepIndex = activePlan.steps.findIndex(step => step.exerciseId === activeExerciseId);
if (nextStepIndex !== -1) {
setCurrentStepIndex(nextStepIndex);
}
} else if (activePlan && !activeExerciseId) {
// Plan is finished
setCurrentStepIndex(activePlan.steps.length);
}
}
} catch (error) {
console.error("Failed to log set:", error);
}
} catch (error) {
console.error("Failed to log set:", error);
}
};
const handleLogSporadicSet = async () => {
if (!selectedExercise) return;
// For unilateral exercises, create two sets (LEFT and RIGHT)
const setData: any = {
exerciseId: selectedExercise.id,
};
if (selectedExercise.isUnilateral) {
const setsToCreate: any[] = [];
setData.side = unilateralSide;
}
if (sameValuesBothSides) {
// Create two identical sets with LEFT and RIGHT sides
const set: any = {
exerciseId: selectedExercise.id,
timestamp: Date.now(),
};
switch (selectedExercise.type) {
case ExerciseType.STRENGTH:
if (weight) setData.weight = parseFloat(weight);
if (reps) setData.reps = parseInt(reps);
break;
case ExerciseType.BODYWEIGHT:
if (weight) setData.weight = parseFloat(weight);
if (reps) setData.reps = parseInt(reps);
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
break;
case ExerciseType.CARDIO:
if (duration) setData.durationSeconds = parseInt(duration);
if (distance) setData.distanceMeters = parseFloat(distance);
break;
case ExerciseType.STATIC:
if (duration) setData.durationSeconds = parseInt(duration);
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
break;
case ExerciseType.HIGH_JUMP:
if (height) setData.height = parseFloat(height);
break;
case ExerciseType.LONG_JUMP:
if (distance) setData.distanceMeters = parseFloat(distance);
break;
case ExerciseType.PLYOMETRIC:
if (reps) setData.reps = parseInt(reps);
break;
}
switch (selectedExercise.type) {
case ExerciseType.STRENGTH:
if (weight) set.weight = parseFloat(weight);
if (reps) set.reps = parseInt(reps);
break;
case ExerciseType.BODYWEIGHT:
if (weight) set.weight = parseFloat(weight);
if (reps) set.reps = parseInt(reps);
set.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
break;
case ExerciseType.CARDIO:
if (duration) set.durationSeconds = parseInt(duration);
if (distance) set.distanceMeters = parseFloat(distance);
break;
case ExerciseType.STATIC:
if (duration) set.durationSeconds = parseInt(duration);
set.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
break;
case ExerciseType.HIGH_JUMP:
if (height) set.height = parseFloat(height);
break;
case ExerciseType.LONG_JUMP:
if (distance) set.distanceMeters = parseFloat(distance);
break;
case ExerciseType.PLYOMETRIC:
if (reps) set.reps = parseInt(reps);
break;
}
setsToCreate.push({ ...set, side: 'LEFT' });
setsToCreate.push({ ...set, side: 'RIGHT' });
} else {
// Create separate sets for LEFT and RIGHT with different values
const leftSet: any = {
exerciseId: selectedExercise.id,
timestamp: Date.now(),
};
const rightSet: any = {
exerciseId: selectedExercise.id,
timestamp: Date.now(),
};
switch (selectedExercise.type) {
case ExerciseType.STRENGTH:
if (weightLeft) leftSet.weight = parseFloat(weightLeft);
if (repsLeft) leftSet.reps = parseInt(repsLeft);
if (weightRight) rightSet.weight = parseFloat(weightRight);
if (repsRight) rightSet.reps = parseInt(repsRight);
break;
case ExerciseType.BODYWEIGHT:
if (weightLeft) leftSet.weight = parseFloat(weightLeft);
if (repsLeft) leftSet.reps = parseInt(repsLeft);
leftSet.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
if (weightRight) rightSet.weight = parseFloat(weightRight);
if (repsRight) rightSet.reps = parseInt(repsRight);
rightSet.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
break;
case ExerciseType.CARDIO:
if (durationLeft) leftSet.durationSeconds = parseInt(durationLeft);
if (distanceLeft) leftSet.distanceMeters = parseFloat(distanceLeft);
if (durationRight) rightSet.durationSeconds = parseInt(durationRight);
if (distanceRight) rightSet.distanceMeters = parseFloat(distanceRight);
break;
case ExerciseType.STATIC:
if (durationLeft) leftSet.durationSeconds = parseInt(durationLeft);
leftSet.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
if (durationRight) rightSet.durationSeconds = parseInt(durationRight);
rightSet.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
break;
case ExerciseType.HIGH_JUMP:
if (heightLeft) leftSet.height = parseFloat(heightLeft);
if (heightRight) rightSet.height = parseFloat(heightRight);
break;
case ExerciseType.LONG_JUMP:
if (distanceLeft) leftSet.distanceMeters = parseFloat(distanceLeft);
if (distanceRight) rightSet.distanceMeters = parseFloat(distanceRight);
break;
case ExerciseType.PLYOMETRIC:
if (repsLeft) leftSet.reps = parseInt(repsLeft);
if (repsRight) rightSet.reps = parseInt(repsRight);
break;
}
setsToCreate.push({ ...leftSet, side: 'LEFT' });
setsToCreate.push({ ...rightSet, side: 'RIGHT' });
}
// Log both sets
try {
for (const set of setsToCreate) {
await logSporadicSet(set);
}
try {
const response = await api.post('/sessions/quick-log/set', setData);
if (response.success) {
setSporadicSuccess(true);
setTimeout(() => setSporadicSuccess(false), 2000);
// Refresh quick log session
const sessionRes = await api.get('/sessions/quick-log');
if (sessionRes.success && sessionRes.session) {
setQuickLogSession(sessionRes.session);
}
// Reset form
setWeight('');
setReps('');
setDuration('');
setDistance('');
setHeight('');
setWeightLeft('');
setWeightRight('');
setRepsLeft('');
setRepsRight('');
setDurationLeft('');
setDurationRight('');
setDistanceLeft('');
setDistanceRight('');
setHeightLeft('');
setHeightRight('');
if (onSporadicSetAdded) onSporadicSetAdded();
} catch (error) {
console.error("Failed to log unilateral sporadic sets:", error);
}
} else {
// Regular bilateral exercise - single set
const set: any = {
exerciseId: selectedExercise.id,
timestamp: Date.now(),
};
switch (selectedExercise.type) {
case ExerciseType.STRENGTH:
if (weight) set.weight = parseFloat(weight);
if (reps) set.reps = parseInt(reps);
break;
case ExerciseType.BODYWEIGHT:
if (weight) set.weight = parseFloat(weight);
if (reps) set.reps = parseInt(reps);
set.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
break;
case ExerciseType.CARDIO:
if (duration) set.durationSeconds = parseInt(duration);
if (distance) set.distanceMeters = parseFloat(distance);
break;
case ExerciseType.STATIC:
if (duration) set.durationSeconds = parseInt(duration);
set.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
break;
case ExerciseType.HIGH_JUMP:
if (height) set.height = parseFloat(height);
break;
case ExerciseType.LONG_JUMP:
if (distance) set.distanceMeters = parseFloat(distance);
break;
case ExerciseType.PLYOMETRIC:
if (reps) set.reps = parseInt(reps);
break;
}
try {
const result = await logSporadicSet(set);
if (result) {
setSporadicSuccess(true);
setTimeout(() => setSporadicSuccess(false), 2000);
// Reset form
setWeight('');
setReps('');
setDuration('');
setDistance('');
setHeight('');
if (onSporadicSetAdded) onSporadicSetAdded();
}
} catch (error) {
console.error("Failed to log sporadic set:", error);
}
} catch (error) {
console.error("Failed to log quick log set:", error);
}
};
@@ -721,29 +482,9 @@ export const useTracker = ({
handleCancelEdit,
jumpToStep,
resetForm,
// Unilateral exercise state
sameValuesBothSides,
setSameValuesBothSides,
weightLeft,
setWeightLeft,
weightRight,
setWeightRight,
repsLeft,
setRepsLeft,
repsRight,
setRepsRight,
durationLeft,
setDurationLeft,
durationRight,
setDurationRight,
distanceLeft,
setDistanceLeft,
distanceRight,
setDistanceRight,
heightLeft,
setHeightLeft,
heightRight,
setHeightRight,
handleToggleSameValues,
unilateralSide,
setUnilateralSide,
quickLogSession, // Export this
loadQuickLogSession, // Export reload function
};
};

View File

@@ -10,7 +10,7 @@
"dependencies": {
"@google/generative-ai": "^0.24.1",
"@prisma/adapter-better-sqlite3": "^7.1.0",
"@prisma/client": "*",
"@prisma/client": "^6.19.0",
"@types/better-sqlite3": "^7.6.13",
"bcryptjs": "*",
"better-sqlite3": "^12.5.0",

View File

@@ -11,7 +11,7 @@
"dependencies": {
"@google/generative-ai": "^0.24.1",
"@prisma/adapter-better-sqlite3": "^7.1.0",
"@prisma/client": "*",
"@prisma/client": "^6.19.0",
"@types/better-sqlite3": "^7.6.13",
"bcryptjs": "*",
"better-sqlite3": "^12.5.0",

Binary file not shown.

View File

@@ -0,0 +1,54 @@
/*
Warnings:
- You are about to drop the `SporadicSet` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropIndex
DROP INDEX "SporadicSet_userId_timestamp_idx";
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "SporadicSet";
PRAGMA foreign_keys=on;
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_WorkoutSession" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"startTime" DATETIME NOT NULL,
"endTime" DATETIME,
"userBodyWeight" REAL,
"note" TEXT,
"planId" TEXT,
"planName" TEXT,
"type" TEXT NOT NULL DEFAULT 'STANDARD',
CONSTRAINT "WorkoutSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_WorkoutSession" ("endTime", "id", "note", "planId", "planName", "startTime", "userBodyWeight", "userId") SELECT "endTime", "id", "note", "planId", "planName", "startTime", "userBodyWeight", "userId" FROM "WorkoutSession";
DROP TABLE "WorkoutSession";
ALTER TABLE "new_WorkoutSession" RENAME TO "WorkoutSession";
CREATE TABLE "new_WorkoutSet" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"exerciseId" TEXT NOT NULL,
"order" INTEGER NOT NULL,
"weight" REAL,
"reps" INTEGER,
"distanceMeters" REAL,
"durationSeconds" INTEGER,
"height" REAL,
"bodyWeightPercentage" REAL,
"completed" BOOLEAN NOT NULL DEFAULT true,
"side" TEXT,
"timestamp" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WorkoutSet_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WorkoutSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WorkoutSet_exerciseId_fkey" FOREIGN KEY ("exerciseId") REFERENCES "Exercise" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_WorkoutSet" ("completed", "distanceMeters", "durationSeconds", "exerciseId", "id", "order", "reps", "sessionId", "side", "weight") SELECT "completed", "distanceMeters", "durationSeconds", "exerciseId", "id", "order", "reps", "sessionId", "side", "weight" FROM "WorkoutSet";
DROP TABLE "WorkoutSet";
ALTER TABLE "new_WorkoutSet" RENAME TO "WorkoutSet";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -25,7 +25,6 @@ model User {
exercises Exercise[]
plans WorkoutPlan[]
weightRecords BodyWeightRecord[]
sporadicSets SporadicSet[]
}
model BodyWeightRecord {
@@ -61,7 +60,6 @@ model Exercise {
isUnilateral Boolean @default(false)
sets WorkoutSet[]
sporadicSets SporadicSet[]
}
model WorkoutSession {
@@ -74,6 +72,7 @@ model WorkoutSession {
note String?
planId String?
planName String?
type String @default("STANDARD") // STANDARD, QUICK_LOG
sets WorkoutSet[]
}
@@ -90,8 +89,11 @@ model WorkoutSet {
reps Int?
distanceMeters Float?
durationSeconds Int?
height Float?
bodyWeightPercentage Float?
completed Boolean @default(true)
side String? // LEFT, RIGHT, or null for bilateral
timestamp DateTime @default(now())
}
model WorkoutPlan {
@@ -105,23 +107,3 @@ model WorkoutPlan {
updatedAt DateTime @updatedAt
}
model SporadicSet {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
exerciseId String
exercise Exercise @relation(fields: [exerciseId], references: [id])
weight Float?
reps Int?
distanceMeters Float?
durationSeconds Int?
height Float?
bodyWeightPercentage Float?
side String? // LEFT, RIGHT, or null for bilateral
timestamp DateTime @default(now())
note String?
@@index([userId, timestamp])
}

128
server/sporadic_backup.json Normal file
View File

@@ -0,0 +1,128 @@
[
{
"id": "afc0252b-81c8-4534-b10c-fd328ead82c8",
"userId": "f9c47b8f-2a34-4157-b5bb-e4a803250a7b",
"exerciseId": "19b3c365-4b2b-448b-8c25-90562aca9a4b",
"weight": 12,
"reps": 13,
"distanceMeters": null,
"durationSeconds": null,
"height": null,
"bodyWeightPercentage": null,
"side": "LEFT",
"timestamp": "2025-12-03T21:25:04.297Z",
"note": null
},
{
"id": "e772067e-bbea-4e70-83bf-128e6a2feab4",
"userId": "f9c47b8f-2a34-4157-b5bb-e4a803250a7b",
"exerciseId": "19b3c365-4b2b-448b-8c25-90562aca9a4b",
"weight": 12,
"reps": 13,
"distanceMeters": null,
"durationSeconds": null,
"height": null,
"bodyWeightPercentage": null,
"side": "RIGHT",
"timestamp": "2025-12-03T21:25:04.335Z",
"note": null
},
{
"id": "b3b86064-935d-45ee-aab2-b7cf7e1de883",
"userId": "f9c47b8f-2a34-4157-b5bb-e4a803250a7b",
"exerciseId": "19b3c365-4b2b-448b-8c25-90562aca9a4b",
"weight": 12,
"reps": 4,
"distanceMeters": null,
"durationSeconds": null,
"height": null,
"bodyWeightPercentage": null,
"side": "LEFT",
"timestamp": "2025-12-03T21:34:13.194Z",
"note": null
},
{
"id": "688c19fa-2cb2-48b0-a96c-71e894047340",
"userId": "f9c47b8f-2a34-4157-b5bb-e4a803250a7b",
"exerciseId": "19b3c365-4b2b-448b-8c25-90562aca9a4b",
"weight": 12,
"reps": 4,
"distanceMeters": null,
"durationSeconds": null,
"height": null,
"bodyWeightPercentage": null,
"side": "RIGHT",
"timestamp": "2025-12-03T21:34:13.226Z",
"note": null
},
{
"id": "93db2e6c-5cab-41a1-b3b4-a66e00ebca1c",
"userId": "f9c47b8f-2a34-4157-b5bb-e4a803250a7b",
"exerciseId": "19b3c365-4b2b-448b-8c25-90562aca9a4b",
"weight": 12,
"reps": 13,
"distanceMeters": null,
"durationSeconds": null,
"height": null,
"bodyWeightPercentage": null,
"side": "RIGHT",
"timestamp": "2025-12-03T21:44:15.119Z",
"note": null
},
{
"id": "7e59647f-a115-47ec-9327-5d46df0e56e8",
"userId": "f9c47b8f-2a34-4157-b5bb-e4a803250a7b",
"exerciseId": "19b3c365-4b2b-448b-8c25-90562aca9a4b",
"weight": 12,
"reps": 13,
"distanceMeters": null,
"durationSeconds": null,
"height": null,
"bodyWeightPercentage": null,
"side": "LEFT",
"timestamp": "2025-12-03T21:44:24.122Z",
"note": null
},
{
"id": "4dd11f30-f96b-4f9f-b6fd-1968315e06ec",
"userId": "f9c47b8f-2a34-4157-b5bb-e4a803250a7b",
"exerciseId": "19b3c365-4b2b-448b-8c25-90562aca9a4b",
"weight": 12,
"reps": 13,
"distanceMeters": null,
"durationSeconds": null,
"height": null,
"bodyWeightPercentage": null,
"side": "LEFT",
"timestamp": "2025-12-03T21:53:54.535Z",
"note": null
},
{
"id": "308c4ec7-7518-45b7-a066-5db1c7e2229e",
"userId": "f9c47b8f-2a34-4157-b5bb-e4a803250a7b",
"exerciseId": "19b3c365-4b2b-448b-8c25-90562aca9a4b",
"weight": 12,
"reps": 13,
"distanceMeters": null,
"durationSeconds": null,
"height": null,
"bodyWeightPercentage": null,
"side": "LEFT",
"timestamp": "2025-12-03T21:54:31.820Z",
"note": null
},
{
"id": "c03a8123-05e9-45c0-aac8-587dd6342c27",
"userId": "f9c47b8f-2a34-4157-b5bb-e4a803250a7b",
"exerciseId": "19b3c365-4b2b-448b-8c25-90562aca9a4b",
"weight": 12,
"reps": 13,
"distanceMeters": null,
"durationSeconds": null,
"height": null,
"bodyWeightPercentage": null,
"side": "LEFT",
"timestamp": "2025-12-03T21:58:44.945Z",
"note": null
}
]

View File

@@ -6,7 +6,7 @@ import sessionRoutes from './routes/sessions';
import planRoutes from './routes/plans';
import aiRoutes from './routes/ai';
import weightRoutes from './routes/weight';
import sporadicSetsRoutes from './routes/sporadic-sets';
import bcrypt from 'bcryptjs';
import { PrismaClient } from '@prisma/client';
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3';
@@ -63,7 +63,7 @@ app.use('/api/sessions', sessionRoutes);
app.use('/api/plans', planRoutes);
app.use('/api/ai', aiRoutes);
app.use('/api/weight', weightRoutes);
app.use('/api/sporadic-sets', sporadicSetsRoutes);
app.get('/', (req, res) => {
res.send('GymFlow AI API is running');

View File

@@ -29,7 +29,18 @@ router.get('/', async (req: any, res) => {
include: { sets: { include: { exercise: true } } },
orderBy: { startTime: 'desc' }
});
res.json(sessions);
// Map exerciseName and type onto each set for frontend convenience
const mappedSessions = sessions.map(session => ({
...session,
sets: session.sets.map(set => ({
...set,
exerciseName: set.exercise.name,
type: set.exercise.type
}))
}));
res.json(mappedSessions);
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
@@ -83,7 +94,11 @@ router.post('/', async (req: any, res) => {
// If creating a new active session (endTime is null), check if one already exists
if (!end) {
const active = await prisma.workoutSession.findFirst({
where: { userId, endTime: null }
where: {
userId,
endTime: null,
type: 'STANDARD' // Only check for standard sessions, not Quick Log
}
});
if (active) {
return res.status(400).json({ error: 'An active session already exists' });
@@ -149,7 +164,8 @@ router.get('/active', async (req: any, res) => {
const activeSession = await prisma.workoutSession.findFirst({
where: {
userId,
endTime: null
endTime: null,
type: 'STANDARD'
},
include: { sets: { include: { exercise: true }, orderBy: { order: 'asc' } } }
});
@@ -228,15 +244,119 @@ router.put('/active', async (req: any, res) => {
}
});
// Get today's quick log session
router.get('/quick-log', async (req: any, res) => {
try {
const userId = req.user.userId;
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date();
endOfDay.setHours(23, 59, 59, 999);
const session = await prisma.workoutSession.findFirst({
where: {
userId,
type: 'QUICK_LOG',
startTime: {
gte: startOfDay,
lte: endOfDay
}
},
include: { sets: { include: { exercise: true }, orderBy: { timestamp: 'desc' } } }
});
if (!session) {
return res.json({ success: true, session: null });
}
// Map exerciseName and type onto sets
const mappedSession = {
...session,
sets: session.sets.map(set => ({
...set,
exerciseName: set.exercise.name,
type: set.exercise.type
}))
};
res.json({ success: true, session: mappedSession });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Server error' });
}
});
// Log a set to today's quick log session
router.post('/quick-log/set', async (req: any, res) => {
try {
const userId = req.user.userId;
const { exerciseId, weight, reps, distanceMeters, durationSeconds, height, bodyWeightPercentage, note, side } = req.body;
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date();
endOfDay.setHours(23, 59, 59, 999);
// Find or create today's quick log session
let session = await prisma.workoutSession.findFirst({
where: {
userId,
type: 'QUICK_LOG',
startTime: {
gte: startOfDay,
lte: endOfDay
}
}
});
if (!session) {
session = await prisma.workoutSession.create({
data: {
userId,
startTime: startOfDay,
type: 'QUICK_LOG',
note: 'Daily Quick Log'
}
});
}
// Create the set
const newSet = await prisma.workoutSet.create({
data: {
sessionId: session.id,
exerciseId,
order: 0,
weight: weight ? parseFloat(weight) : null,
reps: reps ? parseInt(reps) : null,
distanceMeters: distanceMeters ? parseFloat(distanceMeters) : null,
durationSeconds: durationSeconds ? parseInt(durationSeconds) : null,
side: side || null
},
include: { exercise: true }
});
const mappedSet = {
...newSet,
exerciseName: newSet.exercise.name,
type: newSet.exercise.type
};
res.json({ success: true, set: mappedSet });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Server error' });
}
});
// Log a set to the active session
router.post('/active/log-set', async (req: any, res) => {
try {
const userId = req.user.userId;
const { exerciseId, reps, weight, distanceMeters, durationSeconds } = req.body;
const { exerciseId, reps, weight, distanceMeters, durationSeconds, side } = req.body;
// Find active session
const activeSession = await prisma.workoutSession.findFirst({
where: { userId, endTime: null },
where: { userId, endTime: null, type: 'STANDARD' },
include: { sets: true }
});
@@ -257,6 +377,7 @@ router.post('/active/log-set', async (req: any, res) => {
weight: weight ? parseFloat(weight) : null,
distanceMeters: distanceMeters ? parseFloat(distanceMeters) : null,
durationSeconds: durationSeconds ? parseInt(durationSeconds) : null,
side: side || null,
completed: true
},
include: { exercise: true }
@@ -324,7 +445,7 @@ router.put('/active/set/:setId', async (req: any, res) => {
const { setId } = req.params;
const { reps, weight, distanceMeters, durationSeconds } = req.body;
// Find active session
// Find active session (STANDARD or QUICK_LOG)
const activeSession = await prisma.workoutSession.findFirst({
where: { userId, endTime: null },
});
@@ -358,13 +479,58 @@ router.put('/active/set/:setId', async (req: any, res) => {
}
});
// Update a set in the active session (STANDARD or QUICK_LOG)
router.patch('/active/set/:setId', async (req: any, res) => {
try {
const userId = req.user.userId;
const { setId } = req.params;
const { reps, weight, distanceMeters, durationSeconds, height, bodyWeightPercentage, side, note } = req.body;
// Find active session (STANDARD or QUICK_LOG)
const activeSession = await prisma.workoutSession.findFirst({
where: { userId, endTime: null },
});
if (!activeSession) {
return res.status(404).json({ error: 'No active session found' });
}
const updatedSet = await prisma.workoutSet.update({
where: { id: setId },
data: {
reps: reps !== undefined ? (reps ? parseInt(reps) : null) : undefined,
weight: weight !== undefined ? (weight ? parseFloat(weight) : null) : undefined,
distanceMeters: distanceMeters !== undefined ? (distanceMeters ? parseFloat(distanceMeters) : null) : undefined,
durationSeconds: durationSeconds !== undefined ? (durationSeconds ? parseInt(durationSeconds) : null) : undefined,
height: height !== undefined ? (height ? parseFloat(height) : null) : undefined,
bodyWeightPercentage: bodyWeightPercentage !== undefined ? (bodyWeightPercentage ? parseFloat(bodyWeightPercentage) : null) : undefined,
side: side !== undefined ? side : undefined,
note: note !== undefined ? note : undefined,
},
include: { exercise: true }
});
const mappedUpdatedSet = {
...updatedSet,
exerciseName: updatedSet.exercise.name,
type: updatedSet.exercise.type
};
res.json({ success: true, updatedSet: mappedUpdatedSet });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Server error' });
}
});
// Delete a set from the active session
router.delete('/active/set/:setId', async (req: any, res) => {
try {
const userId = req.user.userId;
const { setId } = req.params;
// Find active session
// Find active session (STANDARD or QUICK_LOG)
const activeSession = await prisma.workoutSession.findFirst({
where: { userId, endTime: null },
});
@@ -394,7 +560,8 @@ router.delete('/active', async (req: any, res) => {
await prisma.workoutSession.deleteMany({
where: {
userId,
endTime: null
endTime: null,
type: 'STANDARD'
}
});
@@ -419,4 +586,113 @@ router.delete('/:id', async (req: any, res) => {
}
});
// Get today's quick log session
router.get('/quick-log', async (req: any, res) => {
try {
const userId = req.user.userId;
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date();
endOfDay.setHours(23, 59, 59, 999);
const session = await prisma.workoutSession.findFirst({
where: {
userId,
type: 'QUICK_LOG',
startTime: {
gte: startOfDay,
lte: endOfDay
}
},
include: { sets: { include: { exercise: true }, orderBy: { timestamp: 'desc' } } }
});
if (!session) {
return res.json({ success: true, session: null });
}
// Map exercise properties to sets for frontend compatibility
const mappedSession = {
...session,
sets: session.sets.map((set: any) => ({
...set,
exerciseName: set.exercise.name,
type: set.exercise.type
}))
};
res.json({ success: true, session: mappedSession });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Server error' });
}
});
// Log a set to today's quick log session
router.post('/quick-log/set', async (req: any, res) => {
try {
const userId = req.user.userId;
const { exerciseId, weight, reps, distanceMeters, durationSeconds, height, bodyWeightPercentage, note, side } = req.body;
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date();
endOfDay.setHours(23, 59, 59, 999);
// Find or create today's quick log session
let session = await prisma.workoutSession.findFirst({
where: {
userId,
type: 'QUICK_LOG',
startTime: {
gte: startOfDay,
lte: endOfDay
}
}
});
if (!session) {
session = await prisma.workoutSession.create({
data: {
userId,
startTime: startOfDay,
type: 'QUICK_LOG',
note: 'Daily Quick Log'
}
});
}
// Create the set
const newSet = await prisma.workoutSet.create({
data: {
sessionId: session.id,
exerciseId,
order: 0, // Order not strictly enforced for quick log
weight: weight ? parseFloat(weight) : null,
reps: reps ? parseInt(reps) : null,
distanceMeters: distanceMeters ? parseFloat(distanceMeters) : null,
durationSeconds: durationSeconds ? parseInt(durationSeconds) : null,
height: height ? parseFloat(height) : null,
bodyWeightPercentage: bodyWeightPercentage ? parseFloat(bodyWeightPercentage) : null,
side: side || null,
completed: true,
timestamp: new Date()
},
include: { exercise: true }
});
const mappedSet = {
...newSet,
exerciseName: newSet.exercise.name,
type: newSet.exercise.type
};
res.json({ success: true, newSet: mappedSet });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Server error' });
}
});
export default router;

View File

@@ -1,195 +0,0 @@
import express from 'express';
import jwt from 'jsonwebtoken';
import prisma from '../lib/prisma';
const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
const authenticate = (req: any, res: any, next: any) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Unauthorized' });
try {
const decoded = jwt.verify(token, JWT_SECRET) as any;
req.user = decoded;
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
};
router.use(authenticate);
// Get all sporadic sets for the authenticated user
router.get('/', async (req: any, res) => {
try {
const userId = req.user.userId;
const sporadicSets = await prisma.sporadicSet.findMany({
where: { userId },
include: { exercise: true },
orderBy: { timestamp: 'desc' }
});
// Map to include exercise name and type
const mappedSets = sporadicSets.map(set => ({
id: set.id,
exerciseId: set.exerciseId,
exerciseName: set.exercise.name,
type: set.exercise.type,
weight: set.weight,
reps: set.reps,
distanceMeters: set.distanceMeters,
durationSeconds: set.durationSeconds,
height: set.height,
bodyWeightPercentage: set.bodyWeightPercentage,
timestamp: set.timestamp.getTime(),
note: set.note
}));
res.json({ success: true, sporadicSets: mappedSets });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Server error' });
}
});
// Create a new sporadic set
router.post('/', async (req: any, res) => {
try {
const userId = req.user.userId;
const { exerciseId, weight, reps, distanceMeters, durationSeconds, height, bodyWeightPercentage, note, side } = req.body;
if (!exerciseId) {
return res.status(400).json({ error: 'Exercise ID is required' });
}
// Verify that the exercise exists
const exercise = await prisma.exercise.findUnique({
where: { id: exerciseId }
});
if (!exercise) {
return res.status(400).json({ error: `Exercise with ID ${exerciseId} not found` });
}
const sporadicSet = await prisma.sporadicSet.create({
data: {
userId,
exerciseId,
weight: weight ? parseFloat(weight) : null,
reps: reps ? parseInt(reps) : null,
distanceMeters: distanceMeters ? parseFloat(distanceMeters) : null,
durationSeconds: durationSeconds ? parseInt(durationSeconds) : null,
height: height ? parseFloat(height) : null,
bodyWeightPercentage: bodyWeightPercentage ? parseFloat(bodyWeightPercentage) : null,
note: note || null,
side: side || null
},
include: { exercise: true }
});
const mappedSet = {
id: sporadicSet.id,
exerciseId: sporadicSet.exerciseId,
exerciseName: sporadicSet.exercise.name,
type: sporadicSet.exercise.type,
weight: sporadicSet.weight,
reps: sporadicSet.reps,
distanceMeters: sporadicSet.distanceMeters,
durationSeconds: sporadicSet.durationSeconds,
height: sporadicSet.height,
bodyWeightPercentage: sporadicSet.bodyWeightPercentage,
timestamp: sporadicSet.timestamp.getTime(),
note: sporadicSet.note,
side: sporadicSet.side
};
res.json({ success: true, sporadicSet: mappedSet });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Server error' });
}
});
// Update a sporadic set
router.put('/:id', async (req: any, res) => {
try {
const userId = req.user.userId;
const { id } = req.params;
const { weight, reps, distanceMeters, durationSeconds, height, bodyWeightPercentage, note, side } = req.body;
// Verify ownership
const existing = await prisma.sporadicSet.findFirst({
where: { id, userId }
});
if (!existing) {
return res.status(404).json({ error: 'Sporadic set not found' });
}
const updated = await prisma.sporadicSet.update({
where: { id },
data: {
weight: weight !== undefined ? (weight ? parseFloat(weight) : null) : undefined,
reps: reps !== undefined ? (reps ? parseInt(reps) : null) : undefined,
distanceMeters: distanceMeters !== undefined ? (distanceMeters ? parseFloat(distanceMeters) : null) : undefined,
durationSeconds: durationSeconds !== undefined ? (durationSeconds ? parseInt(durationSeconds) : null) : undefined,
height: height !== undefined ? (height ? parseFloat(height) : null) : undefined,
bodyWeightPercentage: bodyWeightPercentage !== undefined ? (bodyWeightPercentage ? parseFloat(bodyWeightPercentage) : null) : undefined,
note: note !== undefined ? note : undefined,
side: side !== undefined ? side : undefined
},
include: { exercise: true }
});
const mappedSet = {
id: updated.id,
exerciseId: updated.exerciseId,
exerciseName: updated.exercise.name,
type: updated.exercise.type,
weight: updated.weight,
reps: updated.reps,
distanceMeters: updated.distanceMeters,
durationSeconds: updated.durationSeconds,
height: updated.height,
bodyWeightPercentage: updated.bodyWeightPercentage,
timestamp: updated.timestamp.getTime(),
note: updated.note,
side: updated.side
};
res.json({ success: true, sporadicSet: mappedSet });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Server error' });
}
});
// Delete a sporadic set
router.delete('/:id', async (req: any, res) => {
try {
const userId = req.user.userId;
const { id } = req.params;
// Verify ownership
const existing = await prisma.sporadicSet.findFirst({
where: { id, userId }
});
if (!existing) {
return res.status(404).json({ error: 'Sporadic set not found' });
}
await prisma.sporadicSet.delete({
where: { id }
});
res.json({ success: true });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Server error' });
}
});
export default router;

View File

@@ -0,0 +1,21 @@
import { PrismaClient } from '@prisma/client';
import fs from 'fs';
import path from 'path';
const prisma = new PrismaClient();
async function backup() {
try {
console.log('Starting backup...');
const sporadicSets = await prisma.sporadicSet.findMany();
const backupPath = path.join(__dirname, '../../sporadic_backup.json');
fs.writeFileSync(backupPath, JSON.stringify(sporadicSets, null, 2));
console.log(`Backed up ${sporadicSets.length} sporadic sets to ${backupPath}`);
} catch (error) {
console.error('Backup failed:', error);
} finally {
await prisma.$disconnect();
}
}
backup();

View File

@@ -0,0 +1,77 @@
import { PrismaClient } from '@prisma/client';
import fs from 'fs';
import path from 'path';
const prisma = new PrismaClient();
async function restore() {
try {
const backupPath = path.join(__dirname, '../../sporadic_backup.json');
if (!fs.existsSync(backupPath)) {
console.error('Backup file not found!');
return;
}
const sporadicSets = JSON.parse(fs.readFileSync(backupPath, 'utf-8'));
console.log(`Found ${sporadicSets.length} sporadic sets to restore.`);
for (const set of sporadicSets) {
const date = new Date(set.timestamp);
const startOfDay = new Date(date);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(date);
endOfDay.setHours(23, 59, 59, 999);
// Find or create a QUICK_LOG session for this day
let session = await prisma.workoutSession.findFirst({
where: {
userId: set.userId,
type: 'QUICK_LOG',
startTime: {
gte: startOfDay,
lte: endOfDay
}
}
});
if (!session) {
session = await prisma.workoutSession.create({
data: {
userId: set.userId,
startTime: startOfDay, // Use start of day as session start
type: 'QUICK_LOG',
note: 'Daily Quick Log'
}
});
console.log(`Created new QUICK_LOG session for ${startOfDay.toISOString()}`);
}
// Create the WorkoutSet
await prisma.workoutSet.create({
data: {
sessionId: session.id,
exerciseId: set.exerciseId,
order: 0, // Order doesn't matter much for sporadic sets, or we could increment
weight: set.weight,
reps: set.reps,
distanceMeters: set.distanceMeters,
durationSeconds: set.durationSeconds,
height: set.height,
bodyWeightPercentage: set.bodyWeightPercentage,
side: set.side,
timestamp: new Date(set.timestamp),
completed: true
}
});
}
console.log('Restoration complete!');
} catch (error) {
console.error('Restoration failed:', error);
} finally {
await prisma.$disconnect();
}
}
restore();

View File

@@ -22,6 +22,7 @@ export interface WorkoutSet {
bodyWeightPercentage?: number; // Percentage of bodyweight used (e.g. 65 for pushups)
timestamp: number;
side?: 'LEFT' | 'RIGHT'; // For unilateral exercises
completed: boolean;
}
export interface WorkoutSession {
@@ -33,6 +34,7 @@ export interface WorkoutSession {
userBodyWeight?: number;
planId?: string; // Link to a plan if used
planName?: string;
type: 'STANDARD' | 'QUICK_LOG';
}
export interface ExerciseDef {
@@ -81,22 +83,6 @@ export interface BodyWeightRecord {
dateStr: string; // YYYY-MM-DD
}
export interface SporadicSet {
id: string;
exerciseId: string;
exerciseName: string;
type: ExerciseType;
reps?: number;
weight?: number;
durationSeconds?: number;
distanceMeters?: number;
height?: number;
bodyWeightPercentage?: number;
timestamp: number;
note?: string;
side?: 'LEFT' | 'RIGHT'; // For unilateral exercises
}
export interface User {
id: string;
email: string;