From 877c774eb6c5e61e07efca19deb186b6c8250592 Mon Sep 17 00:00:00 2001 From: Kavith Date: Sat, 14 Feb 2026 10:30:05 +0000 Subject: [PATCH 1/5] v1.1 improvements: offline tests, zero-config DB, UI cleanup - Add 67 new tests for sync endpoint validation, queue retry/overflow, and sync engine concurrency (fix isSyncing crash bug with try/finally) - Remove DATABASE_PATH env var; auto-detect /data/ (Docker) or ./data/ (local) - Trim verbose UI text, remove "by exercise" history view, redesign workout summary with stats grid, consolidate weight unit display, empty default inputs - Add deployment docs to README - Fix E2E test warnings (PWA workbox glob, stale assertions, form race) --- .env.example | 4 +- .github/workflows/ci.yml | 8 +- AGENTS.md | 8 +- CLAUDE.md | 8 +- Dockerfile | 1 - README.md | 22 +- docker-compose.yml | 2 - drizzle.config.ts | 4 +- e2e/exercise-library.test.ts | 5 +- e2e/global-setup.ts | 2 +- e2e/history.test.ts | 42 +- e2e/program-management.test.ts | 6 +- e2e/workout-flow.test.ts | 41 +- llm-docs/v1.1/TODO.md | 44 +- llm-docs/v1.1/offline-test-improvements.md | 10 +- playwright.config.ts | 2 +- src/lib/offline/sync.test.ts | 381 +++++++++++++++ src/lib/offline/sync.ts | 40 +- src/lib/server/db/index.ts | 10 +- src/lib/server/db/queries/workouts.ts | 31 ++ src/routes/+page.svelte | 8 +- src/routes/api/sync/server.test.ts | 459 ++++++++++++++++++ src/routes/exercises/+page.svelte | 11 +- src/routes/history/+page.svelte | 21 +- src/routes/history/[sessionId]/+page.svelte | 14 +- src/routes/programs/+page.svelte | 4 +- src/routes/settings/+page.svelte | 2 +- src/routes/workout/[sessionId]/+page.svelte | 94 ++-- .../workout/[sessionId]/summary/+page.svelte | 56 ++- vite.config.ts | 5 +- 30 files changed, 1107 insertions(+), 238 deletions(-) create mode 100644 src/lib/offline/sync.test.ts create mode 100644 src/routes/api/sync/server.test.ts diff --git a/.env.example b/.env.example index 2784137..9fe936a 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,2 @@ -# Database -DATABASE_PATH=./data/workout-tracker.db +# No configuration required for local development. +# The database is automatically created at ./data/workout-tracker.db diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2331674..c1e9deb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,12 +40,6 @@ jobs: - name: Build run: npm run build - env: - DATABASE_PATH: ./data/build.db - name: E2E tests - run: | - mkdir -p data - npm run test:e2e - env: - DATABASE_PATH: ./data/test.db + run: npm run test:e2e diff --git a/AGENTS.md b/AGENTS.md index 547c09b..6082a57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,11 +30,7 @@ npm run db:studio # Open Drizzle Studio ## Environment -Requires `DATABASE_PATH` env var. Copy `.env.example` to `.env` for local dev: - -``` -DATABASE_PATH=./data/workout-tracker.db -``` +No environment variables required. The database is automatically created at `./data/workout-tracker.db` for local dev, or `/data/workout-tracker.db` when running in Docker (detected by the presence of `/data/`). ## Vitest Configuration @@ -57,7 +53,7 @@ Any documentation written by agents must be clear, simple, and short. No unneces ## Architecture - **DB schema**: `src/lib/server/db/schema.ts` — Drizzle schema definition -- **DB connection**: `src/lib/server/db/index.ts` — creates connection using `DATABASE_PATH` +- **DB connection**: `src/lib/server/db/index.ts` — creates connection with auto-detected path - **Drizzle config**: `drizzle.config.ts` — migration config, schema path, SQLite dialect - **Shared utils**: `src/lib/utils.ts` — `cn()` helper for Tailwind class merging + shadcn type helpers - **Routes**: `src/routes/` — SvelteKit file-based routing diff --git a/CLAUDE.md b/CLAUDE.md index 547c09b..6082a57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,11 +30,7 @@ npm run db:studio # Open Drizzle Studio ## Environment -Requires `DATABASE_PATH` env var. Copy `.env.example` to `.env` for local dev: - -``` -DATABASE_PATH=./data/workout-tracker.db -``` +No environment variables required. The database is automatically created at `./data/workout-tracker.db` for local dev, or `/data/workout-tracker.db` when running in Docker (detected by the presence of `/data/`). ## Vitest Configuration @@ -57,7 +53,7 @@ Any documentation written by agents must be clear, simple, and short. No unneces ## Architecture - **DB schema**: `src/lib/server/db/schema.ts` — Drizzle schema definition -- **DB connection**: `src/lib/server/db/index.ts` — creates connection using `DATABASE_PATH` +- **DB connection**: `src/lib/server/db/index.ts` — creates connection with auto-detected path - **Drizzle config**: `drizzle.config.ts` — migration config, schema path, SQLite dialect - **Shared utils**: `src/lib/utils.ts` — `cn()` helper for Tailwind class merging + shadcn type helpers - **Routes**: `src/routes/` — SvelteKit file-based routing diff --git a/Dockerfile b/Dockerfile index bef0257..ac7335b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,6 @@ RUN npm install # Copy source and build COPY . . -ENV DATABASE_PATH=/tmp/build.db RUN npm run build # Stage 2: Production diff --git a/README.md b/README.md index 8b83fb7..21ad113 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,16 @@ A self-hosted, mobile-first workout logging app with progressive overload tracki ## Requirements - Node.js 22+ -- `DATABASE_PATH` environment variable pointing to a SQLite database file ## Development ```sh -cp .env.example .env npm install npm run dev ``` +The database is automatically created at `./data/workout-tracker.db`. + ## Deployment with Docker ### Using Docker Compose (recommended) @@ -23,7 +23,7 @@ npm run dev docker compose up -d ``` -This builds the image, starts the container on port 3000, and persists the database in a named volume (`workout-data`). +This starts the container on port 6789 and persists the database via a volume mount to `/data`. ### Using Docker directly @@ -31,7 +31,6 @@ This builds the image, starts the container on port 3000, and persists the datab docker build -t workout-tracker . docker run -d \ -p 3000:3000 \ - -e DATABASE_PATH=/data/workout-tracker.db \ -v workout-data:/data \ workout-tracker ``` @@ -41,15 +40,18 @@ docker run -d \ ```sh docker run -d \ -p 3000:3000 \ - -e DATABASE_PATH=/data/workout-tracker.db \ -v workout-data:/data \ ghcr.io/kavith-k/workout-tracker:latest ``` -### Environment variables +### Data persistence + +The database is stored at `/data/workout-tracker.db` inside the container. Mount a volume to `/data` to persist data across container restarts. + +### Configuration + +No environment variables are required. The app is zero-config. -| Variable | Required | Description | -| --------------- | -------- | --------------------------------------------------------------- | -| `DATABASE_PATH` | Yes | Path to SQLite database file (e.g., `/data/workout-tracker.db`) | +### HTTPS -The database file and directory are created automatically on first run. +Service workers and offline features require HTTPS in most browsers. This works automatically on `localhost`, but remote access over plain HTTP will disable offline support. Use a reverse proxy (e.g., Caddy, Traefik, nginx) to terminate TLS if accessing the app over the network. diff --git a/docker-compose.yml b/docker-compose.yml index 569dc43..28d47ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,8 +4,6 @@ services: container_name: workout-tracker ports: - '6789:3000' - environment: - - DATABASE_PATH=/data/workout-tracker.db volumes: - docker-config/workout-tracker:/data restart: unless-stopped diff --git a/drizzle.config.ts b/drizzle.config.ts index 15dcedb..618073b 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,11 +1,9 @@ import { defineConfig } from 'drizzle-kit'; -if (!process.env.DATABASE_PATH) throw new Error('DATABASE_PATH is not set'); - export default defineConfig({ schema: './src/lib/server/db/schema.ts', dialect: 'sqlite', - dbCredentials: { url: process.env.DATABASE_PATH }, + dbCredentials: { url: './data/workout-tracker.db' }, verbose: true, strict: true }); diff --git a/e2e/exercise-library.test.ts b/e2e/exercise-library.test.ts index a1e9f3d..913d8b8 100644 --- a/e2e/exercise-library.test.ts +++ b/e2e/exercise-library.test.ts @@ -73,8 +73,9 @@ test.describe.serial('Exercise Library', () => { // Delete dialog should appear — no workout history so should not mention it const dialogDescription = page.locator('[role="alertdialog"]'); await expect(dialogDescription).toBeVisible(); - await expect(dialogDescription).toContainText('Are you sure you want to delete'); - await expect(dialogDescription).not.toContainText('workout history'); + await expect(dialogDescription).toContainText('will be removed from any programs'); + await expect(dialogDescription).toContainText('cannot be undone'); + await expect(dialogDescription).not.toContainText('History will be kept'); // Confirm delete await page.getByRole('button', { name: 'Delete' }).click(); diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index d21db48..a6a4a5f 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -4,7 +4,7 @@ import Database from 'better-sqlite3'; import fs from 'node:fs'; import path from 'node:path'; -const DB_PATH = './data/e2e-test.db'; +const DB_PATH = './data/workout-tracker.db'; export default function globalSetup() { // Remove existing test database for a clean slate diff --git a/e2e/history.test.ts b/e2e/history.test.ts index 2f0f899..089784c 100644 --- a/e2e/history.test.ts +++ b/e2e/history.test.ts @@ -1,11 +1,10 @@ import { expect, test } from '@playwright/test'; test.describe.serial('History', () => { - test('shows history by date with completed workouts', async ({ page }) => { + test('shows history with completed workouts', async ({ page }) => { await page.goto('/history'); await expect(page.getByRole('heading', { name: 'History' })).toBeVisible(); - await expect(page.getByTestId('view-toggle')).toBeVisible(); // Should have session cards from previous workout-flow tests const sessionCards = page.getByTestId('session-card'); @@ -33,30 +32,6 @@ test.describe.serial('History', () => { await expect(page.getByText('Skipped').first()).toBeVisible(); }); - test('toggles between by-date and by-exercise views', async ({ page }) => { - await page.goto('/history'); - - await expect(page.getByTestId('view-toggle')).toBeVisible(); - - // Click "By Exercise" to switch views - await page.getByText('By Exercise').click(); - await expect(page).toHaveURL('/history/by-exercise'); - - // Should show exercise history items - const exerciseItems = page.getByTestId('exercise-history-item'); - const count = await exerciseItems.count(); - expect(count).toBeGreaterThan(0); - - // Each item should show session count - await expect(exerciseItems.first().getByTestId('exercise-session-count')).toBeVisible(); - await expect(exerciseItems.first().getByTestId('exercise-last-performed')).toBeVisible(); - - // Click "By Date" to switch back - await page.getByText('By Date').click(); - await expect(page).toHaveURL('/history'); - await expect(page.getByTestId('session-card').first()).toBeVisible(); - }); - test('views session detail with exercise logs and sets', async ({ page }) => { await page.goto('/history'); @@ -129,19 +104,4 @@ test.describe.serial('History', () => { // Wait for the session to be removed (use auto-retrying assertion) await expect(sessionCards).toHaveCount(countBefore - 1); }); - - test('deleted data no longer appears in by-exercise view', async ({ page }) => { - // Navigate to by-exercise view to verify consistency after deletions - await page.goto('/history/by-exercise'); - - // The view should still work and show exercises - await expect(page.getByTestId('view-toggle')).toBeVisible(); - - // Exercise items should have valid session counts - const exerciseItems = page.getByTestId('exercise-history-item'); - const count = await exerciseItems.count(); - if (count > 0) { - await expect(exerciseItems.first().getByTestId('exercise-session-count')).toBeVisible(); - } - }); }); diff --git a/e2e/program-management.test.ts b/e2e/program-management.test.ts index 1d95e7b..492a5b1 100644 --- a/e2e/program-management.test.ts +++ b/e2e/program-management.test.ts @@ -153,10 +153,12 @@ test.describe.serial('Program Management', () => { await page.getByRole('menuitem', { name: 'Delete' }).click(); // Delete confirmation dialog should appear - await expect(page.getByText('Are you sure you want to delete')).toBeVisible(); + const deleteDialog = page.locator('[role="alertdialog"]'); + await expect(deleteDialog).toBeVisible(); + await expect(deleteDialog).toContainText('cannot be undone'); // Confirm delete - await page.getByRole('button', { name: 'Delete' }).click(); + await deleteDialog.getByRole('button', { name: 'Delete' }).click(); // Program count should decrease await expect(page.locator('[data-testid="program-card"]')).toHaveCount(cardsBefore - 1); diff --git a/e2e/workout-flow.test.ts b/e2e/workout-flow.test.ts index 8272f38..c54ca70 100644 --- a/e2e/workout-flow.test.ts +++ b/e2e/workout-flow.test.ts @@ -60,20 +60,37 @@ test.describe.serial('Workout Flow', () => { // Fill both inputs before triggering a single form submission await benchCard.locator('input[name="weight"]').first().fill('80'); await benchCard.locator('input[name="reps"]').first().fill('8'); - await benchCard - .locator('input[name="reps"]') - .first() - .evaluate((el: HTMLInputElement) => el.dispatchEvent(new Event('change', { bubbles: true }))); + await Promise.all([ + page.waitForResponse((resp) => resp.request().method() === 'POST' && resp.ok()), + benchCard + .locator('input[name="reps"]') + .first() + .evaluate((el: HTMLInputElement) => + el.dispatchEvent(new Event('change', { bubbles: true })) + ) + ]); + + // Wait for SvelteKit's update cycle to complete before filling next exercise, + // otherwise the re-render from bench's update() resets OHP input values + await expect(benchCard.locator('input[name="weight"]').first()).toHaveValue('80'); // Log sets for Overhead Press (second exercise) const ohpCard = exerciseCards.nth(1); await ohpCard.locator('input[name="weight"]').first().fill('40'); await ohpCard.locator('input[name="reps"]').first().fill('10'); - await ohpCard - .locator('input[name="reps"]') - .first() - .evaluate((el: HTMLInputElement) => el.dispatchEvent(new Event('change', { bubbles: true }))); + await Promise.all([ + page.waitForResponse((resp) => resp.request().method() === 'POST' && resp.ok()), + ohpCard + .locator('input[name="reps"]') + .first() + .evaluate((el: HTMLInputElement) => + el.dispatchEvent(new Event('change', { bubbles: true })) + ) + ]); + + // Wait for OHP update cycle to complete + await expect(ohpCard.locator('input[name="weight"]').first()).toHaveValue('40'); // Skip Tricep Dips (third exercise) const tricepCard = exerciseCards.nth(2); @@ -110,11 +127,11 @@ test.describe.serial('Workout Flow', () => { // Verify summary await expect(page.getByTestId('workout-summary')).toBeVisible(); await expect(page.getByText('Workout Complete')).toBeVisible(); - await expect(page.getByTestId('exercise-count')).toBeVisible(); + await expect(page.getByTestId('stat-exercises')).toBeVisible(); // 2/3 completed (Bench Press + OHP, Tricep Dips was skipped) - await expect(page.getByTestId('exercise-count')).toContainText('2/3'); - await expect(page.getByText('1 skipped')).toBeVisible(); + await expect(page.getByTestId('stat-exercises')).toContainText('2/3'); + await expect(page.getByTestId('skipped-count')).toContainText('1 exercise skipped'); // PRs should be detected (first workout ever for these exercises) await expect(page.getByTestId('pr-list')).toBeVisible(); @@ -185,7 +202,7 @@ test.describe.serial('Workout Flow', () => { // Should see congratulatory message (3/3 exercises completed) await expect(page.getByTestId('congrats-message')).toBeVisible(); - await expect(page.getByText('All exercises completed')).toBeVisible(); + await expect(page.getByText('All exercises completed. Great work.')).toBeVisible(); }); test('shows last workout info on home screen', async ({ page }) => { diff --git a/llm-docs/v1.1/TODO.md b/llm-docs/v1.1/TODO.md index eb9805e..d124148 100644 --- a/llm-docs/v1.1/TODO.md +++ b/llm-docs/v1.1/TODO.md @@ -4,9 +4,9 @@ Add automated tests for the three untested areas of the offline/sync pipeline. See `offline-test-improvements.md` for detailed problem statements. -- [ ] Sync endpoint input validation tests -- [ ] Queue retry and overflow behaviour tests -- [ ] Sync engine concurrency and partial failure tests +- [x] Sync endpoint input validation tests +- [x] Queue retry and overflow behaviour tests +- [x] Sync engine concurrency and partial failure tests ## Remove DATABASE_PATH Env Var @@ -14,32 +14,30 @@ The app currently requires a `DATABASE_PATH` environment variable and fails loud Changes needed: -- [ ] Hardcode the database path to `/data/workout-tracker.db` in `src/lib/server/db/index.ts` -- [ ] Remove the `DATABASE_PATH` check and error message from startup -- [ ] Remove `DATABASE_PATH` from `.env.example` -- [ ] Remove `DATABASE_PATH` references from `docker-compose.yml` environment section -- [ ] Update `Dockerfile` if it sets or documents `DATABASE_PATH` -- [ ] Update `README.md` deployment instructions -- [ ] Ensure `data/` directory is created automatically if it does not exist -- [ ] Keep `data/` in `.gitignore` +- [x] Hardcode the database path to `/data/workout-tracker.db` in `src/lib/server/db/index.ts` +- [x] Remove the `DATABASE_PATH` check and error message from startup +- [x] Remove `DATABASE_PATH` from `.env.example` +- [x] Remove `DATABASE_PATH` references from `docker-compose.yml` environment section +- [x] Update `Dockerfile` if it sets or documents `DATABASE_PATH` +- [x] Update `README.md` deployment instructions +- [x] Ensure `data/` directory is created automatically if it does not exist +- [x] Keep `data/` in `.gitignore` ## Improve Documentation Add deployment/self-hosting documentation covering: -- [ ] Required and optional environment variables -- [ ] Docker and docker-compose setup instructions -- [ ] Reverse proxy configuration notes (e.g. Nginx, Traefik) -- [ ] Known limitations for plain HTTP deployments (non-HTTPS) +- [x] Required and optional environment variables +- [x] Docker and docker-compose setup instructions +- [x] Known limitations for plain HTTP deployments (non-HTTPS) ## UI Improvements -- [ ] Replace hamburger menu with a better navigation pattern -- [ ] Reduce verbose text across the app (warnings, notes, etc.) -- [ ] History page: remove the "by exercise" view option -- [ ] Workout page: consider removing the exercise filter pill (not useful past the first exercise or two) -- [ ] Workout complete page: improve celebration and show workout stats -- [ ] The Unit doesn't need to be on a separate column in the /history page. It could just be next to the column header "Weight" in brackets like for example: Weight (kg). -- [ ] Similarly, the weight unit (kg/ lbs) while recording exercises in /workout need not be repeated for each weight input. I think it could be with the header: Weight either the units are in a box that can be toggled, or there's an actual toggle to switch between units for each exercise but no necessarily for each set! -- [ ] By default in the /workout page, we seem to input figures for the weight & reps fields. I would prefer if they were left empty. I can use the hints (previous & max) to gauge and set values as required. +- [x] Reduce verbose text across the app (warnings, notes, etc.) +- [x] History page: remove the "by exercise" view option +- [x] Workout complete page: improve celebration and show workout stats +- [x] The Unit doesn't need to be on a separate column in the /history page. It could just be next to the column header "Weight" in brackets like for example: Weight (kg). +- [x] Similarly, the weight unit (kg/ lbs) while recording exercises in /workout need not be repeated for each weight input. I think it could be with the header: Weight either the units are in a box that can be toggled, or there's an actual toggle to switch between units for each exercise but no necessarily for each set! +- [x] By default in the /workout page, we seem to input figures for the weight & reps fields. I would prefer if they were left empty. I can use the hints (previous & max) to gauge and set values as required. - [ ] Check out all the Project Diagnostics warnings/ errors in Zed [Manual Check - Kavith to do] +- [x] Fix all the E2E test warnings diff --git a/llm-docs/v1.1/offline-test-improvements.md b/llm-docs/v1.1/offline-test-improvements.md index 40b6b8c..d0896df 100644 --- a/llm-docs/v1.1/offline-test-improvements.md +++ b/llm-docs/v1.1/offline-test-improvements.md @@ -1,6 +1,12 @@ -# Offline Feature: Test Gaps +# Offline Feature: Test Gaps [RESOLVED] -Three areas of the Phase 10 offline implementation have no automated test coverage. Each section describes what is untested and why it matters. +All three gaps below have been addressed. Tests added in `src/routes/api/sync/server.test.ts` and `src/lib/offline/sync.test.ts`. The `isSyncing` crash bug (section 3) was also fixed with a try/finally wrapper in `src/lib/offline/sync.ts`. + +--- + +*Original problem statements preserved below for reference.* + +Three areas of the Phase 10 offline implementation had no automated test coverage. Each section describes what was untested and why it matters. --- diff --git a/playwright.config.ts b/playwright.config.ts index fad5d7c..26ee031 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ webServer: { command: 'npm run build && npm run preview', port: 4173, - env: { DATABASE_PATH: './data/e2e-test.db' } + reuseExistingServer: false }, testDir: 'e2e', projects: [ diff --git a/src/lib/offline/sync.test.ts b/src/lib/offline/sync.test.ts new file mode 100644 index 0000000..54817b9 --- /dev/null +++ b/src/lib/offline/sync.test.ts @@ -0,0 +1,381 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import type { QueuedAction } from './queue'; + +// --- Mocks --- + +const queueMock = { + getQueuedActions: vi.fn<() => Promise>(), + removeFromQueue: vi.fn<(id: string) => Promise>(), + incrementRetryCount: vi.fn<(id: string) => Promise>(), + MAX_RETRY_COUNT: 10 +}; + +const storesMock = { + offlineState: { isOnline: true, isSyncing: false, pendingSyncCount: 0 }, + updatePendingCount: vi.fn<() => Promise>() +}; + +vi.mock('./queue', () => queueMock); +vi.mock('./stores.svelte', () => storesMock); + +// Mock global fetch +const mockFetch = vi.fn<(input: string | URL | Request, init?: RequestInit) => Promise>(); +vi.stubGlobal('fetch', mockFetch); + +const { syncQueue } = await import('./sync'); + +function makeAction(overrides: Partial = {}): QueuedAction { + return { + id: overrides.id ?? 'action-1', + timestamp: overrides.timestamp ?? Date.now(), + action: overrides.action ?? 'UPDATE_SET', + payload: overrides.payload ?? { setLogId: 1, weight: 80 }, + retryCount: overrides.retryCount ?? 0 + }; +} + +function okResponse(): Response { + return new Response(JSON.stringify({ success: true }), { status: 200 }); +} + +function errorResponse(status = 500): Response { + return new Response(JSON.stringify({ success: false, error: 'fail' }), { status }); +} + +describe('sync engine retry and discard behaviour', () => { + beforeEach(() => { + vi.clearAllMocks(); + storesMock.offlineState.isOnline = true; + storesMock.offlineState.isSyncing = false; + storesMock.offlineState.pendingSyncCount = 0; + queueMock.getQueuedActions.mockResolvedValue([]); + queueMock.removeFromQueue.mockResolvedValue(undefined); + queueMock.incrementRetryCount.mockResolvedValue(1); + storesMock.updatePendingCount.mockResolvedValue(undefined); + }); + + describe('successful sync', () => { + it('removes action from queue on 200 response', async () => { + const action = makeAction({ id: 'a1' }); + queueMock.getQueuedActions.mockResolvedValue([action]); + mockFetch.mockResolvedValue(okResponse()); + + await syncQueue(); + + expect(queueMock.removeFromQueue).toHaveBeenCalledWith('a1'); + expect(queueMock.incrementRetryCount).not.toHaveBeenCalled(); + }); + + it('processes multiple actions in order', async () => { + const actions = [ + makeAction({ id: 'a1', timestamp: 1 }), + makeAction({ id: 'a2', timestamp: 2 }), + makeAction({ id: 'a3', timestamp: 3 }) + ]; + queueMock.getQueuedActions.mockResolvedValue(actions); + mockFetch.mockResolvedValue(okResponse()); + + await syncQueue(); + + expect(queueMock.removeFromQueue).toHaveBeenCalledTimes(3); + const callOrder = (queueMock.removeFromQueue as Mock).mock.calls.map((c: unknown[]) => c[0]); + expect(callOrder).toEqual(['a1', 'a2', 'a3']); + }); + + it('does nothing when queue is empty', async () => { + queueMock.getQueuedActions.mockResolvedValue([]); + + await syncQueue(); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(queueMock.removeFromQueue).not.toHaveBeenCalled(); + }); + }); + + describe('retry on failure', () => { + it('increments retry count on server error', async () => { + const action = makeAction({ id: 'a1' }); + queueMock.getQueuedActions.mockResolvedValue([action]); + mockFetch.mockResolvedValue(errorResponse(500)); + queueMock.incrementRetryCount.mockResolvedValue(1); + + await syncQueue(); + + expect(queueMock.incrementRetryCount).toHaveBeenCalledWith('a1'); + expect(queueMock.removeFromQueue).not.toHaveBeenCalled(); + }); + + it('increments retry count on 400 response', async () => { + const action = makeAction({ id: 'a1' }); + queueMock.getQueuedActions.mockResolvedValue([action]); + mockFetch.mockResolvedValue(errorResponse(400)); + queueMock.incrementRetryCount.mockResolvedValue(1); + + await syncQueue(); + + expect(queueMock.incrementRetryCount).toHaveBeenCalledWith('a1'); + expect(queueMock.removeFromQueue).not.toHaveBeenCalled(); + }); + + it('increments retry count on network error', async () => { + const action = makeAction({ id: 'a1' }); + queueMock.getQueuedActions.mockResolvedValue([action]); + mockFetch.mockRejectedValue(new TypeError('Failed to fetch')); + queueMock.incrementRetryCount.mockResolvedValue(1); + + await syncQueue(); + + expect(queueMock.incrementRetryCount).toHaveBeenCalledWith('a1'); + expect(queueMock.removeFromQueue).not.toHaveBeenCalled(); + }); + }); + + describe('discard after max retries', () => { + it('removes action when retry count exceeds MAX_RETRY_COUNT on server error', async () => { + const action = makeAction({ id: 'a1', retryCount: 10 }); + queueMock.getQueuedActions.mockResolvedValue([action]); + mockFetch.mockResolvedValue(errorResponse(500)); + // incrementRetryCount returns 11 (exceeds MAX_RETRY_COUNT of 10) + queueMock.incrementRetryCount.mockResolvedValue(11); + + await syncQueue(); + + expect(queueMock.incrementRetryCount).toHaveBeenCalledWith('a1'); + expect(queueMock.removeFromQueue).toHaveBeenCalledWith('a1'); + }); + + it('removes action when retry count exceeds MAX_RETRY_COUNT on network error', async () => { + const action = makeAction({ id: 'a1', retryCount: 10 }); + queueMock.getQueuedActions.mockResolvedValue([action]); + mockFetch.mockRejectedValue(new TypeError('Failed to fetch')); + queueMock.incrementRetryCount.mockResolvedValue(11); + + await syncQueue(); + + expect(queueMock.incrementRetryCount).toHaveBeenCalledWith('a1'); + expect(queueMock.removeFromQueue).toHaveBeenCalledWith('a1'); + }); + + it('keeps action at exactly MAX_RETRY_COUNT', async () => { + const action = makeAction({ id: 'a1', retryCount: 9 }); + queueMock.getQueuedActions.mockResolvedValue([action]); + mockFetch.mockResolvedValue(errorResponse(500)); + // incrementRetryCount returns 10 (equals MAX_RETRY_COUNT, not yet exceeded) + queueMock.incrementRetryCount.mockResolvedValue(10); + + await syncQueue(); + + expect(queueMock.incrementRetryCount).toHaveBeenCalledWith('a1'); + expect(queueMock.removeFromQueue).not.toHaveBeenCalled(); + }); + }); + + describe('partial sync failure', () => { + it('continues processing after a failed action', async () => { + const actions = [ + makeAction({ id: 'a1', timestamp: 1 }), + makeAction({ id: 'a2', timestamp: 2 }), + makeAction({ id: 'a3', timestamp: 3 }) + ]; + queueMock.getQueuedActions.mockResolvedValue(actions); + // a1 succeeds, a2 fails (400), a3 succeeds + mockFetch + .mockResolvedValueOnce(okResponse()) + .mockResolvedValueOnce(errorResponse(400)) + .mockResolvedValueOnce(okResponse()); + queueMock.incrementRetryCount.mockResolvedValue(1); + + await syncQueue(); + + // a1 and a3 removed; a2 retried + expect(queueMock.removeFromQueue).toHaveBeenCalledWith('a1'); + expect(queueMock.removeFromQueue).toHaveBeenCalledWith('a3'); + expect(queueMock.removeFromQueue).not.toHaveBeenCalledWith('a2'); + expect(queueMock.incrementRetryCount).toHaveBeenCalledWith('a2'); + }); + + it('handles network drop mid-batch (remaining actions also fail)', async () => { + const actions = [ + makeAction({ id: 'a1', timestamp: 1 }), + makeAction({ id: 'a2', timestamp: 2 }), + makeAction({ id: 'a3', timestamp: 3 }) + ]; + queueMock.getQueuedActions.mockResolvedValue(actions); + // a1 succeeds, a2 and a3 fail with network error + mockFetch + .mockResolvedValueOnce(okResponse()) + .mockRejectedValueOnce(new TypeError('Failed to fetch')) + .mockRejectedValueOnce(new TypeError('Failed to fetch')); + queueMock.incrementRetryCount.mockResolvedValue(1); + + await syncQueue(); + + // a1 removed, a2 and a3 retried + expect(queueMock.removeFromQueue).toHaveBeenCalledTimes(1); + expect(queueMock.removeFromQueue).toHaveBeenCalledWith('a1'); + expect(queueMock.incrementRetryCount).toHaveBeenCalledWith('a2'); + expect(queueMock.incrementRetryCount).toHaveBeenCalledWith('a3'); + }); + }); + + describe('isSyncing guard', () => { + it('skips sync when already syncing', async () => { + storesMock.offlineState.isSyncing = true; + queueMock.getQueuedActions.mockResolvedValue([makeAction()]); + + await syncQueue(); + + expect(queueMock.getQueuedActions).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('skips sync when offline', async () => { + storesMock.offlineState.isOnline = false; + queueMock.getQueuedActions.mockResolvedValue([makeAction()]); + + await syncQueue(); + + expect(queueMock.getQueuedActions).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('sets isSyncing to true during sync and false after', async () => { + const action = makeAction(); + queueMock.getQueuedActions.mockResolvedValue([action]); + + let syncingDuringFetch = false; + mockFetch.mockImplementation(async () => { + syncingDuringFetch = storesMock.offlineState.isSyncing; + return okResponse(); + }); + + await syncQueue(); + + expect(syncingDuringFetch).toBe(true); + expect(storesMock.offlineState.isSyncing).toBe(false); + }); + + it('resets isSyncing to false even when all actions fail', async () => { + const action = makeAction(); + queueMock.getQueuedActions.mockResolvedValue([action]); + mockFetch.mockRejectedValue(new TypeError('Failed to fetch')); + queueMock.incrementRetryCount.mockResolvedValue(1); + + await syncQueue(); + + expect(storesMock.offlineState.isSyncing).toBe(false); + }); + }); + + describe('pending count updates', () => { + it('updates pending count after each action and at end', async () => { + const actions = [ + makeAction({ id: 'a1', timestamp: 1 }), + makeAction({ id: 'a2', timestamp: 2 }) + ]; + queueMock.getQueuedActions.mockResolvedValue(actions); + mockFetch.mockResolvedValue(okResponse()); + + await syncQueue(); + + // Once at the start, once per action, once at the end = 4 calls + expect(storesMock.updatePendingCount).toHaveBeenCalledTimes(4); + }); + }); + + describe('concurrency', () => { + it('second call is suppressed when first has set isSyncing', async () => { + const action = makeAction({ id: 'a1' }); + queueMock.getQueuedActions.mockResolvedValue([action]); + + // Make fetch slow so we can trigger a concurrent call after isSyncing is set + let resolveFirstFetch: (v: Response) => void; + mockFetch.mockImplementationOnce(() => new Promise((r) => (resolveFirstFetch = r))); + + const firstSync = syncQueue(); + + // Flush microtasks so the first call gets past getQueuedActions and + // sets isSyncing = true before we start the second call + await vi.waitFor(() => { + expect(storesMock.offlineState.isSyncing).toBe(true); + }); + + // Now trigger a second sync -- it should be suppressed by the guard + const secondSync = syncQueue(); + await secondSync; + + // Only the first call should have fetched + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Resolve the first fetch to let it complete + resolveFirstFetch!(okResponse()); + await firstSync; + + expect(storesMock.offlineState.isSyncing).toBe(false); + expect(queueMock.removeFromQueue).toHaveBeenCalledWith('a1'); + }); + + it('processes only the snapshot of queued actions', async () => { + // The sync engine snapshots the queue at the start via getQueuedActions. + // Actions added afterwards are not processed until the next cycle. + const initialActions = [makeAction({ id: 'a1', timestamp: 1 })]; + queueMock.getQueuedActions.mockResolvedValue(initialActions); + mockFetch.mockResolvedValue(okResponse()); + + await syncQueue(); + + // Only one fetch call for the one action in the snapshot + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(queueMock.removeFromQueue).toHaveBeenCalledTimes(1); + expect(queueMock.removeFromQueue).toHaveBeenCalledWith('a1'); + }); + }); + + describe('isSyncing flag crash recovery', () => { + it('resets isSyncing when initial updatePendingCount throws', async () => { + const action = makeAction(); + queueMock.getQueuedActions.mockResolvedValue([action]); + storesMock.updatePendingCount.mockRejectedValueOnce(new Error('IndexedDB read failed')); + + // syncQueue should not leave isSyncing stuck + await expect(syncQueue()).rejects.toThrow('IndexedDB read failed'); + + expect(storesMock.offlineState.isSyncing).toBe(false); + }); + + it('resets isSyncing when in-loop updatePendingCount throws', async () => { + const action = makeAction(); + queueMock.getQueuedActions.mockResolvedValue([action]); + mockFetch.mockResolvedValue(okResponse()); + + // First call (start of sync) succeeds, second call (after action) throws + storesMock.updatePendingCount + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('IndexedDB write failed')); + + await expect(syncQueue()).rejects.toThrow('IndexedDB write failed'); + + expect(storesMock.offlineState.isSyncing).toBe(false); + }); + + it('allows subsequent syncs after a crash', async () => { + const action = makeAction({ id: 'a1' }); + queueMock.getQueuedActions.mockResolvedValue([action]); + + // First sync crashes + storesMock.updatePendingCount.mockRejectedValueOnce(new Error('crash')); + + await expect(syncQueue()).rejects.toThrow('crash'); + expect(storesMock.offlineState.isSyncing).toBe(false); + + // Second sync should work normally + storesMock.updatePendingCount.mockResolvedValue(undefined); + mockFetch.mockResolvedValue(okResponse()); + + await syncQueue(); + + expect(queueMock.removeFromQueue).toHaveBeenCalledWith('a1'); + expect(storesMock.offlineState.isSyncing).toBe(false); + }); + }); +}); diff --git a/src/lib/offline/sync.ts b/src/lib/offline/sync.ts index e9e6d50..af64de1 100644 --- a/src/lib/offline/sync.ts +++ b/src/lib/offline/sync.ts @@ -11,34 +11,38 @@ export async function syncQueue(): Promise { if (queue.length === 0) return; offlineState.isSyncing = true; - await updatePendingCount(); - for (const action of queue) { - try { - const response = await fetch('/api/sync', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: action.action, payload: action.payload }) - }); + try { + await updatePendingCount(); + + for (const action of queue) { + try { + const response = await fetch('/api/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: action.action, payload: action.payload }) + }); - if (response.ok) { - await removeFromQueue(action.id); - } else { + if (response.ok) { + await removeFromQueue(action.id); + } else { + const retries = await incrementRetryCount(action.id); + if (retries > MAX_RETRY_COUNT) { + await removeFromQueue(action.id); + } + } + } catch { const retries = await incrementRetryCount(action.id); if (retries > MAX_RETRY_COUNT) { await removeFromQueue(action.id); } } - } catch { - const retries = await incrementRetryCount(action.id); - if (retries > MAX_RETRY_COUNT) { - await removeFromQueue(action.id); - } + await updatePendingCount(); } - await updatePendingCount(); + } finally { + offlineState.isSyncing = false; } - offlineState.isSyncing = false; await updatePendingCount(); } diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 3ea9fb6..7c37ea9 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -1,15 +1,15 @@ -import { mkdirSync } from 'node:fs'; +import { existsSync, mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; import { drizzle } from 'drizzle-orm/better-sqlite3'; import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; import Database from 'better-sqlite3'; import * as schema from './schema'; -import { env } from '$env/dynamic/private'; -if (!env.DATABASE_PATH) throw new Error('DATABASE_PATH is not set'); +// Use /data/ when running in Docker (volume mount), otherwise ./data/ for local dev +const dbPath = existsSync('/data') ? '/data/workout-tracker.db' : './data/workout-tracker.db'; -mkdirSync(dirname(env.DATABASE_PATH), { recursive: true }); -const client = new Database(env.DATABASE_PATH); +mkdirSync(dirname(dbPath), { recursive: true }); +const client = new Database(dbPath); client.pragma('journal_mode = WAL'); client.pragma('foreign_keys = ON'); diff --git a/src/lib/server/db/queries/workouts.ts b/src/lib/server/db/queries/workouts.ts index 4f6f7ad..4fb9571 100644 --- a/src/lib/server/db/queries/workouts.ts +++ b/src/lib/server/db/queries/workouts.ts @@ -36,6 +36,9 @@ export type WorkoutSummary = { totalExercises: number; completedExercises: number; skippedExercises: number; + totalSets: number; + totalVolume: number; + durationMinutes: number | null; prs: Array<{ exerciseName: string; weight: number; reps: number; unit: string }>; }; @@ -287,6 +290,31 @@ export function getWorkoutSummary(db: Db, sessionId: number): WorkoutSummary | n const skippedExercises = logs.filter((l) => l.status === 'skipped' && !l.isAdhoc).length; const completedExercises = totalExercises - skippedExercises; + // Compute total sets and volume across all logged exercises (including ad-hoc) + const allSets = db + .select() + .from(setLogs) + .innerJoin(exerciseLogs, eq(setLogs.exerciseLogId, exerciseLogs.id)) + .where( + and( + eq(exerciseLogs.sessionId, sessionId), + eq(exerciseLogs.status, 'logged'), + isNotNull(setLogs.weight) + ) + ) + .all(); + + const totalSets = allSets.length; + const totalVolume = allSets.reduce( + (sum, row) => sum + (row.set_logs.weight ?? 0) * (row.set_logs.reps ?? 0), + 0 + ); + + const durationMinutes = + session.completedAt && session.startedAt + ? Math.round((session.completedAt.getTime() - session.startedAt.getTime()) / (1000 * 60)) + : null; + // Detect PRs: for each logged exercise, check if any set in this session // has a higher weight than any previous session const prs: WorkoutSummary['prs'] = []; @@ -345,6 +373,9 @@ export function getWorkoutSummary(db: Db, sessionId: number): WorkoutSummary | n totalExercises, completedExercises, skippedExercises, + totalSets, + totalVolume, + durationMinutes, prs }; } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index dc0f45c..aa764bd 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -23,9 +23,7 @@ class="rounded-2xl border border-neon/20 bg-neon/10 p-4" data-testid="workout-in-progress-notice" > -

- A workout is already in progress. Please finish or stop it before starting a new one. -

+

A workout is already in progress.

{:else} diff --git a/src/routes/api/sync/server.test.ts b/src/routes/api/sync/server.test.ts new file mode 100644 index 0000000..265abff --- /dev/null +++ b/src/routes/api/sync/server.test.ts @@ -0,0 +1,459 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock all query functions before importing the handler +const mocks = { + updateSetLog: vi.fn(), + skipExercise: vi.fn(), + unskipExercise: vi.fn(), + completeWorkout: vi.fn(), + addAdhocExercise: vi.fn(), + addSetToExerciseLog: vi.fn(), + removeSetFromExerciseLog: vi.fn(), + updateExerciseUnitPreference: vi.fn() +}; + +vi.mock('$lib/server/db/queries/workouts', () => mocks); +vi.mock('$lib/server/db', () => ({ db: {} })); + +const { POST } = await import('./+server'); + +function makeRequest(body: unknown): Request { + return new Request('http://localhost/api/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); +} + +async function callSync(action: string, payload: Record = {}) { + const request = makeRequest({ action, payload }); + // The handler expects a RequestEvent; we only need request + const response = await POST({ request } as never); + return { + status: response.status, + body: await response.json() + }; +} + +describe('sync endpoint input validation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('unknown action', () => { + it('rejects unknown action with 400', async () => { + const { status, body } = await callSync('INVALID_ACTION'); + + expect(status).toBe(400); + expect(body.success).toBe(false); + expect(body.error).toBe('Unknown action'); + }); + }); + + describe('UPDATE_SET', () => { + it('accepts valid payload', async () => { + const { status, body } = await callSync('UPDATE_SET', { + setLogId: 1, + weight: 80, + reps: 10, + unit: 'kg' + }); + + expect(status).toBe(200); + expect(body.success).toBe(true); + expect(mocks.updateSetLog).toHaveBeenCalledOnce(); + }); + + it('rejects missing setLogId', async () => { + const { status, body } = await callSync('UPDATE_SET', { weight: 80 }); + + expect(status).toBe(400); + expect(body.error).toMatch(/setLogId/); + expect(mocks.updateSetLog).not.toHaveBeenCalled(); + }); + + it('rejects non-integer setLogId', async () => { + const { status, body } = await callSync('UPDATE_SET', { setLogId: 1.5 }); + + expect(status).toBe(400); + expect(body.error).toMatch(/setLogId/); + }); + + it('rejects negative setLogId', async () => { + const { status, body } = await callSync('UPDATE_SET', { setLogId: -1 }); + + expect(status).toBe(400); + expect(body.error).toMatch(/setLogId/); + }); + + it('rejects zero setLogId', async () => { + const { status, body } = await callSync('UPDATE_SET', { setLogId: 0 }); + + expect(status).toBe(400); + expect(body.error).toMatch(/setLogId/); + }); + + it('rejects string setLogId', async () => { + const { status, body } = await callSync('UPDATE_SET', { setLogId: 'abc' }); + + expect(status).toBe(400); + expect(body.error).toMatch(/setLogId/); + }); + + it('rejects negative weight', async () => { + const { status, body } = await callSync('UPDATE_SET', { + setLogId: 1, + weight: -5 + }); + + expect(status).toBe(400); + expect(body.error).toMatch(/weight/); + }); + + it('rejects string weight', async () => { + const { status, body } = await callSync('UPDATE_SET', { + setLogId: 1, + weight: 'heavy' + }); + + expect(status).toBe(400); + expect(body.error).toMatch(/weight/); + }); + + it('rejects boolean weight', async () => { + const { status, body } = await callSync('UPDATE_SET', { + setLogId: 1, + weight: true + }); + + expect(status).toBe(400); + expect(body.error).toMatch(/weight/); + }); + + it('accepts null weight (clearing a field)', async () => { + const { status, body } = await callSync('UPDATE_SET', { + setLogId: 1, + weight: null + }); + + expect(status).toBe(200); + expect(body.success).toBe(true); + }); + + it('accepts zero weight', async () => { + const { status, body } = await callSync('UPDATE_SET', { + setLogId: 1, + weight: 0 + }); + + expect(status).toBe(200); + expect(body.success).toBe(true); + }); + + it('rejects negative reps', async () => { + const { status, body } = await callSync('UPDATE_SET', { + setLogId: 1, + reps: -1 + }); + + expect(status).toBe(400); + expect(body.error).toMatch(/reps/); + }); + + it('rejects invalid unit', async () => { + const { status, body } = await callSync('UPDATE_SET', { + setLogId: 1, + unit: 'stones' + }); + + expect(status).toBe(400); + expect(body.error).toMatch(/unit/); + }); + + it('accepts kg unit', async () => { + const { status, body } = await callSync('UPDATE_SET', { + setLogId: 1, + unit: 'kg' + }); + + expect(status).toBe(200); + expect(body.success).toBe(true); + }); + + it('accepts lbs unit', async () => { + const { status, body } = await callSync('UPDATE_SET', { + setLogId: 1, + unit: 'lbs' + }); + + expect(status).toBe(200); + expect(body.success).toBe(true); + }); + + it('rejects invalid exerciseId when provided', async () => { + const { status, body } = await callSync('UPDATE_SET', { + setLogId: 1, + exerciseId: -1 + }); + + expect(status).toBe(400); + expect(body.error).toMatch(/exerciseId/); + }); + + it('updates exercise unit preference when unit and exerciseId given', async () => { + await callSync('UPDATE_SET', { + setLogId: 1, + unit: 'lbs', + exerciseId: 5 + }); + + expect(mocks.updateExerciseUnitPreference).toHaveBeenCalledWith(expect.anything(), 5, 'lbs'); + }); + + it('does not update exercise unit preference without exerciseId', async () => { + await callSync('UPDATE_SET', { + setLogId: 1, + unit: 'lbs' + }); + + expect(mocks.updateExerciseUnitPreference).not.toHaveBeenCalled(); + }); + }); + + describe('SKIP_EXERCISE', () => { + it('accepts valid exerciseLogId', async () => { + const { status, body } = await callSync('SKIP_EXERCISE', { exerciseLogId: 1 }); + + expect(status).toBe(200); + expect(body.success).toBe(true); + expect(mocks.skipExercise).toHaveBeenCalledOnce(); + }); + + it('rejects missing exerciseLogId', async () => { + const { status, body } = await callSync('SKIP_EXERCISE', {}); + + expect(status).toBe(400); + expect(body.error).toMatch(/exerciseLogId/); + }); + + it('rejects non-integer exerciseLogId', async () => { + const { status, body } = await callSync('SKIP_EXERCISE', { exerciseLogId: 2.5 }); + + expect(status).toBe(400); + expect(body.error).toMatch(/exerciseLogId/); + }); + + it('rejects string exerciseLogId', async () => { + const { status, body } = await callSync('SKIP_EXERCISE', { exerciseLogId: 'abc' }); + + expect(status).toBe(400); + expect(body.error).toMatch(/exerciseLogId/); + }); + }); + + describe('UNSKIP_EXERCISE', () => { + it('accepts valid exerciseLogId', async () => { + const { status, body } = await callSync('UNSKIP_EXERCISE', { exerciseLogId: 3 }); + + expect(status).toBe(200); + expect(body.success).toBe(true); + expect(mocks.unskipExercise).toHaveBeenCalledOnce(); + }); + + it('rejects invalid exerciseLogId', async () => { + const { status, body } = await callSync('UNSKIP_EXERCISE', { exerciseLogId: 0 }); + + expect(status).toBe(400); + expect(body.error).toMatch(/exerciseLogId/); + }); + }); + + describe('COMPLETE_WORKOUT', () => { + it('accepts valid sessionId', async () => { + const { status, body } = await callSync('COMPLETE_WORKOUT', { sessionId: 1 }); + + expect(status).toBe(200); + expect(body.success).toBe(true); + expect(mocks.completeWorkout).toHaveBeenCalledOnce(); + }); + + it('rejects missing sessionId', async () => { + const { status, body } = await callSync('COMPLETE_WORKOUT', {}); + + expect(status).toBe(400); + expect(body.error).toMatch(/sessionId/); + }); + + it('rejects non-integer sessionId', async () => { + const { status, body } = await callSync('COMPLETE_WORKOUT', { sessionId: 1.7 }); + + expect(status).toBe(400); + expect(body.error).toMatch(/sessionId/); + }); + }); + + describe('ADD_ADHOC', () => { + it('accepts valid payload', async () => { + const { status, body } = await callSync('ADD_ADHOC', { + sessionId: 1, + exerciseName: 'Lateral Raise' + }); + + expect(status).toBe(200); + expect(body.success).toBe(true); + expect(mocks.addAdhocExercise).toHaveBeenCalledWith(expect.anything(), 1, 'Lateral Raise'); + }); + + it('rejects missing sessionId', async () => { + const { status, body } = await callSync('ADD_ADHOC', { exerciseName: 'Curl' }); + + expect(status).toBe(400); + expect(body.error).toMatch(/sessionId/); + }); + + it('rejects missing exerciseName', async () => { + const { status, body } = await callSync('ADD_ADHOC', { sessionId: 1 }); + + expect(status).toBe(400); + expect(body.error).toMatch(/exerciseName/); + }); + + it('rejects empty exerciseName', async () => { + const { status, body } = await callSync('ADD_ADHOC', { + sessionId: 1, + exerciseName: '' + }); + + expect(status).toBe(400); + expect(body.error).toMatch(/exerciseName/); + }); + + it('rejects whitespace-only exerciseName', async () => { + const { status, body } = await callSync('ADD_ADHOC', { + sessionId: 1, + exerciseName: ' ' + }); + + expect(status).toBe(400); + expect(body.error).toMatch(/exerciseName/); + }); + + it('trims exerciseName before passing to query', async () => { + await callSync('ADD_ADHOC', { + sessionId: 1, + exerciseName: ' Lateral Raise ' + }); + + expect(mocks.addAdhocExercise).toHaveBeenCalledWith(expect.anything(), 1, 'Lateral Raise'); + }); + + it('rejects non-string exerciseName', async () => { + const { status, body } = await callSync('ADD_ADHOC', { + sessionId: 1, + exerciseName: 123 + }); + + expect(status).toBe(400); + expect(body.error).toMatch(/exerciseName/); + }); + }); + + describe('ADD_SET', () => { + it('accepts valid exerciseLogId', async () => { + const { status, body } = await callSync('ADD_SET', { exerciseLogId: 1 }); + + expect(status).toBe(200); + expect(body.success).toBe(true); + expect(mocks.addSetToExerciseLog).toHaveBeenCalledOnce(); + }); + + it('rejects missing exerciseLogId', async () => { + const { status, body } = await callSync('ADD_SET', {}); + + expect(status).toBe(400); + expect(body.error).toMatch(/exerciseLogId/); + }); + + it('rejects negative exerciseLogId', async () => { + const { status, body } = await callSync('ADD_SET', { exerciseLogId: -5 }); + + expect(status).toBe(400); + expect(body.error).toMatch(/exerciseLogId/); + }); + }); + + describe('REMOVE_SET', () => { + it('accepts valid setLogId', async () => { + const { status, body } = await callSync('REMOVE_SET', { setLogId: 1 }); + + expect(status).toBe(200); + expect(body.success).toBe(true); + expect(mocks.removeSetFromExerciseLog).toHaveBeenCalledOnce(); + }); + + it('rejects missing setLogId', async () => { + const { status, body } = await callSync('REMOVE_SET', {}); + + expect(status).toBe(400); + expect(body.error).toMatch(/setLogId/); + }); + + it('rejects zero setLogId', async () => { + const { status, body } = await callSync('REMOVE_SET', { setLogId: 0 }); + + expect(status).toBe(400); + expect(body.error).toMatch(/setLogId/); + }); + }); + + describe('UPDATE_UNIT', () => { + it('accepts valid payload', async () => { + const { status, body } = await callSync('UPDATE_UNIT', { + exerciseId: 1, + unit: 'lbs' + }); + + expect(status).toBe(200); + expect(body.success).toBe(true); + expect(mocks.updateExerciseUnitPreference).toHaveBeenCalledWith(expect.anything(), 1, 'lbs'); + }); + + it('rejects missing exerciseId', async () => { + const { status, body } = await callSync('UPDATE_UNIT', { unit: 'kg' }); + + expect(status).toBe(400); + expect(body.error).toMatch(/exerciseId/); + }); + + it('rejects invalid unit', async () => { + const { status, body } = await callSync('UPDATE_UNIT', { + exerciseId: 1, + unit: 'stone' + }); + + expect(status).toBe(400); + expect(body.error).toMatch(/unit/); + }); + + it('rejects missing unit', async () => { + const { status, body } = await callSync('UPDATE_UNIT', { exerciseId: 1 }); + + expect(status).toBe(400); + expect(body.error).toMatch(/unit/); + }); + }); + + describe('server error handling', () => { + it('returns 500 when query function throws', async () => { + mocks.skipExercise.mockImplementation(() => { + throw new Error('Database failure'); + }); + + const { status, body } = await callSync('SKIP_EXERCISE', { exerciseLogId: 1 }); + + expect(status).toBe(500); + expect(body.success).toBe(false); + expect(body.error).toBe('Database failure'); + }); + }); +}); diff --git a/src/routes/exercises/+page.svelte b/src/routes/exercises/+page.svelte index cedc713..9311ced 100644 --- a/src/routes/exercises/+page.svelte +++ b/src/routes/exercises/+page.svelte @@ -93,9 +93,7 @@ class="flex flex-col items-center justify-center gap-3 py-16 text-center" data-testid="empty-state" > -

- No exercises yet. Exercises are added automatically when you create programs. -

+

No exercises yet. Create a program to add some.

{:else} @@ -166,11 +164,10 @@ Delete Exercise {#if deleteTargetHasHistory} - This exercise has workout history. The history will be kept but "{deleteTargetName}" will - be removed from the library and any programs using it. + History will be kept, but "{deleteTargetName}" will be removed from the library and any + programs. {:else} - Are you sure you want to delete "{deleteTargetName}"? It will be removed from any programs - using it. This action cannot be undone. + "{deleteTargetName}" will be removed from any programs. This cannot be undone. {/if} diff --git a/src/routes/history/+page.svelte b/src/routes/history/+page.svelte index 9aa10df..1f1abb6 100644 --- a/src/routes/history/+page.svelte +++ b/src/routes/history/+page.svelte @@ -49,20 +49,6 @@

History

-
- - By Date - - - By Exercise - -
- {#if form?.error}
-

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

+

No workout history yet.

{:else}
@@ -145,8 +129,7 @@ Delete Workout - Are you sure you want to delete this "{deleteTargetDay}" workout? This action cannot be - undone. + Delete this "{deleteTargetDay}" workout? This cannot be undone. diff --git a/src/routes/history/[sessionId]/+page.svelte b/src/routes/history/[sessionId]/+page.svelte index 6661f76..21f3d40 100644 --- a/src/routes/history/[sessionId]/+page.svelte +++ b/src/routes/history/[sessionId]/+page.svelte @@ -134,18 +134,16 @@ {#if log.status !== 'skipped' && log.sets.length > 0}
-
+
Set - Weight + Weight ({log.sets[0].unit}) Reps - Unit
{#each log.sets as set (set.id)} -
+
{set.setNumber} {set.weight ?? '-'} {set.reps ?? '-'} - {set.unit}
{/each}
@@ -162,8 +160,7 @@ Delete Session - Are you sure you want to delete this workout session? This will permanently remove the - session and all logged sets. This action cannot be undone. + This will permanently remove the session and all logged sets. @@ -195,8 +192,7 @@ Delete Exercise - Are you sure you want to delete "{deleteExerciseName}" from this session? This will remove - all logged sets for this exercise. This action cannot be undone. + "{deleteExerciseName}" and all its logged sets will be permanently removed. diff --git a/src/routes/programs/+page.svelte b/src/routes/programs/+page.svelte index 8409898..8cfb288 100644 --- a/src/routes/programs/+page.svelte +++ b/src/routes/programs/+page.svelte @@ -72,7 +72,7 @@ {#if data.programs.length === 0}
-

No programs yet. Create your first workout program.

+

No programs yet.

{#if uploadError || form?.error} diff --git a/src/routes/workout/[sessionId]/+page.svelte b/src/routes/workout/[sessionId]/+page.svelte index dbfe9eb..036ff82 100644 --- a/src/routes/workout/[sessionId]/+page.svelte +++ b/src/routes/workout/[sessionId]/+page.svelte @@ -58,6 +58,32 @@ function formatPreviousSets(sets: Array<{ weight: number; reps: number; unit: string }>): string { return sets.map((s) => `${s.weight}${s.unit} x ${s.reps}`).join(', '); } + + function getExerciseUnit(log: { sets: Array<{ unit: string }> }): string { + return log.sets[0]?.unit ?? 'kg'; + } + + function toggleExerciseUnit(log: { + id: number; + exerciseId: number | null; + sets: Array<{ id: number; unit: string }>; + }) { + const currentUnit = getExerciseUnit(log); + const newUnit = currentUnit === 'kg' ? 'lbs' : 'kg'; + + for (const set of log.sets) { + set.unit = newUnit; + + const form = document.getElementById(`set-form-${set.id}`) as HTMLFormElement | null; + if (form) { + const unitInput = form.querySelector('input[name="unit"]'); + if (unitInput) { + unitInput.value = newUnit; + } + form.requestSubmit(); + } + } + }
@@ -198,7 +224,21 @@ class="grid grid-cols-[2rem_1fr_1fr_auto] items-center gap-2 text-xs font-medium text-muted-foreground" > Set - Weight + + Weight + {#if data.session.status === 'in_progress'} + + {:else} + ({getExerciseUnit(log)}) + {/if} + Reps
@@ -258,43 +298,21 @@ -
- e.currentTarget.form?.requestSubmit()} - data-testid="weight-input-{set.id}" - /> - - -
+ e.currentTarget.form?.requestSubmit()} + data-testid="weight-input-{set.id}" + /> + e.currentTarget.form?.requestSubmit()} @@ -410,8 +428,7 @@ Stop Workout? - Any exercises without logged sets will be marked as skipped. You can review your summary - afterwards. + Exercises without logged sets will be marked as skipped. @@ -455,10 +472,7 @@ Add Exercise - - Add an extra exercise to this workout. If the exercise is new, it will be added to your - library. - + New exercises will be added to your library.
0 && data.summary.completedExercises === data.summary.totalExercises ); + + function formatDuration(minutes: number | null): string { + if (minutes == null) return '--'; + if (minutes < 1) return '<1 min'; + if (minutes < 60) return `${minutes} min`; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return m > 0 ? `${h}h ${m}m` : `${h}h`; + } + + function formatVolume(volume: number): string { + if (volume === 0) return '0'; + if (volume >= 1000) return `${(volume / 1000).toFixed(1).replace(/\.0$/, '')}k`; + return volume.toLocaleString(); + }
@@ -20,21 +35,44 @@ {#if allCompleted}
-

All exercises completed! Great work.

+

All exercises completed. Great work.

{/if} -
-

- {data.summary.completedExercises}/{data.summary.totalExercises} exercises -

- {#if data.summary.skippedExercises > 0} -

- {data.summary.skippedExercises} skipped + +

+
+

+ {data.summary.completedExercises}/{data.summary.totalExercises} +

+

Exercises

+
+
+

+ {data.summary.totalSets} +

+

Sets

+
+
+

+ {formatDuration(data.summary.durationMinutes)}

- {/if} +

Duration

+
+
+

+ {formatVolume(data.summary.totalVolume)} +

+

Volume

+
+ {#if data.summary.skippedExercises > 0} +

+ {data.summary.skippedExercises} exercise{data.summary.skippedExercises !== 1 ? 's' : ''} skipped +

+ {/if} + {#if data.summary.prs.length > 0}

Personal Records

diff --git a/vite.config.ts b/vite.config.ts index d4cb0be..33828b3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -40,7 +40,10 @@ export default defineConfig({ ] }, workbox: { - globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], + globPatterns: ['client/**/*.{js,css,html,ico,png,svg,woff2,webmanifest}'], + modifyURLPrefix: { + 'client/': '' + }, navigateFallback: '/' }, devOptions: { From 03f4bad37bc1582bd6b1c4d2450a1f0bf299fac4 Mon Sep 17 00:00:00 2001 From: Kavith Date: Sat, 14 Feb 2026 10:43:06 +0000 Subject: [PATCH 2/5] Replace min-h-[44px] with min-h-11 --- src/routes/settings/+page.svelte | 8 ++++---- src/routes/workout/[sessionId]/+page.svelte | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 982bf23..df6e4b9 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -54,7 +54,7 @@ Appearance
-
+
Theme
+ + Reps + +
+ {#each exercise.sets as set, i (set.id)} +
+ + {set.setNumber} + + { + const val = e.currentTarget.value; + onupdateset(i, 'weight', val === '' ? null : Number(val)); + }} + data-testid="weight-input-{i}" + /> + { + const val = e.currentTarget.value; + onupdateset(i, 'reps', val === '' ? null : Number(val)); + }} + data-testid="reps-input-{i}" + /> + {#if exercise.sets.length > 1} + + {:else} +
+ {/if} +
+ {/each} +
+ + +
diff --git a/src/lib/components/workout/WizardBottomBar.svelte b/src/lib/components/workout/WizardBottomBar.svelte new file mode 100644 index 0000000..795d3f6 --- /dev/null +++ b/src/lib/components/workout/WizardBottomBar.svelte @@ -0,0 +1,108 @@ + + +
+
+ {#if isSingle} + + + {:else if isLast} + + + + {:else} + {#if !isFirst} + + {/if} + + {/if} +
+
diff --git a/src/lib/components/workout/WizardProgressBar.svelte b/src/lib/components/workout/WizardProgressBar.svelte new file mode 100644 index 0000000..914ed8d --- /dev/null +++ b/src/lib/components/workout/WizardProgressBar.svelte @@ -0,0 +1,62 @@ + + +
+ {#each exercises as exercise, i (exercise.id)} + + {/each} +
+ + diff --git a/src/lib/components/workout/WorkoutWizard.svelte b/src/lib/components/workout/WorkoutWizard.svelte new file mode 100644 index 0000000..e53c1f0 --- /dev/null +++ b/src/lib/components/workout/WorkoutWizard.svelte @@ -0,0 +1,354 @@ + + + + +{#if currentLog} + {#key currentLog.id} + + {/key} +{/if} + + diff --git a/src/lib/offline/queue.ts b/src/lib/offline/queue.ts index 5f23e92..ff5de7f 100644 --- a/src/lib/offline/queue.ts +++ b/src/lib/offline/queue.ts @@ -2,14 +2,11 @@ import { openDB, type IDBPDatabase } from 'idb'; import { generateId } from '$lib/utils'; export type ActionType = - | 'UPDATE_SET' - | 'SKIP_EXERCISE' - | 'UNSKIP_EXERCISE' + | 'SAVE_EXERCISE' | 'COMPLETE_WORKOUT' | 'ADD_ADHOC' | 'ADD_SET' - | 'REMOVE_SET' - | 'UPDATE_UNIT'; + | 'REMOVE_SET'; export interface QueuedAction { id: string; @@ -17,13 +14,16 @@ export interface QueuedAction { action: ActionType; payload: { setLogId?: number; - weight?: number | null; - reps?: number | null; - unit?: 'kg' | 'lbs'; exerciseLogId?: number; exerciseId?: number; sessionId?: number; exerciseName?: string; + sets?: Array<{ + setLogId: number; + weight: number | null; + reps: number | null; + unit: 'kg' | 'lbs'; + }>; }; retryCount: number; } diff --git a/src/lib/offline/sync.test.ts b/src/lib/offline/sync.test.ts index 54817b9..ff364e1 100644 --- a/src/lib/offline/sync.test.ts +++ b/src/lib/offline/sync.test.ts @@ -28,8 +28,12 @@ function makeAction(overrides: Partial = {}): QueuedAction { return { id: overrides.id ?? 'action-1', timestamp: overrides.timestamp ?? Date.now(), - action: overrides.action ?? 'UPDATE_SET', - payload: overrides.payload ?? { setLogId: 1, weight: 80 }, + action: overrides.action ?? 'SAVE_EXERCISE', + payload: overrides.payload ?? { + exerciseLogId: 1, + exerciseId: 1, + sets: [{ setLogId: 1, weight: 80, reps: 8, unit: 'kg' as const }] + }, retryCount: overrides.retryCount ?? 0 }; } diff --git a/src/routes/api/sync/+server.ts b/src/routes/api/sync/+server.ts index 9f8662f..a773f0e 100644 --- a/src/routes/api/sync/+server.ts +++ b/src/routes/api/sync/+server.ts @@ -3,8 +3,6 @@ import type { RequestHandler } from './$types'; import { db } from '$lib/server/db'; import { updateSetLog, - skipExercise, - unskipExercise, completeWorkout, addAdhocExercise, addSetToExerciseLog, @@ -20,10 +18,6 @@ function isValidUnit(value: unknown): value is 'kg' | 'lbs' { return value === 'kg' || value === 'lbs'; } -function isValidMeasurement(value: unknown): value is number { - return typeof value === 'number' && Number.isFinite(value) && value >= 0; -} - function badRequest(error: string) { return json({ success: false, error }, { status: 400 }); } @@ -34,54 +28,31 @@ export const POST: RequestHandler = async ({ request }) => { try { switch (action) { - case 'UPDATE_SET': { - if (!isValidId(payload.setLogId)) { - return badRequest('setLogId must be a positive integer'); - } - if (payload.weight !== undefined && payload.weight !== null) { - if (!isValidMeasurement(payload.weight)) { - return badRequest('weight must be a finite number >= 0'); - } - } - if (payload.reps !== undefined && payload.reps !== null) { - if (!isValidMeasurement(payload.reps)) { - return badRequest('reps must be a finite number >= 0'); - } + case 'SAVE_EXERCISE': { + if (!isValidId(payload.exerciseLogId)) { + return badRequest('exerciseLogId must be a positive integer'); } - if (payload.unit !== undefined && payload.unit !== null) { - if (!isValidUnit(payload.unit)) { - return badRequest('unit must be kg or lbs'); - } + if (!Array.isArray(payload.sets)) { + return badRequest('sets must be an array'); } - if (payload.exerciseId !== undefined && payload.exerciseId !== null) { - if (!isValidId(payload.exerciseId)) { - return badRequest('exerciseId must be a positive integer'); + let unit: 'kg' | 'lbs' | undefined; + for (const set of payload.sets) { + if (!isValidId(set.setLogId)) continue; // skip placeholder sets + if (set.unit && !isValidUnit(set.unit)) { + return badRequest('set unit must be kg or lbs'); } + updateSetLog(db, set.setLogId, { + weight: set.weight ?? null, + reps: set.reps ?? null, + unit: set.unit + }); + unit = set.unit; } - const data: { weight?: number | null; reps?: number | null; unit?: 'kg' | 'lbs' } = {}; - if (payload.weight !== undefined) data.weight = payload.weight; - if (payload.reps !== undefined) data.reps = payload.reps; - if (payload.unit) data.unit = payload.unit; - updateSetLog(db, payload.setLogId, data); - if (payload.unit && payload.exerciseId) { - updateExerciseUnitPreference(db, payload.exerciseId, payload.unit); + if (unit && payload.exerciseId && isValidId(payload.exerciseId)) { + updateExerciseUnitPreference(db, payload.exerciseId, unit); } break; } - case 'SKIP_EXERCISE': { - if (!isValidId(payload.exerciseLogId)) { - return badRequest('exerciseLogId must be a positive integer'); - } - skipExercise(db, payload.exerciseLogId); - break; - } - case 'UNSKIP_EXERCISE': { - if (!isValidId(payload.exerciseLogId)) { - return badRequest('exerciseLogId must be a positive integer'); - } - unskipExercise(db, payload.exerciseLogId); - break; - } case 'COMPLETE_WORKOUT': { if (!isValidId(payload.sessionId)) { return badRequest('sessionId must be a positive integer'); @@ -113,16 +84,6 @@ export const POST: RequestHandler = async ({ request }) => { removeSetFromExerciseLog(db, payload.setLogId); break; } - case 'UPDATE_UNIT': { - if (!isValidId(payload.exerciseId)) { - return badRequest('exerciseId must be a positive integer'); - } - if (!isValidUnit(payload.unit)) { - return badRequest('unit must be kg or lbs'); - } - updateExerciseUnitPreference(db, payload.exerciseId, payload.unit); - break; - } default: return json({ success: false, error: 'Unknown action' }, { status: 400 }); } diff --git a/src/routes/api/sync/server.test.ts b/src/routes/api/sync/server.test.ts index 265abff..c5b1a30 100644 --- a/src/routes/api/sync/server.test.ts +++ b/src/routes/api/sync/server.test.ts @@ -3,8 +3,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock all query functions before importing the handler const mocks = { updateSetLog: vi.fn(), - skipExercise: vi.fn(), - unskipExercise: vi.fn(), completeWorkout: vi.fn(), addAdhocExercise: vi.fn(), addSetToExerciseLog: vi.fn(), @@ -50,221 +48,107 @@ describe('sync endpoint input validation', () => { }); }); - describe('UPDATE_SET', () => { + describe('SAVE_EXERCISE', () => { it('accepts valid payload', async () => { - const { status, body } = await callSync('UPDATE_SET', { - setLogId: 1, - weight: 80, - reps: 10, - unit: 'kg' + const { status, body } = await callSync('SAVE_EXERCISE', { + exerciseLogId: 1, + exerciseId: 5, + sets: [ + { setLogId: 1, weight: 80, reps: 10, unit: 'kg' }, + { setLogId: 2, weight: 85, reps: 8, unit: 'kg' } + ] }); expect(status).toBe(200); expect(body.success).toBe(true); - expect(mocks.updateSetLog).toHaveBeenCalledOnce(); - }); - - it('rejects missing setLogId', async () => { - const { status, body } = await callSync('UPDATE_SET', { weight: 80 }); - - expect(status).toBe(400); - expect(body.error).toMatch(/setLogId/); - expect(mocks.updateSetLog).not.toHaveBeenCalled(); - }); - - it('rejects non-integer setLogId', async () => { - const { status, body } = await callSync('UPDATE_SET', { setLogId: 1.5 }); - - expect(status).toBe(400); - expect(body.error).toMatch(/setLogId/); - }); - - it('rejects negative setLogId', async () => { - const { status, body } = await callSync('UPDATE_SET', { setLogId: -1 }); - - expect(status).toBe(400); - expect(body.error).toMatch(/setLogId/); - }); - - it('rejects zero setLogId', async () => { - const { status, body } = await callSync('UPDATE_SET', { setLogId: 0 }); - - expect(status).toBe(400); - expect(body.error).toMatch(/setLogId/); - }); - - it('rejects string setLogId', async () => { - const { status, body } = await callSync('UPDATE_SET', { setLogId: 'abc' }); - - expect(status).toBe(400); - expect(body.error).toMatch(/setLogId/); - }); - - it('rejects negative weight', async () => { - const { status, body } = await callSync('UPDATE_SET', { - setLogId: 1, - weight: -5 - }); - - expect(status).toBe(400); - expect(body.error).toMatch(/weight/); + expect(mocks.updateSetLog).toHaveBeenCalledTimes(2); }); - it('rejects string weight', async () => { - const { status, body } = await callSync('UPDATE_SET', { - setLogId: 1, - weight: 'heavy' + it('rejects missing exerciseLogId', async () => { + const { status, body } = await callSync('SAVE_EXERCISE', { + sets: [{ setLogId: 1, weight: 80, reps: 10, unit: 'kg' }] }); expect(status).toBe(400); - expect(body.error).toMatch(/weight/); + expect(body.error).toMatch(/exerciseLogId/); }); - it('rejects boolean weight', async () => { - const { status, body } = await callSync('UPDATE_SET', { - setLogId: 1, - weight: true + it('rejects missing sets', async () => { + const { status, body } = await callSync('SAVE_EXERCISE', { + exerciseLogId: 1 }); expect(status).toBe(400); - expect(body.error).toMatch(/weight/); + expect(body.error).toMatch(/sets/); }); - it('accepts null weight (clearing a field)', async () => { - const { status, body } = await callSync('UPDATE_SET', { - setLogId: 1, - weight: null - }); - - expect(status).toBe(200); - expect(body.success).toBe(true); - }); - - it('accepts zero weight', async () => { - const { status, body } = await callSync('UPDATE_SET', { - setLogId: 1, - weight: 0 - }); - - expect(status).toBe(200); - expect(body.success).toBe(true); - }); - - it('rejects negative reps', async () => { - const { status, body } = await callSync('UPDATE_SET', { - setLogId: 1, - reps: -1 + it('rejects non-array sets', async () => { + const { status, body } = await callSync('SAVE_EXERCISE', { + exerciseLogId: 1, + sets: 'not-an-array' }); expect(status).toBe(400); - expect(body.error).toMatch(/reps/); + expect(body.error).toMatch(/sets/); }); - it('rejects invalid unit', async () => { - const { status, body } = await callSync('UPDATE_SET', { - setLogId: 1, - unit: 'stones' + it('rejects invalid unit in set', async () => { + const { status, body } = await callSync('SAVE_EXERCISE', { + exerciseLogId: 1, + sets: [{ setLogId: 1, weight: 80, reps: 10, unit: 'stones' }] }); expect(status).toBe(400); expect(body.error).toMatch(/unit/); }); - it('accepts kg unit', async () => { - const { status, body } = await callSync('UPDATE_SET', { - setLogId: 1, - unit: 'kg' - }); - - expect(status).toBe(200); - expect(body.success).toBe(true); - }); - - it('accepts lbs unit', async () => { - const { status, body } = await callSync('UPDATE_SET', { - setLogId: 1, - unit: 'lbs' + it('skips placeholder sets with non-positive setLogId', async () => { + const { status, body } = await callSync('SAVE_EXERCISE', { + exerciseLogId: 1, + sets: [ + { setLogId: -1, weight: 80, reps: 10, unit: 'kg' }, + { setLogId: 2, weight: 85, reps: 8, unit: 'kg' } + ] }); expect(status).toBe(200); expect(body.success).toBe(true); + // Only the set with positive ID should be processed + expect(mocks.updateSetLog).toHaveBeenCalledTimes(1); }); - it('rejects invalid exerciseId when provided', async () => { - const { status, body } = await callSync('UPDATE_SET', { - setLogId: 1, - exerciseId: -1 - }); - - expect(status).toBe(400); - expect(body.error).toMatch(/exerciseId/); - }); - - it('updates exercise unit preference when unit and exerciseId given', async () => { - await callSync('UPDATE_SET', { - setLogId: 1, - unit: 'lbs', - exerciseId: 5 + it('updates exercise unit preference when exerciseId given', async () => { + await callSync('SAVE_EXERCISE', { + exerciseLogId: 1, + exerciseId: 5, + sets: [{ setLogId: 1, weight: 80, reps: 10, unit: 'lbs' }] }); expect(mocks.updateExerciseUnitPreference).toHaveBeenCalledWith(expect.anything(), 5, 'lbs'); }); it('does not update exercise unit preference without exerciseId', async () => { - await callSync('UPDATE_SET', { - setLogId: 1, - unit: 'lbs' + await callSync('SAVE_EXERCISE', { + exerciseLogId: 1, + sets: [{ setLogId: 1, weight: 80, reps: 10, unit: 'kg' }] }); expect(mocks.updateExerciseUnitPreference).not.toHaveBeenCalled(); }); - }); - - describe('SKIP_EXERCISE', () => { - it('accepts valid exerciseLogId', async () => { - const { status, body } = await callSync('SKIP_EXERCISE', { exerciseLogId: 1 }); - - expect(status).toBe(200); - expect(body.success).toBe(true); - expect(mocks.skipExercise).toHaveBeenCalledOnce(); - }); - - it('rejects missing exerciseLogId', async () => { - const { status, body } = await callSync('SKIP_EXERCISE', {}); - - expect(status).toBe(400); - expect(body.error).toMatch(/exerciseLogId/); - }); - - it('rejects non-integer exerciseLogId', async () => { - const { status, body } = await callSync('SKIP_EXERCISE', { exerciseLogId: 2.5 }); - expect(status).toBe(400); - expect(body.error).toMatch(/exerciseLogId/); - }); - - it('rejects string exerciseLogId', async () => { - const { status, body } = await callSync('SKIP_EXERCISE', { exerciseLogId: 'abc' }); - - expect(status).toBe(400); - expect(body.error).toMatch(/exerciseLogId/); - }); - }); - - describe('UNSKIP_EXERCISE', () => { - it('accepts valid exerciseLogId', async () => { - const { status, body } = await callSync('UNSKIP_EXERCISE', { exerciseLogId: 3 }); + it('handles null weight and reps', async () => { + const { status, body } = await callSync('SAVE_EXERCISE', { + exerciseLogId: 1, + sets: [{ setLogId: 1, weight: null, reps: null, unit: 'kg' }] + }); expect(status).toBe(200); expect(body.success).toBe(true); - expect(mocks.unskipExercise).toHaveBeenCalledOnce(); - }); - - it('rejects invalid exerciseLogId', async () => { - const { status, body } = await callSync('UNSKIP_EXERCISE', { exerciseLogId: 0 }); - - expect(status).toBe(400); - expect(body.error).toMatch(/exerciseLogId/); + expect(mocks.updateSetLog).toHaveBeenCalledWith(expect.anything(), 1, { + weight: null, + reps: null, + unit: 'kg' + }); }); }); @@ -406,50 +290,16 @@ describe('sync endpoint input validation', () => { }); }); - describe('UPDATE_UNIT', () => { - it('accepts valid payload', async () => { - const { status, body } = await callSync('UPDATE_UNIT', { - exerciseId: 1, - unit: 'lbs' - }); - - expect(status).toBe(200); - expect(body.success).toBe(true); - expect(mocks.updateExerciseUnitPreference).toHaveBeenCalledWith(expect.anything(), 1, 'lbs'); - }); - - it('rejects missing exerciseId', async () => { - const { status, body } = await callSync('UPDATE_UNIT', { unit: 'kg' }); - - expect(status).toBe(400); - expect(body.error).toMatch(/exerciseId/); - }); - - it('rejects invalid unit', async () => { - const { status, body } = await callSync('UPDATE_UNIT', { - exerciseId: 1, - unit: 'stone' - }); - - expect(status).toBe(400); - expect(body.error).toMatch(/unit/); - }); - - it('rejects missing unit', async () => { - const { status, body } = await callSync('UPDATE_UNIT', { exerciseId: 1 }); - - expect(status).toBe(400); - expect(body.error).toMatch(/unit/); - }); - }); - describe('server error handling', () => { it('returns 500 when query function throws', async () => { - mocks.skipExercise.mockImplementation(() => { + mocks.updateSetLog.mockImplementation(() => { throw new Error('Database failure'); }); - const { status, body } = await callSync('SKIP_EXERCISE', { exerciseLogId: 1 }); + const { status, body } = await callSync('SAVE_EXERCISE', { + exerciseLogId: 1, + sets: [{ setLogId: 1, weight: 80, reps: 10, unit: 'kg' }] + }); expect(status).toBe(500); expect(body.success).toBe(false); diff --git a/src/routes/workout/[sessionId]/+page.server.ts b/src/routes/workout/[sessionId]/+page.server.ts index 626eef7..2f993cb 100644 --- a/src/routes/workout/[sessionId]/+page.server.ts +++ b/src/routes/workout/[sessionId]/+page.server.ts @@ -4,8 +4,6 @@ import { db } from '$lib/server/db'; import { getWorkoutSession, updateSetLog, - skipExercise, - unskipExercise, addAdhocExercise, addSetToExerciseLog, removeSetFromExerciseLog, @@ -44,42 +42,45 @@ export const load: PageServerLoad = async ({ params }) => { }; export const actions: Actions = { - updateSet: async ({ request }) => { + saveExercise: async ({ request }) => { const formData = await request.formData(); - const setLogId = Number(formData.get('setLogId')); - const weight = formData.get('weight'); - const reps = formData.get('reps'); - const unit = formData.get('unit') as 'kg' | 'lbs' | null; + const exerciseLogId = Number(formData.get('exerciseLogId')); + const exerciseId = Number(formData.get('exerciseId')); + const setsJson = formData.get('sets'); - if (isNaN(setLogId)) return fail(400, { error: 'Invalid set ID' }); + if (isNaN(exerciseLogId)) return fail(400, { error: 'Invalid exercise log ID' }); + if (!setsJson || typeof setsJson !== 'string') { + return fail(400, { error: 'Sets data is required' }); + } - const data: { weight?: number | null; reps?: number | null; unit?: 'kg' | 'lbs' } = {}; - if (weight !== null) data.weight = weight === '' ? null : Number(weight); - if (reps !== null) data.reps = reps === '' ? null : Number(reps); - if (unit) data.unit = unit; + let sets: Array<{ + setLogId: number; + weight: number | null; + reps: number | null; + unit: 'kg' | 'lbs'; + }>; + try { + sets = JSON.parse(setsJson); + } catch { + return fail(400, { error: 'Invalid sets JSON' }); + } - updateSetLog(db, setLogId, data); + let unit: 'kg' | 'lbs' | undefined; + for (const set of sets) { + if (set.setLogId <= 0) continue; // skip placeholder sets from offline add + updateSetLog(db, set.setLogId, { + weight: set.weight, + reps: set.reps, + unit: set.unit + }); + unit = set.unit; + } - // Also update exercise unit preference if unit was changed - if (unit) { - const exerciseId = Number(formData.get('exerciseId')); - if (!isNaN(exerciseId) && exerciseId > 0) { - updateExerciseUnitPreference(db, exerciseId, unit); - } + // Update exercise unit preference if we have a valid exerciseId + if (unit && !isNaN(exerciseId) && exerciseId > 0) { + updateExerciseUnitPreference(db, exerciseId, unit); } }, - skip: async ({ request }) => { - const formData = await request.formData(); - const exerciseLogId = Number(formData.get('exerciseLogId')); - if (isNaN(exerciseLogId)) return fail(400, { error: 'Invalid exercise log ID' }); - skipExercise(db, exerciseLogId); - }, - unskip: async ({ request }) => { - const formData = await request.formData(); - const exerciseLogId = Number(formData.get('exerciseLogId')); - if (isNaN(exerciseLogId)) return fail(400, { error: 'Invalid exercise log ID' }); - unskipExercise(db, exerciseLogId); - }, addAdhoc: async ({ request }) => { const formData = await request.formData(); const sessionId = Number(formData.get('sessionId')); diff --git a/src/routes/workout/[sessionId]/+page.svelte b/src/routes/workout/[sessionId]/+page.svelte index 3585d0d..52f4400 100644 --- a/src/routes/workout/[sessionId]/+page.svelte +++ b/src/routes/workout/[sessionId]/+page.svelte @@ -2,7 +2,6 @@ import { enhance } from '$app/forms'; import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; - import { Badge } from '$lib/components/ui/badge'; import { AlertDialog, AlertDialogAction, @@ -25,6 +24,7 @@ import { Label } from '$lib/components/ui/label'; import { addToQueue, type ActionType } from '$lib/offline/queue'; import { offlineState, updatePendingCount } from '$lib/offline/stores.svelte'; + import WorkoutWizard from '$lib/components/workout/WorkoutWizard.svelte'; let { data } = $props(); @@ -46,63 +46,12 @@ let adhocExerciseName = $state(''); let adhocError = $state(''); let adhocSubmitting = $state(false); - - function formatDate(date: Date): string { - return new Date(date).toLocaleDateString('en-GB', { - day: 'numeric', - month: 'short', - year: 'numeric' - }); - } - - function formatPreviousSets(sets: Array<{ weight: number; reps: number; unit: string }>): string { - return sets.map((s) => `${s.weight}${s.unit} x ${s.reps}`).join(', '); - } - - function getExerciseUnit(log: { sets: Array<{ unit: string }> }): string { - return log.sets[0]?.unit ?? 'kg'; - } - - function toggleExerciseUnit(log: { - id: number; - exerciseId: number | null; - sets: Array<{ id: number; unit: string }>; - }) { - const currentUnit = getExerciseUnit(log); - const newUnit = currentUnit === 'kg' ? 'lbs' : 'kg'; - - for (const set of log.sets) { - set.unit = newUnit; - - const form = document.getElementById(`set-form-${set.id}`) as HTMLFormElement | null; - if (form) { - const unitInput = form.querySelector('input[name="unit"]'); - if (unitInput) { - unitInput.value = newUnit; - } - form.requestSubmit(); - } - } - }
-
-
-

{data.session.dayName}

-

{data.session.programName}

-
- {#if data.session.status === 'in_progress'} - - {/if} +
+

{data.session.dayName}

+

{data.session.programName}

{#if data.session.status === 'completed'} @@ -114,311 +63,13 @@
{/if} - -
- {#each data.session.exerciseLogs as log (log.id)} -
-
-
-

- {log.exerciseName} -

- {#if log.isAdhoc} - Ad-hoc - {/if} -
-
- {#if data.session.status === 'in_progress'} - {#if log.status === 'skipped'} - { - return async ({ result, update }) => { - if (isNetworkError(result)) { - await queueAction('UNSKIP_EXERCISE', { - exerciseLogId: log.id - }); - const unskipLog = data.session.exerciseLogs.find((l) => l.id === log.id); - if (unskipLog) unskipLog.status = 'logged'; - } else { - await update(); - } - }; - }} - > - - - - {:else} -
{ - return async ({ result, update }) => { - if (isNetworkError(result)) { - await queueAction('SKIP_EXERCISE', { - exerciseLogId: log.id - }); - const skipLog = data.session.exerciseLogs.find((l) => l.id === log.id); - if (skipLog) skipLog.status = 'skipped'; - } else { - await update(); - } - }; - }} - > - - -
- {/if} - {/if} -
-
- - - {#if data.progressiveOverload[log.id]} - {@const overload = data.progressiveOverload[log.id]} -
- {#if overload.previous} -

- Previous ({formatDate(overload.previous.date)}): {formatPreviousSets( - overload.previous.sets - )} -

- {/if} - {#if overload.max} -

- Max: {overload.max.weight}{overload.max.unit} x {overload.max.reps} reps ({formatDate( - overload.max.date - )}) -

- {/if} -
- {/if} - - {#if log.status !== 'skipped'} - -
-
- Set - - Weight - {#if data.session.status === 'in_progress'} - - {:else} - ({getExerciseUnit(log)}) - {/if} - - Reps - -
- {#each log.sets as set (set.id)} -
- - {#if set.weight != null && set.reps != null} - ✓ - {:else} - {set.setNumber} - {/if} - -
{ - return async ({ result, update }) => { - if (isNetworkError(result)) { - const weight = formData.get('weight'); - const reps = formData.get('reps'); - await queueAction('UPDATE_SET', { - setLogId: Number(formData.get('setLogId')), - exerciseId: Number(formData.get('exerciseId')), - weight: weight === '' ? null : Number(weight), - reps: reps === '' ? null : Number(reps), - unit: formData.get('unit') - }); - for (const exerciseLog of data.session.exerciseLogs) { - const setEntry = exerciseLog.sets.find( - (s) => s.id === Number(formData.get('setLogId')) - ); - if (setEntry) { - const w = formData.get('weight'); - const r = formData.get('reps'); - const u = formData.get('unit') as 'kg' | 'lbs' | null; - if (w !== null) setEntry.weight = w === '' ? null : Number(w); - if (r !== null) setEntry.reps = r === '' ? null : Number(r); - if (u) setEntry.unit = u; - break; - } - } - } else { - await update({ reset: false }); - } - }; - }} - class="contents" - > - - - - e.currentTarget.form?.requestSubmit()} - data-testid="weight-input-{set.id}" - /> - - e.currentTarget.form?.requestSubmit()} - data-testid="reps-input-{set.id}" - /> -
- {#if data.session.status === 'in_progress' && log.sets.length > 1} -
{ - return async ({ result, update }) => { - if (isNetworkError(result)) { - await queueAction('REMOVE_SET', { - setLogId: set.id - }); - for (const exerciseLog of data.session.exerciseLogs) { - const idx = exerciseLog.sets.findIndex((s) => s.id === set.id); - if (idx !== -1) { - exerciseLog.sets.splice(idx, 1); - exerciseLog.sets.forEach((s, i) => (s.setNumber = i + 1)); - break; - } - } - } else { - await update(); - } - }; - }} - > - - -
- {:else} -
- {/if} -
- {/each} -
- - {#if data.session.status === 'in_progress'} -
{ - return async ({ result, update }) => { - if (isNetworkError(result)) { - await queueAction('ADD_SET', { - exerciseLogId: log.id - }); - const targetLog = data.session.exerciseLogs.find((l) => l.id === log.id); - if (targetLog) { - const lastSet = targetLog.sets[targetLog.sets.length - 1]; - targetLog.sets.push({ - id: -Date.now(), - exerciseLogId: log.id, - setNumber: targetLog.sets.length + 1, - weight: null, - reps: null, - unit: lastSet?.unit ?? 'kg', - createdAt: new Date() - }); - } - } else { - await update(); - } - }; - }} - class="mt-2" - > - - -
- {/if} - {/if} -
- {/each} -
- {#if data.session.status === 'in_progress'} -
- -
+ (stopDialogOpen = true)} + onaddexercise={() => (adhocDialogOpen = true)} + /> {/if}
@@ -426,7 +77,7 @@ - Stop Workout? + Finish Workout? Exercises without logged sets will be marked as skipped. @@ -460,7 +111,7 @@ disabled={stoppingWorkout} data-testid="confirm-stop-btn" > - {stoppingWorkout ? 'Stopping...' : 'Stop Workout'} + {stoppingWorkout ? 'Finishing...' : 'Finish Workout'} From 678a7806b81f10f95d53c9725b162d4be2d13f9e Mon Sep 17 00:00:00 2001 From: Kavith Date: Sat, 14 Feb 2026 22:30:35 +0000 Subject: [PATCH 5/5] Fix code review issues: wizard sync, error handling, reactivity, volume units, validation - Replace array-length sync trigger with fingerprint-based change detection in WorkoutWizard, so server updates (e.g. placeholder IDs becoming real) are picked up even when exercise count stays the same - Extract duplicated placeholder-set creation into createPlaceholderSet helper - Remove placeholder flash on online addSet by skipping the placeholder and letting invalidateAll + syncFromServer bring in the real set - Surface save errors to the user instead of silently swallowing non-OK responses; display inline error text above the bottom bar - Replace plain getUnit() function with $derived rune in ExerciseStep for guaranteed reactivity when toggling units - Convert lbs volume to kg in getWorkoutSummary so total volume is always displayed in kg on the summary page - Tighten set ID validation in sync endpoint and saveExercise action: skip only negative IDs (offline placeholders), return 400 for other invalid values --- .../components/workout/ExerciseStep.svelte | 6 +- .../components/workout/WorkoutWizard.svelte | 88 ++++++++++--------- src/lib/server/db/queries/workouts.ts | 11 ++- src/routes/api/sync/+server.ts | 5 +- .../workout/[sessionId]/+page.server.ts | 9 +- .../workout/[sessionId]/summary/+page.svelte | 2 +- 6 files changed, 67 insertions(+), 54 deletions(-) diff --git a/src/lib/components/workout/ExerciseStep.svelte b/src/lib/components/workout/ExerciseStep.svelte index 79dc3c0..23493c1 100644 --- a/src/lib/components/workout/ExerciseStep.svelte +++ b/src/lib/components/workout/ExerciseStep.svelte @@ -50,9 +50,7 @@ return sets.map((s) => `${s.weight}${s.unit} x ${s.reps}`).join(', '); } - function getUnit(): string { - return exercise.sets[0]?.unit ?? 'kg'; - } + let unit = $derived(exercise.sets[0]?.unit ?? 'kg');
@@ -99,7 +97,7 @@ onclick={ontoggleunit} data-testid="unit-toggle" > - {getUnit()} + {unit} Reps diff --git a/src/lib/components/workout/WorkoutWizard.svelte b/src/lib/components/workout/WorkoutWizard.svelte index e53c1f0..8883460 100644 --- a/src/lib/components/workout/WorkoutWizard.svelte +++ b/src/lib/components/workout/WorkoutWizard.svelte @@ -51,7 +51,8 @@ let currentIndex = $state(0); let saving = $state(false); - let lastKnownLength = $state(-1); + let saveError = $state(null); + let lastFingerprint = $state(''); function cloneLogs(logs: ExerciseLogData[]): ExerciseLogData[] { return logs.map((log) => ({ @@ -60,15 +61,38 @@ })); } + function computeFingerprint(logs: ExerciseLogData[]): string { + return logs.map((log) => `${log.id}:${log.sets.map((s) => s.id).join(',')}`).join('|'); + } + + function createPlaceholderSet(log: ExerciseLogData): SetData { + const lastSet = log.sets[log.sets.length - 1]; + return { + id: -Date.now(), + exerciseLogId: log.id, + setNumber: log.sets.length + 1, + weight: null, + reps: null, + unit: lastSet?.unit ?? 'kg', + createdAt: new Date() + }; + } + // Deep clone exercise logs into local state for editing without server round-trips. // Re-syncs when exerciseLogs changes (e.g. after page reload or adhoc add). let localLogs = $state([]); $effect(() => { - const newLen = exerciseLogs.length; - if (newLen !== lastKnownLength) { - localLogs = cloneLogs(exerciseLogs); - lastKnownLength = newLen; + const fingerprint = computeFingerprint(exerciseLogs); + if (fingerprint !== lastFingerprint) { + if (lastFingerprint === '') { + // Initial load -- full clone + localLogs = cloneLogs(exerciseLogs); + } else { + // Subsequent changes -- preserve in-progress edits + syncFromServer(); + } + lastFingerprint = fingerprint; } }); @@ -131,11 +155,18 @@ sets: setsPayload }); } else if (!response.ok) { - // Check if it's a network error vs a server error - saving = false; + let message = 'Failed to save exercise'; + try { + const body = await response.json(); + if (body?.error) message = body.error; + } catch { + // Response body not JSON, use default message + } + saveError = message; return false; } + saveError = null; return true; } catch { // Network error -- queue offline @@ -220,49 +251,16 @@ if (!response.ok && !offlineState.isOnline) { await queueAction('ADD_SET', { exerciseLogId: log.id }); - // Add placeholder locally - const lastSet = log.sets[log.sets.length - 1]; - log.sets.push({ - id: -Date.now(), - exerciseLogId: log.id, - setNumber: log.sets.length + 1, - weight: null, - reps: null, - unit: lastSet?.unit ?? 'kg', - createdAt: new Date() - }); + log.sets.push(createPlaceholderSet(log)); } else if (response.ok) { - // Parse the response to get updated data - // SvelteKit form actions return HTML -- we need to invalidate - // For now, add a placeholder and let reload sync - const lastSet = log.sets[log.sets.length - 1]; - log.sets.push({ - id: -Date.now(), - exerciseLogId: log.id, - setNumber: log.sets.length + 1, - weight: null, - reps: null, - unit: lastSet?.unit ?? 'kg', - createdAt: new Date() - }); - // Invalidate to get the real set ID from server + // Invalidate to get the real set from server data const { invalidateAll } = await import('$app/navigation'); await invalidateAll(); - // Re-sync local logs but preserve current edits syncFromServer(); } } catch { await queueAction('ADD_SET', { exerciseLogId: log.id }); - const lastSet = log.sets[log.sets.length - 1]; - log.sets.push({ - id: -Date.now(), - exerciseLogId: log.id, - setNumber: log.sets.length + 1, - weight: null, - reps: null, - unit: lastSet?.unit ?? 'kg', - createdAt: new Date() - }); + log.sets.push(createPlaceholderSet(log)); } } @@ -341,6 +339,10 @@ {/key} {/if} +{#if saveError} +

{saveError}

+{/if} + sum + (row.set_logs.weight ?? 0) * (row.set_logs.reps ?? 0), - 0 - ); + const totalVolume = allSets.reduce((sum, row) => { + const weight = row.set_logs.weight ?? 0; + const reps = row.set_logs.reps ?? 0; + const weightKg = row.set_logs.unit === 'lbs' ? weight * LBS_TO_KG : weight; + return sum + weightKg * reps; + }, 0); const durationMinutes = session.completedAt && session.startedAt diff --git a/src/routes/api/sync/+server.ts b/src/routes/api/sync/+server.ts index a773f0e..06a4821 100644 --- a/src/routes/api/sync/+server.ts +++ b/src/routes/api/sync/+server.ts @@ -37,7 +37,10 @@ export const POST: RequestHandler = async ({ request }) => { } let unit: 'kg' | 'lbs' | undefined; for (const set of payload.sets) { - if (!isValidId(set.setLogId)) continue; // skip placeholder sets + if (typeof set.setLogId === 'number' && set.setLogId < 0) continue; // skip offline placeholders + if (!isValidId(set.setLogId)) { + return badRequest('set setLogId must be a positive integer'); + } if (set.unit && !isValidUnit(set.unit)) { return badRequest('set unit must be kg or lbs'); } diff --git a/src/routes/workout/[sessionId]/+page.server.ts b/src/routes/workout/[sessionId]/+page.server.ts index 2f993cb..b173f01 100644 --- a/src/routes/workout/[sessionId]/+page.server.ts +++ b/src/routes/workout/[sessionId]/+page.server.ts @@ -67,7 +67,14 @@ export const actions: Actions = { let unit: 'kg' | 'lbs' | undefined; for (const set of sets) { - if (set.setLogId <= 0) continue; // skip placeholder sets from offline add + if (typeof set.setLogId === 'number' && set.setLogId < 0) continue; // skip offline placeholders + if ( + typeof set.setLogId !== 'number' || + !Number.isInteger(set.setLogId) || + set.setLogId <= 0 + ) { + return fail(400, { error: 'set setLogId must be a positive integer' }); + } updateSetLog(db, set.setLogId, { weight: set.weight, reps: set.reps, diff --git a/src/routes/workout/[sessionId]/summary/+page.svelte b/src/routes/workout/[sessionId]/summary/+page.svelte index 2f2a377..245ce61 100644 --- a/src/routes/workout/[sessionId]/summary/+page.svelte +++ b/src/routes/workout/[sessionId]/summary/+page.svelte @@ -61,7 +61,7 @@

- {formatVolume(data.summary.totalVolume)} + {formatVolume(data.summary.totalVolume)} kg

Volume