diff --git a/CLAUDE.md b/CLAUDE.md index 6082a57..85ecc93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ ## Project Overview -Workout Tracker — a self-hosted, mobile-first SvelteKit app for logging workouts with progressive overload tracking. Single-user, no auth. See `llm-docs/v1/REQUIREMENTS.md` for full product requirements and `llm-docs/v1/TECHNICAL_DESIGN.md` for architecture details. +Workout Tracker -- a self-hosted, mobile-first SvelteKit app for logging workouts with progressive overload tracking. Single-user, no auth. See `docs/` for architecture, database schema, offline design, and deployment. See `llm-docs/` for original product requirements and technical design. ## Tech Stack @@ -50,41 +50,36 @@ Two test projects configured in `vite.config.ts`: Any documentation written by agents must be clear, simple, and short. No unnecessary verbosity. -## Architecture +## Key File Locations -- **DB schema**: `src/lib/server/db/schema.ts` — Drizzle schema definition -- **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 -- **Components**: `src/lib/components/` — organized by domain (layout, workout, program, history, exercises, shared, ui) -- **Server logic**: `src/lib/server/` — DB queries in `db/queries/`, utilities in `utils/` +- **DB schema**: `src/lib/server/db/schema.ts` +- **DB connection**: `src/lib/server/db/index.ts` +- **DB queries**: `src/lib/server/db/queries/` +- **Drizzle config**: `drizzle.config.ts` +- **Shared utils**: `src/lib/utils.ts` +- **Routes**: `src/routes/` +- **Components**: `src/lib/components/` (layout, workout, program, shared, ui) +- **Server logic**: `src/lib/server/` +- **Offline module**: `src/lib/offline/` ## Documentation Lookup Rules **CRITICAL: Never rely on training data for syntax or APIs. Always look up latest docs.** -- **Svelte/SvelteKit**: Use the Svelte MCP server (`list-sections` then `get-documentation`). Always run `svelte-autofixer` on any Svelte code before finalizing. -- **shadcn-svelte**: Use https://www.shadcn-svelte.com — index at https://www.shadcn-svelte.com/llms.txt +- **Svelte/SvelteKit**: Use the Svelte MCP server (`list-sections` then `get-documentation`). Always run `svelte-autofixer` on any Svelte code before finalising. +- **shadcn-svelte**: Use https://www.shadcn-svelte.com -- index at https://www.shadcn-svelte.com/llms.txt - **All other technologies**: Use the Context7 MCP Server for up-to-date documentation. ## Svelte MCP Tools -1. **list-sections** — Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths. When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections. -2. **get-documentation** — Retrieves full documentation content for specific sections. Accepts single or multiple sections. After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task. -3. **svelte-autofixer** — Analyzes Svelte code and returns issues and suggestions. You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned. +1. **list-sections** -- Use this FIRST to discover all available documentation sections. +2. **get-documentation** -- Retrieves full documentation content for specific sections. After calling list-sections, analyse the returned sections and fetch ALL relevant ones. +3. **svelte-autofixer** -- Analyses Svelte code and returns issues and suggestions. MUST be used on any Svelte code before finalising. Keep calling until no issues remain. ## Context7 MCP Tools -1. **resolve-library-id** — Resolves a general library name into a Context7-compatible library ID. - -- `query` (required): The user's question or task (used to rank results by relevance) -- `libraryName` (required): The name of the library to search for - -2. **query-docs** — Retrieves documentation for a library using a Context7-compatible library ID. - -- `libraryId` (required): Exact Context7-compatible library ID (e.g., shadcn-svelte.com/docs, tailwindcss.com/docs) -- `query` (required): The question or task to get relevant documentation for +1. **resolve-library-id** -- Resolves a library name into a Context7-compatible library ID. +2. **query-docs** -- Retrieves documentation for a library using a Context7-compatible library ID. ## Branching & PR Workflow @@ -121,7 +116,7 @@ The `playwright-cli` skill is available for any Playwright browser automation ac At the end of each phase, a verifier subagent MUST be kicked off to check: -1. Everything is built as per the plan — no outstanding items +1. Everything is built as per the plan -- no outstanding items 2. No regressions or bugs introduced -3. All rules in this file were followed — no shortcuts taken +3. All rules in this file were followed -- no shortcuts taken 4. Code quality: DRY/YAGNI, clear, self-documenting diff --git a/README.md b/README.md index 21ad113..18b5744 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # Workout Tracker -A self-hosted, mobile-first workout logging app with progressive overload tracking. Built with SvelteKit, SQLite, and Docker. +A self-hosted, mobile-first workout logging app built with SvelteKit and SQLite. Create workout programmes, log sets with weight and reps, and track progressive overload over time. Single-user, zero-config, works offline as a PWA. ## Requirements -- Node.js 22+ +- Node.js 22+ (for local development) +- Docker (for deployment) -## Development +## Local Development ```sh npm install @@ -15,43 +16,21 @@ npm run dev The database is automatically created at `./data/workout-tracker.db`. -## Deployment with Docker - -### Using Docker Compose (recommended) +## Docker Deployment ```sh docker compose up -d ``` -This starts the container on port 6789 and persists the database via a volume mount to `/data`. - -### Using Docker directly - -```sh -docker build -t workout-tracker . -docker run -d \ - -p 3000:3000 \ - -v workout-data:/data \ - workout-tracker -``` - -### Using the pre-built image from GHCR +Or use the pre-built image: ```sh -docker run -d \ - -p 3000:3000 \ - -v workout-data:/data \ - ghcr.io/kavith-k/workout-tracker:latest +docker run -d -p 3000:3000 -v workout-data:/data ghcr.io/kavith-k/workout-tracker:latest ``` -### 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. - -### HTTPS +## Documentation -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. +- [Architecture](docs/architecture.md) -- tech stack, directory structure, data flow +- [Database](docs/database.md) -- schema, relationships, migration workflow +- [Offline](docs/offline.md) -- PWA, IndexedDB queue, sync engine +- [Deployment](docs/deployment.md) -- Docker, reverse proxy, HTTPS, data persistence diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..27ac726 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,122 @@ +# Architecture + +## Tech Stack + +| Layer | Technology | +| --------- | ------------------------------------- | +| Framework | SvelteKit 2 (Svelte 5), Node adapter | +| Styling | TailwindCSS v4 + shadcn-svelte | +| Database | SQLite via better-sqlite3 + Drizzle ORM | +| Offline | PWA with Service Worker (Workbox) | +| Testing | Vitest (unit/component) + Playwright (E2E) | + +## Directory Structure + +``` +src/ + lib/ + server/ + db/ + index.ts # DB connection (auto-detected path) + schema.ts # Drizzle schema definition + queries/ # Reusable query functions + programs.ts # Programme CRUD + exercises.ts # Exercise CRUD + workouts.ts # Workout session lifecycle + history.ts # History browsing and deletion + export.ts # JSON/CSV export assembly + utils/ # Server-side utilities + components/ + ui/ # shadcn-svelte components + layout/ # App shell, navigation, resume banner + home/ # ConsistencyGrid (GitHub-style activity heatmap) + program/ # Programme form (drag-to-reorder via svelte-dnd-action) + workout/ # Workout wizard, ExerciseStep (set logging, copy-down, volume) + shared/ # Offline indicator, empty state, confirm dialog + offline/ + queue.ts # IndexedDB queue management + sync.ts # Sync engine (periodic + on-reconnect) + stores.svelte.ts # Reactive online/sync status + utils.ts # cn() helper, shadcn type helpers + routes/ # SvelteKit file-based routing (see below) + app.html + app.css # Tailwind imports, global styles +static/ # PWA icons, favicon +drizzle/ # Generated migrations +e2e/ # Playwright E2E tests +scripts/ + seed-test-data.mjs # Wipe DB and populate with 2 years of sample data +``` + +## Routes + +| Route | Purpose | +| ------------------------------ | --------------------------------------------- | +| `/` | Home screen with active programme days and consistency grid | +| `/workout/start` | Form action to create session and redirect | +| `/workout/[sessionId]` | Active workout logging interface | +| `/workout/[sessionId]/summary` | Post-workout summary with PRs and stats | +| `/history` | Past workouts by date with infinite scroll | +| `/history/[sessionId]` | Single workout session details | +| `/api/history` | GET endpoint for paginated history (infinite scroll) | +| `/programs` | List and manage programmes | +| `/programs/new` | Create new programme | +| `/programs/[programId]` | Edit existing programme | +| `/exercises` | Exercise library with stats | +| `/settings` | Data export (JSON/CSV) | +| `/api/sync` | POST endpoint for offline queue synchronisation | + +## Data Flow + +### Starting a Workout + +1. User taps a workout day on the home screen +2. `POST /workout/start` creates a `workout_session` (status: `in_progress`), pre-creates `exercise_logs` and empty `set_logs` +3. Redirects to `/workout/[sessionId]` +4. Load function fetches session details, exercise logs with sets, and progressive overload data + +### Logging Sets + +Each set row is a `
` with `use:enhance`. Weight and reps inputs fire `requestSubmit()` on change for immediate server-side persistence. The enhance callback skips `update()` to avoid re-rendering during active editing. + +If the server is unreachable, the action is queued to IndexedDB for later sync (see [offline docs](offline.md)). + +A **copy-down** button (arrow icon) appears on empty sets, copying weight and reps from the previous set. Live **volume** (weight x reps summed) is shown per exercise alongside the previous session's volume. + +### Completing a Workout + +1. User taps "Stop" and confirms +2. Unlogged exercises are auto-marked as `skipped` +3. If **no exercises** have any logged reps, the workout is cancelled: all logs and the session are deleted, and the user is redirected home with a cancellation message +4. Otherwise, session marked as `completed` and redirected to summary page with PRs and stats + +### Programme Editing + +Days and exercises within the programme form are reordered via **drag-and-drop** (using `svelte-dnd-action` with drag handles). Touch-friendly with flip animations. + +### Stale Workout Cleanup + +The root layout server load calls `closeStaleWorkouts()` on every page load. Any `in_progress` session older than 4 hours is auto-completed with unlogged exercises marked as `skipped`. This prevents abandoned workouts from blocking new ones. + +## Test Data + +To populate the database with realistic sample data for development or testing: + +```bash +node scripts/seed-test-data.mjs +``` + +This deletes the existing database, runs migrations to recreate the schema, then seeds ~240 completed workout sessions spanning 2 years (Feb 2024 -- Feb 2026) with progressive overload, sporadic scheduling, and occasional skipped exercises. + +## Key Design Decisions + +| Decision | Rationale | +| --------------------------- | ------------------------------------------------------------- | +| Drizzle ORM | Type-safe, lightweight, good SQLite support | +| SvelteKit form actions | Progressive enhancement, works with PWA offline fallback | +| Snapshot fields in logs | Preserves historical accuracy when entities are renamed/deleted | +| Per-set immediate save | Maximum data safety, survives crashes/closes | +| Single active programme | Simplifies home screen and workout start flow | +| IndexedDB for offline queue | Browser-native, reliable, works with service workers | +| svelte-dnd-action | Touch-friendly drag-and-drop with Svelte integration | +| Empty workout cancellation | Prevents clutter from accidental/empty workout sessions | diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..777bbeb --- /dev/null +++ b/docs/database.md @@ -0,0 +1,111 @@ +# Database + +SQLite database managed by Drizzle ORM. Schema defined in `src/lib/server/db/schema.ts`. + +## Connection + +The database path is auto-detected: + +- **Local dev**: `./data/workout-tracker.db` (created automatically) +- **Docker**: `/data/workout-tracker.db` (detected by the presence of `/data/`) + +No environment variables required. + +## Schema + +``` +programs + id INTEGER PK autoincrement + name TEXT NOT NULL + is_active BOOLEAN NOT NULL DEFAULT false + created_at TIMESTAMP NOT NULL + updated_at TIMESTAMP NOT NULL + +workout_days + id INTEGER PK autoincrement + program_id INTEGER FK -> programs(id) ON DELETE CASCADE + name TEXT NOT NULL + sort_order INTEGER NOT NULL + created_at TIMESTAMP NOT NULL + +exercises + id INTEGER PK autoincrement + name TEXT NOT NULL UNIQUE + unit_preference TEXT NOT NULL DEFAULT 'kg' ('kg' | 'lbs') + created_at TIMESTAMP NOT NULL + +day_exercises + id INTEGER PK autoincrement + workout_day_id INTEGER FK -> workout_days(id) ON DELETE CASCADE + exercise_id INTEGER FK -> exercises(id) + sets_count INTEGER NOT NULL DEFAULT 3 + sort_order INTEGER NOT NULL + created_at TIMESTAMP NOT NULL + +workout_sessions + id INTEGER PK autoincrement + program_id INTEGER FK -> programs(id) ON DELETE SET NULL (nullable) + workout_day_id INTEGER FK -> workout_days(id) ON DELETE SET NULL (nullable) + program_name TEXT NOT NULL (snapshot) + day_name TEXT NOT NULL (snapshot) + status TEXT NOT NULL DEFAULT 'in_progress' ('in_progress' | 'completed') + started_at TIMESTAMP NOT NULL + completed_at TIMESTAMP (nullable) + +exercise_logs + id INTEGER PK autoincrement + exercise_id INTEGER FK -> exercises(id) ON DELETE SET NULL (nullable) + session_id INTEGER FK -> workout_sessions(id) ON DELETE CASCADE + exercise_name TEXT NOT NULL (snapshot) + status TEXT NOT NULL DEFAULT 'logged' ('logged' | 'skipped') + is_adhoc BOOLEAN NOT NULL DEFAULT false + sort_order INTEGER NOT NULL + created_at TIMESTAMP NOT NULL + +set_logs + id INTEGER PK autoincrement + exercise_log_id INTEGER FK -> exercise_logs(id) ON DELETE CASCADE + set_number INTEGER NOT NULL + weight REAL (nullable) + reps INTEGER (nullable) + unit TEXT NOT NULL DEFAULT 'kg' ('kg' | 'lbs') + created_at TIMESTAMP NOT NULL +``` + +## Relationships + +``` +Program 1---* WorkoutDay 1---* DayExercise *---1 Exercise + | +WorkoutSession 1---* ExerciseLog 1---* SetLog | + | | | + *---1 Program? *---1 Exercise? | + *---1 WorkoutDay? (snapshot: exercise_name) | + (snapshots: program_name, day_name) +``` + +## Key Schema Decisions + +- **Snapshot fields**: `program_name`, `day_name`, `exercise_name` are stored at workout time. Historical logs remain accurate even if the source is renamed or deleted. +- **Nullable FKs**: `program_id`, `workout_day_id`, `exercise_id` become null if the referenced entity is deleted, but snapshot fields preserve the data. +- **Sort order**: `workout_days` and `day_exercises` have `sort_order` for user-defined ordering. +- **Unit preference**: Stored per-exercise in `exercises` and per-set in `set_logs`. +- **Status tracking**: Sessions track `in_progress` vs `completed`. Exercise logs track `logged` vs `skipped`. + +## Migration Workflow + +```bash +# Generate a migration after changing schema.ts +npm run db:generate + +# Run pending migrations +npm run db:migrate + +# Push schema directly (dev only, no migration file) +npm run db:push + +# Open Drizzle Studio for visual inspection +npm run db:studio +``` + +Migrations are stored in `drizzle/` and are copied into the Docker image at build time. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..a49260f --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,96 @@ +# Deployment + +## Docker Compose (recommended) + +```sh +docker compose up -d +``` + +This starts the container on port 6789 (mapped to internal port 3000) and persists the database via a volume mount. + +Example `docker-compose.yml`: + +```yaml +services: + workout-tracker: + image: ghcr.io/kavith-k/workout-tracker:latest + container_name: workout-tracker + ports: + - '6789:3000' + volumes: + - docker-config/workout-tracker:/data + restart: unless-stopped +``` + +## Docker (manual) + +### Build from source + +```sh +docker build -t workout-tracker . +docker run -d \ + -p 3000:3000 \ + -v workout-data:/data \ + workout-tracker +``` + +### Pre-built image from GHCR + +```sh +docker run -d \ + -p 3000:3000 \ + -v workout-data:/data \ + ghcr.io/kavith-k/workout-tracker:latest +``` + +## Data Persistence + +The database is stored at `/data/workout-tracker.db` inside the container. Mount a volume or bind mount to `/data` to persist data across container restarts. + +## Configuration + +No environment variables are required. The app is zero-config. + +## HTTPS and Reverse Proxy + +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. + +### Caddy example + +``` +workout.example.com { + reverse_proxy localhost:6789 +} +``` + +Caddy automatically provisions and renews HTTPS certificates via Let's Encrypt. + +### nginx example + +```nginx +server { + listen 443 ssl; + server_name workout.example.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://localhost:6789; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +## Docker Image Details + +The image uses a multi-stage build: + +1. **Build stage**: Node 22 Alpine, installs dependencies, builds the SvelteKit app +2. **Production stage**: Node 22 Alpine, production dependencies only, includes built output and Drizzle migrations + +The container exposes port 3000 and includes a healthcheck that polls the root URL every 30 seconds. diff --git a/docs/offline.md b/docs/offline.md new file mode 100644 index 0000000..45d8d8c --- /dev/null +++ b/docs/offline.md @@ -0,0 +1,100 @@ +# Offline Support + +The app is a PWA with offline write support. When the server is unreachable, workout actions are queued locally and synced when connectivity returns. + +## Service Worker + +Configured via `@vite-pwa/sveltekit` with Workbox `generateSW` strategy and `autoUpdate` registration. + +- All static assets (`*.{js,css,html,ico,png,svg,woff2}`) are precached +- Navigate fallback to `/` serves the SPA shell for offline navigation +- Disabled in dev mode + +## Module Structure + +``` +src/lib/offline/ + queue.ts # IndexedDB queue for pending write actions + stores.svelte.ts # Reactive state (online status, sync status, pending count) + sync.ts # Sync engine (periodic + on-reconnect) +``` + +## IndexedDB Queue + +Database `workout-tracker-offline` (version 1) with object store `sync-queue`. + +Each queued action contains: + +| Field | Description | +| ----------- | ---------------------------------------- | +| id | `crypto.randomUUID()` identifier | +| timestamp | `Date.now()` when queued | +| action | Action type (see below) | +| payload | Action-specific data (set IDs, weights, etc.) | +| retryCount | Starts at 0, incremented on each failure | + +Actions are dropped after 10 failed retries. + +### Action Types + +| Action | Description | +| ------------------ | ------------------------------------ | +| `SAVE_EXERCISE` | Save set weight/reps/unit | +| `COMPLETE_WORKOUT` | Complete or cancel session (see below) | +| `ADD_ADHOC` | Add an ad-hoc exercise to a session | +| `ADD_SET` | Add a set to an exercise log | +| `REMOVE_SET` | Remove a set from an exercise log | + +## Sync Engine + +- **On reconnect**: Immediately processes the queue via `online` event listener +- **Periodic**: Runs every 30 seconds via `setInterval` +- **Sequential**: Actions are processed one at a time in timestamp order +- **Endpoint**: Each action is sent as `POST /api/sync` with `{ action, payload }` +- **Success**: Action removed from queue +- **Failure**: Retry count incremented; dropped after 10 retries + +The `/api/sync` endpoint dispatches to existing server-side query functions (e.g., `updateSetLog`, `completeWorkout`, `addAdhocExercise`). + +### Empty Workout Cancellation + +When `COMPLETE_WORKOUT` is processed (online or via sync), `completeWorkout()` checks whether any exercise has logged reps. If no reps were logged for any exercise, the session, exercise logs, and set logs are deleted and the response includes `{ cancelled: true }`. The client redirects to home instead of the summary page. + +## Reactive State + +`stores.svelte.ts` exports a singleton `offlineState` using Svelte 5 `$state` runes: + +- `isOnline` -- tracks `navigator.onLine` plus event listeners +- `pendingSyncCount` -- number of queued actions +- `isSyncing` -- guard against re-entrant sync + +## Offline Form Handling + +Workout logging forms use `use:enhance` with an offline fallback pattern: + +```svelte +use:enhance={() => { + return async ({ result }) => { + if (result.type === 'error') { + // Network failure - queue for later sync + await addToQueue('ACTION_TYPE', { ... }); + } + }; +}} +``` + +## UI Indicator + +`OfflineIndicator.svelte` (mounted in `AppShell`) shows a fixed pill at bottom-right: + +- **Offline**: "Offline" when `!isOnline` +- **Syncing**: "Syncing N..." when online with pending actions +- **Hidden**: When fully connected with no pending actions + +## HTTPS Requirement + +Service workers require HTTPS (except on `localhost`). For remote access, use a reverse proxy to terminate TLS. See [deployment docs](deployment.md). + +## Initialisation + +The root layout (`+layout.svelte`) calls `initOfflineListeners()` and `setupSyncListeners()` on mount. Both return cleanup functions invoked on unmount. diff --git a/e2e/workout-flow.test.ts b/e2e/workout-flow.test.ts index 9ce1252..f0f786f 100644 --- a/e2e/workout-flow.test.ts +++ b/e2e/workout-flow.test.ts @@ -182,8 +182,12 @@ test.describe.serial('Workout Flow', () => { const circleCount = await page.getByTestId('wizard-progress-bar').locator('button').count(); await page.getByTestId(`progress-circle-${circleCount - 1}`).click(); await page.getByTestId('wizard-finish-btn').click(); + await expect(page.getByText('Finish Workout?')).toBeVisible(); await page.getByTestId('confirm-stop-btn').click(); - await expect(page.getByTestId('workout-summary')).toBeVisible(); + + // No exercises were logged, so the workout is cancelled and redirects to home + await expect(page).toHaveURL('/'); + await expect(page.getByTestId('workout-cancelled-banner')).toBeVisible(); }); test('shows congratulatory message when all exercises are completed', async ({ page }) => { diff --git a/llm-docs/v1.2/TODO.md b/llm-docs/v1.2/TODO.md new file mode 100644 index 0000000..d04e43a --- /dev/null +++ b/llm-docs/v1.2/TODO.md @@ -0,0 +1,225 @@ +# v1.2 TODO + +## 1. Documentation Overhaul + +The current documentation is scattered across `llm-docs/v1/`, `llm-docs/v1.1/`, `CLAUDE.md`, and `README.md`. The `llm-docs/` folders are build-time artefacts (plans, phase checklists, issue trackers) rather than proper project documentation. A developer cloning this repo has no single place to understand the app's architecture, data model, or feature set. + +### Goals + +- Create a `docs/` folder at the project root with clean, permanent documentation. +- Extract and consolidate architectural information from `llm-docs/v1/TECHNICAL_DESIGN.md` and `CLAUDE.md` into `docs/`: + - `docs/architecture.md` — tech stack, directory structure, component organisation, data flow. + - `docs/database.md` — schema overview, table relationships, migration workflow. + - `docs/offline.md` — offline queue design, sync endpoint, IndexedDB usage, action types. + - `docs/deployment.md` — Docker, docker-compose, reverse proxy setup, HTTPS, data persistence (move detailed deployment content out of README). +- Rewrite `README.md` to be concise and user-facing: + - What the app is (one paragraph). + - Screenshots or a demo GIF (optional, placeholder link). + - Requirements (Node 22+, Docker). + - Quick start for local dev (3-4 lines). + - Quick start for Docker deployment (3-4 lines). + - Link to `docs/` for everything else. +- The `llm-docs/` folders can remain for historical reference but should not be the source of truth for anything. + +### Files to create or modify + +| File | Action | +|---|---| +| `docs/architecture.md` | Create — tech stack, directory layout, component organisation | +| `docs/database.md` | Create — schema, relationships, migrations | +| `docs/offline.md` | Create — offline queue, sync, IndexedDB | +| `docs/deployment.md` | Create — Docker, reverse proxy, HTTPS, persistence | +| `README.md` | Rewrite — concise, links to `docs/` | +| `CLAUDE.md` | Trim — remove architecture prose, keep agent instructions | + +--- + +## 2. Save Horizontal Space in Program Creation + +The program creation form (`src/lib/components/program/ProgramForm.svelte`) uses up/down arrow buttons (ChevronUp/ChevronDown) to reorder both days and exercises within days. Each item gets two buttons, consuming horizontal space on narrow screens. Additionally, set deletion during workouts takes space that may be unnecessary for standard prescribed sets. + +### 2a. Drag-to-reorder exercises and days + +Replace the arrow buttons with a drag handle (grip/move icon) and implement touch-friendly drag-and-drop reordering. + +**Current implementation:** +- `ProgramForm.svelte` lines 90-127: `moveDayUp`, `moveDayDown`, `moveExerciseUp`, `moveExerciseDown` functions that splice array elements. +- Lines 212-233: Day arrow buttons rendered per day. +- Lines 266-288: Exercise arrow buttons rendered per exercise within each day. + +**What to change:** +- Remove the up/down ChevronUp/ChevronDown button pairs. +- Add a drag handle icon (e.g., `GripVertical` from lucide) to each day row and each exercise row. +- Implement drag-and-drop reordering. Options: + - Native HTML5 drag-and-drop with touch polyfill. + - A lightweight library like `svelte-dnd-action` or similar. +- Ensure it works well on mobile (touch drag, not just mouse). +- The underlying data mutation stays the same (splice the `days` array or `days[i].exercises` array), just triggered by drag end instead of button click. +- Update `sortOrder` values on save as before. + +### 2b. Remove set deletion for standard sets + +During a workout, each set row in `ExerciseStep.svelte` shows a delete button (lines 135-144, rendered when `exercise.sets.length > 1`). For a standard workout where the user does the prescribed number of sets (e.g., 3), the delete button is unnecessary clutter. + +**What to change:** +- Only show the delete button on sets that were manually added (ad-hoc sets beyond the prescribed count). +- The prescribed set count comes from `dayExercises.sets` in the schema (`schema.ts` line 48). This value is available when the workout is started and exercise logs are created. +- Need to pass the prescribed set count to `ExerciseStep` and only render the delete button on sets whose index exceeds that count. + +--- + +## 3. Copy Previous Set Values + +When logging a workout, users often repeat the same weight and reps across multiple sets. Currently, each set's weight and reps inputs start empty, requiring manual entry every time. + +### Goal + +Add a mechanism to copy the previous set's weight and/or reps into the current set's input fields. + +**Current implementation:** +- `ExerciseStep.svelte` lines 111-133: Weight and reps `Input` components per set row. +- Values default to `null` (rendered as empty). +- No auto-fill or copy mechanism exists. + +### Design options (pick one or combine) + +1. **Copy button per set row**: A small button (e.g., a clipboard or copy-down icon) next to or between the weight/reps inputs that, when tapped, fills that set's weight and reps with the values from the set above it (set index - 1). Disabled on the first set. + +2. **Copy-down on the set header row**: A single button on the set table header that fills all empty sets with the first set's values (bulk fill). + +3. **Long-press or double-tap on input**: Auto-fills from previous set on a gesture. + +### Implementation notes + +- The copy should populate both weight and reps from the previous set in the same exercise (not from a previous workout session — that's what the progressive overload hints are for). +- Trigger the same `onupdateset` callback so the values are tracked in local state. +- Copying should never be possible existing values. It should only be available for empty fields. +- Only operates on sets within the current exercise card. + +--- + +## 4. Show Volume During Workout + +Currently, volume (weight x total reps) is only calculated at workout completion in `getWorkoutSummary()` (`workouts.ts` lines 283-383). During the workout, only "Previous" and "Max" hints are shown per exercise. There is no live feedback on current vs. previous volume to motivate the user. + +### Goal + +Display current volume and previous volume per exercise while the user is working out, so they can see at a glance whether they are on track to beat their previous session. + +### Design + +**Per-exercise volume display on `ExerciseStep.svelte`:** +- Calculate current volume from the sets already logged in the current session for this exercise: `sum(weight * reps)` for each set where both weight and reps are filled. +- Show previous volume from the most recent completed session for the same exercise (data already fetched via `getPreviousPerformance` — just needs summing). +- Display format: `Volume: 1,200 kg (prev: 1,050 kg)` or similar. +- Use the exercise's own unit (kg or lbs) — do not convert. Show the unit label. + +**Where to place it:** +- The workout wizard now shows one exercise per card (`ExerciseStep`), freeing up vertical space. +- The progressive overload hints section (lines 68-85 in `ExerciseStep.svelte`) already shows "Previous" and "Max". Volume could sit alongside or below these hints. +- Consider reorganising the hints area: group "Previous sets", "Max", and "Volume comparison" into a compact stats block at the top of the exercise card. + +### Data flow + +- Previous performance data is already loaded in `+page.server.ts` (lines 16-41) and passed to `ExerciseStep` as `exercise.previousPerformance`. +- Previous volume = `sum(weight * reps)` across all sets in `exercise.previousPerformance.sets`. +- Current volume = computed reactively from the local set state in `ExerciseStep`. +- No new server queries needed — all data is already available client-side. + +### Volume unit handling + +- Use whatever unit the exercise's sets are in (kg or lbs). Do not convert. +- Display the unit explicitly: "1,200 kg" or "2,640 lbs". +- Note: `getWorkoutSummary()` currently converts everything to kg for the summary page. That can stay as-is. + +--- + +## 5. Consistency Visualisation (GitHub-style Grid) + +Add a GitHub-style contribution/activity grid to the home page showing workout consistency over a configurable period (month or year). + +### Goal + +Give the user a quick visual indicator of how consistently they have been training. Each cell in the grid represents a day; filled/coloured cells indicate days where a workout was completed. + +### Design + +- **Grid layout**: 7 rows (days of the week) x 12 columns (weeks). Similar to GitHub's contribution graph. Horizontally scrollable to see history. +- **Colour intensity**: Just binary (worked out / didn't). No need to vary intensity of colour. +- **Placement**: Home page (`src/routes/+page.svelte`), below the active program section or above the workout day buttons. + +### Data requirements + +- Query: all `workoutSessions` with `status = 'completed'` and `completedAt`. +- Group by date (day). Each unique date with at least one completed session = filled cell. +- New server query needed in `+page.server.ts` load function (or a dedicated query in `workouts.ts`). + +### Implementation notes + +- Build as a standalone component: `src/lib/components/home/ConsistencyGrid.svelte` (or similar). +- Use CSS grid or SVG for the layout. +- Needs to handle the current day marker (today). +- Keep it lightweight; this is a read-only visualisation with no interactivity other than scrolling horizontally to view more than 3 months of data (if it exists). +- Mobile-first: ensure it fits within the viewport width. Horizontal scroll is acceptable for longer periods. + +--- + +## 6. Cancel Empty Workouts + +Currently, when a user finishes a workout without logging any reps against any exercise, the workout is still saved with all exercises marked as "skipped" and `status = 'completed'`. This clutters history with meaningless entries. + +### Goal + +If no reps are logged against any exercise in the session, treat the workout as cancelled rather than completed. Do not store it in history. + +### Current behaviour + +- `completeWorkout()` in `workouts.ts` (lines 255-281): + - Iterates exercise logs, marks those without filled sets (where `weight IS NOT NULL`) as skipped. + - Sets session `status = 'completed'` and `completedAt = now()`. + - Does not check whether the entire workout is empty. +- The summary page then shows "0/N exercises completed" but the session exists in history. + +### What to change + +- In `completeWorkout()`, after marking individual exercises as skipped, check if **any** exercise log still has `status = 'logged'` (i.e., has at least one set with reps filled). +- If zero exercises are logged: + - Delete all `setLogs` for this session. + - Delete all `exerciseLogs` for this session. + - Delete the `workoutSession` itself. + - Return a flag or distinct response indicating the workout was cancelled. +- On the client side (`+page.server.ts` stop action and `WorkoutWizard.svelte` offline handling): + - If the server returns "cancelled", redirect to home instead of the summary page. + - Show a brief toast or message: "Workout cancelled — no exercises were logged." +- For offline mode: the `COMPLETE_WORKOUT` sync action should apply the same logic when processed by the sync endpoint. +- The check should be based on `reps IS NOT NULL`. A set with reps but no weight is arguably still a logged set (bodyweight exercises). + +--- + +## 7. Investigate Offline Mode over HTTP + +Service workers (and by extension, the offline queue sync and any future caching) require a secure context. Browsers restrict service worker registration to HTTPS origins (with `localhost` as the sole exception). + +### Goal + +Investigate whether the app's offline features can work over plain HTTP (e.g., when accessed on a local network without TLS), and document the findings for future developers. + +### What to investigate + +1. **Which browsers enforce the HTTPS requirement for service workers?** Is it all modern browsers or are there exceptions? +2. **Does the current offline implementation actually depend on a service worker?** The app uses IndexedDB for queuing and a JS-based sync loop (`src/lib/offline/sync.ts`) — these do not require a service worker. The service worker (via `@vite-pwa/sveltekit`) may only be needed for asset caching / "install as app" functionality. Clarify what breaks on HTTP vs. what still works. +3. **Are there workarounds?** For example: + - Chrome flags (`--unsafely-treat-insecure-origin-as-secure`). + - `localhost` aliases (e.g., adding a hosts entry for `workout.local` pointing to the server's LAN IP — does this count as localhost?). + - Self-signed certificates with trust store installation. + - mDNS / `.local` domains. +4. **What is the realistic recommendation?** For a self-hosted app on a home network, is HTTPS via a reverse proxy the only practical path, or are there simpler alternatives? + +### Output + +Write findings to `llm-docs/v1.2/offline-mode-http.md`. Structure: +- Summary of the problem. +- What currently works on HTTP vs. what does not. +- Browser-by-browser breakdown if relevant. +- Workaround options with pros/cons. +- Recommendation for self-hosters. diff --git a/llm-docs/v1.2/offline-mode-http.md b/llm-docs/v1.2/offline-mode-http.md new file mode 100644 index 0000000..0e8e995 --- /dev/null +++ b/llm-docs/v1.2/offline-mode-http.md @@ -0,0 +1,121 @@ +# Offline Mode over HTTP + +## Summary + +The app uses two layers for offline support: + +1. **Application-level offline queue** — IndexedDB (via `idb`) stores queued actions, and a JS-based sync loop (`setInterval` + `online`/`offline` events) replays them via `fetch()` when connectivity returns. +2. **Service worker (PWA)** — `@vite-pwa/sveltekit` with Workbox generates a service worker that caches static assets for offline page loads, and provides the PWA install prompt. + +The first layer works entirely over plain HTTP. The second layer requires a secure context (HTTPS or `localhost`). This distinction matters for self-hosters accessing the app over a LAN IP address (e.g. `http://192.168.1.50:3000`). + +## What works on plain HTTP + +The following browser APIs used by the app do **not** require a secure context: + +| Feature | Used in | Works on HTTP? | +|---|---|---| +| IndexedDB | `src/lib/offline/queue.ts` | Yes | +| `navigator.onLine` | `src/lib/offline/stores.svelte.ts` | Yes | +| `online`/`offline` events | `src/lib/offline/stores.svelte.ts` | Yes | +| `fetch()` | `src/lib/offline/sync.ts` | Yes | +| `setInterval` | `src/lib/offline/sync.ts` | Yes | + +**Conclusion:** The core offline queue — saving actions to IndexedDB while offline, detecting connectivity changes, and syncing queued actions back to the server — works over plain HTTP in all major browsers. + +## What does NOT work on plain HTTP + +| Feature | Requires secure context? | Impact | +|---|---|---| +| Service worker registration | Yes | No asset caching; pages will not load offline | +| PWA install prompt | Yes | Users cannot "Add to Home Screen" | +| Web app manifest (install) | Yes | No standalone app experience | +| `navigator.storage.persist()` | Yes | Browser may evict IndexedDB data under storage pressure | + +**Conclusion:** Without HTTPS, the app cannot cache pages for offline loading and cannot be installed as a PWA. However, if the user has an active browser tab already loaded, the IndexedDB queue and sync loop still function — actions are queued locally and replayed when the server becomes reachable again. + +## Browser-specific notes + +### Chrome / Edge (Chromium) +- Service workers require HTTPS, with an exception for `localhost` and `127.0.0.1` only. +- The `chrome://flags/#unsafely-treat-insecure-origin-as-secure` flag can whitelist specific HTTP origins as secure (e.g. `http://192.168.1.50:3000`). This enables service workers and the install prompt on that origin. +- On Android, setting this flag requires root access or developer mode. + +### Firefox +- Service workers over HTTP can be enabled via `devtools.serviceWorkers.testing.enabled` in `about:config`, but only while DevTools are open. +- Firefox desktop has limited PWA install support regardless of protocol. + +### Safari (iOS / macOS) +- No flag-based workaround exists. Service workers strictly require HTTPS. +- iOS is the most restrictive platform for HTTP-based PWA features. + +## Workaround options for self-hosters + +### 1. Access via `localhost` (no setup needed) + +If accessing the app from the same machine running the server, `http://localhost` is treated as a secure context by all browsers. Service workers and PWA install work without any additional configuration. + +**Pros:** Zero setup. +**Cons:** Only works on the host machine. Not useful for phones or other devices on the LAN. + +### 2. Chrome flag: treat insecure origin as secure + +Set `chrome://flags/#unsafely-treat-insecure-origin-as-secure` to the app's HTTP URL (e.g. `http://192.168.1.50:3000`). + +**Pros:** No server changes needed. Works immediately. +**Cons:** Must be set per browser on each device. Not available on iOS Safari. Resets on browser updates. Not suitable for non-technical users. + +### 3. Reverse proxy with `mkcert` (recommended) + +Use [mkcert](https://github.com/FiloSottile/mkcert) to generate locally-trusted TLS certificates, then terminate TLS with a reverse proxy like [Caddy](https://caddyserver.com/). + +Setup: +```bash +# Install mkcert and create local CA +mkcert -install + +# Generate cert for your LAN IP +mkcert -key-file key.pem -cert-file cert.pem 192.168.1.50 localhost + +# Caddyfile example +https://192.168.1.50 { + tls /path/to/cert.pem /path/to/key.pem + reverse_proxy localhost:3000 +} +``` + +To trust the certificates on other devices, export the root CA (`mkcert -CAROOT`) and install it on each client: +- **iOS:** AirDrop or email the `rootCA.pem`, install via Settings > Profile Downloaded, then enable full trust. +- **Android:** Install the CA certificate under Settings > Security > Encryption & Credentials. +- **Other computers:** Import into the system trust store or browser certificate manager. + +**Pros:** Full PWA support including install prompt and service worker. Works on all devices once the CA is trusted. Caddy configuration is minimal. +**Cons:** Initial setup required. Root CA must be distributed to each client device. If the LAN IP changes, certificates must be regenerated (use a local DNS name to avoid this). + +### 4. Caddy with internal CA (alternative to mkcert) + +Caddy can act as its own Certificate Authority for local domains. Configure a local DNS name (e.g. `workout.local`) pointing to the server, and Caddy will auto-generate and manage certificates. + +**Pros:** No external tools needed beyond Caddy. Automatic certificate renewal. +**Cons:** Still requires distributing Caddy's root CA to client devices. Requires local DNS configuration. + +### 5. Public domain with Let's Encrypt + +If the server has a domain name (even via a free dynamic DNS service), Caddy or Certbot can obtain real Let's Encrypt certificates automatically. The server does not need to be publicly accessible if using DNS-01 challenge validation. + +**Pros:** Certificates trusted by all devices without any manual CA distribution. Fully standard HTTPS. +**Cons:** Requires owning or configuring a domain name. DNS-01 requires API access to the DNS provider. + +## Recommendation + +For most self-hosters on a home network: + +1. **The offline queue works without any changes** — IndexedDB, fetch, and online/offline detection all function over plain HTTP. Users who keep a browser tab open will get action queueing and sync without HTTPS. + +2. **For full PWA support** (offline page loading, install to home screen), set up HTTPS using **option 3 (mkcert + Caddy)**. It is straightforward, works on all devices, and Caddy's configuration is minimal. This is the most practical approach for a home network. + +3. **For quick testing** on a single Chromium-based browser, use **option 2 (Chrome flag)** as a temporary measure. + +4. If the self-hoster already has a domain, **option 5 (Let's Encrypt)** is the cleanest long-term solution. + +No code changes are needed in the app itself. The offline queue is protocol-agnostic, and the service worker is already configured via `@vite-pwa/sveltekit` and will activate automatically when served over HTTPS. diff --git a/package-lock.json b/package-lock.json index 109db65..2a2dedd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "prettier-plugin-tailwindcss": "^0.7.2", "svelte": "^5.49.2", "svelte-check": "^4.3.6", + "svelte-dnd-action": "^0.9.69", "tailwind-merge": "^3.4.0", "tailwind-variants": "^3.2.2", "tailwindcss": "^4.1.18", @@ -9212,6 +9213,16 @@ "typescript": ">=5.0.0" } }, + "node_modules/svelte-dnd-action": { + "version": "0.9.69", + "resolved": "https://registry.npmjs.org/svelte-dnd-action/-/svelte-dnd-action-0.9.69.tgz", + "integrity": "sha512-NAmSOH7htJoYraTQvr+q5whlIuVoq88vEuHr4NcFgscDRUxfWPPxgie2OoxepBCQCikrXZV4pqV86aun60wVyw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "svelte": ">=3.23.0 || ^5.0.0-next.0" + } + }, "node_modules/svelte-eslint-parser": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.4.1.tgz", diff --git a/package.json b/package.json index 0174c1f..d92fff5 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "prettier-plugin-tailwindcss": "^0.7.2", "svelte": "^5.49.2", "svelte-check": "^4.3.6", + "svelte-dnd-action": "^0.9.69", "tailwind-merge": "^3.4.0", "tailwind-variants": "^3.2.2", "tailwindcss": "^4.1.18", diff --git a/scripts/seed-test-data.mjs b/scripts/seed-test-data.mjs new file mode 100644 index 0000000..9600520 --- /dev/null +++ b/scripts/seed-test-data.mjs @@ -0,0 +1,227 @@ +/** + * Generates 2 years of realistic workout data for testing. + * Destroys the existing database and rebuilds it with migrations before seeding. + * + * Usage: node scripts/seed-test-data.mjs + */ + +import { execSync } from 'child_process'; +import { existsSync, unlinkSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); +const dbPath = join(root, 'data', 'workout-tracker.db'); + +// -- Step 1: Delete existing database -- + +if (existsSync(dbPath)) { + unlinkSync(dbPath); + console.log('Deleted existing database.'); +} else { + console.log('No existing database found.'); +} + +// -- Step 2: Run migrations to recreate schema -- + +console.log('Running migrations...'); +execSync('npm run db:migrate', { cwd: root, stdio: 'inherit' }); + +// -- Step 3: Seed data -- + +const Database = (await import('better-sqlite3')).default; +const db = new Database(dbPath); + +// Exercises +const exerciseRows = [ + ['Bench Press', 'kg'], + ['Overhead Press', 'kg'], + ['Incline Dumbbell Press', 'kg'], + ['Lateral Raise', 'kg'], + ['Tricep Dips', 'kg'], + ['Squat', 'kg'], + ['Deadlift', 'kg'], + ['Barbell Row', 'kg'], + ['Pull Up', 'kg'], + ['Romanian Deadlift', 'kg'], + ['Leg Press', 'kg'], + ['Face Pull', 'kg'] +]; + +const insertExercise = db.prepare( + `INSERT INTO exercises (name, unit_preference, created_at) VALUES (?, ?, ?)` +); +const epoch = Math.floor(new Date('2024-01-15').getTime() / 1000); + +const exerciseIds = {}; +for (const [name, unit] of exerciseRows) { + const result = insertExercise.run(name, unit, epoch); + exerciseIds[name] = Number(result.lastInsertRowid); +} +console.log(`Inserted ${exerciseRows.length} exercises.`); + +// Programme +const programResult = db + .prepare(`INSERT INTO programs (name, is_active, created_at, updated_at) VALUES (?, 1, ?, ?)`) + .run('Push Pull Legs', epoch, epoch); +const programId = Number(programResult.lastInsertRowid); + +// Workout days and day-exercise mappings +const dayTemplates = [ + { + name: 'Push', + exercises: [ + { name: 'Bench Press', baseWeight: 60, maxWeight: 95, sets: 4 }, + { name: 'Overhead Press', baseWeight: 35, maxWeight: 55, sets: 3 }, + { name: 'Incline Dumbbell Press', baseWeight: 20, maxWeight: 34, sets: 3 }, + { name: 'Lateral Raise', baseWeight: 8, maxWeight: 14, sets: 3 }, + { name: 'Tricep Dips', baseWeight: 0, maxWeight: 20, sets: 3 } + ] + }, + { + name: 'Pull', + exercises: [ + { name: 'Deadlift', baseWeight: 80, maxWeight: 140, sets: 3 }, + { name: 'Barbell Row', baseWeight: 50, maxWeight: 80, sets: 4 }, + { name: 'Pull Up', baseWeight: 0, maxWeight: 15, sets: 3 }, + { name: 'Face Pull', baseWeight: 10, maxWeight: 20, sets: 3 } + ] + }, + { + name: 'Legs', + exercises: [ + { name: 'Squat', baseWeight: 60, maxWeight: 110, sets: 4 }, + { name: 'Leg Press', baseWeight: 100, maxWeight: 200, sets: 3 }, + { name: 'Romanian Deadlift', baseWeight: 50, maxWeight: 90, sets: 3 } + ] + } +]; + +const insertDay = db.prepare( + `INSERT INTO workout_days (program_id, name, sort_order, created_at) VALUES (?, ?, ?, ?)` +); +const insertDayExercise = db.prepare( + `INSERT INTO day_exercises (workout_day_id, exercise_id, sets_count, sort_order, created_at) + VALUES (?, ?, ?, ?, ?)` +); + +for (let i = 0; i < dayTemplates.length; i++) { + const t = dayTemplates[i]; + const dayResult = insertDay.run(programId, t.name, i, epoch); + const dayId = Number(dayResult.lastInsertRowid); + for (let j = 0; j < t.exercises.length; j++) { + const ex = t.exercises[j]; + insertDayExercise.run(dayId, exerciseIds[ex.name], ex.sets, j, epoch); + } +} +console.log('Created programme with Push/Pull/Legs days.'); + +// -- Generate sessions from Feb 2024 to Feb 2026 -- + +const startDate = new Date('2024-02-01'); +const endDate = new Date('2026-02-10'); +const sessions = []; + +let current = new Date(startDate); +let dayIndex = 0; + +while (current < endDate) { + // 0-2 rest days between sessions + const skip = Math.floor(Math.random() * 3); + current.setDate(current.getDate() + 1 + skip); + + if (current >= endDate) break; + + // ~6% chance of skipping a whole week (holiday, illness) + if (Math.random() < 0.06) { + current.setDate(current.getDate() + 7); + continue; + } + + // ~10% chance of skipping a single session (busy day) + if (Math.random() < 0.1) continue; + + const day = dayTemplates[dayIndex % 3]; + dayIndex++; + + sessions.push({ date: new Date(current), day }); +} + +const insertSession = db.prepare( + `INSERT INTO workout_sessions (program_id, program_name, day_name, status, started_at, completed_at) + VALUES (?, ?, ?, 'completed', ?, ?)` +); +const insertLog = db.prepare( + `INSERT INTO exercise_logs (exercise_id, session_id, exercise_name, status, is_adhoc, sort_order, created_at) + VALUES (?, ?, ?, ?, 0, ?, ?)` +); +const insertSet = db.prepare( + `INSERT INTO set_logs (exercise_log_id, set_number, weight, reps, unit, created_at) + VALUES (?, ?, ?, ?, 'kg', ?)` +); + +const txn = db.transaction(() => { + for (let i = 0; i < sessions.length; i++) { + const s = sessions[i]; + const progress = i / sessions.length; + + // Session start: morning, ~07:00-09:59 + const startTime = new Date(s.date); + startTime.setHours(7 + Math.floor(Math.random() * 3), Math.floor(Math.random() * 60)); + const endTime = new Date(startTime); + endTime.setMinutes(endTime.getMinutes() + 45 + Math.floor(Math.random() * 30)); + + const startTs = Math.floor(startTime.getTime() / 1000); + const endTs = Math.floor(endTime.getTime() / 1000); + + const sessionResult = insertSession.run( + programId, + 'Push Pull Legs', + s.day.name, + startTs, + endTs + ); + const sessionId = Number(sessionResult.lastInsertRowid); + + for (let j = 0; j < s.day.exercises.length; j++) { + const ex = s.day.exercises[j]; + + // ~8% chance of skipping an exercise + const skipped = Math.random() < 0.08; + const status = skipped ? 'skipped' : 'logged'; + + const logResult = insertLog.run(exerciseIds[ex.name], sessionId, ex.name, status, j, startTs); + const logId = Number(logResult.lastInsertRowid); + + if (!skipped) { + const weightRange = ex.maxWeight - ex.baseWeight; + const currentWeight = ex.baseWeight + weightRange * progress; + const noise = 1 + (Math.random() - 0.5) * 0.1; + + for (let setNum = 1; setNum <= ex.sets; setNum++) { + let weight = Math.round((currentWeight * noise) / 2.5) * 2.5; + if (weight < 0) weight = 0; + + const baseReps = ex.name === 'Deadlift' ? 5 : ex.name === 'Pull Up' ? 8 : 10; + let reps = baseReps + Math.floor(Math.random() * 3) - (setNum > 2 ? 1 : 0); + if (reps < 3) reps = 3; + + insertSet.run(logId, setNum, weight, reps, startTs); + } + } + } + } +}); + +txn(); + +const sessionCount = db + .prepare("SELECT COUNT(*) as c FROM workout_sessions WHERE status='completed'") + .get(); +const setCount = db.prepare('SELECT COUNT(*) as c FROM set_logs').get(); + +console.log(`\nSeeded ${sessionCount.c} completed sessions with ${setCount.c} set logs.`); +console.log('Done.'); + +db.close(); diff --git a/src/lib/components/home/ConsistencyGrid.svelte b/src/lib/components/home/ConsistencyGrid.svelte new file mode 100644 index 0000000..e4a6c7a --- /dev/null +++ b/src/lib/components/home/ConsistencyGrid.svelte @@ -0,0 +1,177 @@ + + +
+
+ {#if weekCount > 0} +
+ +
+ {#each grid as week, wi (wi)} +
+ {#if week.monthLabel} + {#if week.monthLabel.year} + {week.monthLabel.year} + {/if} + {week.monthLabel.month} + {/if} +
+ {/each} + + + {#each DAYS as dayLabel, dayIndex (dayIndex)} + +
+ {dayLabel} +
+ + + {#each grid as week, wi (wi)} + {@const day = week.days[dayIndex]} +
+ {/each} + {/each} +
+ {/if} +
+
diff --git a/src/lib/components/program/ProgramForm.svelte b/src/lib/components/program/ProgramForm.svelte index cfcbd94..7335eb5 100644 --- a/src/lib/components/program/ProgramForm.svelte +++ b/src/lib/components/program/ProgramForm.svelte @@ -4,18 +4,20 @@ import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; import { Separator } from '$lib/components/ui/separator'; - import { Plus, Trash2, ChevronUp, ChevronDown, X } from '@lucide/svelte'; + import { Plus, Trash2, GripVertical, X } from '@lucide/svelte'; import { untrack } from 'svelte'; + import { flip } from 'svelte/animate'; + import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action'; import { generateId } from '$lib/utils'; type DayExercise = { - tempId: string; + id: string; exerciseName: string; setsCount: number; }; type Day = { - tempId: string; + id: string; name: string; exercises: DayExercise[]; }; @@ -31,10 +33,10 @@ function buildInitialDays(data?: InitialData): Day[] { if (!data?.days) return []; return data.days.map((d) => ({ - tempId: generateId(), + id: generateId(), name: d.name, exercises: d.exercises.map((ex) => ({ - tempId: generateId(), + id: generateId(), exerciseName: ex.exerciseName, setsCount: ex.setsCount })) @@ -74,56 +76,40 @@ }) ); + const FLIP_DURATION_MS = 150; + function addDay() { days.push({ - tempId: generateId(), + id: generateId(), name: '', exercises: [] }); } - function removeDay(dayTempId: string) { - const idx = days.findIndex((d) => d.tempId === dayTempId); + function removeDay(dayId: string) { + const idx = days.findIndex((d) => d.id === dayId); if (idx !== -1) days.splice(idx, 1); } - function moveDayUp(dayIndex: number) { - if (dayIndex <= 0) return; - const item = days.splice(dayIndex, 1)[0]; - days.splice(dayIndex - 1, 0, item); - } - - function moveDayDown(dayIndex: number) { - if (dayIndex >= days.length - 1) return; - const item = days.splice(dayIndex, 1)[0]; - days.splice(dayIndex + 1, 0, item); + function handleDaySort(e: CustomEvent>) { + days = e.detail.items; } function addExercise(dayIndex: number) { days[dayIndex].exercises.push({ - tempId: generateId(), + id: generateId(), exerciseName: '', setsCount: 3 }); } - function removeExercise(dayIndex: number, exTempId: string) { - const idx = days[dayIndex].exercises.findIndex((ex) => ex.tempId === exTempId); + function removeExercise(dayIndex: number, exId: string) { + const idx = days[dayIndex].exercises.findIndex((ex) => ex.id === exId); if (idx !== -1) days[dayIndex].exercises.splice(idx, 1); } - function moveExerciseUp(dayIndex: number, exIndex: number) { - if (exIndex <= 0) return; - const exs = days[dayIndex].exercises; - const item = exs.splice(exIndex, 1)[0]; - exs.splice(exIndex - 1, 0, item); - } - - function moveExerciseDown(dayIndex: number, exIndex: number) { - const exs = days[dayIndex].exercises; - if (exIndex >= exs.length - 1) return; - const item = exs.splice(exIndex, 1)[0]; - exs.splice(exIndex + 1, 0, item); + function handleExerciseSort(dayIndex: number, e: CustomEvent>) { + days[dayIndex].exercises = e.detail.items; } function validate(): boolean { @@ -143,14 +129,14 @@ for (const day of days) { if (!day.name.trim()) { - dayNameErrors[day.tempId] = 'Day name is required'; + dayNameErrors[day.id] = 'Day name is required'; } for (const ex of day.exercises) { if (!ex.exerciseName.trim()) { - exerciseNameErrors[ex.tempId] = 'Exercise name is required'; + exerciseNameErrors[ex.id] = 'Exercise name is required'; } if (ex.setsCount < 1) { - exerciseSetsErrors[ex.tempId] = 'Sets must be at least 1'; + exerciseSetsErrors[ex.id] = 'Sets must be at least 1'; } } } @@ -199,135 +185,131 @@

{errors.days}

{/if} - {#each days as day, dayIndex (day.tempId)} -
-
-
- - {#if errors.dayNames?.[day.tempId]} -

{errors.dayNames[day.tempId]}

- {/if} -
-
- - + + +
+ + {#if errors.dayNames?.[day.id]} +

{errors.dayNames[day.id]}

+ {/if} +
-
- {#each day.exercises as exercise, exIndex (exercise.tempId)} -
-
-
- -
-
- -
-
- - - +
handleExerciseSort(dayIndex, e)} + onfinalize={(e) => handleExerciseSort(dayIndex, e)} + class="space-y-2 pl-2" + > + {#each day.exercises as exercise (exercise.id)} +
+
+ +
+ +
+
+ +
+ +
+ {#if errors.exerciseNames?.[exercise.id]} +

+ {errors.exerciseNames[exercise.id]} +

+ {/if} + {#if errors.exerciseSets?.[exercise.id]} +

+ {errors.exerciseSets[exercise.id]} +

+ {/if}
-
- {#if errors.exerciseNames?.[exercise.tempId]} -

{errors.exerciseNames[exercise.tempId]}

- {/if} - {#if errors.exerciseSets?.[exercise.tempId]} -

{errors.exerciseSets[exercise.tempId]}

- {/if} + {/each}
- {/each} - - -
- {/each} - - +
+ {/each} +
+ + - diff --git a/src/lib/components/workout/ExerciseStep.svelte b/src/lib/components/workout/ExerciseStep.svelte index 23493c1..84aabef 100644 --- a/src/lib/components/workout/ExerciseStep.svelte +++ b/src/lib/components/workout/ExerciseStep.svelte @@ -25,6 +25,7 @@ let { exercise, overload, + prescribedSets, onupdateset, ontoggleunit, onaddset, @@ -32,12 +33,44 @@ }: { exercise: ExerciseLog; overload: OverloadData | undefined; + prescribedSets?: number; onupdateset: (setIndex: number, field: 'weight' | 'reps', value: number | null) => void; ontoggleunit: () => void; onaddset: () => void; onremoveset: (setIndex: number) => void; } = $props(); + function canRemoveSet(index: number): boolean { + // If no prescribed count is known, fall back to allowing removal when there's more than one set + if (prescribedSets == null) return exercise.sets.length > 1; + // Only allow removal of manually added sets (beyond the prescribed count) + return index >= prescribedSets; + } + + function canCopyDown(index: number): boolean { + if (index === 0) return false; + const set = exercise.sets[index]; + // Only available when current set fields are empty + return set.weight == null && set.reps == null; + } + + function handleCopyDown(index: number) { + const prev = exercise.sets[index - 1]; + if (!prev) return; + if (prev.weight != null) onupdateset(index, 'weight', prev.weight); + if (prev.reps != null) onupdateset(index, 'reps', prev.reps); + } + + function getPreviousValues(index: number): { weight: string; reps: string } | null { + if (index === 0) return null; + const prev = exercise.sets[index - 1]; + if (!prev || (prev.weight == null && prev.reps == null)) return null; + return { + weight: prev.weight != null ? String(prev.weight) : '', + reps: prev.reps != null ? String(prev.reps) : '' + }; + } + function formatDate(date: Date): string { return new Date(date).toLocaleDateString('en-GB', { day: 'numeric', @@ -51,6 +84,19 @@ } let unit = $derived(exercise.sets[0]?.unit ?? 'kg'); + + const fmt = new Intl.NumberFormat('en-GB'); + + let currentVolume = $derived( + exercise.sets.reduce((sum, s) => { + if (s.weight != null && s.reps != null) return sum + s.weight * s.reps; + return sum; + }, 0) + ); + + let previousVolume = $derived( + overload?.previous?.sets.reduce((sum, s) => sum + s.weight * s.reps, 0) ?? 0 + );
@@ -81,12 +127,20 @@ )})

{/if} + {#if currentVolume > 0 || previousVolume > 0} +

+ Volume: {fmt.format(currentVolume)} + {unit}{#if previousVolume > 0} + (prev: {fmt.format(previousVolume)} {unit}) + {/if} +

+ {/if}
{/if}
Set @@ -104,13 +158,27 @@
{#each exercise.sets as set, i (set.id)} -
- - {set.setNumber} - + {@const prev = canCopyDown(i) ? getPreviousValues(i) : null} +
+ {#if canCopyDown(i)} + + {:else} + + {set.setNumber} + + {/if} { @@ -131,12 +200,12 @@ }} data-testid="reps-input-{i}" /> - {#if exercise.sets.length > 1} + {#if canRemoveSet(i)}