From 46c47eb7e551610833a7e2e854b42d676842d2e6 Mon Sep 17 00:00:00 2001 From: Kavith Date: Sat, 14 Feb 2026 23:32:57 +0000 Subject: [PATCH 01/14] Restrict set deletion to manually added sets during workout Add getPrescribedSetCounts query that looks up the original set count from dayExercises for each exercise log. Thread the prescribed count through the page loader, WorkoutWizard, and into ExerciseStep as a new prescribedSets prop. The delete button now only appears on sets whose index >= the prescribed count (i.e. sets added via "+ Add Set"). Standard programme sets can no longer be accidentally removed. --- .../components/workout/ExerciseStep.svelte | 11 ++- .../components/workout/WorkoutWizard.svelte | 3 + src/lib/server/db/queries/workouts.ts | 93 ++++++++++++++++++- .../workout/[sessionId]/+page.server.ts | 10 +- src/routes/workout/[sessionId]/+page.svelte | 1 + 5 files changed, 112 insertions(+), 6 deletions(-) diff --git a/src/lib/components/workout/ExerciseStep.svelte b/src/lib/components/workout/ExerciseStep.svelte index 23493c1..8948b83 100644 --- a/src/lib/components/workout/ExerciseStep.svelte +++ b/src/lib/components/workout/ExerciseStep.svelte @@ -25,6 +25,7 @@ let { exercise, overload, + prescribedSets, onupdateset, ontoggleunit, onaddset, @@ -32,12 +33,20 @@ }: { exercise: ExerciseLog; overload: OverloadData | undefined; + prescribedSets?: number; onupdateset: (setIndex: number, field: 'weight' | 'reps', value: number | null) => void; ontoggleunit: () => void; onaddset: () => void; onremoveset: (setIndex: number) => void; } = $props(); + function canRemoveSet(index: number): boolean { + // If no prescribed count is known, fall back to allowing removal when there's more than one set + if (prescribedSets == null) return exercise.sets.length > 1; + // Only allow removal of manually added sets (beyond the prescribed count) + return index >= prescribedSets; + } + function formatDate(date: Date): string { return new Date(date).toLocaleDateString('en-GB', { day: 'numeric', @@ -131,7 +140,7 @@ }} data-testid="reps-input-{i}" /> - {#if exercise.sets.length > 1} + {#if canRemoveSet(i)} + {:else if canRemoveSet(i)} @@ -237,16 +237,13 @@ class="space-y-2 pl-2" > {#each day.exercises as exercise (exercise.id)} -
+
From bea653e9abedea747af44a58a2da464f840cbc3b Mon Sep 17 00:00:00 2001 From: Kavith Date: Sun, 15 Feb 2026 11:27:59 +0000 Subject: [PATCH 10/14] Add infinite scroll to history page, remove dead exercise history code - Add /api/history GET endpoint for paginated session data - Add IntersectionObserver-based infinite scroll to /history page - Remove unused /history/by-exercise route and backing query functions (getHistoryByExercise, getExerciseHistory, ExerciseWithHistory, ExerciseHistoryEntry) and their tests - Add seed-test-data.mjs script for populating the DB with 2 years of sample workout data - Update architecture docs with new routes and test data section --- docs/architecture.md | 16 +- scripts/seed-test-data.mjs | 227 ++++++++++++++++++ src/lib/server/db/queries/history.test.ts | 199 +-------------- src/lib/server/db/queries/history.ts | 99 +------- src/routes/+page.svelte | 10 +- src/routes/api/history/+server.ts | 12 + src/routes/history/+page.svelte | 61 ++++- .../history/by-exercise/+page.server.ts | 8 - src/routes/history/by-exercise/+page.svelte | 72 ------ 9 files changed, 318 insertions(+), 386 deletions(-) create mode 100644 scripts/seed-test-data.mjs create mode 100644 src/routes/api/history/+server.ts delete mode 100644 src/routes/history/by-exercise/+page.server.ts delete mode 100644 src/routes/history/by-exercise/+page.svelte diff --git a/docs/architecture.md b/docs/architecture.md index 9c37258..27ac726 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,6 +44,8 @@ src/ static/ # PWA icons, favicon drizzle/ # Generated migrations e2e/ # Playwright E2E tests +scripts/ + seed-test-data.mjs # Wipe DB and populate with 2 years of sample data ``` ## Routes @@ -54,9 +56,9 @@ e2e/ # Playwright E2E tests | `/workout/start` | Form action to create session and redirect | | `/workout/[sessionId]` | Active workout logging interface | | `/workout/[sessionId]/summary` | Post-workout summary with PRs and stats | -| `/history` | Past workouts by date (default view) | -| `/history/by-exercise` | History grouped by exercise | +| `/history` | Past workouts by date with infinite scroll | | `/history/[sessionId]` | Single workout session details | +| `/api/history` | GET endpoint for paginated history (infinite scroll) | | `/programs` | List and manage programmes | | `/programs/new` | Create new programme | | `/programs/[programId]` | Edit existing programme | @@ -96,6 +98,16 @@ Days and exercises within the programme form are reordered via **drag-and-drop** The root layout server load calls `closeStaleWorkouts()` on every page load. Any `in_progress` session older than 4 hours is auto-completed with unlogged exercises marked as `skipped`. This prevents abandoned workouts from blocking new ones. +## Test Data + +To populate the database with realistic sample data for development or testing: + +```bash +node scripts/seed-test-data.mjs +``` + +This deletes the existing database, runs migrations to recreate the schema, then seeds ~240 completed workout sessions spanning 2 years (Feb 2024 -- Feb 2026) with progressive overload, sporadic scheduling, and occasional skipped exercises. + ## Key Design Decisions | Decision | Rationale | diff --git a/scripts/seed-test-data.mjs b/scripts/seed-test-data.mjs new file mode 100644 index 0000000..9600520 --- /dev/null +++ b/scripts/seed-test-data.mjs @@ -0,0 +1,227 @@ +/** + * Generates 2 years of realistic workout data for testing. + * Destroys the existing database and rebuilds it with migrations before seeding. + * + * Usage: node scripts/seed-test-data.mjs + */ + +import { execSync } from 'child_process'; +import { existsSync, unlinkSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); +const dbPath = join(root, 'data', 'workout-tracker.db'); + +// -- Step 1: Delete existing database -- + +if (existsSync(dbPath)) { + unlinkSync(dbPath); + console.log('Deleted existing database.'); +} else { + console.log('No existing database found.'); +} + +// -- Step 2: Run migrations to recreate schema -- + +console.log('Running migrations...'); +execSync('npm run db:migrate', { cwd: root, stdio: 'inherit' }); + +// -- Step 3: Seed data -- + +const Database = (await import('better-sqlite3')).default; +const db = new Database(dbPath); + +// Exercises +const exerciseRows = [ + ['Bench Press', 'kg'], + ['Overhead Press', 'kg'], + ['Incline Dumbbell Press', 'kg'], + ['Lateral Raise', 'kg'], + ['Tricep Dips', 'kg'], + ['Squat', 'kg'], + ['Deadlift', 'kg'], + ['Barbell Row', 'kg'], + ['Pull Up', 'kg'], + ['Romanian Deadlift', 'kg'], + ['Leg Press', 'kg'], + ['Face Pull', 'kg'] +]; + +const insertExercise = db.prepare( + `INSERT INTO exercises (name, unit_preference, created_at) VALUES (?, ?, ?)` +); +const epoch = Math.floor(new Date('2024-01-15').getTime() / 1000); + +const exerciseIds = {}; +for (const [name, unit] of exerciseRows) { + const result = insertExercise.run(name, unit, epoch); + exerciseIds[name] = Number(result.lastInsertRowid); +} +console.log(`Inserted ${exerciseRows.length} exercises.`); + +// Programme +const programResult = db + .prepare(`INSERT INTO programs (name, is_active, created_at, updated_at) VALUES (?, 1, ?, ?)`) + .run('Push Pull Legs', epoch, epoch); +const programId = Number(programResult.lastInsertRowid); + +// Workout days and day-exercise mappings +const dayTemplates = [ + { + name: 'Push', + exercises: [ + { name: 'Bench Press', baseWeight: 60, maxWeight: 95, sets: 4 }, + { name: 'Overhead Press', baseWeight: 35, maxWeight: 55, sets: 3 }, + { name: 'Incline Dumbbell Press', baseWeight: 20, maxWeight: 34, sets: 3 }, + { name: 'Lateral Raise', baseWeight: 8, maxWeight: 14, sets: 3 }, + { name: 'Tricep Dips', baseWeight: 0, maxWeight: 20, sets: 3 } + ] + }, + { + name: 'Pull', + exercises: [ + { name: 'Deadlift', baseWeight: 80, maxWeight: 140, sets: 3 }, + { name: 'Barbell Row', baseWeight: 50, maxWeight: 80, sets: 4 }, + { name: 'Pull Up', baseWeight: 0, maxWeight: 15, sets: 3 }, + { name: 'Face Pull', baseWeight: 10, maxWeight: 20, sets: 3 } + ] + }, + { + name: 'Legs', + exercises: [ + { name: 'Squat', baseWeight: 60, maxWeight: 110, sets: 4 }, + { name: 'Leg Press', baseWeight: 100, maxWeight: 200, sets: 3 }, + { name: 'Romanian Deadlift', baseWeight: 50, maxWeight: 90, sets: 3 } + ] + } +]; + +const insertDay = db.prepare( + `INSERT INTO workout_days (program_id, name, sort_order, created_at) VALUES (?, ?, ?, ?)` +); +const insertDayExercise = db.prepare( + `INSERT INTO day_exercises (workout_day_id, exercise_id, sets_count, sort_order, created_at) + VALUES (?, ?, ?, ?, ?)` +); + +for (let i = 0; i < dayTemplates.length; i++) { + const t = dayTemplates[i]; + const dayResult = insertDay.run(programId, t.name, i, epoch); + const dayId = Number(dayResult.lastInsertRowid); + for (let j = 0; j < t.exercises.length; j++) { + const ex = t.exercises[j]; + insertDayExercise.run(dayId, exerciseIds[ex.name], ex.sets, j, epoch); + } +} +console.log('Created programme with Push/Pull/Legs days.'); + +// -- Generate sessions from Feb 2024 to Feb 2026 -- + +const startDate = new Date('2024-02-01'); +const endDate = new Date('2026-02-10'); +const sessions = []; + +let current = new Date(startDate); +let dayIndex = 0; + +while (current < endDate) { + // 0-2 rest days between sessions + const skip = Math.floor(Math.random() * 3); + current.setDate(current.getDate() + 1 + skip); + + if (current >= endDate) break; + + // ~6% chance of skipping a whole week (holiday, illness) + if (Math.random() < 0.06) { + current.setDate(current.getDate() + 7); + continue; + } + + // ~10% chance of skipping a single session (busy day) + if (Math.random() < 0.1) continue; + + const day = dayTemplates[dayIndex % 3]; + dayIndex++; + + sessions.push({ date: new Date(current), day }); +} + +const insertSession = db.prepare( + `INSERT INTO workout_sessions (program_id, program_name, day_name, status, started_at, completed_at) + VALUES (?, ?, ?, 'completed', ?, ?)` +); +const insertLog = db.prepare( + `INSERT INTO exercise_logs (exercise_id, session_id, exercise_name, status, is_adhoc, sort_order, created_at) + VALUES (?, ?, ?, ?, 0, ?, ?)` +); +const insertSet = db.prepare( + `INSERT INTO set_logs (exercise_log_id, set_number, weight, reps, unit, created_at) + VALUES (?, ?, ?, ?, 'kg', ?)` +); + +const txn = db.transaction(() => { + for (let i = 0; i < sessions.length; i++) { + const s = sessions[i]; + const progress = i / sessions.length; + + // Session start: morning, ~07:00-09:59 + const startTime = new Date(s.date); + startTime.setHours(7 + Math.floor(Math.random() * 3), Math.floor(Math.random() * 60)); + const endTime = new Date(startTime); + endTime.setMinutes(endTime.getMinutes() + 45 + Math.floor(Math.random() * 30)); + + const startTs = Math.floor(startTime.getTime() / 1000); + const endTs = Math.floor(endTime.getTime() / 1000); + + const sessionResult = insertSession.run( + programId, + 'Push Pull Legs', + s.day.name, + startTs, + endTs + ); + const sessionId = Number(sessionResult.lastInsertRowid); + + for (let j = 0; j < s.day.exercises.length; j++) { + const ex = s.day.exercises[j]; + + // ~8% chance of skipping an exercise + const skipped = Math.random() < 0.08; + const status = skipped ? 'skipped' : 'logged'; + + const logResult = insertLog.run(exerciseIds[ex.name], sessionId, ex.name, status, j, startTs); + const logId = Number(logResult.lastInsertRowid); + + if (!skipped) { + const weightRange = ex.maxWeight - ex.baseWeight; + const currentWeight = ex.baseWeight + weightRange * progress; + const noise = 1 + (Math.random() - 0.5) * 0.1; + + for (let setNum = 1; setNum <= ex.sets; setNum++) { + let weight = Math.round((currentWeight * noise) / 2.5) * 2.5; + if (weight < 0) weight = 0; + + const baseReps = ex.name === 'Deadlift' ? 5 : ex.name === 'Pull Up' ? 8 : 10; + let reps = baseReps + Math.floor(Math.random() * 3) - (setNum > 2 ? 1 : 0); + if (reps < 3) reps = 3; + + insertSet.run(logId, setNum, weight, reps, startTs); + } + } + } + } +}); + +txn(); + +const sessionCount = db + .prepare("SELECT COUNT(*) as c FROM workout_sessions WHERE status='completed'") + .get(); +const setCount = db.prepare('SELECT COUNT(*) as c FROM set_logs').get(); + +console.log(`\nSeeded ${sessionCount.c} completed sessions with ${setCount.c} set logs.`); +console.log('Done.'); + +db.close(); diff --git a/src/lib/server/db/queries/history.test.ts b/src/lib/server/db/queries/history.test.ts index 5467740..39d7159 100644 --- a/src/lib/server/db/queries/history.test.ts +++ b/src/lib/server/db/queries/history.test.ts @@ -1,13 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { createTestDb } from '../test-helper'; -import { - getSessionsByDate, - getSessionDetail, - getHistoryByExercise, - getExerciseHistory, - deleteSession, - deleteExerciseLog -} from './history'; +import { getSessionsByDate, getSessionDetail, deleteSession, deleteExerciseLog } from './history'; import { startWorkout, getWorkoutSession, @@ -16,7 +9,7 @@ import { skipExercise } from './workouts'; import { createProgram, addWorkoutDay, addDayExercise, setActiveProgram } from './programs'; -import { exercises, exerciseLogs, setLogs, workoutSessions } from '../schema'; +import { exerciseLogs, setLogs, workoutSessions } from '../schema'; import { eq } from 'drizzle-orm'; describe('history queries', () => { @@ -208,194 +201,6 @@ describe('history queries', () => { }); }); - describe('getHistoryByExercise', () => { - it('returns empty array when no history exists', () => { - const result = getHistoryByExercise(db); - - expect(result).toEqual([]); - }); - - it('returns exercises with session count and last performed date', () => { - const { day } = setupProgramWithDay('PPL', 'Push', ['Bench Press']); - - completeWorkoutWithSets(day.id, [ - { exerciseName: 'Bench Press', sets: [{ weight: 60, reps: 10 }] } - ]); - completeWorkoutWithSets(day.id, [ - { exerciseName: 'Bench Press', sets: [{ weight: 70, reps: 8 }] } - ]); - - const result = getHistoryByExercise(db); - - expect(result).toHaveLength(1); - expect(result[0].name).toBe('Bench Press'); - expect(result[0].sessionCount).toBe(2); - expect(result[0].lastPerformed).toBeDefined(); - }); - - it('orders by exercise name alphabetically', () => { - const { day } = setupProgramWithDay('PPL', 'Push', [ - 'Overhead Press', - 'Bench Press', - 'Cable Flyes' - ]); - - completeWorkoutWithSets(day.id, [ - { exerciseName: 'Overhead Press', sets: [{ weight: 40, reps: 10 }] }, - { exerciseName: 'Bench Press', sets: [{ weight: 80, reps: 8 }] }, - { exerciseName: 'Cable Flyes', sets: [{ weight: 15, reps: 12 }] } - ]); - - const result = getHistoryByExercise(db); - - expect(result).toHaveLength(3); - expect(result[0].name).toBe('Bench Press'); - expect(result[1].name).toBe('Cable Flyes'); - expect(result[2].name).toBe('Overhead Press'); - }); - - it('counts distinct sessions (not duplicate entries)', () => { - const { day } = setupProgramWithDay('PPL', 'Push', ['Bench Press']); - - // Complete one session (Bench Press appears once per session) - completeWorkoutWithSets(day.id, [ - { exerciseName: 'Bench Press', sets: [{ weight: 60, reps: 10 }] } - ]); - - const result = getHistoryByExercise(db); - - expect(result).toHaveLength(1); - expect(result[0].sessionCount).toBe(1); - }); - - it('does not include exercises only in in-progress sessions', () => { - const { day } = setupProgramWithDay('PPL', 'Push', ['Bench Press']); - - // Start but do not complete - const session = startWorkout(db, day.id); - const ws = getWorkoutSession(db, session.id)!; - updateSetLog(db, ws.exerciseLogs[0].sets[0].id, { weight: 80, reps: 8 }); - - const result = getHistoryByExercise(db); - - expect(result).toEqual([]); - }); - }); - - describe('getExerciseHistory', () => { - it('returns empty object when no history exists for exercise', () => { - setupProgramWithDay('PPL', 'Push', ['Bench Press']); - const ex = db.select().from(exercises).where(eq(exercises.name, 'Bench Press')).get()!; - - const result = getExerciseHistory(db, ex.id); - - expect(result.entries).toEqual([]); - expect(result.total).toBe(0); - }); - - it('returns historical logs for a specific exercise', () => { - const { day } = setupProgramWithDay('PPL', 'Push', ['Bench Press']); - - completeWorkoutWithSets(day.id, [ - { exerciseName: 'Bench Press', sets: [{ weight: 60, reps: 10 }] } - ]); - completeWorkoutWithSets(day.id, [ - { exerciseName: 'Bench Press', sets: [{ weight: 70, reps: 8 }] } - ]); - - const ex = db.select().from(exercises).where(eq(exercises.name, 'Bench Press')).get()!; - const result = getExerciseHistory(db, ex.id); - - expect(result.entries).toHaveLength(2); - expect(result.total).toBe(2); - expect(result.entries[0].programName).toBe('PPL'); - expect(result.entries[0].dayName).toBe('Push'); - }); - - it('includes sets data', () => { - const { day } = setupProgramWithDay('PPL', 'Push', ['Bench Press'], 3); - - completeWorkoutWithSets(day.id, [ - { - exerciseName: 'Bench Press', - sets: [ - { weight: 60, reps: 10 }, - { weight: 70, reps: 8 }, - { weight: 80, reps: 5 } - ] - } - ]); - - const ex = db.select().from(exercises).where(eq(exercises.name, 'Bench Press')).get()!; - const result = getExerciseHistory(db, ex.id); - - expect(result.entries).toHaveLength(1); - expect(result.entries[0].sets).toHaveLength(3); - expect(result.entries[0].sets[0].weight).toBe(60); - expect(result.entries[0].sets[0].reps).toBe(10); - expect(result.entries[0].sets[1].weight).toBe(70); - expect(result.entries[0].sets[2].weight).toBe(80); - }); - - it('orders by completedAt descending', () => { - const { day } = setupProgramWithDay('PPL', 'Push', ['Bench Press']); - - completeWorkoutWithSets(day.id, [ - { exerciseName: 'Bench Press', sets: [{ weight: 60, reps: 10 }] } - ]); - completeWorkoutWithSets(day.id, [ - { exerciseName: 'Bench Press', sets: [{ weight: 70, reps: 8 }] } - ]); - - const ex = db.select().from(exercises).where(eq(exercises.name, 'Bench Press')).get()!; - const result = getExerciseHistory(db, ex.id); - - expect(result.entries).toHaveLength(2); - expect(result.entries[0].completedAt.getTime()).toBeGreaterThanOrEqual( - result.entries[1].completedAt.getTime() - ); - }); - - it('respects pagination', () => { - const { day } = setupProgramWithDay('PPL', 'Push', ['Bench Press']); - - for (let i = 0; i < 5; i++) { - completeWorkoutWithSets(day.id, [ - { exerciseName: 'Bench Press', sets: [{ weight: 60 + i * 5, reps: 10 }] } - ]); - } - - const ex = db.select().from(exercises).where(eq(exercises.name, 'Bench Press')).get()!; - - const page1 = getExerciseHistory(db, ex.id, 1, 2); - const page2 = getExerciseHistory(db, ex.id, 2, 2); - const page3 = getExerciseHistory(db, ex.id, 3, 2); - - expect(page1.entries).toHaveLength(2); - expect(page1.total).toBe(5); - expect(page2.entries).toHaveLength(2); - expect(page3.entries).toHaveLength(1); - }); - - it('includes skipped entries', () => { - const { day } = setupProgramWithDay('PPL', 'Push', ['Bench Press', 'OHP']); - - const session = startWorkout(db, day.id); - const ws = getWorkoutSession(db, session.id)!; - - // Log Bench Press, skip OHP - updateSetLog(db, ws.exerciseLogs[0].sets[0].id, { weight: 80, reps: 8 }); - skipExercise(db, ws.exerciseLogs[1].id); - completeWorkout(db, session.id); - - const ohpEx = db.select().from(exercises).where(eq(exercises.name, 'OHP')).get()!; - const result = getExerciseHistory(db, ohpEx.id); - - expect(result.entries).toHaveLength(1); - expect(result.entries[0].status).toBe('skipped'); - }); - }); - describe('deleteSession', () => { it('deletes a session and its exercise logs and set logs (cascade)', () => { const { day } = setupProgramWithDay('PPL', 'Push', ['Bench Press']); diff --git a/src/lib/server/db/queries/history.ts b/src/lib/server/db/queries/history.ts index 62b9cfb..d3f021e 100644 --- a/src/lib/server/db/queries/history.ts +++ b/src/lib/server/db/queries/history.ts @@ -1,4 +1,4 @@ -import { eq, and, asc, desc, count, countDistinct, sql } from 'drizzle-orm'; +import { eq, and, asc, desc, count } from 'drizzle-orm'; import { workoutSessions, exerciseLogs, setLogs } from '../schema'; import type { createTestDb } from '../test-helper'; import type { WorkoutSession } from './workouts'; @@ -16,23 +16,6 @@ export type SessionSummary = { skippedCount: number; }; -export type ExerciseHistoryEntry = { - exerciseLogId: number; - sessionId: number; - programName: string; - dayName: string; - completedAt: Date; - status: string; - sets: Array<{ setNumber: number; weight: number | null; reps: number | null; unit: string }>; -}; - -export type ExerciseWithHistory = { - id: number; - name: string; - sessionCount: number; - lastPerformed: Date | null; -}; - export function getSessionsByDate( db: Db, page: number = 1, @@ -114,86 +97,6 @@ export function getSessionDetail(db: Db, sessionId: number): WorkoutSession | nu return { ...session, exerciseLogs: logsWithSets }; } -export function getHistoryByExercise(db: Db): ExerciseWithHistory[] { - const rows = db - .select({ - id: exerciseLogs.exerciseId, - name: exerciseLogs.exerciseName, - sessionCount: countDistinct(exerciseLogs.sessionId), - lastPerformed: sql`MAX(${workoutSessions.completedAt})` - }) - .from(exerciseLogs) - .innerJoin(workoutSessions, eq(exerciseLogs.sessionId, workoutSessions.id)) - .where(eq(workoutSessions.status, 'completed')) - .groupBy(exerciseLogs.exerciseId, exerciseLogs.exerciseName) - .orderBy(asc(exerciseLogs.exerciseName)) - .all(); - - return rows.map((row) => ({ - id: row.id!, - name: row.name, - sessionCount: row.sessionCount, - lastPerformed: row.lastPerformed - })); -} - -export function getExerciseHistory( - db: Db, - exerciseId: number, - page: number = 1, - limit: number = 20 -): { entries: ExerciseHistoryEntry[]; total: number } { - const totalResult = db - .select({ count: countDistinct(exerciseLogs.id) }) - .from(exerciseLogs) - .innerJoin(workoutSessions, eq(exerciseLogs.sessionId, workoutSessions.id)) - .where(and(eq(exerciseLogs.exerciseId, exerciseId), eq(workoutSessions.status, 'completed'))) - .get(); - - const total = totalResult?.count ?? 0; - - const offset = (page - 1) * limit; - - const logs = db - .select({ - exerciseLogId: exerciseLogs.id, - sessionId: exerciseLogs.sessionId, - programName: workoutSessions.programName, - dayName: workoutSessions.dayName, - completedAt: workoutSessions.completedAt, - status: exerciseLogs.status - }) - .from(exerciseLogs) - .innerJoin(workoutSessions, eq(exerciseLogs.sessionId, workoutSessions.id)) - .where(and(eq(exerciseLogs.exerciseId, exerciseId), eq(workoutSessions.status, 'completed'))) - .orderBy(desc(workoutSessions.completedAt)) - .limit(limit) - .offset(offset) - .all(); - - const entries: ExerciseHistoryEntry[] = logs.map((log) => { - const sets = db - .select({ - setNumber: setLogs.setNumber, - weight: setLogs.weight, - reps: setLogs.reps, - unit: setLogs.unit - }) - .from(setLogs) - .where(eq(setLogs.exerciseLogId, log.exerciseLogId)) - .orderBy(asc(setLogs.setNumber)) - .all(); - - return { - ...log, - completedAt: log.completedAt!, - sets - }; - }); - - return { entries, total }; -} - export function deleteSession(db: Db, sessionId: number) { return db.delete(workoutSessions).where(eq(workoutSessions.id, sessionId)).run(); } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index ec98a1d..1560062 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -128,10 +128,8 @@
{/if} - {#if data.workoutDates.length > 0} -
-

Activity

- -
- {/if} +
+

Activity

+ +
diff --git a/src/routes/api/history/+server.ts b/src/routes/api/history/+server.ts new file mode 100644 index 0000000..311810f --- /dev/null +++ b/src/routes/api/history/+server.ts @@ -0,0 +1,12 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { getSessionsByDate } from '$lib/server/db/queries/history'; + +export const GET: RequestHandler = async ({ url }) => { + const page = Number(url.searchParams.get('page') ?? '1'); + const limit = Number(url.searchParams.get('limit') ?? '20'); + + const result = getSessionsByDate(db, page, limit); + return json(result); +}; diff --git a/src/routes/history/+page.svelte b/src/routes/history/+page.svelte index 1f1abb6..2fd9bac 100644 --- a/src/routes/history/+page.svelte +++ b/src/routes/history/+page.svelte @@ -18,15 +18,61 @@ AlertDialogHeader, AlertDialogTitle } from '$lib/components/ui/alert-dialog'; - import { ChevronRight, Ellipsis } from '@lucide/svelte'; + import { ChevronRight, Ellipsis, LoaderCircle } from '@lucide/svelte'; + import type { SessionSummary } from '$lib/server/db/queries/history'; let { data, form } = $props(); + let sessions = $state([]); + let currentPage = $state(1); + let loading = $state(false); + let hasMore = $state(true); + let sentinel = $state(); + let deleteDialogOpen = $state(false); let deleteTargetId = $state(null); let deleteTargetDay = $state(''); let deletingSession = $state(false); + // Initialise from server-loaded data + $effect(() => { + sessions = data.sessions; + currentPage = data.page; + hasMore = data.page * data.limit < data.total; + }); + + async function loadMore() { + if (loading || !hasMore) return; + loading = true; + + const nextPage = currentPage + 1; + const res = await fetch(`/api/history?page=${nextPage}&limit=${data.limit}`); + const result: { sessions: SessionSummary[]; total: number } = await res.json(); + + sessions = [...sessions, ...result.sessions]; + currentPage = nextPage; + hasMore = nextPage * data.limit < result.total; + loading = false; + } + + // Set up IntersectionObserver on the sentinel element + $effect(() => { + if (!sentinel) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + loadMore(); + } + }, + { rootMargin: '200px' } + ); + + observer.observe(sentinel); + + return () => observer.disconnect(); + }); + function openDeleteDialog(id: number, dayName: string) { deleteTargetId = id; deleteTargetDay = dayName; @@ -58,7 +104,7 @@
{/if} - {#if data.sessions.length === 0} + {#if sessions.length === 0}
{:else}
- {#each data.sessions as session (session.id)} + {#each sessions as session (session.id)}
{/each}
+ + + {#if hasMore} +
+ {#if loading} + + {/if} +
+ {/if} {/if}
diff --git a/src/routes/history/by-exercise/+page.server.ts b/src/routes/history/by-exercise/+page.server.ts deleted file mode 100644 index 3bb9c60..0000000 --- a/src/routes/history/by-exercise/+page.server.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { PageServerLoad } from './$types'; -import { db } from '$lib/server/db'; -import { getHistoryByExercise } from '$lib/server/db/queries/history'; - -export const load: PageServerLoad = async () => { - const exercises = getHistoryByExercise(db); - return { exercises }; -}; diff --git a/src/routes/history/by-exercise/+page.svelte b/src/routes/history/by-exercise/+page.svelte deleted file mode 100644 index 431fe10..0000000 --- a/src/routes/history/by-exercise/+page.svelte +++ /dev/null @@ -1,72 +0,0 @@ - - -
-

History

- -
- - By Date - - - By Exercise - -
- - {#if data.exercises.length === 0} -
-

- No exercise history yet. Complete a workout to see it here. -

-
- {:else} -
- {#each data.exercises as exercise (exercise.id)} -
-
-

{exercise.name}

-
-

- {exercise.sessionCount} - {exercise.sessionCount === 1 ? 'session' : 'sessions'} -

-

- Last performed: {exercise.lastPerformed - ? formatRelativeDate(exercise.lastPerformed) - : 'Never'} -

-
-
-
- {/each} -
- {/if} -
From 19b8a708553906feae379fcf7827c98b659438a0 Mon Sep 17 00:00:00 2001 From: Kavith Date: Mon, 16 Feb 2026 05:54:49 +0000 Subject: [PATCH 11/14] Fix activity grid: correct date query and make width responsive The workout dates query compared seconds (DB) to milliseconds (Date.getTime()), so no dates ever matched. Replaced raw SQL with Drizzle gte() operator. Made the grid measure its container width and compute how many week columns fit, instead of a fixed 16. Added bottom padding to prevent the today-ring from being clipped. Server now fetches 52 weeks of data to support wider screens. --- .../components/home/ConsistencyGrid.svelte | 96 +++++++++++-------- src/lib/server/db/queries/workouts.ts | 4 +- src/routes/+page.server.ts | 6 +- 3 files changed, 61 insertions(+), 45 deletions(-) diff --git a/src/lib/components/home/ConsistencyGrid.svelte b/src/lib/components/home/ConsistencyGrid.svelte index eb8367f..4bf40e5 100644 --- a/src/lib/components/home/ConsistencyGrid.svelte +++ b/src/lib/components/home/ConsistencyGrid.svelte @@ -2,12 +2,26 @@ /* eslint-disable svelte/prefer-svelte-reactivity -- Date used for computation only, not reactive state */ let { workoutDates }: { workoutDates: string[] } = $props(); - const WEEKS = 16; const DAYS = ['Mon', '', 'Wed', '', 'Fri', '', '']; + // Column step: cell (size-3.25 = 0.8125rem) + gap (gap-0.75 = 0.1875rem) = 1rem = 16px + // Label column + gap: ~2rem = 32px + const COL_STEP_PX = 16; + const LABEL_PX = 32; + const MAX_WEEKS = 52; + + let containerWidth = $state(0); + + let weekCount = $derived( + containerWidth > 0 + ? Math.max(4, Math.min(MAX_WEEKS, Math.floor((containerWidth - LABEL_PX) / COL_STEP_PX))) + : 0 + ); let workoutSet = $derived(new Set(workoutDates)); let grid = $derived.by(() => { + if (weekCount === 0) return []; + const today = new Date(); const todayStr = formatDate(today); @@ -17,16 +31,16 @@ const currentMonday = new Date(today); currentMonday.setDate(today.getDate() - mondayOffset); - // Go back WEEKS-1 more weeks to get the start + // Go back weekCount-1 more weeks to get the start const startMonday = new Date(currentMonday); - startMonday.setDate(currentMonday.getDate() - (WEEKS - 1) * 7); + startMonday.setDate(currentMonday.getDate() - (weekCount - 1) * 7); const weeks: Array<{ days: Array<{ date: string; hasWorkout: boolean; isToday: boolean; isFuture: boolean }>; monthLabel: string | null; }> = []; - for (let w = 0; w < WEEKS; w++) { + for (let w = 0; w < weekCount; w++) { const weekStart = new Date(startMonday); weekStart.setDate(startMonday.getDate() + w * 7); @@ -78,43 +92,45 @@ } -
-
- -
- {#each grid as week, wi (wi)} -
- {#if week.monthLabel} - {week.monthLabel} - {/if} -
- {/each} - - - {#each DAYS as dayLabel, dayIndex (dayIndex)} - -
- {dayLabel} -
- - +
+ {#if weekCount > 0} +
+ +
{#each grid as week, wi (wi)} - {@const day = week.days[dayIndex]} +
+ {#if week.monthLabel} + {week.monthLabel} + {/if} +
+ {/each} + + + {#each DAYS as dayLabel, dayIndex (dayIndex)} +
+ class="flex items-center justify-end pr-1 text-[10px] leading-none text-muted-foreground" + > + {dayLabel} +
+ + + {#each grid as week, wi (wi)} + {@const day = week.days[dayIndex]} +
+ {/each} {/each} - {/each} -
+
+ {/if}
diff --git a/src/lib/server/db/queries/workouts.ts b/src/lib/server/db/queries/workouts.ts index e09cf23..7e38cd6 100644 --- a/src/lib/server/db/queries/workouts.ts +++ b/src/lib/server/db/queries/workouts.ts @@ -1,4 +1,4 @@ -import { eq, and, asc, desc, lt, isNotNull, max, sql } from 'drizzle-orm'; +import { eq, and, asc, desc, lt, gte, isNotNull, max, sql } from 'drizzle-orm'; import { workoutSessions, exerciseLogs, @@ -542,7 +542,7 @@ export function getCompletedWorkoutDates(db: Db, since: Date): string[] { and( eq(workoutSessions.status, 'completed'), isNotNull(workoutSessions.completedAt), - sql`${workoutSessions.completedAt} >= ${since.getTime()}` + gte(workoutSessions.completedAt, since) ) ) .all(); diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 305c9ee..121462e 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -25,9 +25,9 @@ export const load: PageServerLoad = async () => { } } - const sixteenWeeksAgo = new Date(); - sixteenWeeksAgo.setDate(sixteenWeeksAgo.getDate() - 16 * 7); - const workoutDates = getCompletedWorkoutDates(db, sixteenWeeksAgo); + const oneYearAgo = new Date(); + oneYearAgo.setDate(oneYearAgo.getDate() - 52 * 7); + const workoutDates = getCompletedWorkoutDates(db, oneYearAgo); return { activeProgram, lastWorkout, workoutDates }; }; From bd4e3d94a9186ca7bdf8c0c53e2a9f6b974d1d1a Mon Sep 17 00:00:00 2001 From: Kavith Date: Mon, 16 Feb 2026 06:18:56 +0000 Subject: [PATCH 12/14] Improve copy-down UX and activity grid design Replace the visible copy-down arrow button with a tappable set number and placeholder hints, keeping input widths consistent across all rows. Redesign the activity grid: add a bordered card container, make it horizontally scrollable (starting at the latest date), show all historical data instead of capping at 52 weeks, improve contrast on missed-day cells, and add year labels above January month markers. --- .../components/home/ConsistencyGrid.svelte | 145 +++++++++++------- .../components/workout/ExerciseStep.svelte | 51 +++--- src/lib/server/db/queries/workouts.ts | 16 +- src/routes/+page.server.ts | 4 +- 4 files changed, 134 insertions(+), 82 deletions(-) diff --git a/src/lib/components/home/ConsistencyGrid.svelte b/src/lib/components/home/ConsistencyGrid.svelte index 4bf40e5..e4a6c7a 100644 --- a/src/lib/components/home/ConsistencyGrid.svelte +++ b/src/lib/components/home/ConsistencyGrid.svelte @@ -1,21 +1,38 @@ -
- {#if weekCount > 0} -
- -
- {#each grid as week, wi (wi)} -
- {#if week.monthLabel} - {week.monthLabel} - {/if} -
- {/each} - - - {#each DAYS as dayLabel, dayIndex (dayIndex)} - -
- {dayLabel} -
- - +
+
+ {#if weekCount > 0} +
+ +
{#each grid as week, wi (wi)} - {@const day = week.days[dayIndex]}
+ class="flex flex-col items-start justify-end text-[10px] leading-none text-muted-foreground" + > + {#if week.monthLabel} + {#if week.monthLabel.year} + {week.monthLabel.year} + {/if} + {week.monthLabel.month} + {/if} +
+ {/each} + + + {#each DAYS as dayLabel, dayIndex (dayIndex)} + +
+ {dayLabel} +
+ + + {#each grid as week, wi (wi)} + {@const day = week.days[dayIndex]} +
+ {/each} {/each} - {/each} -
- {/if} +
+ {/if} +
diff --git a/src/lib/components/workout/ExerciseStep.svelte b/src/lib/components/workout/ExerciseStep.svelte index 200d235..84aabef 100644 --- a/src/lib/components/workout/ExerciseStep.svelte +++ b/src/lib/components/workout/ExerciseStep.svelte @@ -2,7 +2,6 @@ import { Input } from '$lib/components/ui/input'; import { Button } from '$lib/components/ui/button'; import { Badge } from '$lib/components/ui/badge'; - import { ArrowDown } from '@lucide/svelte'; interface SetData { id: number; @@ -62,6 +61,16 @@ if (prev.reps != null) onupdateset(index, 'reps', prev.reps); } + function getPreviousValues(index: number): { weight: string; reps: string } | null { + if (index === 0) return null; + const prev = exercise.sets[index - 1]; + if (!prev || (prev.weight == null && prev.reps == null)) return null; + return { + weight: prev.weight != null ? String(prev.weight) : '', + reps: prev.reps != null ? String(prev.reps) : '' + }; + } + function formatDate(date: Date): string { return new Date(date).toLocaleDateString('en-GB', { day: 'numeric', @@ -131,7 +140,7 @@
Set @@ -149,13 +158,27 @@
{#each exercise.sets as set, i (set.id)} -
- - {set.setNumber} - + {@const prev = canCopyDown(i) ? getPreviousValues(i) : null} +
+ {#if canCopyDown(i)} + + {:else} + + {set.setNumber} + + {/if} { @@ -176,23 +200,12 @@ }} data-testid="reps-input-{i}" /> - {#if canCopyDown(i)} - - {:else if canRemoveSet(i)} + {#if canRemoveSet(i)}