From eea33321e56dc25c6fa988d36f9735968e179d66 Mon Sep 17 00:00:00 2001 From: Robert Hamilton Date: Sun, 12 Apr 2026 19:49:33 -0500 Subject: [PATCH 1/4] refactor(adapters): eliminate data-mapper layer via camelizeKeys utility Remove data-mapper.ts (1,097 LOC) and inline all 54 mapper functions directly into SupabaseAdapter and TauriAdapter. Add adapter-utils.ts with a shallow camelizeKeys transform (snake_case -> camelCase keys) applied per-call before read-path mapping, removing the need for manual field renaming in each to* function. New entity flow: adapter + domain type (2 files, not 4+). Build clean, 2,290 tests passing. --- .../2026-04-12-remove-data-mapper.md | 118 + src/lib/__tests__/data-mapper-gym.test.ts | 391 --- src/lib/__tests__/data-mapper.test.ts | 2764 ----------------- src/lib/adapter-utils.ts | 39 + src/lib/data-mapper.ts | 1097 ------- src/lib/supabase-adapter.ts | 1127 +++++-- src/lib/tauri-adapter.ts | 1060 ++++--- 7 files changed, 1684 insertions(+), 4912 deletions(-) create mode 100644 Context/QuickPlans/2026-04-12-remove-data-mapper.md delete mode 100644 src/lib/__tests__/data-mapper-gym.test.ts delete mode 100644 src/lib/__tests__/data-mapper.test.ts create mode 100644 src/lib/adapter-utils.ts delete mode 100644 src/lib/data-mapper.ts diff --git a/Context/QuickPlans/2026-04-12-remove-data-mapper.md b/Context/QuickPlans/2026-04-12-remove-data-mapper.md new file mode 100644 index 00000000..0ab0baa0 --- /dev/null +++ b/Context/QuickPlans/2026-04-12-remove-data-mapper.md @@ -0,0 +1,118 @@ +# Quick Plan: Remove DataMapper Layer + +**Task:** Eliminate `data-mapper.ts` (1,097 LOC) by enabling Supabase JS built-in camelCase transformation and inlining non-trivial mappings into the adapters directly. + +**Branch:** `chore/camelcase-conv` (already in progress) + +--- + +## Goal + +Reduce adapter infrastructure surface area by ~1,100+ LOC. New domain entities should touch at most 2 files (adapter + domain type), not 4+. The `to*` read-path mappers that do mechanical snake_case -> camelCase renaming become redundant once the client handles it. + +--- + +## Approach + +### Step 1 -- Verify Supabase JS camelCase API + +Before touching code, confirm the exact client option. The Supabase JS v2 client passes through to `@supabase/postgrest-js`. Look for: + +```ts +// Option A: db-level transform (postgrest-js >= 1.16 / supabase-js >= 2.x) +createClient(url, key, { + db: { schema: 'public' }, + global: { + fetch: /* ... */, + }, +}) +``` + +Check `bun.lockb` / `package.json` for `@supabase/supabase-js` version, then verify against its changelog or source whether `transform: { camelize: true }` or a `fetchOptions` flag exists. + +**If the flag does not exist in the installed version:** either upgrade `@supabase/supabase-js` to the version that ships it, or evaluate `postgrest-js`'s `camelize` option directly. Do not proceed until this is confirmed -- the entire approach depends on it. + +### Step 2 -- Audit mapper functions (classify before deleting) + +Read `data-mapper.ts` and tag each function as one of: + +| Class | Description | Action | +|---|---|---| +| **A -- Pure rename** | Only snake_case -> camelCase field renames, no logic | Delete after enabling client transform | +| **B -- Non-trivial read** | JSON parsing, type coercion, computed fields, nested mapping | Inline into `supabase-adapter.ts` at the call site | +| **C -- Write path** | `from*` mappers: camelCase domain -> snake_case DB row | Keep, inline into adapter (client camelCase flag does not affect writes) | +| **D -- Shared utility** | `parseJsonOrValue`, helpers used by multiple mappers | Inline or move to a small `adapter-utils.ts` | + +The preview shows 54 functions: `parseJsonOrValue`, `toExercise`, `fromExercise`, `toWorkoutLog`, `fromWorkoutLog`, `toLoggedActivityGroup`, and ~49 more. Expect ~60% to be Class A (eliminate), ~20% Class B (inline), ~20% Class C (write-path, inline). + +### Step 3 -- Enable camelCase on Supabase client + +Locate `createClient` call (likely `src/lib/supabase.ts` or similar). Add the camelCase flag once confirmed from Step 1. + +Update `ExerciseRow`, `WorkoutLogRow`, etc. row types if they will now be returned camelCased by the client -- or remove them entirely if the domain types are used directly after this change. + +### Step 4 -- Delete Class A mappers; inline B and C + +For each Class A `to*` function: +- Find all call sites in `supabase-adapter.ts` via search +- Remove the mapper call (client now returns camelCase directly) +- Remove the function from `data-mapper.ts` + +For each Class B `to*` function: +- Move the non-trivial logic inline to the adapter call site or a private helper in the adapter file +- Remove from `data-mapper.ts` + +For each Class C `from*` function: +- Move inline to the adapter write method (insert/upsert) +- Remove from `data-mapper.ts` + +### Step 5 -- Handle TauriAdapter + +`src/lib/tauri-adapter.ts` (2,717 LOC) also uses mapper functions but calls SQLite via Tauri commands, not the Supabase JS client. The camelCase flag does not help here. Two options: + +- **Option A (preferred):** Inline all tauri-side mappings directly into the adapter. Same classification (A/B/C) applies but all classes must be inlined (no client magic). +- **Option B (deferred):** Leave Tauri mappings in a reduced `tauri-mapper.ts` if the volume is large enough to warrant it. Acceptable as a follow-up. + +Decide per volume -- if tauri adapter uses >20 mapper functions, Option B is reasonable. + +### Step 6 -- Check sharing mappers + +The description mentions `sharing-mappers.ts` and `share-rpc-mapper.ts`. Search confirms these do not currently exist on this branch. If added in future, apply the same classification at the time of creation -- do not reintroduce a separate mapper layer. + +### Step 7 -- Delete `data-mapper.ts` + +Once all functions are migrated, confirm `data-mapper.ts` has zero imports via: +```bash +bun run build +``` + +Then delete it. Run tests. + +--- + +## Verification + +- [ ] `bun run build` passes with no TS errors +- [ ] `bun run test` passes +- [ ] `src/lib/data-mapper.ts` no longer exists +- [ ] No import of `data-mapper` anywhere in codebase +- [ ] New entity checklist: touches adapter file + domain type only (not 4+ files) +- [ ] Supabase read paths return camelCased fields confirmed via manual smoke test + +--- + +## Risks + +| Risk | Mitigation | +|---|---| +| Supabase camelCase flag does not exist in installed version | Verify version before starting; upgrade if needed | +| JSONB columns return nested snake_case (flag may not recurse into JSON) | Audit JSON fields in Class B -- keep explicit deep-transform helpers for those | +| Write-path (`from*` mappers) breaks if Supabase client also camelize-transforms inputs | Test inserts after enabling flag; writes likely unaffected (flag is read-side) | +| TauriAdapter volume makes inline approach unwieldy | Accept a smaller `tauri-mapper.ts` (Option B) if >20 functions -- explicit decision, not default | +| Row type definitions become orphaned or misaligned | Remove or rename `*Row` types to match new camelCase client output; validate with `tsc` | + +--- + +## Execution + +`/impl` -- straightforward single-adapter refactor with clear classification steps. diff --git a/src/lib/__tests__/data-mapper-gym.test.ts b/src/lib/__tests__/data-mapper-gym.test.ts deleted file mode 100644 index 362590cd..00000000 --- a/src/lib/__tests__/data-mapper-gym.test.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - toGym, - fromGym, - toGymMember, - fromGymMember, - toGymInvitation, - fromGymInvitation, - toGymOwnershipTransfer, - fromGymOwnershipTransfer, - toGymMemberCount, - toUserProfile, - fromUserProfile, -} from '../data-mapper' -import type { - GymRow, - GymMemberRow, - GymInvitationRow, - GymOwnershipTransferRow, - GymMemberCountRow, - UserProfileRow, -} from '../database.types' - -// =========================================================================== -// F018 (S008-T) -- data-mapper gym round-trips and displayVisible regression. -// F021 (S008-T) -- added coverage for GymInvitation, GymOwnershipTransfer, -// and GymMemberCount mappers. `is_default` / `isDefault` removed in F021. -// =========================================================================== - -const now = '2026-04-06T10:00:00Z' -const later = '2026-04-06T11:30:00Z' - -// --------------------------------------------------------------------------- -// Gym fixtures -// --------------------------------------------------------------------------- - -const gymRow: GymRow = { - id: 'gym-001', - name: 'Home Garage', - owner_user_id: 'user-001', - created_at: now, - updated_at: later, -} - -const gymRowSecondary: GymRow = { - id: 'gym-002', - name: 'Iron Pit', - owner_user_id: 'user-002', - created_at: now, - updated_at: now, -} - -// --------------------------------------------------------------------------- -// GymMember fixtures -// --------------------------------------------------------------------------- - -const gymMemberRow: GymMemberRow = { - gym_id: 'gym-001', - user_id: 'user-007', - joined_at: now, -} - -// --------------------------------------------------------------------------- -// F021 fixtures -// --------------------------------------------------------------------------- - -const gymInvitationRow: GymInvitationRow = { - id: 'inv-001', - gym_id: 'gym-001', - token: 'tok_abcdef123456', - expires_at: later, - max_uses: 10, - uses_count: 3, - created_by: 'user-001', - created_at: now, -} - -const gymOwnershipTransferRow: GymOwnershipTransferRow = { - gym_id: 'gym-001', - proposed_by: 'user-001', - proposed_to: 'user-007', - proposed_at: now, -} - -const gymMemberCountRow: GymMemberCountRow = { - gym_id: 'gym-001', - member_count: 12, -} - -// =========================================================================== -// toGym / fromGym -// =========================================================================== - -describe('toGym / fromGym', () => { - it('maps a fully-populated gym row to a domain Gym', () => { - const result = toGym(gymRow) - - expect(result).toEqual({ - id: 'gym-001', - createdAt: now, - updatedAt: later, - name: 'Home Garage', - ownerUserId: 'user-001', - }) - }) - - it('maps a secondary gym row correctly', () => { - const result = toGym(gymRowSecondary) - - expect(result.id).toBe('gym-002') - expect(result.name).toBe('Iron Pit') - expect(result.ownerUserId).toBe('user-002') - }) - - it('does not surface any unexpected fields on the output Gym', () => { - const result = toGym(gymRow) - - expect(Object.keys(result).sort()).toEqual( - ['createdAt', 'id', 'name', 'ownerUserId', 'updatedAt'].sort(), - ) - }) - - it('round-trips a Gym back to a partial row with all fields preserved', () => { - const gym = toGym(gymRow) - const row = fromGym(gym) - - expect(row).toEqual({ - id: 'gym-001', - created_at: now, - updated_at: later, - name: 'Home Garage', - owner_user_id: 'user-001', - }) - }) - - it('omits unset fields from a partial fromGym write', () => { - const row = fromGym({ name: 'New Name' }) - - expect(row).toEqual({ name: 'New Name' }) - expect(row.id).toBeUndefined() - expect(row.owner_user_id).toBeUndefined() - }) - - it('handles a creation-shaped Gym (name + ownerUserId, no id/timestamps)', () => { - const row = fromGym({ - name: 'Garage', - ownerUserId: 'user-001', - }) - - expect(row).toEqual({ - name: 'Garage', - owner_user_id: 'user-001', - }) - }) -}) - -// =========================================================================== -// toGymMember / fromGymMember -// =========================================================================== - -describe('toGymMember / fromGymMember', () => { - it('maps a fully-populated gym_members row to a domain GymMember', () => { - const result = toGymMember(gymMemberRow) - - expect(result).toEqual({ - gymId: 'gym-001', - userId: 'user-007', - joinedAt: now, - }) - }) - - it('does not surface any unexpected fields on the output GymMember', () => { - const result = toGymMember(gymMemberRow) - - expect(Object.keys(result).sort()).toEqual(['gymId', 'joinedAt', 'userId'].sort()) - }) - - it('round-trips a GymMember back to a partial row with all fields preserved', () => { - const member = toGymMember(gymMemberRow) - const row = fromGymMember(member) - - expect(row).toEqual({ - gym_id: 'gym-001', - user_id: 'user-007', - joined_at: now, - }) - }) - - it('omits unset fields from a partial fromGymMember write', () => { - const row = fromGymMember({ gymId: 'gym-001', userId: 'user-007' }) - - expect(row).toEqual({ gym_id: 'gym-001', user_id: 'user-007' }) - expect(row.joined_at).toBeUndefined() - }) -}) - -// =========================================================================== -// toGymInvitation / fromGymInvitation (F021) -// =========================================================================== - -describe('toGymInvitation / fromGymInvitation', () => { - it('maps a fully-populated gym_invitations row to a domain GymInvitation', () => { - const result = toGymInvitation(gymInvitationRow) - - expect(result).toEqual({ - id: 'inv-001', - gymId: 'gym-001', - token: 'tok_abcdef123456', - expiresAt: later, - maxUses: 10, - usesCount: 3, - createdBy: 'user-001', - createdAt: now, - }) - }) - - it('does not surface any unexpected fields on the output GymInvitation', () => { - const result = toGymInvitation(gymInvitationRow) - - expect(Object.keys(result).sort()).toEqual( - [ - 'createdAt', - 'createdBy', - 'expiresAt', - 'gymId', - 'id', - 'maxUses', - 'token', - 'usesCount', - ].sort(), - ) - }) - - it('round-trips a GymInvitation back to a row with all fields preserved', () => { - const invitation = toGymInvitation(gymInvitationRow) - const row = fromGymInvitation(invitation) - - expect(row).toEqual({ - id: 'inv-001', - gym_id: 'gym-001', - token: 'tok_abcdef123456', - expires_at: later, - max_uses: 10, - uses_count: 3, - created_by: 'user-001', - created_at: now, - }) - }) - - it('omits unset fields from a partial fromGymInvitation write', () => { - const row = fromGymInvitation({ gymId: 'gym-001', token: 'tok_new' }) - - expect(row).toEqual({ gym_id: 'gym-001', token: 'tok_new' }) - expect(row.id).toBeUndefined() - expect(row.max_uses).toBeUndefined() - expect(row.uses_count).toBeUndefined() - }) -}) - -// =========================================================================== -// toGymOwnershipTransfer / fromGymOwnershipTransfer (F021) -// =========================================================================== - -describe('toGymOwnershipTransfer / fromGymOwnershipTransfer', () => { - it('maps a fully-populated gym_ownership_transfers row to domain', () => { - const result = toGymOwnershipTransfer(gymOwnershipTransferRow) - - expect(result).toEqual({ - gymId: 'gym-001', - proposedBy: 'user-001', - proposedTo: 'user-007', - proposedAt: now, - }) - }) - - it('does not surface any unexpected fields on the output transfer', () => { - const result = toGymOwnershipTransfer(gymOwnershipTransferRow) - - expect(Object.keys(result).sort()).toEqual( - ['gymId', 'proposedAt', 'proposedBy', 'proposedTo'].sort(), - ) - }) - - it('round-trips a GymOwnershipTransfer back to a row with all fields preserved', () => { - const transfer = toGymOwnershipTransfer(gymOwnershipTransferRow) - const row = fromGymOwnershipTransfer(transfer) - - expect(row).toEqual({ - gym_id: 'gym-001', - proposed_by: 'user-001', - proposed_to: 'user-007', - proposed_at: now, - }) - }) - - it('omits unset fields from a partial fromGymOwnershipTransfer write', () => { - const row = fromGymOwnershipTransfer({ gymId: 'gym-001', proposedTo: 'user-007' }) - - expect(row).toEqual({ gym_id: 'gym-001', proposed_to: 'user-007' }) - expect(row.proposed_by).toBeUndefined() - expect(row.proposed_at).toBeUndefined() - }) -}) - -// =========================================================================== -// toGymMemberCount (F021) -- one-way (view, no fromX) -// =========================================================================== - -describe('toGymMemberCount', () => { - it('maps a fully-populated gym_member_counts view row to domain', () => { - const result = toGymMemberCount(gymMemberCountRow) - - expect(result).toEqual({ - gymId: 'gym-001', - memberCount: 12, - }) - }) - - it('does not surface any unexpected fields on the output count', () => { - const result = toGymMemberCount(gymMemberCountRow) - - expect(Object.keys(result).sort()).toEqual(['gymId', 'memberCount'].sort()) - }) - - it('coerces a null member_count to 0 (safety-net path)', () => { - // The view is declared nullable by generated types. A coerced 0 is the - // expected safety-net behaviour per data-mapper.ts comments. - const row = { gym_id: 'gym-001', member_count: null } as unknown as GymMemberCountRow - const result = toGymMemberCount(row) - expect(result.memberCount).toBe(0) - }) -}) - -// =========================================================================== -// toUserProfile / fromUserProfile -- F018 (M10) display_visible regression -// =========================================================================== - -describe('toUserProfile / fromUserProfile -- displayVisible removal regression', () => { - it('toUserProfile does not surface a `displayVisible` field even when display_visible is present on the row', () => { - const staleRow = { - id: 'user-001', - display_name: 'Coach Hamilton', - display_visible: true, - preferred_units: 'IMPERIAL', - bodyweight: null, - training_age: null, - exercise_maxes: null, - max_reps: null, - created_at: now, - updated_at: now, - } as unknown as UserProfileRow - - const result = toUserProfile(staleRow) as Record - - expect(result.displayVisible).toBeUndefined() - expect('displayVisible' in result).toBe(false) - }) - - it('fromUserProfile does not write display_visible even if a legacy field is supplied via cast', () => { - const legacyInput = { - id: 'user-001', - displayName: 'Coach Hamilton', - displayVisible: true, - } as unknown as Parameters[0] - - const row = fromUserProfile(legacyInput) as Record - - expect(row.display_visible).toBeUndefined() - expect('display_visible' in row).toBe(false) - }) - - it('toUserProfile still maps a clean row (no display_visible) correctly', () => { - const cleanRow: UserProfileRow = { - id: 'user-001', - display_name: 'Coach Hamilton', - preferred_units: 'IMPERIAL', - bodyweight: null, - training_age: null, - exercise_maxes: null, - max_reps: null, - created_at: now, - updated_at: now, - } - - const result = toUserProfile(cleanRow) - - expect(result.id).toBe('user-001') - expect(result.displayName).toBe('Coach Hamilton') - expect(result.preferredUnits).toBe('IMPERIAL') - }) -}) diff --git a/src/lib/__tests__/data-mapper.test.ts b/src/lib/__tests__/data-mapper.test.ts deleted file mode 100644 index 157dfc00..00000000 --- a/src/lib/__tests__/data-mapper.test.ts +++ /dev/null @@ -1,2764 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { ZodError } from 'zod' -import { - toExercise, - fromExercise, - toWorkoutLog, - fromWorkoutLog, - toLoggedActivityGroup, - fromLoggedActivityGroup, - toLoggedActivity, - fromLoggedActivity, - toLoggedSet, - fromLoggedSet, - toUserProfile, - fromUserProfile, - toOneRepMaxHistory, - fromOneRepMaxHistory, - toSessionTemplate, - fromSessionTemplate, - toActivityGroupFlat, - fromActivityGroup, - toActivity, - fromActivity, - toProgram, - fromProgram, - toBlock, - fromBlock, - toBlockWeek, - fromBlockWeek, - toScheduledSession, - fromScheduledSession, - toProgramActivation, - fromProgramActivation, - toEventItem, - fromEventItem, - toConversation, - fromConversation, - toConversationParticipant, - fromConversationParticipant, - toMessage, - fromMessage, - toMediaAttachment, - fromMediaAttachment, - toWeekStatus, - fromWeekStatus, -} from '../data-mapper' -import type { - ExerciseRow, - WorkoutLogRow, - LoggedActivityGroupRow, - LoggedActivityRow, - LoggedSetRow, - UserProfileRow, - OneRepMaxHistoryRow, - SessionTemplateRow, - ActivityGroupRow, - ActivityRow, - ProgramRow, - BlockRow, - BlockWeekRow, - ScheduledSessionRow, - ProgramActivationRow, - ProgramWeekStatusRow, - EventItemRow, - ConversationRow, - ConversationParticipantRow, - MessageRow, - MediaAttachmentRow, -} from '../database.types' - -// =========================================================================== -// Shared fixtures -- realistic data matching actual schema shapes -// =========================================================================== - -const now = '2025-06-15T10:00:00Z' -const later = '2025-06-15T11:30:00Z' - -// --------------------------------------------------------------------------- -// Exercise fixtures -// --------------------------------------------------------------------------- - -const exerciseRow: ExerciseRow = { - id: 'ex-squat-001', - name: 'Barbell Back Squat', - aliases: ['Back Squat', 'Low Bar Squat'], - category: 'BARBELL', - movement_pattern: 'SQUAT', - muscle_groups: { primary: ['QUADS', 'GLUTES'], secondary: ['HAMSTRINGS', 'CORE'] }, - is_bilateral: true, - supports_1rm: true, - equipment_required: ['BARBELL', 'SQUAT_RACK'], - is_custom: false, - is_public: false, - user_id: null, - created_at: now, - updated_at: now, -} - -const exerciseRowMinimal: ExerciseRow = { - id: 'ex-pushup-002', - name: 'Push-up', - aliases: [], - category: 'BODYWEIGHT', - movement_pattern: 'PUSH', - muscle_groups: { primary: ['CHEST'], secondary: [] }, - is_bilateral: true, - supports_1rm: false, - equipment_required: ['NONE'], - is_custom: false, - is_public: false, - user_id: null, - created_at: now, - updated_at: now, -} - -// --------------------------------------------------------------------------- -// WorkoutLog fixtures -// --------------------------------------------------------------------------- - -const workoutLogRowFull: WorkoutLogRow = { - id: 'wl-001', - user_id: 'user-001', - title: 'Upper Body Day', - started_at: now, - completed_at: later, - session_template_id: 'st-001', - program_context: { - programId: 'prog-001', - blockId: 'block-001', - weekNumber: 3, - dayLabel: 'Monday', - }, - perceived_difficulty: 7, - bodyweight_at_session: { value: 185, unit: 'lb' }, - overall_notes: 'Felt strong today', - note_tags: [], - event_metadata: null, - paused_at: null, - total_paused_ms: 0, - created_at: now, - updated_at: later, -} - -const workoutLogRowNulls: WorkoutLogRow = { - id: 'wl-002', - user_id: 'user-001', - title: null, - started_at: now, - completed_at: null, - session_template_id: null, - program_context: null, - perceived_difficulty: null, - bodyweight_at_session: null, - overall_notes: null, - note_tags: [], - event_metadata: null, - paused_at: null, - total_paused_ms: 0, - created_at: now, - updated_at: now, -} - -// --------------------------------------------------------------------------- -// LoggedActivityGroup fixtures -// --------------------------------------------------------------------------- - -const groupRowFull: LoggedActivityGroupRow = { - id: 'lag-001', - workout_log_id: 'wl-001', - user_id: 'user-001', - group_type: 'STRAIGHT_SETS', - ordinal: 1, - actual_rounds_completed: 4, - completion_time: { seconds: 1800 }, - created_at: now, - updated_at: now, -} - -const groupRowNulls: LoggedActivityGroupRow = { - id: 'lag-002', - workout_log_id: 'wl-001', - user_id: 'user-001', - group_type: 'SUPERSET', - ordinal: 2, - actual_rounds_completed: null, - completion_time: null, - created_at: now, - updated_at: now, -} - -// --------------------------------------------------------------------------- -// LoggedActivity fixtures -// --------------------------------------------------------------------------- - -const activityRowFull: LoggedActivityRow = { - id: 'la-001', - logged_group_id: 'lag-001', - user_id: 'user-001', - exercise_id: 'ex-squat-001', - ordinal: 1, - notes: 'Focus on depth', - note_tags: [], - created_at: now, - updated_at: now, -} - -const activityRowNulls: LoggedActivityRow = { - id: 'la-002', - logged_group_id: 'lag-001', - user_id: 'user-001', - exercise_id: 'ex-pushup-002', - ordinal: 2, - notes: null, - note_tags: [], - created_at: now, - updated_at: now, -} - -// --------------------------------------------------------------------------- -// LoggedSet fixtures -// --------------------------------------------------------------------------- - -const setRowFull: LoggedSetRow = { - id: 'ls-001', - logged_activity_id: 'la-001', - user_id: 'user-001', - set_number: 1, - set_type: 'WORKING', - prescribed: { reps: 5, weight: { value: 225, unit: 'lb' } }, - actual_reps: 5, - actual_weight: { value: 225, unit: 'lb' }, - actual_duration: { seconds: 45 }, - actual_distance: { value: 0, unit: 'm' }, - actual_pace: { minutesPerUnit: 8.5, unit: 'mi' }, - actual_heart_rate: 155, - ruck_load: { value: 45, unit: 'lb' }, - elevation_gain: { value: 100, unit: 'm' }, - rpe: 8, - completed: true, - notes: 'Solid set', - note_tags: [], - created_at: now, - updated_at: now, -} - -const setRowNulls: LoggedSetRow = { - id: 'ls-002', - logged_activity_id: 'la-001', - user_id: 'user-001', - set_number: 2, - set_type: 'WARMUP', - prescribed: null, - actual_reps: null, - actual_weight: null, - actual_duration: null, - actual_distance: null, - actual_pace: null, - actual_heart_rate: null, - ruck_load: null, - elevation_gain: null, - rpe: null, - completed: false, - notes: null, - note_tags: [], - created_at: now, - updated_at: now, -} - -// --------------------------------------------------------------------------- -// UserProfile fixtures -// --------------------------------------------------------------------------- - -const userProfileRowFull: UserProfileRow = { - id: 'user-001', - display_name: 'Coach Hamilton', - preferred_units: 'IMPERIAL', - bodyweight: { value: 200, unit: 'lb' }, - training_age: { seconds: 157680000 }, // ~5 years - exercise_maxes: { - 'ex-squat-001': { - weight: { value: 405, unit: 'lb' }, - testedAt: '2025-06-01T00:00:00Z', - estimated: false, - }, - }, - max_reps: { 'ex-pullup-001': 20 }, - created_at: now, - updated_at: now, -} - -const userProfileRowNulls: UserProfileRow = { - id: 'user-002', - display_name: null, - preferred_units: 'METRIC', - bodyweight: null, - training_age: null, - exercise_maxes: null, - max_reps: null, - created_at: now, - updated_at: now, -} - -// --------------------------------------------------------------------------- -// OneRepMaxHistory fixtures -// --------------------------------------------------------------------------- - -const ormhRow: OneRepMaxHistoryRow = { - id: 'ormh-001', - user_id: 'user-001', - exercise_id: 'ex-squat-001', - weight: { value: 405, unit: 'lb' }, - estimated: false, - recorded_at: '2025-06-10T09:00:00Z', - created_at: now, -} - -// =========================================================================== -// Tests -// =========================================================================== - -// --------------------------------------------------------------------------- -// toExercise / fromExercise -// --------------------------------------------------------------------------- - -describe('toExercise / fromExercise', () => { - it('maps a fully populated exercise row to domain type', () => { - const result = toExercise(exerciseRow) - expect(result.id).toBe('ex-squat-001') - expect(result.name).toBe('Barbell Back Squat') - expect(result.aliases).toEqual(['Back Squat', 'Low Bar Squat']) - expect(result.category).toBe('BARBELL') - expect(result.movementPattern).toBe('SQUAT') - expect(result.muscleGroups).toEqual({ - primary: ['QUADS', 'GLUTES'], - secondary: ['HAMSTRINGS', 'CORE'], - }) - expect(result.isBilateral).toBe(true) - expect(result.supports1RM).toBe(true) - expect(result.equipmentRequired).toEqual(['BARBELL', 'SQUAT_RACK']) - expect(result.isCustom).toBe(false) - expect(result.createdAt).toBe(now) - expect(result.updatedAt).toBe(now) - }) - - it('maps a minimal exercise row (empty arrays, BODYWEIGHT category)', () => { - const result = toExercise(exerciseRowMinimal) - expect(result.id).toBe('ex-pushup-002') - expect(result.aliases).toEqual([]) - expect(result.category).toBe('BODYWEIGHT') - expect(result.muscleGroups.secondary).toEqual([]) - expect(result.equipmentRequired).toEqual(['NONE']) - }) - - it('throws ZodError on invalid category enum', () => { - const bad = { ...exerciseRow, category: 'POWERLIFTING' } - expect(() => toExercise(bad)).toThrow(ZodError) - }) - - it('throws ZodError on invalid movement_pattern enum', () => { - const bad = { ...exerciseRow, movement_pattern: 'JUMP' } - expect(() => toExercise(bad)).toThrow(ZodError) - }) - - it('throws ZodError on malformed muscle_groups JSON (missing primary)', () => { - const bad = { ...exerciseRow, muscle_groups: { secondary: ['CORE'] } } - expect(() => toExercise(bad)).toThrow(ZodError) - }) - - it('throws ZodError on muscle_groups with invalid muscle name', () => { - const bad = { - ...exerciseRow, - muscle_groups: { primary: ['INVALID_MUSCLE'], secondary: [] }, - } - expect(() => toExercise(bad)).toThrow(ZodError) - }) - - it('throws ZodError on aliases that is not an array', () => { - const bad = { ...exerciseRow, aliases: 'not-an-array' } - expect(() => toExercise(bad)).toThrow(ZodError) - }) - - it('throws ZodError on aliases with non-string elements', () => { - const bad = { ...exerciseRow, aliases: [42, true] } - expect(() => toExercise(bad)).toThrow(ZodError) - }) - - it('throws ZodError on equipment_required with invalid equipment', () => { - const bad = { ...exerciseRow, equipment_required: ['MAGIC_WAND'] } - expect(() => toExercise(bad)).toThrow(ZodError) - }) - - it('fromExercise maps domain exercise to DB row shape', () => { - const domain = toExercise(exerciseRow) - const { id: _, createdAt: _c, updatedAt: _u, ...domainBody } = domain - const row = fromExercise(domainBody) - expect(row.name).toBe('Barbell Back Squat') - expect(row.category).toBe('BARBELL') - expect(row.movement_pattern).toBe('SQUAT') - expect(row.muscle_groups).toEqual({ - primary: ['QUADS', 'GLUTES'], - secondary: ['HAMSTRINGS', 'CORE'], - }) - expect(row.is_bilateral).toBe(true) - expect(row.supports_1rm).toBe(true) - expect(row.equipment_required).toEqual(['BARBELL', 'SQUAT_RACK']) - expect(row.is_custom).toBe(false) - }) - - it('fromExercise omits id, created_at, updated_at', () => { - const domain = toExercise(exerciseRow) - const { id: _, createdAt: _c, updatedAt: _u, ...domainBody } = domain - const row = fromExercise(domainBody) - expect(row).not.toHaveProperty('id') - expect(row).not.toHaveProperty('created_at') - expect(row).not.toHaveProperty('updated_at') - }) - - it('round-trip: toExercise(row) -> fromExercise -> matches original row fields', () => { - const domain = toExercise(exerciseRow) - const { id: _, createdAt: _c, updatedAt: _u, ...domainBody } = domain - const roundTripped = fromExercise(domainBody) - expect(roundTripped.name).toBe(exerciseRow.name) - expect(roundTripped.category).toBe(exerciseRow.category) - expect(roundTripped.movement_pattern).toBe(exerciseRow.movement_pattern) - expect(roundTripped.muscle_groups).toEqual(exerciseRow.muscle_groups) - expect(roundTripped.is_bilateral).toBe(exerciseRow.is_bilateral) - expect(roundTripped.supports_1rm).toBe(exerciseRow.supports_1rm) - expect(roundTripped.equipment_required).toEqual(exerciseRow.equipment_required) - expect(roundTripped.is_custom).toBe(exerciseRow.is_custom) - }) -}) - -// --------------------------------------------------------------------------- -// toWorkoutLog / fromWorkoutLog -// --------------------------------------------------------------------------- - -describe('toWorkoutLog / fromWorkoutLog', () => { - it('maps a fully populated workout log row to domain type', () => { - const result = toWorkoutLog(workoutLogRowFull) - expect(result.id).toBe('wl-001') - expect(result.userId).toBe('user-001') - expect(result.title).toBe('Upper Body Day') - expect(result.startedAt).toBe(now) - expect(result.completedAt).toBe(later) - expect(result.sessionTemplateId).toBe('st-001') - expect(result.programContext).toEqual({ - programId: 'prog-001', - blockId: 'block-001', - weekNumber: 3, - dayLabel: 'Monday', - }) - expect(result.perceivedDifficulty).toBe(7) - expect(result.bodyweightAtSession).toEqual({ value: 185, unit: 'lb' }) - expect(result.overallNotes).toBe('Felt strong today') - }) - - it('maps null optional fields to undefined', () => { - const result = toWorkoutLog(workoutLogRowNulls) - expect(result.title).toBeUndefined() - expect(result.completedAt).toBeUndefined() - expect(result.sessionTemplateId).toBeUndefined() - expect(result.programContext).toBeUndefined() - expect(result.perceivedDifficulty).toBeUndefined() - expect(result.bodyweightAtSession).toBeUndefined() - expect(result.overallNotes).toBeUndefined() - }) - - it('throws ZodError on invalid program_context JSON shape', () => { - const bad = { ...workoutLogRowFull, program_context: { invalid: true } } - expect(() => toWorkoutLog(bad)).toThrow(ZodError) - }) - - it('throws ZodError on program_context with non-positive weekNumber', () => { - const bad = { - ...workoutLogRowFull, - program_context: { - programId: 'prog-001', - blockId: 'block-001', - weekNumber: 0, - dayLabel: 'Monday', - }, - } - expect(() => toWorkoutLog(bad)).toThrow(ZodError) - }) - - it('throws ZodError on malformed bodyweight_at_session (negative value)', () => { - const bad = { ...workoutLogRowFull, bodyweight_at_session: { value: -5, unit: 'lb' } } - expect(() => toWorkoutLog(bad)).toThrow(ZodError) - }) - - it('throws ZodError on bodyweight_at_session with zero value', () => { - const bad = { ...workoutLogRowFull, bodyweight_at_session: { value: 0, unit: 'kg' } } - expect(() => toWorkoutLog(bad)).toThrow(ZodError) - }) - - it('throws ZodError on bodyweight_at_session with invalid unit', () => { - const bad = { ...workoutLogRowFull, bodyweight_at_session: { value: 80, unit: 'stone' } } - expect(() => toWorkoutLog(bad)).toThrow(ZodError) - }) - - it('fromWorkoutLog maps undefined optional fields to null', () => { - const domain = toWorkoutLog(workoutLogRowNulls) - const { id: _, createdAt: _c, updatedAt: _u, ...body } = domain - const row = fromWorkoutLog(body) - expect(row.title).toBeNull() - expect(row.completed_at).toBeNull() - expect(row.session_template_id).toBeNull() - expect(row.program_context).toBeNull() - expect(row.perceived_difficulty).toBeNull() - expect(row.bodyweight_at_session).toBeNull() - expect(row.overall_notes).toBeNull() - }) - - it('fromWorkoutLog maps populated optional fields correctly', () => { - const domain = toWorkoutLog(workoutLogRowFull) - const { id: _, createdAt: _c, updatedAt: _u, ...body } = domain - const row = fromWorkoutLog(body) - expect(row.user_id).toBe('user-001') - expect(row.title).toBe('Upper Body Day') - expect(row.completed_at).toBe(later) - expect(row.session_template_id).toBe('st-001') - expect(row.program_context).toEqual({ - programId: 'prog-001', - blockId: 'block-001', - weekNumber: 3, - dayLabel: 'Monday', - }) - expect(row.perceived_difficulty).toBe(7) - expect(row.bodyweight_at_session).toEqual({ value: 185, unit: 'lb' }) - expect(row.overall_notes).toBe('Felt strong today') - }) - - it('fromWorkoutLog omits id, created_at, updated_at', () => { - const domain = toWorkoutLog(workoutLogRowFull) - const { id: _, createdAt: _c, updatedAt: _u, ...body } = domain - const row = fromWorkoutLog(body) - expect(row).not.toHaveProperty('id') - expect(row).not.toHaveProperty('created_at') - expect(row).not.toHaveProperty('updated_at') - }) - - it('round-trip: preserves non-null data fields', () => { - const domain = toWorkoutLog(workoutLogRowFull) - const { id: _, createdAt: _c, updatedAt: _u, ...body } = domain - const roundTripped = fromWorkoutLog(body) - expect(roundTripped.user_id).toBe(workoutLogRowFull.user_id) - expect(roundTripped.title).toBe(workoutLogRowFull.title) - expect(roundTripped.started_at).toBe(workoutLogRowFull.started_at) - expect(roundTripped.completed_at).toBe(workoutLogRowFull.completed_at) - expect(roundTripped.session_template_id).toBe(workoutLogRowFull.session_template_id) - expect(roundTripped.overall_notes).toBe(workoutLogRowFull.overall_notes) - }) -}) - -// --------------------------------------------------------------------------- -// toLoggedActivityGroup / fromLoggedActivityGroup -// --------------------------------------------------------------------------- - -describe('toLoggedActivityGroup / fromLoggedActivityGroup', () => { - it('maps a fully populated group row to domain type', () => { - const result = toLoggedActivityGroup(groupRowFull) - expect(result.id).toBe('lag-001') - expect(result.workoutLogId).toBe('wl-001') - expect(result.groupType).toBe('STRAIGHT_SETS') - expect(result.ordinal).toBe(1) - expect(result.actualRoundsCompleted).toBe(4) - expect(result.completionTime).toEqual({ seconds: 1800 }) - }) - - it('maps null optional fields to undefined', () => { - const result = toLoggedActivityGroup(groupRowNulls) - expect(result.actualRoundsCompleted).toBeUndefined() - expect(result.completionTime).toBeUndefined() - }) - - it('throws ZodError on invalid group_type enum', () => { - const bad = { ...groupRowFull, group_type: 'INVALID_GROUP' } - expect(() => toLoggedActivityGroup(bad)).toThrow(ZodError) - }) - - it('throws ZodError on each invalid group_type value', () => { - for (const badType of ['TABATA', 'TRISET', '']) { - expect(() => toLoggedActivityGroup({ ...groupRowFull, group_type: badType })).toThrow( - ZodError, - ) - } - }) - - it('accepts all valid group types', () => { - const validTypes = [ - 'STRAIGHT_SETS', - 'SUPERSET', - 'CIRCUIT', - 'COMPLEX', - 'EMOM', - 'AMRAP', - 'COUPLET', - ] - for (const groupType of validTypes) { - const result = toLoggedActivityGroup({ ...groupRowFull, group_type: groupType }) - expect(result.groupType).toBe(groupType) - } - }) - - it('throws ZodError on malformed completion_time (negative seconds)', () => { - const bad = { ...groupRowFull, completion_time: { seconds: -10 } } - expect(() => toLoggedActivityGroup(bad)).toThrow(ZodError) - }) - - it('throws ZodError on completion_time with non-integer seconds', () => { - const bad = { ...groupRowFull, completion_time: { seconds: 45.5 } } - expect(() => toLoggedActivityGroup(bad)).toThrow(ZodError) - }) - - it('fromLoggedActivityGroup maps undefined optional fields to null', () => { - const domain = toLoggedActivityGroup(groupRowNulls) - const { id: _, ...body } = domain - const row = fromLoggedActivityGroup(body, 'user-001') - expect(row.actual_rounds_completed).toBeNull() - expect(row.completion_time).toBeNull() - }) - - it('fromLoggedActivityGroup maps populated optional fields correctly', () => { - const domain = toLoggedActivityGroup(groupRowFull) - const { id: _, ...body } = domain - const row = fromLoggedActivityGroup(body, 'user-001') - expect(row.workout_log_id).toBe('wl-001') - expect(row.user_id).toBe('user-001') - expect(row.group_type).toBe('STRAIGHT_SETS') - expect(row.ordinal).toBe(1) - expect(row.actual_rounds_completed).toBe(4) - expect(row.completion_time).toEqual({ seconds: 1800 }) - }) - - it('fromLoggedActivityGroup omits id', () => { - const domain = toLoggedActivityGroup(groupRowFull) - const { id: _, ...body } = domain - const row = fromLoggedActivityGroup(body, 'user-001') - expect(row).not.toHaveProperty('id') - }) - - it('round-trip: preserves non-null data fields', () => { - const domain = toLoggedActivityGroup(groupRowFull) - const { id: _, ...body } = domain - const roundTripped = fromLoggedActivityGroup(body, 'user-001') - expect(roundTripped.workout_log_id).toBe(groupRowFull.workout_log_id) - expect(roundTripped.group_type).toBe(groupRowFull.group_type) - expect(roundTripped.ordinal).toBe(groupRowFull.ordinal) - expect(roundTripped.actual_rounds_completed).toBe(groupRowFull.actual_rounds_completed) - expect(roundTripped.completion_time).toEqual(groupRowFull.completion_time) - }) -}) - -// --------------------------------------------------------------------------- -// toLoggedActivity / fromLoggedActivity -// --------------------------------------------------------------------------- - -describe('toLoggedActivity / fromLoggedActivity', () => { - it('maps a fully populated activity row to domain type', () => { - const result = toLoggedActivity(activityRowFull) - expect(result.id).toBe('la-001') - expect(result.loggedGroupId).toBe('lag-001') - expect(result.exerciseId).toBe('ex-squat-001') - expect(result.ordinal).toBe(1) - expect(result.notes).toBe('Focus on depth') - }) - - it('maps null notes to undefined', () => { - const result = toLoggedActivity(activityRowNulls) - expect(result.notes).toBeUndefined() - }) - - it('fromLoggedActivity maps undefined notes to null', () => { - const domain = toLoggedActivity(activityRowNulls) - const { id: _, ...body } = domain - const row = fromLoggedActivity(body, 'user-001') - expect(row.notes).toBeNull() - }) - - it('fromLoggedActivity maps present notes correctly', () => { - const domain = toLoggedActivity(activityRowFull) - const { id: _, ...body } = domain - const row = fromLoggedActivity(body, 'user-001') - expect(row.notes).toBe('Focus on depth') - expect(row.logged_group_id).toBe('lag-001') - expect(row.user_id).toBe('user-001') - expect(row.exercise_id).toBe('ex-squat-001') - expect(row.ordinal).toBe(1) - }) - - it('fromLoggedActivity omits id', () => { - const domain = toLoggedActivity(activityRowFull) - const { id: _, ...body } = domain - const row = fromLoggedActivity(body, 'user-001') - expect(row).not.toHaveProperty('id') - }) - - it('round-trip: preserves data fields', () => { - const domain = toLoggedActivity(activityRowFull) - const { id: _, ...body } = domain - const roundTripped = fromLoggedActivity(body, 'user-001') - expect(roundTripped.logged_group_id).toBe(activityRowFull.logged_group_id) - expect(roundTripped.exercise_id).toBe(activityRowFull.exercise_id) - expect(roundTripped.ordinal).toBe(activityRowFull.ordinal) - expect(roundTripped.notes).toBe(activityRowFull.notes) - }) -}) - -// --------------------------------------------------------------------------- -// toLoggedSet / fromLoggedSet -// --------------------------------------------------------------------------- - -describe('toLoggedSet / fromLoggedSet', () => { - it('maps a fully populated set row to domain type', () => { - const result = toLoggedSet(setRowFull) - expect(result.id).toBe('ls-001') - expect(result.loggedActivityId).toBe('la-001') - expect(result.setNumber).toBe(1) - expect(result.setType).toBe('WORKING') - expect(result.prescribed).toEqual({ reps: 5, weight: { value: 225, unit: 'lb' } }) - expect(result.actualReps).toBe(5) - expect(result.actualWeight).toEqual({ value: 225, unit: 'lb' }) - expect(result.actualDuration).toEqual({ seconds: 45 }) - expect(result.actualDistance).toEqual({ value: 0, unit: 'm' }) - expect(result.actualPace).toEqual({ minutesPerUnit: 8.5, unit: 'mi' }) - expect(result.actualHeartRate).toBe(155) - expect(result.rpe).toBe(8) - expect(result.completed).toBe(true) - expect(result.notes).toBe('Solid set') - expect(result.ruckLoad).toEqual({ value: 45, unit: 'lb' }) - expect(result.elevationGain).toEqual({ value: 100, unit: 'm' }) - }) - - it('maps null optional fields to undefined', () => { - const result = toLoggedSet(setRowNulls) - expect(result.prescribed).toBeUndefined() - expect(result.actualReps).toBeUndefined() - expect(result.actualWeight).toBeUndefined() - expect(result.actualDuration).toBeUndefined() - expect(result.actualDistance).toBeUndefined() - expect(result.actualPace).toBeUndefined() - expect(result.actualHeartRate).toBeUndefined() - expect(result.rpe).toBeUndefined() - expect(result.notes).toBeUndefined() - expect(result.ruckLoad).toBeUndefined() - expect(result.elevationGain).toBeUndefined() - }) - - it('throws ZodError on invalid set_type enum', () => { - const bad = { ...setRowFull, set_type: 'FAILURE' } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - - it('throws ZodError on empty string set_type', () => { - const bad = { ...setRowFull, set_type: '' } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - - it('accepts all valid set types', () => { - const validTypes = ['WORKING', 'WARMUP', 'DROP', 'AMRAP', 'PEAK', 'BACKOFF'] - for (const setType of validTypes) { - const result = toLoggedSet({ ...setRowFull, set_type: setType }) - expect(result.setType).toBe(setType) - } - }) - - it('throws ZodError on invalid prescribed JSON (empty object fails refine)', () => { - const bad = { ...setRowFull, prescribed: {} } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - - it('throws ZodError on prescribed with negative reps', () => { - const bad = { ...setRowFull, prescribed: { reps: -1 } } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - - it('throws ZodError on prescribed with zero reps', () => { - const bad = { ...setRowFull, prescribed: { reps: 0 } } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - - it('throws ZodError on prescribed weight with negative value', () => { - const bad = { - ...setRowFull, - prescribed: { weight: { value: -100, unit: 'lb' } }, - } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - - it('throws ZodError on actual_weight with zero value', () => { - const bad = { ...setRowFull, actual_weight: { value: 0, unit: 'lb' } } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - - it('throws ZodError on actual_weight with invalid unit', () => { - const bad = { ...setRowFull, actual_weight: { value: 100, unit: 'stone' } } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - - it('throws ZodError on actual_duration with negative seconds', () => { - const bad = { ...setRowFull, actual_duration: { seconds: -5 } } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - - it('throws ZodError on actual_duration with non-integer seconds', () => { - const bad = { ...setRowFull, actual_duration: { seconds: 30.5 } } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - - it('throws ZodError on actual_distance with negative value', () => { - const bad = { ...setRowFull, actual_distance: { value: -1, unit: 'mi' } } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - - it('throws ZodError on actual_distance with invalid unit', () => { - const bad = { ...setRowFull, actual_distance: { value: 5, unit: 'furlongs' } } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - - it('throws ZodError on actual_pace with zero minutesPerUnit', () => { - const bad = { ...setRowFull, actual_pace: { minutesPerUnit: 0, unit: 'mi' } } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - - it('throws ZodError on actual_pace with negative minutesPerUnit', () => { - const bad = { ...setRowFull, actual_pace: { minutesPerUnit: -5, unit: 'km' } } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - - it('throws ZodError on actual_pace with invalid unit', () => { - const bad = { ...setRowFull, actual_pace: { minutesPerUnit: 8, unit: 'm' } } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - - it('throws ZodError on ruck_load with zero value', () => { - const bad = { ...setRowFull, ruck_load: { value: 0, unit: 'lb' } } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - - it('throws ZodError on elevation_gain with invalid unit', () => { - const bad = { ...setRowFull, elevation_gain: { value: 50, unit: 'ft' } } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - - it('fromLoggedSet maps undefined optional fields to null', () => { - const domain = toLoggedSet(setRowNulls) - const { id: _, ...body } = domain - const row = fromLoggedSet(body, 'user-001') - expect(row.prescribed).toBeNull() - expect(row.actual_reps).toBeNull() - expect(row.actual_weight).toBeNull() - expect(row.actual_duration).toBeNull() - expect(row.actual_distance).toBeNull() - expect(row.actual_pace).toBeNull() - expect(row.actual_heart_rate).toBeNull() - expect(row.ruck_load).toBeNull() - expect(row.elevation_gain).toBeNull() - expect(row.rpe).toBeNull() - expect(row.notes).toBeNull() - }) - - it('fromLoggedSet maps populated optional fields correctly', () => { - const domain = toLoggedSet(setRowFull) - const { id: _, ...body } = domain - const row = fromLoggedSet(body, 'user-001') - expect(row.logged_activity_id).toBe('la-001') - expect(row.user_id).toBe('user-001') - expect(row.set_number).toBe(1) - expect(row.set_type).toBe('WORKING') - expect(row.prescribed).toEqual({ reps: 5, weight: { value: 225, unit: 'lb' } }) - expect(row.actual_reps).toBe(5) - expect(row.actual_weight).toEqual({ value: 225, unit: 'lb' }) - expect(row.actual_duration).toEqual({ seconds: 45 }) - expect(row.actual_distance).toEqual({ value: 0, unit: 'm' }) - expect(row.actual_pace).toEqual({ minutesPerUnit: 8.5, unit: 'mi' }) - expect(row.actual_heart_rate).toBe(155) - expect(row.rpe).toBe(8) - expect(row.completed).toBe(true) - expect(row.notes).toBe('Solid set') - expect(row.ruck_load).toEqual({ value: 45, unit: 'lb' }) - expect(row.elevation_gain).toEqual({ value: 100, unit: 'm' }) - }) - - it('fromLoggedSet omits id', () => { - const domain = toLoggedSet(setRowFull) - const { id: _, ...body } = domain - const row = fromLoggedSet(body, 'user-001') - expect(row).not.toHaveProperty('id') - }) - - it('round-trip: preserves non-null data fields', () => { - const domain = toLoggedSet(setRowFull) - const { id: _, ...body } = domain - const roundTripped = fromLoggedSet(body, 'user-001') - expect(roundTripped.set_number).toBe(setRowFull.set_number) - expect(roundTripped.set_type).toBe(setRowFull.set_type) - expect(roundTripped.prescribed).toEqual(setRowFull.prescribed) - expect(roundTripped.actual_reps).toBe(setRowFull.actual_reps) - expect(roundTripped.actual_weight).toEqual(setRowFull.actual_weight) - expect(roundTripped.rpe).toBe(setRowFull.rpe) - expect(roundTripped.completed).toBe(setRowFull.completed) - expect(roundTripped.notes).toBe(setRowFull.notes) - }) - - it('round-trip: null fields survive toLoggedSet -> fromLoggedSet', () => { - const domain = toLoggedSet(setRowNulls) - const { id: _, ...body } = domain - const roundTripped = fromLoggedSet(body, 'user-001') - expect(roundTripped.prescribed).toBeNull() - expect(roundTripped.actual_reps).toBeNull() - expect(roundTripped.actual_weight).toBeNull() - expect(roundTripped.actual_duration).toBeNull() - expect(roundTripped.actual_distance).toBeNull() - expect(roundTripped.actual_pace).toBeNull() - expect(roundTripped.actual_heart_rate).toBeNull() - expect(roundTripped.ruck_load).toBeNull() - expect(roundTripped.elevation_gain).toBeNull() - expect(roundTripped.rpe).toBeNull() - expect(roundTripped.notes).toBeNull() - }) - - it('prescribed with only notes satisfies the at-least-one-field refine', () => { - const row = { ...setRowFull, prescribed: { notes: 'Go heavy' } } - const result = toLoggedSet(row) - expect(result.prescribed).toEqual({ notes: 'Go heavy' }) - }) - - it('prescribed with only loadSpec passes validation', () => { - const row = { - ...setRowFull, - prescribed: { loadSpec: { type: 'rpe', target: 8 } }, - } - const result = toLoggedSet(row) - expect(result.prescribed?.loadSpec).toEqual({ type: 'rpe', target: 8 }) - }) - - it('prescribed with only duration passes validation', () => { - const row = { - ...setRowFull, - prescribed: { duration: { seconds: 60 } }, - } - const result = toLoggedSet(row) - expect(result.prescribed?.duration).toEqual({ seconds: 60 }) - }) - - it('prescribed with only distance passes validation', () => { - const row = { - ...setRowFull, - prescribed: { distance: { value: 400, unit: 'm' } }, - } - const result = toLoggedSet(row) - expect(result.prescribed?.distance).toEqual({ value: 400, unit: 'm' }) - }) -}) - -// --------------------------------------------------------------------------- -// toUserProfile / fromUserProfile -// --------------------------------------------------------------------------- - -describe('toUserProfile / fromUserProfile', () => { - it('maps a fully populated user profile row to domain type', () => { - const result = toUserProfile(userProfileRowFull) - expect(result.id).toBe('user-001') - expect(result.displayName).toBe('Coach Hamilton') - expect(result.preferredUnits).toBe('IMPERIAL') - expect(result.bodyweight).toEqual({ value: 200, unit: 'lb' }) - expect(result.trainingAge).toEqual({ seconds: 157680000 }) - expect(result.exerciseMaxes).toEqual({ - 'ex-squat-001': { - weight: { value: 405, unit: 'lb' }, - testedAt: '2025-06-01T00:00:00Z', - estimated: false, - }, - }) - expect(result.maxReps).toEqual({ 'ex-pullup-001': 20 }) - expect(result.createdAt).toBe(now) - expect(result.updatedAt).toBe(now) - }) - - it('maps null optional fields to undefined (display_name)', () => { - const result = toUserProfile(userProfileRowNulls) - expect(result.displayName).toBeUndefined() - }) - - it('maps null bodyweight to undefined', () => { - const result = toUserProfile(userProfileRowNulls) - expect(result.bodyweight).toBeUndefined() - }) - - it('maps null training_age to undefined', () => { - const result = toUserProfile(userProfileRowNulls) - expect(result.trainingAge).toBeUndefined() - }) - - it('maps null exercise_maxes to empty object', () => { - const result = toUserProfile(userProfileRowNulls) - expect(result.exerciseMaxes).toEqual({}) - }) - - it('maps null max_reps to empty object', () => { - const result = toUserProfile(userProfileRowNulls) - expect(result.maxReps).toEqual({}) - }) - - it('throws ZodError on invalid preferred_units enum', () => { - const bad = { ...userProfileRowFull, preferred_units: 'STONE' } - expect(() => toUserProfile(bad)).toThrow(ZodError) - }) - - it('throws ZodError on empty string preferred_units', () => { - const bad = { ...userProfileRowFull, preferred_units: '' } - expect(() => toUserProfile(bad)).toThrow(ZodError) - }) - - it('throws ZodError on malformed bodyweight (zero value)', () => { - const bad = { ...userProfileRowFull, bodyweight: { value: 0, unit: 'kg' } } - expect(() => toUserProfile(bad)).toThrow(ZodError) - }) - - it('throws ZodError on malformed bodyweight (negative value)', () => { - const bad = { ...userProfileRowFull, bodyweight: { value: -10, unit: 'lb' } } - expect(() => toUserProfile(bad)).toThrow(ZodError) - }) - - it('throws ZodError on bodyweight with invalid unit', () => { - const bad = { ...userProfileRowFull, bodyweight: { value: 180, unit: 'stone' } } - expect(() => toUserProfile(bad)).toThrow(ZodError) - }) - - it('throws ZodError on training_age with negative seconds', () => { - const bad = { ...userProfileRowFull, training_age: { seconds: -1 } } - expect(() => toUserProfile(bad)).toThrow(ZodError) - }) - - it('throws ZodError on training_age with non-integer seconds', () => { - const bad = { ...userProfileRowFull, training_age: { seconds: 100.5 } } - expect(() => toUserProfile(bad)).toThrow(ZodError) - }) - - it('throws ZodError on exercise_maxes with invalid oneRepMax (zero weight)', () => { - const bad = { - ...userProfileRowFull, - exercise_maxes: { - 'ex-squat-001': { - weight: { value: 0, unit: 'lb' }, - testedAt: '2025-06-01T00:00:00Z', - estimated: false, - }, - }, - } - expect(() => toUserProfile(bad)).toThrow(ZodError) - }) - - it('throws ZodError on max_reps with zero value', () => { - const bad = { ...userProfileRowFull, max_reps: { 'ex-pullup-001': 0 } } - expect(() => toUserProfile(bad)).toThrow(ZodError) - }) - - it('throws ZodError on max_reps with negative value', () => { - const bad = { ...userProfileRowFull, max_reps: { 'ex-pullup-001': -5 } } - expect(() => toUserProfile(bad)).toThrow(ZodError) - }) - - it('throws ZodError on max_reps with non-integer value', () => { - const bad = { ...userProfileRowFull, max_reps: { 'ex-pullup-001': 12.5 } } - expect(() => toUserProfile(bad)).toThrow(ZodError) - }) - - // fromUserProfile conditional logic - it('fromUserProfile only includes id when no optional fields set', () => { - const row = fromUserProfile({ id: 'user-003' }) - expect(row).toEqual({ id: 'user-003' }) - }) - - it('fromUserProfile includes displayName when explicitly set', () => { - const row = fromUserProfile({ id: 'user-003', displayName: 'New Name' }) - expect(row.display_name).toBe('New Name') - expect(row).not.toHaveProperty('preferred_units') - expect(row).not.toHaveProperty('bodyweight') - expect(row).not.toHaveProperty('training_age') - expect(row).not.toHaveProperty('exercise_maxes') - expect(row).not.toHaveProperty('max_reps') - }) - - it('fromUserProfile includes preferredUnits when explicitly set', () => { - const row = fromUserProfile({ id: 'user-003', preferredUnits: 'METRIC' }) - expect(row.preferred_units).toBe('METRIC') - expect(row).not.toHaveProperty('display_name') - }) - - it('fromUserProfile includes bodyweight when explicitly set', () => { - const row = fromUserProfile({ id: 'user-003', bodyweight: { value: 180, unit: 'lb' } }) - expect(row.bodyweight).toEqual({ value: 180, unit: 'lb' }) - }) - - it('fromUserProfile skips displayName when value is undefined (guard uses !== undefined)', () => { - // When displayName is undefined, the !== undefined guard is false, - // so the field is not added to the row at all - const row = fromUserProfile({ id: 'user-003', displayName: undefined }) - expect(row).not.toHaveProperty('display_name') - }) - - it('fromUserProfile skips bodyweight when value is undefined', () => { - const row = fromUserProfile({ id: 'user-003', bodyweight: undefined }) - expect(row).not.toHaveProperty('bodyweight') - }) - - it('fromUserProfile skips trainingAge when value is undefined', () => { - const row = fromUserProfile({ id: 'user-003', trainingAge: undefined }) - expect(row).not.toHaveProperty('training_age') - }) - - it('fromUserProfile includes displayName empty string (truthy !== undefined check)', () => { - const row = fromUserProfile({ id: 'user-003', displayName: '' }) - expect(row.display_name).toBe('') - }) - - it('fromUserProfile includes exerciseMaxes when explicitly set', () => { - const maxes = { - 'ex-bench-001': { - weight: { value: 275, unit: 'lb' as const }, - testedAt: '2025-06-01T00:00:00Z', - estimated: false, - }, - } - const row = fromUserProfile({ id: 'user-003', exerciseMaxes: maxes }) - expect(row.exercise_maxes).toEqual(maxes) - }) - - it('fromUserProfile includes maxReps when explicitly set', () => { - const row = fromUserProfile({ id: 'user-003', maxReps: { 'ex-dip-001': 25 } }) - expect(row.max_reps).toEqual({ 'ex-dip-001': 25 }) - }) - - it('fromUserProfile includes multiple fields when all set', () => { - const row = fromUserProfile({ - id: 'user-003', - displayName: 'Athlete', - preferredUnits: 'IMPERIAL', - bodyweight: { value: 200, unit: 'lb' }, - trainingAge: { seconds: 31536000 }, - exerciseMaxes: {}, - maxReps: {}, - }) - expect(row.id).toBe('user-003') - expect(row.display_name).toBe('Athlete') - expect(row.preferred_units).toBe('IMPERIAL') - expect(row.bodyweight).toEqual({ value: 200, unit: 'lb' }) - expect(row.training_age).toEqual({ seconds: 31536000 }) - expect(row.exercise_maxes).toEqual({}) - expect(row.max_reps).toEqual({}) - }) - - it('fromUserProfile does not include createdAt or updatedAt', () => { - const row = fromUserProfile({ - id: 'user-003', - displayName: 'Test', - preferredUnits: 'METRIC', - }) - expect(row).not.toHaveProperty('created_at') - expect(row).not.toHaveProperty('updated_at') - }) -}) - -// --------------------------------------------------------------------------- -// toOneRepMaxHistory / fromOneRepMaxHistory -// --------------------------------------------------------------------------- - -describe('toOneRepMaxHistory / fromOneRepMaxHistory', () => { - it('maps a valid one rep max history row to domain type', () => { - const result = toOneRepMaxHistory(ormhRow) - expect(result.id).toBe('ormh-001') - expect(result.createdAt).toBe(now) - expect(result.userId).toBe('user-001') - expect(result.exerciseId).toBe('ex-squat-001') - expect(result.weight).toEqual({ value: 405, unit: 'lb' }) - expect(result.estimated).toBe(false) - expect(result.recordedAt).toBe('2025-06-10T09:00:00Z') - }) - - it('does not include updatedAt in result', () => { - const result = toOneRepMaxHistory(ormhRow) - expect(result).not.toHaveProperty('updatedAt') - }) - - it('does not include updated_at even if source row had extra properties', () => { - // The row type has no updated_at, but test defense against accidental spread - const result = toOneRepMaxHistory(ormhRow) - expect(Object.keys(result)).not.toContain('updatedAt') - expect(Object.keys(result)).not.toContain('updated_at') - }) - - it('maps estimated true correctly', () => { - const row = { ...ormhRow, estimated: true } - const result = toOneRepMaxHistory(row) - expect(result.estimated).toBe(true) - }) - - it('throws ZodError on weight with zero value', () => { - const bad = { ...ormhRow, weight: { value: 0, unit: 'lb' } } - expect(() => toOneRepMaxHistory(bad)).toThrow(ZodError) - }) - - it('throws ZodError on weight with negative value', () => { - const bad = { ...ormhRow, weight: { value: -100, unit: 'kg' } } - expect(() => toOneRepMaxHistory(bad)).toThrow(ZodError) - }) - - it('throws ZodError on weight with invalid unit', () => { - const bad = { ...ormhRow, weight: { value: 405, unit: 'stone' } } - expect(() => toOneRepMaxHistory(bad)).toThrow(ZodError) - }) - - it('throws ZodError on weight missing value field', () => { - const bad = { ...ormhRow, weight: { unit: 'lb' } } - expect(() => toOneRepMaxHistory(bad)).toThrow(ZodError) - }) - - it('throws ZodError on weight missing unit field', () => { - const bad = { ...ormhRow, weight: { value: 405 } } - expect(() => toOneRepMaxHistory(bad)).toThrow(ZodError) - }) - - it('throws ZodError on weight as a plain number (not an object)', () => { - const bad = { ...ormhRow, weight: 405 } - expect(() => toOneRepMaxHistory(bad)).toThrow(ZodError) - }) - - it('throws ZodError on weight as a string', () => { - const bad = { ...ormhRow, weight: '405 lb' } - expect(() => toOneRepMaxHistory(bad)).toThrow(ZodError) - }) - - it('accepts weight in kg', () => { - const row = { ...ormhRow, weight: { value: 180, unit: 'kg' } } - const result = toOneRepMaxHistory(row) - expect(result.weight).toEqual({ value: 180, unit: 'kg' }) - }) - - it('fromOneRepMaxHistory maps domain to DB row shape', () => { - const domain = toOneRepMaxHistory(ormhRow) - const { id: _, createdAt: _c, ...body } = domain - const row = fromOneRepMaxHistory(body) - expect(row.user_id).toBe('user-001') - expect(row.exercise_id).toBe('ex-squat-001') - expect(row.weight).toEqual({ value: 405, unit: 'lb' }) - expect(row.estimated).toBe(false) - expect(row.recorded_at).toBe('2025-06-10T09:00:00Z') - }) - - it('fromOneRepMaxHistory omits id and created_at', () => { - const domain = toOneRepMaxHistory(ormhRow) - const { id: _, createdAt: _c, ...body } = domain - const row = fromOneRepMaxHistory(body) - expect(row).not.toHaveProperty('id') - expect(row).not.toHaveProperty('created_at') - }) - - it('round-trip: preserves data fields', () => { - const domain = toOneRepMaxHistory(ormhRow) - const { id: _, createdAt: _c, ...body } = domain - const roundTripped = fromOneRepMaxHistory(body) - expect(roundTripped.user_id).toBe(ormhRow.user_id) - expect(roundTripped.exercise_id).toBe(ormhRow.exercise_id) - expect(roundTripped.weight).toEqual(ormhRow.weight) - expect(roundTripped.estimated).toBe(ormhRow.estimated) - expect(roundTripped.recorded_at).toBe(ormhRow.recorded_at) - }) -}) - -// --------------------------------------------------------------------------- -// Cross-cutting: Zod validation edge cases on JSON fields -// --------------------------------------------------------------------------- - -describe('Cross-cutting Zod validation on JSON fields', () => { - describe('weightSchema validation via toLoggedSet', () => { - it('rejects weight with value as string', () => { - const bad = { ...setRowFull, actual_weight: { value: '225', unit: 'lb' } } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - - it('rejects weight with missing fields', () => { - const bad = { ...setRowFull, actual_weight: {} } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - - it('rejects weight as null when field is supposed to be JSON', () => { - // When field is non-null, the parse path is taken; null skips it. - // This tests that a non-null but invalid shape fails. - const bad = { ...setRowFull, actual_weight: 'not-json' } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - }) - - describe('durationSchema validation via toLoggedActivityGroup', () => { - it('rejects duration with string seconds', () => { - const bad = { ...groupRowFull, completion_time: { seconds: '1800' } } - expect(() => toLoggedActivityGroup(bad)).toThrow(ZodError) - }) - - it('rejects duration with missing seconds field', () => { - const bad = { ...groupRowFull, completion_time: { minutes: 30 } } - expect(() => toLoggedActivityGroup(bad)).toThrow(ZodError) - }) - - it('accepts duration with zero seconds', () => { - const row = { ...groupRowFull, completion_time: { seconds: 0 } } - const result = toLoggedActivityGroup(row) - expect(result.completionTime).toEqual({ seconds: 0 }) - }) - }) - - describe('distanceSchema validation via toLoggedSet', () => { - it('rejects distance with string value', () => { - const bad = { ...setRowFull, actual_distance: { value: '400', unit: 'm' } } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - - it('accepts distance with zero value (nonnegative)', () => { - const row = { ...setRowFull, actual_distance: { value: 0, unit: 'km' } } - const result = toLoggedSet(row) - expect(result.actualDistance).toEqual({ value: 0, unit: 'km' }) - }) - - it('accepts all valid distance units', () => { - for (const unit of ['mi', 'km', 'm', 'yd']) { - const row = { ...setRowFull, actual_distance: { value: 1, unit } } - const result = toLoggedSet(row) - expect(result.actualDistance?.unit).toBe(unit) - } - }) - }) - - describe('paceSchema validation via toLoggedSet', () => { - it('rejects pace with string minutesPerUnit', () => { - const bad = { ...setRowFull, actual_pace: { minutesPerUnit: '8', unit: 'mi' } } - expect(() => toLoggedSet(bad)).toThrow(ZodError) - }) - - it('accepts all valid pace units', () => { - for (const unit of ['mi', 'km']) { - const row = { ...setRowFull, actual_pace: { minutesPerUnit: 7.5, unit } } - const result = toLoggedSet(row) - expect(result.actualPace?.unit).toBe(unit) - } - }) - }) - - describe('programContextSchema validation via toWorkoutLog', () => { - it('rejects program_context missing programId', () => { - const bad = { - ...workoutLogRowFull, - program_context: { blockId: 'b-1', weekNumber: 1, dayLabel: 'Mon' }, - } - expect(() => toWorkoutLog(bad)).toThrow(ZodError) - }) - - it('rejects program_context missing blockId', () => { - const bad = { - ...workoutLogRowFull, - program_context: { programId: 'p-1', weekNumber: 1, dayLabel: 'Mon' }, - } - expect(() => toWorkoutLog(bad)).toThrow(ZodError) - }) - - it('rejects program_context with empty programId', () => { - const bad = { - ...workoutLogRowFull, - program_context: { programId: '', blockId: 'b-1', weekNumber: 1, dayLabel: 'Mon' }, - } - expect(() => toWorkoutLog(bad)).toThrow(ZodError) - }) - - it('rejects program_context with weekNumber of 0', () => { - const bad = { - ...workoutLogRowFull, - program_context: { programId: 'p-1', blockId: 'b-1', weekNumber: 0, dayLabel: 'Mon' }, - } - expect(() => toWorkoutLog(bad)).toThrow(ZodError) - }) - - it('rejects program_context with negative weekNumber', () => { - const bad = { - ...workoutLogRowFull, - program_context: { programId: 'p-1', blockId: 'b-1', weekNumber: -1, dayLabel: 'Mon' }, - } - expect(() => toWorkoutLog(bad)).toThrow(ZodError) - }) - }) - - describe('oneRepMaxSchema validation via toUserProfile', () => { - it('rejects exercise_maxes with missing testedAt', () => { - const bad = { - ...userProfileRowFull, - exercise_maxes: { - 'ex-squat-001': { - weight: { value: 405, unit: 'lb' }, - estimated: false, - }, - }, - } - expect(() => toUserProfile(bad)).toThrow(ZodError) - }) - - it('rejects exercise_maxes with invalid testedAt format', () => { - const bad = { - ...userProfileRowFull, - exercise_maxes: { - 'ex-squat-001': { - weight: { value: 405, unit: 'lb' }, - testedAt: 'not-a-date', - estimated: false, - }, - }, - } - expect(() => toUserProfile(bad)).toThrow(ZodError) - }) - - it('rejects exercise_maxes with negative weight', () => { - const bad = { - ...userProfileRowFull, - exercise_maxes: { - 'ex-squat-001': { - weight: { value: -100, unit: 'lb' }, - testedAt: '2025-06-01T00:00:00Z', - estimated: false, - }, - }, - } - expect(() => toUserProfile(bad)).toThrow(ZodError) - }) - }) -}) - -// --------------------------------------------------------------------------- -// Structural completeness: verify all mapper functions exist -// --------------------------------------------------------------------------- - -describe('Mapper function exports', () => { - it('exports toExercise as a function', () => { - expect(typeof toExercise).toBe('function') - }) - - it('exports fromExercise as a function', () => { - expect(typeof fromExercise).toBe('function') - }) - - it('exports toWorkoutLog as a function', () => { - expect(typeof toWorkoutLog).toBe('function') - }) - - it('exports fromWorkoutLog as a function', () => { - expect(typeof fromWorkoutLog).toBe('function') - }) - - it('exports toLoggedActivityGroup as a function', () => { - expect(typeof toLoggedActivityGroup).toBe('function') - }) - - it('exports fromLoggedActivityGroup as a function', () => { - expect(typeof fromLoggedActivityGroup).toBe('function') - }) - - it('exports toLoggedActivity as a function', () => { - expect(typeof toLoggedActivity).toBe('function') - }) - - it('exports fromLoggedActivity as a function', () => { - expect(typeof fromLoggedActivity).toBe('function') - }) - - it('exports toLoggedSet as a function', () => { - expect(typeof toLoggedSet).toBe('function') - }) - - it('exports fromLoggedSet as a function', () => { - expect(typeof fromLoggedSet).toBe('function') - }) - - it('exports toUserProfile as a function', () => { - expect(typeof toUserProfile).toBe('function') - }) - - it('exports fromUserProfile as a function', () => { - expect(typeof fromUserProfile).toBe('function') - }) - - it('exports toOneRepMaxHistory as a function', () => { - expect(typeof toOneRepMaxHistory).toBe('function') - }) - - it('exports fromOneRepMaxHistory as a function', () => { - expect(typeof fromOneRepMaxHistory).toBe('function') - }) - - it('exports toSessionTemplate as a function', () => { - expect(typeof toSessionTemplate).toBe('function') - }) - - it('exports fromSessionTemplate as a function', () => { - expect(typeof fromSessionTemplate).toBe('function') - }) - - it('exports toActivityGroupFlat as a function', () => { - expect(typeof toActivityGroupFlat).toBe('function') - }) - - it('exports fromActivityGroup as a function', () => { - expect(typeof fromActivityGroup).toBe('function') - }) - - it('exports toActivity as a function', () => { - expect(typeof toActivity).toBe('function') - }) - - it('exports fromActivity as a function', () => { - expect(typeof fromActivity).toBe('function') - }) -}) - -// --------------------------------------------------------------------------- -// SessionTemplate fixtures -// --------------------------------------------------------------------------- - -const sessionTemplateRowFull: SessionTemplateRow = { - id: 'st-001', - user_id: 'user-001', - name: 'Upper Body Strength', - description: 'Heavy pressing and pulling', - category: 'STRENGTH', - rest_between_groups: JSON.stringify({ seconds: 120 }), - time_cap: JSON.stringify({ seconds: 3600 }), - scoring: 'NONE', - is_public: false, - event_metadata: null, - last_assigned_at: null, - created_at: now, - updated_at: now, -} - -const sessionTemplateRowNulls: SessionTemplateRow = { - id: 'st-002', - user_id: 'user-001', - name: 'Quick WOD', - description: null, - category: 'CONDITIONING', - rest_between_groups: null, - time_cap: null, - scoring: 'FOR_TIME', - is_public: false, - event_metadata: null, - last_assigned_at: null, - created_at: now, - updated_at: now, -} - -// --------------------------------------------------------------------------- -// ActivityGroup fixtures -// --------------------------------------------------------------------------- - -const activityGroupRowFull: ActivityGroupRow = { - id: 'ag-001', - session_template_id: 'st-001', - group_type: 'STRAIGHT_SETS', - ordinal: 1, - rounds: 4, - rest_between_rounds: JSON.stringify({ seconds: 90 }), - rest_between_activities: JSON.stringify({ seconds: 60 }), - created_at: now, - updated_at: now, -} - -const activityGroupRowNulls: ActivityGroupRow = { - id: 'ag-002', - session_template_id: 'st-001', - group_type: 'SUPERSET', - ordinal: 2, - rounds: null, - rest_between_rounds: null, - rest_between_activities: null, - created_at: now, - updated_at: now, -} - -// --------------------------------------------------------------------------- -// Activity fixtures -// --------------------------------------------------------------------------- - -const fixedSetsScheme = { - type: 'fixedSets', - sets: 3, - reps: 5, - load: { type: 'absolute', weight: { value: 225, unit: 'lb' } }, -} - -const cardioIntervalScheme = { - type: 'cardioInterval', - workDuration: { seconds: 30 }, - rest: { seconds: 60 }, - rounds: 10, - modality: 'RUNNING', -} - -const activityRowWithFixedSets: ActivityRow = { - id: 'act-001', - activity_group_id: 'ag-001', - exercise_id: 'ex-squat-001', - ordinal: 1, - set_scheme: JSON.stringify(fixedSetsScheme), - notes: 'Focus on depth', - created_at: now, - updated_at: now, -} - -const activityRowWithCardioInterval: ActivityRow = { - id: 'act-002', - activity_group_id: 'ag-001', - exercise_id: 'ex-run-001', - ordinal: 2, - set_scheme: JSON.stringify(cardioIntervalScheme), - notes: null, - created_at: now, - updated_at: now, -} - -// --------------------------------------------------------------------------- -// toSessionTemplate / fromSessionTemplate -// --------------------------------------------------------------------------- - -describe('toSessionTemplate / fromSessionTemplate', () => { - it('toSessionTemplate: maps fully populated row', () => { - const result = toSessionTemplate(sessionTemplateRowFull) - expect(result.id).toBe('st-001') - expect(result.userId).toBe('user-001') - expect(result.name).toBe('Upper Body Strength') - expect(result.description).toBe('Heavy pressing and pulling') - expect(result.category).toBe('STRENGTH') - expect(result.restBetweenGroups).toEqual({ seconds: 120 }) - expect(result.timeCap).toEqual({ seconds: 3600 }) - expect(result.scoring).toBe('NONE') - expect(result.createdAt).toBe(now) - expect(result.updatedAt).toBe(now) - }) - - it('toSessionTemplate: handles null optional fields', () => { - const result = toSessionTemplate(sessionTemplateRowNulls) - expect(result.description).toBeUndefined() - expect(result.restBetweenGroups).toBeUndefined() - expect(result.timeCap).toBeUndefined() - }) - - it('toSessionTemplate: throws on invalid category', () => { - const bad = { ...sessionTemplateRowFull, category: 'YOGA' } - expect(() => toSessionTemplate(bad)).toThrow(ZodError) - }) - - it('round-trip: fromSessionTemplate -> toSessionTemplate preserves data', () => { - const domain = toSessionTemplate(sessionTemplateRowFull) - const { id: _, createdAt: _c, updatedAt: _u, ...body } = domain - const row = fromSessionTemplate(body) - // Reconstruct a full row for round-trip verification - const fullRow: SessionTemplateRow = { - id: 'st-roundtrip', - created_at: now, - updated_at: now, - ...row, - } as SessionTemplateRow - const roundTripped = toSessionTemplate(fullRow) - expect(roundTripped.userId).toBe(domain.userId) - expect(roundTripped.name).toBe(domain.name) - expect(roundTripped.description).toBe(domain.description) - expect(roundTripped.category).toBe(domain.category) - expect(roundTripped.restBetweenGroups).toEqual(domain.restBetweenGroups) - expect(roundTripped.timeCap).toEqual(domain.timeCap) - expect(roundTripped.scoring).toBe(domain.scoring) - }) -}) - -// --------------------------------------------------------------------------- -// toActivityGroupFlat / fromActivityGroup -// --------------------------------------------------------------------------- - -describe('toActivityGroupFlat / fromActivityGroup', () => { - it('toActivityGroupFlat: maps fully populated row', () => { - const result = toActivityGroupFlat(activityGroupRowFull) - expect(result.id).toBe('ag-001') - expect(result.sessionTemplateId).toBe('st-001') - expect(result.groupType).toBe('STRAIGHT_SETS') - expect(result.ordinal).toBe(1) - expect(result.rounds).toBe(4) - expect(result.restBetweenRounds).toEqual({ seconds: 90 }) - expect(result.restBetweenActivities).toEqual({ seconds: 60 }) - }) - - it('toActivityGroupFlat: handles null optional fields (rounds, rest durations)', () => { - const result = toActivityGroupFlat(activityGroupRowNulls) - expect(result.rounds).toBeUndefined() - expect(result.restBetweenRounds).toBeUndefined() - expect(result.restBetweenActivities).toBeUndefined() - }) - - it('toActivityGroupFlat: throws on invalid group_type', () => { - const bad = { ...activityGroupRowFull, group_type: 'TRISET' } - expect(() => toActivityGroupFlat(bad)).toThrow(ZodError) - }) - - it('round-trip preserves data', () => { - const domain = toActivityGroupFlat(activityGroupRowFull) - const { id: _, ...body } = domain - const row = fromActivityGroup(body) - expect(row.session_template_id).toBe(activityGroupRowFull.session_template_id) - expect(row.group_type).toBe(activityGroupRowFull.group_type) - expect(row.ordinal).toBe(activityGroupRowFull.ordinal) - expect(row.rounds).toBe(activityGroupRowFull.rounds) - // rest durations are stored as JSON strings -- verify round-trip consistency - expect(row.rest_between_rounds).toBe(activityGroupRowFull.rest_between_rounds) - expect(row.rest_between_activities).toBe(activityGroupRowFull.rest_between_activities) - }) -}) - -// --------------------------------------------------------------------------- -// toActivity / fromActivity -// --------------------------------------------------------------------------- - -describe('toActivity / fromActivity', () => { - it('toActivity: maps fixedSets set_scheme', () => { - const result = toActivity(activityRowWithFixedSets) - expect(result.id).toBe('act-001') - expect(result.exerciseId).toBe('ex-squat-001') - expect(result.ordinal).toBe(1) - expect(result.setScheme.type).toBe('fixedSets') - if (result.setScheme.type === 'fixedSets') { - expect(result.setScheme.sets).toBe(3) - expect(result.setScheme.reps).toBe(5) - } - expect(result.notes).toBe('Focus on depth') - }) - - it('toActivity: maps cardioInterval set_scheme', () => { - const result = toActivity(activityRowWithCardioInterval) - expect(result.setScheme.type).toBe('cardioInterval') - if (result.setScheme.type === 'cardioInterval') { - expect(result.setScheme.workDuration).toEqual({ seconds: 30 }) - expect(result.setScheme.rest).toEqual({ seconds: 60 }) - expect(result.setScheme.rounds).toBe(10) - expect(result.setScheme.modality).toBe('RUNNING') - } - }) - - it('toActivity: preserves activityGroupId', () => { - const result = toActivity(activityRowWithFixedSets) - expect(result.activityGroupId).toBe('ag-001') - }) - - it('toActivity: throws on invalid set_scheme JSON', () => { - const bad = { ...activityRowWithFixedSets, set_scheme: '{"type":"unknownType"}' } - expect(() => toActivity(bad)).toThrow(ZodError) - }) - - it('toActivity: handles null notes', () => { - const result = toActivity(activityRowWithCardioInterval) - expect(result.notes).toBeUndefined() - }) - - it('fromActivity: includes activity_group_id in output', () => { - const domain = toActivity(activityRowWithFixedSets) - const { id: _, ...body } = domain - const row = fromActivity(body) - expect(row.activity_group_id).toBe('ag-001') - }) - - it('round-trip: activityGroupId preserved', () => { - const domain = toActivity(activityRowWithFixedSets) - const { id: _, ...body } = domain - const row = fromActivity(body) - // Reconstruct full row for round-trip - const fullRow: ActivityRow = { - id: 'act-roundtrip', - created_at: now, - updated_at: now, - ...row, - } as ActivityRow - const roundTripped = toActivity(fullRow) - expect(roundTripped.activityGroupId).toBe(domain.activityGroupId) - expect(roundTripped.exerciseId).toBe(domain.exerciseId) - expect(roundTripped.ordinal).toBe(domain.ordinal) - expect(roundTripped.setScheme).toEqual(domain.setScheme) - expect(roundTripped.notes).toBe(domain.notes) - }) -}) - -// =========================================================================== -// Program domain mapper fixtures and tests -// =========================================================================== - -const userId = 'user-001' - -// --------------------------------------------------------------------------- -// Program fixtures -// --------------------------------------------------------------------------- - -const programRow: ProgramRow = { - id: 'prog-op3w-001', - user_id: userId, - name: 'TB Operator 3-Week', - description: 'Tactical Barbell Operator template', - source: 'TEMPLATE', - duration_weeks: 3, - is_public: false, - created_by: userId, - created_at: now, - updated_at: now, -} - -const blockRow: BlockRow = { - id: 'blk-acc-001', - program_id: 'prog-op3w-001', - name: 'Accumulation', - ordinal: 1, - duration_weeks: 4, - block_type: 'ACCUMULATION', - created_at: now, - updated_at: now, -} - -const blockWeekRow: BlockWeekRow = { - id: 'bw-w1-001', - block_id: 'blk-acc-001', - week_number: 1, - created_at: now, - updated_at: now, -} - -const scheduledSessionRow: ScheduledSessionRow = { - id: 'ss-mon-001', - block_week_id: 'bw-w1-001', - day_of_week: 1, - day_label: 'Monday Upper', - session_type: 'STRENGTH', - session_template_id: 'st-upper-001', - notes: 'Heavy day', - overrides: null, - created_at: now, - updated_at: now, -} - -const programActivationRow: ProgramActivationRow = { - id: 'pa-001', - user_id: userId, - program_id: 'prog-op3w-001', - current_block_ordinal: 1, - current_week_number: 2, - start_date: '2025-06-15', - created_at: now, - updated_at: now, -} - -// --------------------------------------------------------------------------- -// toProgram / fromProgram -// --------------------------------------------------------------------------- - -describe('toProgram / fromProgram', () => { - it('maps a fully populated program row to domain type', () => { - const result = toProgram(programRow) - expect(result.id).toBe('prog-op3w-001') - expect(result.userId).toBe(userId) - expect(result.name).toBe('TB Operator 3-Week') - expect(result.description).toBe('Tactical Barbell Operator template') - expect(result.source).toBe('TEMPLATE') - expect(result.durationWeeks).toBe(3) - expect(result.isPublic).toBe(false) - expect(result.createdBy).toBe(userId) - expect(result.createdAt).toBe(now) - expect(result.updatedAt).toBe(now) - }) - - it('maps nullable description to undefined', () => { - const row = { ...programRow, description: null } - const result = toProgram(row) - expect(result.description).toBeUndefined() - }) - - it('maps nullable duration_weeks to undefined', () => { - const row = { ...programRow, duration_weeks: null } - const result = toProgram(row) - expect(result.durationWeeks).toBeUndefined() - }) - - it('maps nullable created_by to user_id fallback', () => { - const row = { ...programRow, created_by: null } - const result = toProgram(row) - expect(result.createdBy).toBe(userId) - }) - - it('throws Error on invalid source enum', () => { - const bad = { ...programRow, source: 'PIRATED' } - expect(() => toProgram(bad)).toThrow(Error) - try { - toProgram(bad) - } catch (err) { - expect((err as Error).message).toContain('Failed to map program') - } - }) - - it('fromProgram maps all fields to snake_case', () => { - const domain = toProgram(programRow) - const { id: _, createdAt: _c, updatedAt: _u, ...body } = domain - const row = fromProgram(body) - expect(row.user_id).toBe(userId) - expect(row.name).toBe('TB Operator 3-Week') - expect(row.description).toBe('Tactical Barbell Operator template') - expect(row.source).toBe('TEMPLATE') - expect(row.duration_weeks).toBe(3) - expect(row.is_public).toBe(false) - expect(row.created_by).toBe(userId) - }) - - it('fromProgram maps optional fields to null', () => { - const row = fromProgram({ - userId, - name: 'Minimal', - source: 'CUSTOM', - isPublic: false, - createdBy: userId, - }) - expect(row.description).toBeNull() - expect(row.duration_weeks).toBeNull() - }) -}) - -// --------------------------------------------------------------------------- -// toBlock / fromBlock -// --------------------------------------------------------------------------- - -describe('toBlock / fromBlock', () => { - it('maps a fully populated block row to domain type', () => { - const result = toBlock(blockRow) - expect(result.id).toBe('blk-acc-001') - expect(result.programId).toBe('prog-op3w-001') - expect(result.name).toBe('Accumulation') - expect(result.ordinal).toBe(1) - expect(result.durationWeeks).toBe(4) - expect(result.blockType).toBe('ACCUMULATION') - }) - - it('throws Error on invalid block_type enum', () => { - const bad = { ...blockRow, block_type: 'PEAKING' } - expect(() => toBlock(bad)).toThrow(Error) - try { - toBlock(bad) - } catch (err) { - expect((err as Error).message).toContain('Failed to map block') - } - }) - - it('fromBlock maps correctly with explicit programId argument', () => { - const domain = toBlock(blockRow) - const { id: _, ...body } = domain - const row = fromBlock(body, 'prog-override') - expect(row.program_id).toBe('prog-override') - expect(row.name).toBe('Accumulation') - expect(row.ordinal).toBe(1) - expect(row.duration_weeks).toBe(4) - expect(row.block_type).toBe('ACCUMULATION') - }) - - it('fromBlock maps correctly using row programId if no arg provided', () => { - const domain = toBlock(blockRow) - const { id: _, ...body } = domain - const row = fromBlock(body) - expect(row.program_id).toBe('prog-op3w-001') - }) -}) - -// --------------------------------------------------------------------------- -// toBlockWeek / fromBlockWeek -// --------------------------------------------------------------------------- - -describe('toBlockWeek / fromBlockWeek', () => { - it('maps a fully populated block week row to domain type', () => { - const result = toBlockWeek(blockWeekRow) - expect(result.id).toBe('bw-w1-001') - expect(result.blockId).toBe('blk-acc-001') - expect(result.weekNumber).toBe(1) - }) - - it('fromBlockWeek maps correctly', () => { - const domain = toBlockWeek(blockWeekRow) - const { id: _, ...body } = domain - const row = fromBlockWeek(body) - expect(row.block_id).toBe('blk-acc-001') - expect(row.week_number).toBe(1) - }) -}) - -// --------------------------------------------------------------------------- -// toScheduledSession / fromScheduledSession -// --------------------------------------------------------------------------- - -describe('toScheduledSession / fromScheduledSession', () => { - it('maps a fully populated scheduled session row to domain type', () => { - const result = toScheduledSession(scheduledSessionRow) - expect(result.id).toBe('ss-mon-001') - expect(result.blockWeekId).toBe('bw-w1-001') - expect(result.dayOfWeek).toBe(1) - expect(result.dayLabel).toBe('Monday Upper') - expect(result.sessionType).toBe('STRENGTH') - expect(result.sessionTemplateId).toBe('st-upper-001') - expect(result.notes).toBe('Heavy day') - }) - - it('maps nullable day_of_week to undefined', () => { - const row = { ...scheduledSessionRow, day_of_week: null } - const result = toScheduledSession(row) - expect(result.dayOfWeek).toBeUndefined() - }) - - it('maps nullable notes to undefined', () => { - const row = { ...scheduledSessionRow, notes: null } - const result = toScheduledSession(row) - expect(result.notes).toBeUndefined() - }) - - it('throws Error on invalid session_type enum', () => { - const bad = { ...scheduledSessionRow, session_type: 'YOGA' } - expect(() => toScheduledSession(bad)).toThrow(Error) - try { - toScheduledSession(bad) - } catch (err) { - expect((err as Error).message).toContain('Failed to map scheduled session') - } - }) - - it('fromScheduledSession maps correctly with optional fields to null', () => { - const domain = toScheduledSession({ ...scheduledSessionRow, day_of_week: null, notes: null }) - const { id: _, ...body } = domain - const row = fromScheduledSession(body) - expect(row.block_week_id).toBe('bw-w1-001') - expect(row.day_of_week).toBeNull() - expect(row.day_label).toBe('Monday Upper') - expect(row.session_type).toBe('STRENGTH') - expect(row.session_template_id).toBe('st-upper-001') - expect(row.notes).toBeNull() - }) - - it('fromScheduledSession maps populated optional fields correctly', () => { - const domain = toScheduledSession(scheduledSessionRow) - const { id: _, ...body } = domain - const row = fromScheduledSession(body) - expect(row.day_of_week).toBe(1) - expect(row.notes).toBe('Heavy day') - }) - - // ------------------------------------------------------------------------- - // overrides field coverage - // ------------------------------------------------------------------------- - - it('parses overrides from a valid JSON string (Tauri path)', () => { - const row = { - ...scheduledSessionRow, - overrides: JSON.stringify({ - activityOverrides: { 'act-1': { exerciseId: 'ex-bench' } }, - }), - } - const result = toScheduledSession(row) - expect(result.overrides).toBeDefined() - expect(result.overrides?.activityOverrides?.['act-1']?.exerciseId).toBe('ex-bench') - }) - - it('parses overrides from a pre-parsed object (Supabase path)', () => { - const row = { - ...scheduledSessionRow, - overrides: { - activityOverrides: { 'act-1': { exerciseId: 'ex-bench' } }, - }, - } - const result = toScheduledSession(row) - expect(result.overrides).toBeDefined() - expect(result.overrides?.activityOverrides?.['act-1']?.exerciseId).toBe('ex-bench') - }) - - it('falls back to undefined and warns on malformed JSON string', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const row = { ...scheduledSessionRow, overrides: '{not valid json' } - const result = toScheduledSession(row) - expect(result.overrides).toBeUndefined() - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('[data-mapper] Failed to parse overrides'), - expect.anything(), - ) - warnSpy.mockRestore() - }) - - it('falls back to undefined and warns on schema-invalid JSON', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - // Valid JSON, but activityOverrides values must be objects, not strings - const row = { - ...scheduledSessionRow, - overrides: '{"activityOverrides":{"act-1":"not-an-object"}}', - } - const result = toScheduledSession(row) - expect(result.overrides).toBeUndefined() - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('[data-mapper] Failed to parse overrides'), - expect.anything(), - ) - warnSpy.mockRestore() - }) - - it('overrides survive toScheduledSession -> fromScheduledSession round-trip', () => { - const overridesObj = { - activityOverrides: { 'act-1': { exerciseId: 'ex-bench' } }, - } - const row = { ...scheduledSessionRow, overrides: JSON.stringify(overridesObj) } - const domain = toScheduledSession(row) - expect(domain.overrides).toBeDefined() - - const { id: _, ...body } = domain - const backToRow = fromScheduledSession(body) - expect(backToRow.overrides).toEqual(overridesObj) - }) -}) - -// --------------------------------------------------------------------------- -// toProgramActivation / fromProgramActivation -// --------------------------------------------------------------------------- - -describe('toProgramActivation / fromProgramActivation', () => { - it('maps a fully populated program activation row to domain type', () => { - const result = toProgramActivation(programActivationRow) - expect(result.id).toBe('pa-001') - expect(result.userId).toBe(userId) - expect(result.programId).toBe('prog-op3w-001') - expect(result.currentBlockOrdinal).toBe(1) - expect(result.currentWeekNumber).toBe(2) - expect(result.startDate).toBe('2025-06-15') - expect(result.createdAt).toBe(now) - expect(result.updatedAt).toBe(now) - }) - - it('fromProgramActivation maps all fields to snake_case', () => { - const domain = toProgramActivation(programActivationRow) - const { id: _, createdAt: _c, updatedAt: _u, ...body } = domain - const row = fromProgramActivation(body) - expect(row.user_id).toBe(userId) - expect(row.program_id).toBe('prog-op3w-001') - expect(row.current_block_ordinal).toBe(1) - expect(row.current_week_number).toBe(2) - expect(row.start_date).toBe('2025-06-15') - }) -}) - -// =========================================================================== -// WeekStatus (Program Time Travel) -// =========================================================================== - -const weekStatusRow: ProgramWeekStatusRow = { - id: 'ws-001', - activation_id: 'pa-001', - block_ordinal: 2, - week_number: 3, - status: 'done', - created_at: now, -} - -describe('toWeekStatus / fromWeekStatus', () => { - it('maps all fields from snake_case to camelCase', () => { - const result = toWeekStatus(weekStatusRow) - expect(result.id).toBe('ws-001') - expect(result.activationId).toBe('pa-001') - expect(result.blockOrdinal).toBe(2) - expect(result.weekNumber).toBe(3) - expect(result.status).toBe('done') - expect(result.createdAt).toBe(now) - }) - - it('maps skipped status correctly', () => { - const skippedRow = { ...weekStatusRow, status: 'skipped' } - const result = toWeekStatus(skippedRow) - expect(result.status).toBe('skipped') - }) - - it('throws on invalid status enum value', () => { - const bad = { ...weekStatusRow, status: 'completed' } - expect(() => toWeekStatus(bad as ProgramWeekStatusRow)).toThrow() - }) - - it('fromWeekStatus maps camelCase to snake_case', () => { - const domain = toWeekStatus(weekStatusRow) - const { id: _, createdAt: _c, ...body } = domain - const row = fromWeekStatus(body) - expect(row.activation_id).toBe('pa-001') - expect(row.block_ordinal).toBe(2) - expect(row.week_number).toBe(3) - expect(row.status).toBe('done') - }) - - it('fromWeekStatus omits id and createdAt', () => { - const domain = toWeekStatus(weekStatusRow) - const { id: _, createdAt: _c, ...body } = domain - const row = fromWeekStatus(body) - expect(row).not.toHaveProperty('id') - expect(row).not.toHaveProperty('created_at') - }) - - it('round-trip: fromWeekStatus -> reconstruct row -> toWeekStatus preserves data', () => { - const domain = toWeekStatus(weekStatusRow) - const { id: _, createdAt: _c, ...body } = domain - const partialRow = fromWeekStatus(body) - - // Reconstruct a full row for round-trip verification - const fullRow: ProgramWeekStatusRow = { - id: 'ws-roundtrip', - created_at: now, - ...partialRow, - } as ProgramWeekStatusRow - - const roundTripped = toWeekStatus(fullRow) - expect(roundTripped.activationId).toBe(domain.activationId) - expect(roundTripped.blockOrdinal).toBe(domain.blockOrdinal) - expect(roundTripped.weekNumber).toBe(domain.weekNumber) - expect(roundTripped.status).toBe(domain.status) - }) -}) - -// =========================================================================== -// EventItem -// =========================================================================== - -const eventItemRow: EventItemRow = { - id: 'ei-001', - session_template_id: 'st-001', - workout_log_id: null, - user_id: userId, - name: 'Ruck plate', - category: 'Gear', - quantity: 2, - is_packed: true, - sort_order: 3, - notes: 'The heavy one', - created_at: now, - updated_at: later, -} - -describe('EventItem mappers', () => { - it('toEventItem maps all fields from snake_case to camelCase', () => { - const result = toEventItem(eventItemRow) - expect(result.id).toBe('ei-001') - expect(result.sessionTemplateId).toBe('st-001') - expect(result.workoutLogId).toBeUndefined() - expect(result.userId).toBe(userId) - expect(result.name).toBe('Ruck plate') - expect(result.category).toBe('Gear') - expect(result.quantity).toBe(2) - expect(result.isPacked).toBe(true) - expect(result.sortOrder).toBe(3) - expect(result.notes).toBe('The heavy one') - expect(result.createdAt).toBe(now) - expect(result.updatedAt).toBe(later) - }) - - it('toEventItem converts null optional fields to undefined', () => { - const row: EventItemRow = { - ...eventItemRow, - session_template_id: null, - workout_log_id: 'wl-001', - category: null, - notes: null, - } - const result = toEventItem(row) - expect(result.sessionTemplateId).toBeUndefined() - expect(result.workoutLogId).toBe('wl-001') - expect(result.category).toBeUndefined() - expect(result.notes).toBeUndefined() - }) - - it('fromEventItem maps camelCase to snake_case for template parent', () => { - const domain = toEventItem(eventItemRow) - const { id: _, createdAt: _c, updatedAt: _u, ...body } = domain - const row = fromEventItem(body, 'st-001', 'template') - expect(row.session_template_id).toBe('st-001') - expect(row.workout_log_id).toBeNull() - expect(row.user_id).toBe(userId) - expect(row.name).toBe('Ruck plate') - expect(row.category).toBe('Gear') - expect(row.quantity).toBe(2) - expect(row.is_packed).toBe(true) - expect(row.sort_order).toBe(3) - expect(row.notes).toBe('The heavy one') - }) - - it('fromEventItem maps camelCase to snake_case for log parent', () => { - const domain = toEventItem(eventItemRow) - const { id: _, createdAt: _c, updatedAt: _u, ...body } = domain - const row = fromEventItem(body, 'wl-001', 'log') - expect(row.session_template_id).toBeNull() - expect(row.workout_log_id).toBe('wl-001') - }) - - it('fromEventItem converts undefined optional fields to null', () => { - const row = fromEventItem( - { - sessionTemplateId: undefined, - workoutLogId: undefined, - userId, - name: 'Water bottle', - category: undefined, - quantity: 1, - isPacked: false, - sortOrder: 0, - notes: undefined, - }, - 'st-001', - 'template', - ) - expect(row.category).toBeNull() - expect(row.notes).toBeNull() - }) -}) - -// =========================================================================== -// Chat Mappers -// =========================================================================== - -// --------------------------------------------------------------------------- -// Chat fixtures -// --------------------------------------------------------------------------- - -const conversationRowDirect: ConversationRow = { - id: 'conv-001', - type: 'direct', - title: null, - group_id: null, - created_at: now, - updated_at: now, -} - -const conversationRowGroup: ConversationRow = { - id: 'conv-002', - type: 'group', - title: 'Team Chat', - group_id: 'group-001', - created_at: now, - updated_at: later, -} - -const participantRowFull: ConversationParticipantRow = { - id: 'cp-001', - conversation_id: 'conv-001', - user_id: 'user-001', - last_read_at: later, - is_archived: false, - joined_at: now, - left_at: null, -} - -const participantRowNulls: ConversationParticipantRow = { - id: 'cp-002', - conversation_id: 'conv-001', - user_id: 'user-002', - last_read_at: null, - is_archived: true, - joined_at: now, - left_at: later, -} - -const messageRowFull: MessageRow = { - id: 'msg-001', - conversation_id: 'conv-001', - sender_id: 'user-001', - message_type: 'text', - content: 'Hey, great workout today!', - created_at: now, - updated_at: now, - sync_status: 'synced', -} - -const messageRowNulls: MessageRow = { - id: 'msg-002', - conversation_id: 'conv-001', - sender_id: null, - message_type: 'system', - content: null, - created_at: now, - updated_at: now, -} - -const mediaAttachmentRowFull: MediaAttachmentRow = { - id: 'media-001', - message_id: 'msg-001', - provider: 'cloudflare_stream', - provider_asset_id: 'cf-asset-abc123', - media_type: 'video', - original_filename: 'squat-form-check.mp4', - mime_type: 'video/mp4', - thumbnail_url: 'https://cdn.example.com/thumb/abc123.jpg', - playback_url: 'https://stream.example.com/abc123/manifest.m3u8', - duration_seconds: 45, - file_size_bytes: 15728640, - status: 'ready', - created_at: now, - updated_at: now, -} - -const mediaAttachmentRowNulls: MediaAttachmentRow = { - id: 'media-002', - message_id: 'msg-001', - provider: 'supabase_storage', - provider_asset_id: null, - media_type: 'image', - original_filename: null, - mime_type: null, - thumbnail_url: null, - playback_url: null, - duration_seconds: null, - file_size_bytes: null, - status: 'processing', - created_at: now, - updated_at: now, -} - -// --------------------------------------------------------------------------- -// toConversation / fromConversation -// --------------------------------------------------------------------------- - -describe('Chat Mappers', () => { - describe('Conversation', () => { - it('maps a direct conversation row to domain type', () => { - const result = toConversation(conversationRowDirect) - expect(result.id).toBe('conv-001') - expect(result.type).toBe('direct') - expect(result.title).toBeUndefined() - expect(result.groupId).toBeUndefined() - expect(result.createdAt).toBe(now) - expect(result.updatedAt).toBe(now) - }) - - it('maps a group conversation row with all optional fields', () => { - const result = toConversation(conversationRowGroup) - expect(result.id).toBe('conv-002') - expect(result.type).toBe('group') - expect(result.title).toBe('Team Chat') - expect(result.groupId).toBe('group-001') - expect(result.updatedAt).toBe(later) - }) - - it('throws on invalid conversation type enum', () => { - const bad = { ...conversationRowDirect, type: 'channel' } - expect(() => toConversation(bad)).toThrow(Error) - }) - - it('throws on empty string conversation type', () => { - const bad = { ...conversationRowDirect, type: '' } - expect(() => toConversation(bad)).toThrow(Error) - }) - - it('fromConversation maps domain to DB row shape', () => { - const domain = toConversation(conversationRowGroup) - const { id: _, createdAt: _c, updatedAt: _u, ...body } = domain - const row = fromConversation(body) - expect(row.type).toBe('group') - expect(row.title).toBe('Team Chat') - expect(row.group_id).toBe('group-001') - }) - - it('fromConversation maps undefined optional fields to null', () => { - const domain = toConversation(conversationRowDirect) - const { id: _, createdAt: _c, updatedAt: _u, ...body } = domain - const row = fromConversation(body) - expect(row.title).toBeNull() - expect(row.group_id).toBeNull() - }) - - it('fromConversation omits id, created_at, updated_at', () => { - const domain = toConversation(conversationRowGroup) - const { id: _, createdAt: _c, updatedAt: _u, ...body } = domain - const row = fromConversation(body) - expect(row).not.toHaveProperty('id') - expect(row).not.toHaveProperty('created_at') - expect(row).not.toHaveProperty('updated_at') - }) - - it('round-trip: preserves key fields', () => { - const domain = toConversation(conversationRowGroup) - const { id: _, createdAt: _c, updatedAt: _u, ...body } = domain - const roundTripped = fromConversation(body) - expect(roundTripped.type).toBe(conversationRowGroup.type) - expect(roundTripped.title).toBe(conversationRowGroup.title) - expect(roundTripped.group_id).toBe(conversationRowGroup.group_id) - }) - }) - - // ------------------------------------------------------------------------- - // ConversationParticipant - // ------------------------------------------------------------------------- - - describe('ConversationParticipant', () => { - it('maps a fully populated participant row to domain type', () => { - const result = toConversationParticipant(participantRowFull) - expect(result.id).toBe('cp-001') - expect(result.conversationId).toBe('conv-001') - expect(result.userId).toBe('user-001') - expect(result.lastReadAt).toBe(later) - expect(result.isArchived).toBe(false) - expect(result.joinedAt).toBe(now) - expect(result.leftAt).toBeUndefined() - }) - - it('maps null optional fields to undefined, non-null to values', () => { - const result = toConversationParticipant(participantRowNulls) - expect(result.lastReadAt).toBeUndefined() - expect(result.isArchived).toBe(true) - expect(result.leftAt).toBe(later) - }) - - it('uses joined_at for createdAt', () => { - const result = toConversationParticipant(participantRowFull) - expect(result.createdAt).toBe(participantRowFull.joined_at) - }) - - it('fromConversationParticipant maps domain to DB row shape', () => { - const domain = toConversationParticipant(participantRowFull) - const { id: _, createdAt: _c, updatedAt: _u, ...body } = domain - const row = fromConversationParticipant(body) - expect(row.conversation_id).toBe('conv-001') - expect(row.user_id).toBe('user-001') - expect(row.last_read_at).toBe(later) - expect(row.is_archived).toBe(false) - expect(row.joined_at).toBe(now) - expect(row.left_at).toBeNull() - }) - - it('fromConversationParticipant maps undefined optional fields to null', () => { - const domain = toConversationParticipant(participantRowFull) - const { id: _, createdAt: _c, updatedAt: _u, ...body } = domain - const row = fromConversationParticipant(body) - expect(row.left_at).toBeNull() - }) - - it('fromConversationParticipant omits id', () => { - const domain = toConversationParticipant(participantRowFull) - const { id: _, createdAt: _c, updatedAt: _u, ...body } = domain - const row = fromConversationParticipant(body) - expect(row).not.toHaveProperty('id') - }) - - it('round-trip: preserves key fields', () => { - const domain = toConversationParticipant(participantRowFull) - const { id: _, createdAt: _c, updatedAt: _u, ...body } = domain - const roundTripped = fromConversationParticipant(body) - expect(roundTripped.conversation_id).toBe(participantRowFull.conversation_id) - expect(roundTripped.user_id).toBe(participantRowFull.user_id) - expect(roundTripped.is_archived).toBe(participantRowFull.is_archived) - expect(roundTripped.joined_at).toBe(participantRowFull.joined_at) - }) - }) - - // ------------------------------------------------------------------------- - // Message - // ------------------------------------------------------------------------- - - describe('Message', () => { - it('maps a fully populated message row to domain type', () => { - const result = toMessage(messageRowFull) - expect(result.id).toBe('msg-001') - expect(result.conversationId).toBe('conv-001') - expect(result.senderId).toBe('user-001') - expect(result.messageType).toBe('text') - expect(result.content).toBe('Hey, great workout today!') - expect(result.createdAt).toBe(now) - expect(result.syncStatus).toBe('synced') - }) - - it('maps null optional fields to undefined', () => { - const result = toMessage(messageRowNulls) - expect(result.senderId).toBeUndefined() - expect(result.content).toBeUndefined() - expect(result.syncStatus).toBeUndefined() - }) - - it('throws on invalid message_type enum', () => { - const bad = { ...messageRowFull, message_type: 'emoji' } - expect(() => toMessage(bad)).toThrow(Error) - }) - - it('throws on invalid sync_status enum', () => { - const bad = { ...messageRowFull, sync_status: 'queued' } - expect(() => toMessage(bad)).toThrow(Error) - }) - - it('accepts all valid message types', () => { - const validTypes = ['text', 'workout', 'media', 'file', 'system'] - for (const msgType of validTypes) { - const row = - msgType === 'system' - ? { ...messageRowFull, message_type: msgType, sender_id: null } - : { ...messageRowFull, message_type: msgType } - const result = toMessage(row) - expect(result.messageType).toBe(msgType) - } - }) - - it('fromMessage maps domain to DB row shape', () => { - const domain = toMessage(messageRowFull) - const { id: _, createdAt: _c, ...body } = domain - const row = fromMessage(body) - expect(row.conversation_id).toBe('conv-001') - expect(row.sender_id).toBe('user-001') - expect(row.message_type).toBe('text') - expect(row.content).toBe('Hey, great workout today!') - expect(row.sync_status).toBe('synced') - }) - - it('fromMessage maps undefined optional fields to null', () => { - const domain = toMessage(messageRowNulls) - const { id: _, createdAt: _c, ...body } = domain - const row = fromMessage(body) - expect(row.sender_id).toBeNull() - expect(row.content).toBeNull() - }) - - it('fromMessage omits id, created_at', () => { - const domain = toMessage(messageRowFull) - const { id: _, createdAt: _c, ...body } = domain - const row = fromMessage(body) - expect(row).not.toHaveProperty('id') - expect(row).not.toHaveProperty('created_at') - }) - - it('round-trip: preserves key fields', () => { - const domain = toMessage(messageRowFull) - const { id: _, createdAt: _c, ...body } = domain - const roundTripped = fromMessage(body) - expect(roundTripped.conversation_id).toBe(messageRowFull.conversation_id) - expect(roundTripped.sender_id).toBe(messageRowFull.sender_id) - expect(roundTripped.message_type).toBe(messageRowFull.message_type) - expect(roundTripped.content).toBe(messageRowFull.content) - }) - }) - - // ------------------------------------------------------------------------- - // MediaAttachment - // ------------------------------------------------------------------------- - - describe('MediaAttachment', () => { - it('maps a fully populated media attachment row to domain type', () => { - const result = toMediaAttachment(mediaAttachmentRowFull) - expect(result.id).toBe('media-001') - expect(result.messageId).toBe('msg-001') - expect(result.provider).toBe('cloudflare_stream') - expect(result.providerAssetId).toBe('cf-asset-abc123') - expect(result.mediaType).toBe('video') - expect(result.originalFilename).toBe('squat-form-check.mp4') - expect(result.mimeType).toBe('video/mp4') - expect(result.thumbnailUrl).toBe('https://cdn.example.com/thumb/abc123.jpg') - expect(result.playbackUrl).toBe('https://stream.example.com/abc123/manifest.m3u8') - expect(result.durationSeconds).toBe(45) - expect(result.fileSizeBytes).toBe(15728640) - expect(result.status).toBe('ready') - expect(result.createdAt).toBe(now) - expect(result.updatedAt).toBe(now) - }) - - it('maps null optional fields to undefined', () => { - const result = toMediaAttachment(mediaAttachmentRowNulls) - expect(result.providerAssetId).toBeUndefined() - expect(result.originalFilename).toBeUndefined() - expect(result.mimeType).toBeUndefined() - expect(result.thumbnailUrl).toBeUndefined() - expect(result.playbackUrl).toBeUndefined() - expect(result.durationSeconds).toBeUndefined() - expect(result.fileSizeBytes).toBeUndefined() - }) - - it('throws on invalid provider enum', () => { - const bad = { ...mediaAttachmentRowFull, provider: 'aws_s3' } - expect(() => toMediaAttachment(bad)).toThrow(Error) - }) - - it('throws on invalid media_type enum', () => { - const bad = { ...mediaAttachmentRowFull, media_type: 'audio' } - expect(() => toMediaAttachment(bad)).toThrow(Error) - }) - - it('throws on invalid status enum', () => { - const bad = { ...mediaAttachmentRowFull, status: 'uploading' } - expect(() => toMediaAttachment(bad)).toThrow(Error) - }) - - it('accepts all valid provider values', () => { - for (const provider of ['cloudflare_stream', 'supabase_storage']) { - const result = toMediaAttachment({ ...mediaAttachmentRowFull, provider }) - expect(result.provider).toBe(provider) - } - }) - - it('accepts all valid media_type values', () => { - for (const mediaType of ['video', 'image', 'file']) { - const result = toMediaAttachment({ ...mediaAttachmentRowFull, media_type: mediaType }) - expect(result.mediaType).toBe(mediaType) - } - }) - - it('accepts all valid status values', () => { - for (const status of ['processing', 'ready', 'failed']) { - const result = toMediaAttachment({ ...mediaAttachmentRowFull, status }) - expect(result.status).toBe(status) - } - }) - - it('fromMediaAttachment maps domain to DB row shape', () => { - const domain = toMediaAttachment(mediaAttachmentRowFull) - const { id: _, createdAt: _c, updatedAt: _u, ...body } = domain - const row = fromMediaAttachment(body) - expect(row.message_id).toBe('msg-001') - expect(row.provider).toBe('cloudflare_stream') - expect(row.provider_asset_id).toBe('cf-asset-abc123') - expect(row.media_type).toBe('video') - expect(row.original_filename).toBe('squat-form-check.mp4') - expect(row.mime_type).toBe('video/mp4') - expect(row.thumbnail_url).toBe('https://cdn.example.com/thumb/abc123.jpg') - expect(row.playback_url).toBe('https://stream.example.com/abc123/manifest.m3u8') - expect(row.duration_seconds).toBe(45) - expect(row.file_size_bytes).toBe(15728640) - expect(row.status).toBe('ready') - }) - - it('fromMediaAttachment maps undefined optional fields to null', () => { - const domain = toMediaAttachment(mediaAttachmentRowNulls) - const { id: _, createdAt: _c, updatedAt: _u, ...body } = domain - const row = fromMediaAttachment(body) - expect(row.provider_asset_id).toBeNull() - expect(row.original_filename).toBeNull() - expect(row.mime_type).toBeNull() - expect(row.thumbnail_url).toBeNull() - expect(row.playback_url).toBeNull() - expect(row.duration_seconds).toBeNull() - expect(row.file_size_bytes).toBeNull() - }) - - it('fromMediaAttachment omits id, created_at, updated_at', () => { - const domain = toMediaAttachment(mediaAttachmentRowFull) - const { id: _, createdAt: _c, updatedAt: _u, ...body } = domain - const row = fromMediaAttachment(body) - expect(row).not.toHaveProperty('id') - expect(row).not.toHaveProperty('created_at') - expect(row).not.toHaveProperty('updated_at') - }) - - it('round-trip: preserves key fields', () => { - const domain = toMediaAttachment(mediaAttachmentRowFull) - const { id: _, createdAt: _c, updatedAt: _u, ...body } = domain - const roundTripped = fromMediaAttachment(body) - expect(roundTripped.message_id).toBe(mediaAttachmentRowFull.message_id) - expect(roundTripped.provider).toBe(mediaAttachmentRowFull.provider) - expect(roundTripped.media_type).toBe(mediaAttachmentRowFull.media_type) - expect(roundTripped.status).toBe(mediaAttachmentRowFull.status) - expect(roundTripped.provider_asset_id).toBe(mediaAttachmentRowFull.provider_asset_id) - expect(roundTripped.original_filename).toBe(mediaAttachmentRowFull.original_filename) - expect(roundTripped.duration_seconds).toBe(mediaAttachmentRowFull.duration_seconds) - expect(roundTripped.file_size_bytes).toBe(mediaAttachmentRowFull.file_size_bytes) - }) - }) -}) diff --git a/src/lib/adapter-utils.ts b/src/lib/adapter-utils.ts new file mode 100644 index 00000000..8ffb8d14 --- /dev/null +++ b/src/lib/adapter-utils.ts @@ -0,0 +1,39 @@ +/** + * Shared utilities for database adapter layers (Supabase PostgREST and Tauri SQLite). + * + * - `camelizeKeys` normalizes snake_case column names returned by both adapters into + * camelCase before mapping to domain types. Only the top-level keys are transformed; + * JSONB values are left as-is because they are stored and retrieved in camelCase form + * (serialized from domain objects via JSON.stringify). + * + * - `parseJsonOrValue` handles the divergence between Supabase PostgREST (JSONB comes + * back as a parsed object) and Tauri SQLite (JSONB comes back as a raw JSON string). + */ + +/** + * Converts a single snake_case string to camelCase. + * Only transforms underscore-delimited segments; already-camelCase strings are unchanged. + */ +function snakeToCamel(s: string): string { + return s.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()) +} + +/** + * Shallow-converts all snake_case keys in a database row object to camelCase. + * Values are copied by reference; no deep transformation is performed. + * + * @example + * camelizeKeys({ created_at: '2024-01-01', user_id: 'abc' }) + * // => { createdAt: '2024-01-01', userId: 'abc' } + */ +export function camelizeKeys(obj: Record): Record { + return Object.fromEntries(Object.entries(obj).map(([key, value]) => [snakeToCamel(key), value])) +} + +/** + * Safely parses a value that may already be a parsed object (PostgREST JSONB) or a + * raw JSON string (Tauri SQLite JSONB). Returns the parsed value in both cases. + */ +export function parseJsonOrValue(value: string | object): unknown { + return typeof value === 'string' ? JSON.parse(value) : value +} diff --git a/src/lib/data-mapper.ts b/src/lib/data-mapper.ts deleted file mode 100644 index 46aca53d..00000000 --- a/src/lib/data-mapper.ts +++ /dev/null @@ -1,1097 +0,0 @@ -import { z } from 'zod' - -/** - * Safely parse a value that may be a JSON string or an already-parsed object. - * Supabase PostgREST returns JSONB columns as objects, but the Tauri SQLite - * adapter returns them as strings. This handles both cases. - */ -function parseJsonOrValue(value: string | object): unknown { - return typeof value === 'string' ? JSON.parse(value) : value -} - -import type { - Exercise, - WorkoutLog, - LoggedActivityGroup, - LoggedActivity, - LoggedSet, - UserProfile, - OneRepMaxHistory, - SessionTemplate, - ActivityGroup, - Activity, - Program, - Block, - BlockWeek, - ScheduledSession, - ProgramActivation, - WeekStatus, - ShareLink, - EventItem, - Conversation, - ConversationParticipant, - Message, - MediaAttachment, - Gym, - GymMember, - GymInvitation, - GymOwnershipTransfer, - GymMemberCount, -} from '@/domain/types' -import { - entityId, - exerciseCategorySchema, - movementPatternSchema, - muscleGroupSpecSchema, - equipmentSchema, - weightSchema, - durationSchema, - distanceSchema, - paceSchema, - programContextSchema, - prescriptionSchema, - groupTypeSchema, - setTypeSchema, - preferredUnitsSchema, - oneRepMaxSchema, - sessionTypeSchema, - scoringTypeSchema, - setSchemeSchema, - programSourceSchema, - blockTypeSchema, - sessionOverridesSchema, - weekStatusValueSchema, - shareableEntityTypeSchema, - shareTokenSchema, - eventMetadataSchema, - conversationSchema, - conversationParticipantSchema, - messageSchema, - mediaProviderSchema, - mediaTypeSchema, - mediaStatusSchema, - gymSchema, - gymMemberSchema, - gymInvitationSchema, - gymOwnershipTransferSchema, - gymMemberCountSchema, -} from '@/domain/types' -import type { - GymInvitationRow, - GymOwnershipTransferRow, - GymMemberCountRow, - ExerciseRow, - WorkoutLogRow, - LoggedActivityGroupRow, - LoggedActivityRow, - LoggedSetRow, - UserProfileRow, - OneRepMaxHistoryRow, - SessionTemplateRow, - ActivityGroupRow, - ActivityRow, - ProgramRow, - BlockRow, - BlockWeekRow, - ScheduledSessionRow, - ProgramActivationRow, - ProgramWeekStatusRow, - ShareLinkRow, - EventItemRow, - ConversationRow, - ConversationParticipantRow, - MessageRow, - MediaAttachmentRow, - GymRow, - GymMemberRow, -} from './database.types' - -/** - * Bidirectional mappers between database row types and domain types. - * - * Naming convention: - * - `toXxx()` converts a database row to a domain type (DB -> Domain) - * - `fromXxx()` converts a domain type to a partial database row (Domain -> DB) - * - * Null/undefined conversion strategy: - * - DB null -> Domain undefined (optional fields absent in domain model) - * - Domain undefined -> DB null (explicit nulls for SQL columns) - * - * JSON columns use Zod schema validation (`.parse()`) instead of `as` casts - * to catch data corruption at the persistence boundary. - */ - -// --------------------------------------------------------------------------- -// Exercise -// --------------------------------------------------------------------------- - -export function toExercise(row: ExerciseRow): Exercise { - return { - id: row.id, - createdAt: row.created_at, - updatedAt: row.updated_at, - name: row.name, - aliases: z.array(z.string()).parse(row.aliases), - category: exerciseCategorySchema.parse(row.category), - movementPattern: movementPatternSchema.parse(row.movement_pattern), - muscleGroups: muscleGroupSpecSchema.parse(row.muscle_groups), - isBilateral: row.is_bilateral, - supports1RM: row.supports_1rm, - equipmentRequired: z.array(equipmentSchema).parse(row.equipment_required), - isCustom: row.is_custom, - isPublic: row.is_public, - } -} - -export function fromExercise( - exercise: Omit, -): Partial { - return { - name: exercise.name, - aliases: exercise.aliases, - category: exercise.category, - movement_pattern: exercise.movementPattern, - muscle_groups: exercise.muscleGroups, - is_bilateral: exercise.isBilateral, - supports_1rm: exercise.supports1RM, - equipment_required: exercise.equipmentRequired, - is_custom: exercise.isCustom, - is_public: exercise.isPublic, - } -} - -// --------------------------------------------------------------------------- -// WorkoutLog -// --------------------------------------------------------------------------- - -export function toWorkoutLog(row: WorkoutLogRow): WorkoutLog { - return { - id: row.id, - createdAt: row.created_at, - updatedAt: row.updated_at, - userId: row.user_id, - title: row.title ?? undefined, - startedAt: row.started_at, - completedAt: row.completed_at ?? undefined, - sessionTemplateId: row.session_template_id ?? undefined, - programContext: - row.program_context != null ? programContextSchema.parse(row.program_context) : undefined, - perceivedDifficulty: row.perceived_difficulty ?? undefined, - bodyweightAtSession: - row.bodyweight_at_session != null ? weightSchema.parse(row.bodyweight_at_session) : undefined, - overallNotes: row.overall_notes ?? undefined, - noteTags: row.note_tags && row.note_tags.length > 0 ? row.note_tags : undefined, - eventMetadata: - row.event_metadata != null ? eventMetadataSchema.parse(row.event_metadata) : undefined, - pausedAt: row.paused_at ?? undefined, - totalPausedMs: row.total_paused_ms ?? 0, - } -} - -export function fromWorkoutLog( - log: Omit, -): Partial { - return { - user_id: log.userId, - title: log.title ?? null, - started_at: log.startedAt, - completed_at: log.completedAt ?? null, - session_template_id: log.sessionTemplateId ?? null, - program_context: log.programContext ?? null, - perceived_difficulty: log.perceivedDifficulty ?? null, - bodyweight_at_session: log.bodyweightAtSession ?? null, - overall_notes: log.overallNotes ?? null, - note_tags: log.noteTags ?? [], - event_metadata: log.eventMetadata ?? null, - paused_at: log.pausedAt ?? null, - total_paused_ms: log.totalPausedMs ?? 0, - } -} - -// --------------------------------------------------------------------------- -// LoggedActivityGroup -// --------------------------------------------------------------------------- - -export function toLoggedActivityGroup(row: LoggedActivityGroupRow): LoggedActivityGroup { - return { - id: row.id, - workoutLogId: row.workout_log_id, - groupType: groupTypeSchema.parse(row.group_type), - ordinal: row.ordinal, - actualRoundsCompleted: row.actual_rounds_completed ?? undefined, - completionTime: - row.completion_time != null ? durationSchema.parse(row.completion_time) : undefined, - } -} - -export function fromLoggedActivityGroup( - group: Omit, - userId: string, -): Partial { - return { - workout_log_id: group.workoutLogId, - user_id: userId, - group_type: group.groupType, - ordinal: group.ordinal, - actual_rounds_completed: group.actualRoundsCompleted ?? null, - completion_time: group.completionTime ?? null, - } -} - -// --------------------------------------------------------------------------- -// LoggedActivity -// --------------------------------------------------------------------------- - -export function toLoggedActivity(row: LoggedActivityRow): LoggedActivity { - return { - id: row.id, - loggedGroupId: row.logged_group_id, - exerciseId: row.exercise_id, - ordinal: row.ordinal, - notes: row.notes ?? undefined, - noteTags: row.note_tags && row.note_tags.length > 0 ? row.note_tags : undefined, - } -} - -export function fromLoggedActivity( - activity: Omit, - userId: string, -): Partial { - return { - logged_group_id: activity.loggedGroupId, - user_id: userId, - exercise_id: activity.exerciseId, - ordinal: activity.ordinal, - notes: activity.notes ?? null, - note_tags: activity.noteTags ?? [], - } -} - -// --------------------------------------------------------------------------- -// LoggedSet -// --------------------------------------------------------------------------- - -export function toLoggedSet(row: LoggedSetRow): LoggedSet { - return { - id: row.id, - loggedActivityId: row.logged_activity_id, - setNumber: row.set_number, - setType: setTypeSchema.parse(row.set_type), - prescribed: row.prescribed != null ? prescriptionSchema.parse(row.prescribed) : undefined, - actualReps: row.actual_reps ?? undefined, - actualWeight: row.actual_weight != null ? weightSchema.parse(row.actual_weight) : undefined, - actualDuration: - row.actual_duration != null ? durationSchema.parse(row.actual_duration) : undefined, - actualDistance: - row.actual_distance != null ? distanceSchema.parse(row.actual_distance) : undefined, - actualPace: row.actual_pace != null ? paceSchema.parse(row.actual_pace) : undefined, - actualHeartRate: row.actual_heart_rate ?? undefined, - rpe: row.rpe ?? undefined, - completed: row.completed, - notes: row.notes ?? undefined, - noteTags: row.note_tags && row.note_tags.length > 0 ? row.note_tags : undefined, - ruckLoad: row.ruck_load != null ? weightSchema.parse(row.ruck_load) : undefined, - elevationGain: - row.elevation_gain != null ? distanceSchema.parse(row.elevation_gain) : undefined, - } -} - -export function fromLoggedSet(set: Omit, userId: string): Partial { - return { - logged_activity_id: set.loggedActivityId, - user_id: userId, - set_number: set.setNumber, - set_type: set.setType, - prescribed: set.prescribed ?? null, - actual_reps: set.actualReps ?? null, - actual_weight: set.actualWeight ?? null, - actual_duration: set.actualDuration ?? null, - actual_distance: set.actualDistance ?? null, - actual_pace: set.actualPace ?? null, - actual_heart_rate: set.actualHeartRate ?? null, - ruck_load: set.ruckLoad ?? null, - elevation_gain: set.elevationGain ?? null, - rpe: set.rpe ?? null, - completed: set.completed, - notes: set.notes ?? null, - note_tags: set.noteTags ?? [], - } -} - -// --------------------------------------------------------------------------- -// UserProfile -// --------------------------------------------------------------------------- - -export function toUserProfile(row: UserProfileRow): UserProfile { - return { - id: row.id, - createdAt: row.created_at, - updatedAt: row.updated_at, - displayName: row.display_name ?? undefined, - preferredUnits: preferredUnitsSchema.parse(row.preferred_units), - bodyweight: row.bodyweight != null ? weightSchema.parse(row.bodyweight) : undefined, - trainingAge: row.training_age != null ? durationSchema.parse(row.training_age) : undefined, - exerciseMaxes: - row.exercise_maxes != null - ? z.record(entityId, oneRepMaxSchema).parse(row.exercise_maxes) - : {}, - maxReps: - row.max_reps != null - ? z.record(entityId, z.number().int().positive()).parse(row.max_reps) - : {}, - } -} - -export function fromUserProfile( - profile: Partial & { id: string }, -): Partial { - const row: Partial = { id: profile.id } - - if (profile.displayName !== undefined) row.display_name = profile.displayName ?? null - if (profile.preferredUnits !== undefined) row.preferred_units = profile.preferredUnits - if (profile.bodyweight !== undefined) row.bodyweight = profile.bodyweight ?? null - if (profile.trainingAge !== undefined) row.training_age = profile.trainingAge ?? null - if (profile.exerciseMaxes !== undefined) row.exercise_maxes = profile.exerciseMaxes - if (profile.maxReps !== undefined) row.max_reps = profile.maxReps - - return row -} - -// --------------------------------------------------------------------------- -// Gym (F018, Tech.md D1) -- a physical place where lifting happens. -// --------------------------------------------------------------------------- - -export function toGym(row: GymRow): Gym { - // P14-039: defense in depth -- the row types declare these as non-null, - // but a future migration that introduces a nullable column would silently - // produce malformed Gym objects. Warn at the adapter boundary so the - // failure is observable. - if (row.id == null || row.name == null || row.owner_user_id == null) { - console.warn('[data-mapper] toGym: missing required fields', row) - } - // P14-005: parse through the schema so a future Postgres migration that - // loosens column constraints (or returns over-long names from a stale - // view) cannot silently ship malformed Gym objects through the app. The - // try/catch matches the convention used by toConversation/toMessage. - try { - return gymSchema.parse({ - id: row.id, - createdAt: row.created_at, - updatedAt: row.updated_at, - name: row.name, - ownerUserId: row.owner_user_id, - }) - } catch (err) { - console.error('[data-mapper] Failed to map gym row:', err, row) - throw new Error(`Failed to map gym row (id=${row.id}): ${(err as Error).message}`) - } -} - -export function fromGym(gym: Partial): Partial { - const row: Partial = {} - - if (gym.id !== undefined) row.id = gym.id - if (gym.name !== undefined) row.name = gym.name - if (gym.ownerUserId !== undefined) row.owner_user_id = gym.ownerUserId - if (gym.createdAt !== undefined) row.created_at = gym.createdAt - if (gym.updatedAt !== undefined) row.updated_at = gym.updatedAt - - return row -} - -// --------------------------------------------------------------------------- -// GymMember (F018, Tech.md D1) -- M:N join row between a user and a gym. -// No `id` -- composite (gym_id, user_id) is the primary key. -// --------------------------------------------------------------------------- - -export function toGymMember(row: GymMemberRow): GymMember { - if (row.gym_id == null || row.user_id == null) { - console.warn('[data-mapper] toGymMember: missing required fields', row) - } - // P14-005: parse through the schema for the same reason as toGym -- a - // schema-drift event must surface as a loud failure rather than a silently - // malformed object propagating through the app. - try { - return gymMemberSchema.parse({ - gymId: row.gym_id, - userId: row.user_id, - joinedAt: row.joined_at, - }) - } catch (err) { - console.error('[data-mapper] Failed to map gym_member row:', err, row) - throw new Error( - `Failed to map gym_member row (gym=${row.gym_id}, user=${row.user_id}): ${(err as Error).message}`, - ) - } -} - -export function fromGymMember(member: Partial): Partial { - const row: Partial = {} - - if (member.gymId !== undefined) row.gym_id = member.gymId - if (member.userId !== undefined) row.user_id = member.userId - if (member.joinedAt !== undefined) row.joined_at = member.joinedAt - - return row -} - -// --------------------------------------------------------------------------- -// GymInvitation (F021) -- redeemable invite token row. -// --------------------------------------------------------------------------- - -export function toGymInvitation(row: GymInvitationRow): GymInvitation { - try { - return gymInvitationSchema.parse({ - id: row.id, - gymId: row.gym_id, - token: row.token, - expiresAt: row.expires_at, - maxUses: row.max_uses, - usesCount: row.uses_count, - createdBy: row.created_by, - createdAt: row.created_at, - }) - } catch (err) { - console.error('[data-mapper] Failed to map gym_invitation row:', err, row) - throw new Error(`Failed to map gym_invitation row (id=${row.id}): ${(err as Error).message}`) - } -} - -export function fromGymInvitation(invitation: Partial): Partial { - const row: Partial = {} - if (invitation.id !== undefined) row.id = invitation.id - if (invitation.gymId !== undefined) row.gym_id = invitation.gymId - if (invitation.token !== undefined) row.token = invitation.token - if (invitation.expiresAt !== undefined) row.expires_at = invitation.expiresAt - if (invitation.maxUses !== undefined) row.max_uses = invitation.maxUses - if (invitation.usesCount !== undefined) row.uses_count = invitation.usesCount - if (invitation.createdBy !== undefined) row.created_by = invitation.createdBy - if (invitation.createdAt !== undefined) row.created_at = invitation.createdAt - return row -} - -// --------------------------------------------------------------------------- -// GymOwnershipTransfer (F021) -- pending transfer proposal row. The composite -// PK is (gym_id), enforcing single-pending-transfer-per-gym. -// --------------------------------------------------------------------------- - -export function toGymOwnershipTransfer(row: GymOwnershipTransferRow): GymOwnershipTransfer { - try { - return gymOwnershipTransferSchema.parse({ - gymId: row.gym_id, - proposedBy: row.proposed_by, - proposedTo: row.proposed_to, - proposedAt: row.proposed_at, - }) - } catch (err) { - console.error('[data-mapper] Failed to map gym_ownership_transfer row:', err, row) - throw new Error( - `Failed to map gym_ownership_transfer row (gym=${row.gym_id}): ${(err as Error).message}`, - ) - } -} - -export function fromGymOwnershipTransfer( - transfer: Partial, -): Partial { - const row: Partial = {} - if (transfer.gymId !== undefined) row.gym_id = transfer.gymId - if (transfer.proposedBy !== undefined) row.proposed_by = transfer.proposedBy - if (transfer.proposedTo !== undefined) row.proposed_to = transfer.proposedTo - if (transfer.proposedAt !== undefined) row.proposed_at = transfer.proposedAt - return row -} - -// --------------------------------------------------------------------------- -// GymMemberCount (F021) -- read-only view row, mapped one-way. -// -// The view's columns are nullable in the generated types because Postgres -// views cannot enforce NOT NULL. In practice the view's underlying query -// (LEFT JOIN gyms + COUNT) always emits a non-null gym_id and a non-null -// member_count >= 0. We log + throw on the safety-net path so a future view -// regression surfaces loudly rather than producing silently malformed -// objects (per .claude/rules/error-handling.md adapter boundary policy). -// --------------------------------------------------------------------------- - -export function toGymMemberCount(row: GymMemberCountRow): GymMemberCount { - if (row.gym_id == null) { - console.warn('[data-mapper] toGymMemberCount: gym_id was null, view contract violated', row) - } - if (row.member_count == null) { - console.warn('[data-mapper] toGymMemberCount: member_count was null, coercing to 0', row) - } - try { - return gymMemberCountSchema.parse({ - gymId: row.gym_id, - memberCount: row.member_count ?? 0, - }) - } catch (err) { - console.error('[data-mapper] Failed to map gym_member_counts row:', err, row) - throw new Error( - `Failed to map gym_member_counts row (gym=${row.gym_id}): ${(err as Error).message}`, - ) - } -} - -// --------------------------------------------------------------------------- -// OneRepMaxHistory -// --------------------------------------------------------------------------- - -export function toOneRepMaxHistory(row: OneRepMaxHistoryRow): OneRepMaxHistory { - return { - id: row.id, - createdAt: row.created_at, - userId: row.user_id, - exerciseId: row.exercise_id, - weight: weightSchema.parse(row.weight), - estimated: row.estimated, - recordedAt: row.recorded_at, - } -} - -export function fromOneRepMaxHistory( - entry: Omit, -): Partial { - return { - user_id: entry.userId, - exercise_id: entry.exerciseId, - weight: entry.weight, - estimated: entry.estimated, - recorded_at: entry.recordedAt, - } -} - -// --------------------------------------------------------------------------- -// SessionTemplate -// --------------------------------------------------------------------------- - -export function toSessionTemplate(row: SessionTemplateRow): SessionTemplate { - return { - id: row.id, - createdAt: row.created_at, - updatedAt: row.updated_at, - userId: row.user_id, - name: row.name, - description: row.description ?? undefined, - category: sessionTypeSchema.parse(row.category), - restBetweenGroups: - row.rest_between_groups != null - ? durationSchema.parse(parseJsonOrValue(row.rest_between_groups)) - : undefined, - timeCap: - row.time_cap != null ? durationSchema.parse(parseJsonOrValue(row.time_cap)) : undefined, - scoring: scoringTypeSchema.parse(row.scoring), - eventMetadata: - row.event_metadata != null - ? eventMetadataSchema.parse(parseJsonOrValue(row.event_metadata)) - : undefined, - lastAssignedAt: row.last_assigned_at ?? undefined, - isPublic: row.is_public, - } -} - -export function fromSessionTemplate( - template: Omit, -): Partial { - return { - user_id: template.userId, - name: template.name, - description: template.description ?? null, - category: template.category, - rest_between_groups: template.restBetweenGroups - ? JSON.stringify(template.restBetweenGroups) - : null, - time_cap: template.timeCap ? JSON.stringify(template.timeCap) : null, - scoring: template.scoring, - event_metadata: template.eventMetadata ? JSON.stringify(template.eventMetadata) : null, - is_public: template.isPublic, - } -} - -// --------------------------------------------------------------------------- -// ActivityGroup (flat -- without nested activities) -// --------------------------------------------------------------------------- - -export function toActivityGroupFlat(row: ActivityGroupRow): Omit { - return { - id: row.id, - sessionTemplateId: row.session_template_id, - groupType: groupTypeSchema.parse(row.group_type), - ordinal: row.ordinal, - rounds: row.rounds ?? undefined, - restBetweenRounds: - row.rest_between_rounds != null - ? durationSchema.parse(parseJsonOrValue(row.rest_between_rounds)) - : undefined, - restBetweenActivities: - row.rest_between_activities != null - ? durationSchema.parse(parseJsonOrValue(row.rest_between_activities)) - : undefined, - } -} - -export function fromActivityGroup( - group: Omit | Omit, - templateId?: string, -): Partial { - return { - session_template_id: templateId ?? group.sessionTemplateId, - group_type: group.groupType, - ordinal: group.ordinal, - rounds: group.rounds ?? null, - rest_between_rounds: group.restBetweenRounds ? JSON.stringify(group.restBetweenRounds) : null, - rest_between_activities: group.restBetweenActivities - ? JSON.stringify(group.restBetweenActivities) - : null, - } -} - -// --------------------------------------------------------------------------- -// Activity -// --------------------------------------------------------------------------- - -export function toActivity(row: ActivityRow): Activity { - return { - id: row.id, - activityGroupId: row.activity_group_id, - exerciseId: row.exercise_id, - ordinal: row.ordinal, - setScheme: setSchemeSchema.parse(parseJsonOrValue(row.set_scheme)), - notes: row.notes ?? undefined, - } -} - -export function fromActivity( - activity: Omit | Omit, - groupId?: string, -): Partial { - return { - activity_group_id: - groupId ?? ('activityGroupId' in activity ? activity.activityGroupId : undefined), - exercise_id: activity.exerciseId, - ordinal: activity.ordinal, - set_scheme: JSON.stringify(activity.setScheme), - notes: activity.notes ?? null, - } -} - -// --------------------------------------------------------------------------- -// Program -// --------------------------------------------------------------------------- - -export function toProgram(row: ProgramRow): Program { - try { - return { - id: row.id, - createdAt: row.created_at, - updatedAt: row.updated_at, - userId: row.user_id, - name: row.name, - description: row.description ?? undefined, - source: programSourceSchema.parse(row.source), - durationWeeks: row.duration_weeks ?? undefined, - isPublic: row.is_public, - createdBy: row.created_by ?? row.user_id, - } - } catch (err) { - throw new Error( - `Failed to map program "${row.name}" (${row.id}): ${err instanceof Error ? err.message : String(err)}`, - ) - } -} - -export function fromProgram( - program: Omit, -): Partial { - return { - user_id: program.userId, - name: program.name, - description: program.description ?? null, - source: program.source, - duration_weeks: program.durationWeeks ?? null, - is_public: program.isPublic, - created_by: program.createdBy ?? null, - } -} - -// --------------------------------------------------------------------------- -// Block -// --------------------------------------------------------------------------- - -export function toBlock(row: BlockRow): Block { - try { - return { - id: row.id, - programId: row.program_id, - name: row.name, - ordinal: row.ordinal, - durationWeeks: row.duration_weeks, - blockType: blockTypeSchema.parse(row.block_type), - } - } catch (err) { - throw new Error( - `Failed to map block "${row.name}" (${row.id}): ${err instanceof Error ? err.message : String(err)}`, - ) - } -} - -export function fromBlock( - block: Omit | Omit, - programId?: string, -): Partial { - return { - program_id: programId ?? ('programId' in block ? block.programId : undefined), - name: block.name, - ordinal: block.ordinal, - duration_weeks: block.durationWeeks, - block_type: block.blockType, - } -} - -// --------------------------------------------------------------------------- -// BlockWeek -// --------------------------------------------------------------------------- - -export function toBlockWeek(row: BlockWeekRow): BlockWeek { - return { - id: row.id, - blockId: row.block_id, - weekNumber: row.week_number, - } -} - -export function fromBlockWeek( - week: Omit | Omit, - blockId?: string, -): Partial { - return { - block_id: blockId ?? ('blockId' in week ? week.blockId : undefined), - week_number: week.weekNumber, - } -} - -// --------------------------------------------------------------------------- -// ScheduledSession -// --------------------------------------------------------------------------- - -export function toScheduledSession(row: ScheduledSessionRow): ScheduledSession { - try { - let overrides: ScheduledSession['overrides'] - if (row.overrides != null) { - try { - const parsed = typeof row.overrides === 'string' ? JSON.parse(row.overrides) : row.overrides - overrides = sessionOverridesSchema.parse(parsed) - } catch (err) { - console.warn( - `[data-mapper] Failed to parse overrides for scheduled session ${row.id}, falling back to undefined:`, - err, - ) - overrides = undefined - } - } - - return { - id: row.id, - blockWeekId: row.block_week_id, - dayOfWeek: row.day_of_week ?? undefined, - dayLabel: row.day_label, - sessionType: sessionTypeSchema.parse(row.session_type), - sessionTemplateId: row.session_template_id, - notes: row.notes ?? undefined, - overrides, - } - } catch (err) { - throw new Error( - `Failed to map scheduled session (${row.id}): ${err instanceof Error ? err.message : String(err)}`, - ) - } -} - -export function fromScheduledSession( - session: Omit | Omit, - blockWeekId?: string, -): Partial { - return { - block_week_id: blockWeekId ?? ('blockWeekId' in session ? session.blockWeekId : undefined), - day_of_week: session.dayOfWeek ?? null, - day_label: session.dayLabel, - session_type: session.sessionType, - session_template_id: session.sessionTemplateId, - notes: session.notes ?? null, - overrides: session.overrides ?? null, - } -} - -// --------------------------------------------------------------------------- -// ProgramActivation -// --------------------------------------------------------------------------- - -export function toProgramActivation(row: ProgramActivationRow): ProgramActivation { - return { - id: row.id, - createdAt: row.created_at, - updatedAt: row.updated_at, - userId: row.user_id, - programId: row.program_id, - currentBlockOrdinal: row.current_block_ordinal, - currentWeekNumber: row.current_week_number, - startDate: row.start_date, - } -} - -export function fromProgramActivation( - activation: Omit, -): Partial { - return { - user_id: activation.userId, - program_id: activation.programId, - current_block_ordinal: activation.currentBlockOrdinal, - current_week_number: activation.currentWeekNumber, - start_date: activation.startDate, - } -} - -// --------------------------------------------------------------------------- -// WeekStatus (Program Time Travel) -// --------------------------------------------------------------------------- - -export function toWeekStatus(row: ProgramWeekStatusRow): WeekStatus { - return { - id: row.id, - activationId: row.activation_id, - blockOrdinal: row.block_ordinal, - weekNumber: row.week_number, - status: weekStatusValueSchema.parse(row.status), - createdAt: row.created_at, - } -} - -export function fromWeekStatus( - status: Omit, -): Partial { - return { - activation_id: status.activationId, - block_ordinal: status.blockOrdinal, - week_number: status.weekNumber, - status: status.status, - } -} - -// --------------------------------------------------------------------------- -// ShareLink -// --------------------------------------------------------------------------- - -export function toShareLink(row: ShareLinkRow): ShareLink { - return { - id: row.id, - token: shareTokenSchema.parse(row.token), - entityType: shareableEntityTypeSchema.parse(row.entity_type), - entityId: row.entity_id, - createdBy: row.created_by, - isActive: row.is_active, - createdAt: row.created_at, - updatedAt: row.updated_at, - } -} - -export function fromShareLink(link: Partial): Record { - const row: Record = {} - if (link.token !== undefined) row.token = link.token - if (link.entityType !== undefined) row.entity_type = link.entityType - if (link.entityId !== undefined) row.entity_id = link.entityId - if (link.createdBy !== undefined) row.created_by = link.createdBy - if (link.isActive !== undefined) row.is_active = link.isActive - return row -} - -// --------------------------------------------------------------------------- -// EventItem -// --------------------------------------------------------------------------- - -export function toEventItem(row: EventItemRow): EventItem { - return { - id: row.id, - createdAt: row.created_at, - updatedAt: row.updated_at, - sessionTemplateId: row.session_template_id ?? undefined, - workoutLogId: row.workout_log_id ?? undefined, - userId: row.user_id, - name: row.name, - category: row.category ?? undefined, - quantity: row.quantity, - isPacked: row.is_packed, - sortOrder: row.sort_order, - notes: row.notes ?? undefined, - } -} - -export function fromEventItem( - item: Omit, - parentId: string, - parentType: 'template' | 'log', -): Partial { - return { - session_template_id: parentType === 'template' ? parentId : null, - workout_log_id: parentType === 'log' ? parentId : null, - user_id: item.userId, - name: item.name, - category: item.category ?? null, - quantity: item.quantity, - is_packed: item.isPacked, - sort_order: item.sortOrder, - notes: item.notes ?? null, - } -} - -// --------------------------------------------------------------------------- -// Conversation -// --------------------------------------------------------------------------- - -export function toConversation( - row: ConversationRow, - participantUserIds: string[] = [], -): Conversation { - try { - return conversationSchema.parse({ - id: row.id, - createdAt: row.created_at, - updatedAt: row.updated_at, - type: row.type, - title: row.title ?? undefined, - groupId: row.group_id ?? undefined, - participantUserIds, - }) - } catch (err) { - throw new Error( - `Failed to map conversation (${row.id}): ${err instanceof Error ? err.message : String(err)}`, - ) - } -} - -export function fromConversation( - conversation: Omit, -): Partial { - return { - type: conversation.type, - title: conversation.title ?? null, - group_id: conversation.groupId ?? null, - } -} - -// --------------------------------------------------------------------------- -// ConversationParticipant -// --------------------------------------------------------------------------- - -export function toConversationParticipant( - row: ConversationParticipantRow, -): ConversationParticipant { - try { - return conversationParticipantSchema.parse({ - id: row.id, - createdAt: row.joined_at, - updatedAt: row.last_read_at ?? row.joined_at, - conversationId: row.conversation_id, - userId: row.user_id, - lastReadAt: row.last_read_at ?? undefined, - isArchived: row.is_archived, - joinedAt: row.joined_at, - leftAt: row.left_at ?? undefined, - }) - } catch (err) { - throw new Error( - `Failed to map conversation participant (${row.id}): ${err instanceof Error ? err.message : String(err)}`, - ) - } -} - -export function fromConversationParticipant( - participant: Omit, -): Partial { - return { - conversation_id: participant.conversationId, - user_id: participant.userId, - last_read_at: participant.lastReadAt ?? null, - is_archived: participant.isArchived, - joined_at: participant.joinedAt, - left_at: participant.leftAt ?? null, - } -} - -// --------------------------------------------------------------------------- -// Message -// --------------------------------------------------------------------------- - -export function toMessage(row: MessageRow): Message { - try { - return messageSchema.parse({ - id: row.id, - createdAt: row.created_at, - conversationId: row.conversation_id, - senderId: row.sender_id ?? undefined, - messageType: row.message_type, - content: row.content ?? undefined, - syncStatus: row.sync_status ?? undefined, - }) - } catch (err) { - throw new Error( - `Failed to map message (${row.id}): ${err instanceof Error ? err.message : String(err)}`, - ) - } -} - -export function fromMessage(message: Omit): Partial { - return { - conversation_id: message.conversationId, - sender_id: message.senderId ?? null, - message_type: message.messageType, - content: message.content ?? null, - sync_status: message.syncStatus ?? undefined, - } -} - -// --------------------------------------------------------------------------- -// MediaAttachment -// --------------------------------------------------------------------------- - -export function toMediaAttachment(row: MediaAttachmentRow): MediaAttachment { - try { - return { - id: row.id, - createdAt: row.created_at, - updatedAt: row.updated_at, - messageId: row.message_id, - provider: mediaProviderSchema.parse(row.provider), - providerAssetId: row.provider_asset_id ?? undefined, - mediaType: mediaTypeSchema.parse(row.media_type), - originalFilename: row.original_filename ?? undefined, - mimeType: row.mime_type ?? undefined, - thumbnailUrl: row.thumbnail_url ?? undefined, - playbackUrl: row.playback_url ?? undefined, - durationSeconds: row.duration_seconds ?? undefined, - fileSizeBytes: row.file_size_bytes ?? undefined, - status: mediaStatusSchema.parse(row.status), - } - } catch (err) { - throw new Error( - `Failed to map media attachment (${row.id}): ${err instanceof Error ? err.message : String(err)}`, - ) - } -} - -export function fromMediaAttachment( - attachment: Omit, -): Partial { - return { - message_id: attachment.messageId, - provider: attachment.provider, - provider_asset_id: attachment.providerAssetId ?? null, - media_type: attachment.mediaType, - original_filename: attachment.originalFilename ?? null, - mime_type: attachment.mimeType ?? null, - thumbnail_url: attachment.thumbnailUrl ?? null, - playback_url: attachment.playbackUrl ?? null, - duration_seconds: attachment.durationSeconds ?? null, - file_size_bytes: attachment.fileSizeBytes ?? null, - status: attachment.status, - } -} diff --git a/src/lib/supabase-adapter.ts b/src/lib/supabase-adapter.ts index 71cb9042..294527e6 100644 --- a/src/lib/supabase-adapter.ts +++ b/src/lib/supabase-adapter.ts @@ -1,3 +1,4 @@ +import { z } from 'zod' import type { SupabaseClient } from '@supabase/supabase-js' import type { DataAdapter, @@ -52,81 +53,43 @@ import type { GymOwnershipTransfer, RedeemInviteError, } from '@/domain/types' -import type { - GymInvitationRow, - GymOwnershipTransferRow, - GymMemberCountRow, - ExerciseRow, - WorkoutLogRow, - LoggedActivityGroupRow, - LoggedActivityRow, - LoggedSetRow, - UserProfileRow, - OneRepMaxHistoryRow, - SessionTemplateRow, - ActivityGroupRow, - ActivityRow, - ProgramRow, - BlockRow, - BlockWeekRow, - ScheduledSessionRow, - ProgramActivationRow, - ProgramWeekStatusRow, - ShareLinkRow, - EventItemRow, - ConversationRow, - ConversationParticipantRow, - MessageRow, - MediaAttachmentRow, - GymRow, - GymMemberRow, -} from './database.types' import { - toExercise, - fromExercise, - toWorkoutLog, - fromWorkoutLog, - toLoggedActivityGroup, - fromLoggedActivityGroup, - toLoggedActivity, - fromLoggedActivity, - toLoggedSet, - fromLoggedSet, - toUserProfile, - fromUserProfile, - toGym, - fromGym, - toGymMember, - toOneRepMaxHistory, - fromOneRepMaxHistory, - toSessionTemplate, - fromSessionTemplate, - toActivityGroupFlat, - fromActivityGroup, - toActivity, - fromActivity, - toProgram, - fromProgram, - toBlock, - fromBlock, - toBlockWeek, - fromBlockWeek, - toScheduledSession, - fromScheduledSession, - toProgramActivation, - toShareLink, - fromShareLink, - toEventItem, - fromEventItem, - toConversation, - fromConversation, - toConversationParticipant, - toMessage, - fromMessage, - toMediaAttachment, - fromMediaAttachment, - toWeekStatus, -} from './data-mapper' + entityId, + exerciseCategorySchema, + movementPatternSchema, + muscleGroupSpecSchema, + equipmentSchema, + weightSchema, + durationSchema, + distanceSchema, + paceSchema, + programContextSchema, + prescriptionSchema, + groupTypeSchema, + setTypeSchema, + preferredUnitsSchema, + oneRepMaxSchema, + sessionTypeSchema, + scoringTypeSchema, + setSchemeSchema, + programSourceSchema, + blockTypeSchema, + sessionOverridesSchema, + weekStatusValueSchema, + shareableEntityTypeSchema, + shareTokenSchema, + eventMetadataSchema, + conversationSchema, + conversationParticipantSchema, + messageSchema, + mediaProviderSchema, + mediaTypeSchema, + mediaStatusSchema, + gymSchema, + gymMemberSchema, +} from '@/domain/types' +import type { WorkoutLogRow } from './database.types' +import { camelizeKeys, parseJsonOrValue } from './adapter-utils' import { toAccountabilityGroup, toGroupMember, @@ -149,6 +112,485 @@ export class SupabaseAdapter implements DataAdapter { return user.id } + // --------------------------------------------------------------------------- + // Private read-path mappers (DB row -> domain type) + // --------------------------------------------------------------------------- + + private mapExercise(raw: Record): Exercise { + const r = camelizeKeys(raw) + return { + id: r.id as string, + createdAt: r.createdAt as string, + updatedAt: r.updatedAt as string, + name: r.name as string, + aliases: z.array(z.string()).parse(r.aliases), + category: exerciseCategorySchema.parse(r.category), + movementPattern: movementPatternSchema.parse(r.movementPattern), + muscleGroups: muscleGroupSpecSchema.parse(r.muscleGroups), + isBilateral: r.isBilateral as boolean, + supports1RM: r.supports1Rm as boolean, + equipmentRequired: z.array(equipmentSchema).parse(r.equipmentRequired), + isCustom: r.isCustom as boolean, + isPublic: r.isPublic as boolean, + } + } + + private mapWorkoutLog(raw: Record): WorkoutLog { + const r = camelizeKeys(raw) + return { + id: r.id as string, + createdAt: r.createdAt as string, + updatedAt: r.updatedAt as string, + userId: r.userId as string, + title: (r.title ?? undefined) as string | undefined, + startedAt: r.startedAt as string, + completedAt: (r.completedAt ?? undefined) as string | undefined, + sessionTemplateId: (r.sessionTemplateId ?? undefined) as string | undefined, + programContext: + r.programContext != null + ? programContextSchema.parse(r.programContext) + : undefined, + perceivedDifficulty: (r.perceivedDifficulty ?? undefined) as number | undefined, + bodyweightAtSession: + r.bodyweightAtSession != null + ? weightSchema.parse(r.bodyweightAtSession) + : undefined, + overallNotes: (r.overallNotes ?? undefined) as string | undefined, + noteTags: + Array.isArray(r.noteTags) && (r.noteTags as unknown[]).length > 0 + ? (r.noteTags as string[]) + : undefined, + eventMetadata: + r.eventMetadata != null + ? eventMetadataSchema.parse(r.eventMetadata) + : undefined, + pausedAt: (r.pausedAt ?? undefined) as string | undefined, + totalPausedMs: (r.totalPausedMs ?? 0) as number, + } + } + + private mapLoggedActivityGroup(raw: Record): LoggedActivityGroup { + const r = camelizeKeys(raw) + return { + id: r.id as string, + workoutLogId: r.workoutLogId as string, + groupType: groupTypeSchema.parse(r.groupType), + ordinal: r.ordinal as number, + actualRoundsCompleted: (r.actualRoundsCompleted ?? undefined) as number | undefined, + completionTime: + r.completionTime != null + ? durationSchema.parse(r.completionTime) + : undefined, + } + } + + private mapLoggedActivity(raw: Record): LoggedActivity { + const r = camelizeKeys(raw) + return { + id: r.id as string, + loggedGroupId: r.loggedGroupId as string, + exerciseId: r.exerciseId as string, + ordinal: r.ordinal as number, + notes: (r.notes ?? undefined) as string | undefined, + noteTags: + Array.isArray(r.noteTags) && (r.noteTags as unknown[]).length > 0 + ? (r.noteTags as string[]) + : undefined, + } + } + + private mapLoggedSet(raw: Record): LoggedSet { + const r = camelizeKeys(raw) + return { + id: r.id as string, + loggedActivityId: r.loggedActivityId as string, + setNumber: r.setNumber as number, + setType: setTypeSchema.parse(r.setType), + prescribed: + r.prescribed != null ? prescriptionSchema.parse(r.prescribed) : undefined, + actualReps: (r.actualReps ?? undefined) as number | undefined, + actualWeight: + r.actualWeight != null ? weightSchema.parse(r.actualWeight) : undefined, + actualDuration: + r.actualDuration != null ? durationSchema.parse(r.actualDuration) : undefined, + actualDistance: + r.actualDistance != null ? distanceSchema.parse(r.actualDistance) : undefined, + actualPace: + r.actualPace != null ? paceSchema.parse(r.actualPace) : undefined, + actualHeartRate: (r.actualHeartRate ?? undefined) as number | undefined, + rpe: (r.rpe ?? undefined) as number | undefined, + completed: r.completed as boolean, + notes: (r.notes ?? undefined) as string | undefined, + noteTags: + Array.isArray(r.noteTags) && (r.noteTags as unknown[]).length > 0 + ? (r.noteTags as string[]) + : undefined, + ruckLoad: r.ruckLoad != null ? weightSchema.parse(r.ruckLoad) : undefined, + elevationGain: + r.elevationGain != null ? distanceSchema.parse(r.elevationGain) : undefined, + } + } + + private mapUserProfile(raw: Record): UserProfile { + const r = camelizeKeys(raw) + return { + id: r.id as string, + createdAt: r.createdAt as string, + updatedAt: r.updatedAt as string, + displayName: (r.displayName ?? undefined) as string | undefined, + preferredUnits: preferredUnitsSchema.parse(r.preferredUnits), + bodyweight: + r.bodyweight != null ? weightSchema.parse(r.bodyweight) : undefined, + trainingAge: + r.trainingAge != null ? durationSchema.parse(r.trainingAge) : undefined, + exerciseMaxes: + r.exerciseMaxes != null + ? z.record(entityId, oneRepMaxSchema).parse(r.exerciseMaxes) + : {}, + maxReps: + r.maxReps != null + ? z.record(entityId, z.number().int().positive()).parse(r.maxReps) + : {}, + } + } + + private mapGym(raw: Record): Gym { + const r = camelizeKeys(raw) + if (r.id == null || r.name == null || r.ownerUserId == null) { + console.warn('[supabase-adapter] mapGym: missing required fields', raw) + } + try { + return gymSchema.parse({ + id: r.id, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + name: r.name, + ownerUserId: r.ownerUserId, + }) + } catch (err) { + console.error('[supabase-adapter] Failed to map gym row:', err, raw) + throw new Error(`Failed to map gym row (id=${r.id as string}): ${(err as Error).message}`) + } + } + + private mapGymMember(raw: Record): GymMember { + const r = camelizeKeys(raw) + if (r.gymId == null || r.userId == null) { + console.warn('[supabase-adapter] mapGymMember: missing required fields', raw) + } + try { + return gymMemberSchema.parse({ + gymId: r.gymId, + userId: r.userId, + joinedAt: r.joinedAt, + }) + } catch (err) { + console.error('[supabase-adapter] Failed to map gym_member row:', err, raw) + throw new Error( + `Failed to map gym_member row (gym=${r.gymId as string}, user=${r.userId as string}): ${(err as Error).message}`, + ) + } + } + + + private mapOneRepMaxHistory(raw: Record): OneRepMaxHistory { + const r = camelizeKeys(raw) + return { + id: r.id as string, + createdAt: r.createdAt as string, + userId: r.userId as string, + exerciseId: r.exerciseId as string, + weight: weightSchema.parse(r.weight), + estimated: r.estimated as boolean, + recordedAt: r.recordedAt as string, + } + } + + private mapSessionTemplate(raw: Record): SessionTemplate { + const r = camelizeKeys(raw) + return { + id: r.id as string, + createdAt: r.createdAt as string, + updatedAt: r.updatedAt as string, + userId: r.userId as string, + name: r.name as string, + description: (r.description ?? undefined) as string | undefined, + category: sessionTypeSchema.parse(r.category), + restBetweenGroups: + r.restBetweenGroups != null + ? durationSchema.parse(parseJsonOrValue(r.restBetweenGroups as string | object)) + : undefined, + timeCap: + r.timeCap != null + ? durationSchema.parse(parseJsonOrValue(r.timeCap as string | object)) + : undefined, + scoring: scoringTypeSchema.parse(r.scoring), + eventMetadata: + r.eventMetadata != null + ? eventMetadataSchema.parse(parseJsonOrValue(r.eventMetadata as string | object)) + : undefined, + lastAssignedAt: (r.lastAssignedAt ?? undefined) as string | undefined, + isPublic: r.isPublic as boolean, + } + } + + private mapActivityGroupFlat(raw: Record): Omit { + const r = camelizeKeys(raw) + return { + id: r.id as string, + sessionTemplateId: r.sessionTemplateId as string, + groupType: groupTypeSchema.parse(r.groupType), + ordinal: r.ordinal as number, + rounds: (r.rounds ?? undefined) as number | undefined, + restBetweenRounds: + r.restBetweenRounds != null + ? durationSchema.parse(parseJsonOrValue(r.restBetweenRounds as string | object)) + : undefined, + restBetweenActivities: + r.restBetweenActivities != null + ? durationSchema.parse(parseJsonOrValue(r.restBetweenActivities as string | object)) + : undefined, + } + } + + private mapActivity(raw: Record): Activity { + const r = camelizeKeys(raw) + return { + id: r.id as string, + activityGroupId: r.activityGroupId as string, + exerciseId: r.exerciseId as string, + ordinal: r.ordinal as number, + setScheme: setSchemeSchema.parse(parseJsonOrValue(r.setScheme as string | object)), + notes: (r.notes ?? undefined) as string | undefined, + } + } + + private mapProgram(raw: Record): Program { + const r = camelizeKeys(raw) + try { + return { + id: r.id as string, + createdAt: r.createdAt as string, + updatedAt: r.updatedAt as string, + userId: r.userId as string, + name: r.name as string, + description: (r.description ?? undefined) as string | undefined, + source: programSourceSchema.parse(r.source), + durationWeeks: (r.durationWeeks ?? undefined) as number | undefined, + isPublic: r.isPublic as boolean, + createdBy: (r.createdBy ?? r.userId) as string, + } + } catch (err) { + throw new Error( + `Failed to map program "${r.name as string}" (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) + } + } + + private mapBlock(raw: Record): Block { + const r = camelizeKeys(raw) + try { + return { + id: r.id as string, + programId: r.programId as string, + name: r.name as string, + ordinal: r.ordinal as number, + durationWeeks: r.durationWeeks as number, + blockType: blockTypeSchema.parse(r.blockType), + } + } catch (err) { + throw new Error( + `Failed to map block "${r.name as string}" (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) + } + } + + private mapBlockWeek(raw: Record): BlockWeek { + const r = camelizeKeys(raw) + return { + id: r.id as string, + blockId: r.blockId as string, + weekNumber: r.weekNumber as number, + } + } + + private mapScheduledSession(raw: Record): ScheduledSession { + const r = camelizeKeys(raw) + try { + let overrides: ScheduledSession['overrides'] + if (r.overrides != null) { + try { + const parsed = + typeof r.overrides === 'string' ? JSON.parse(r.overrides) : r.overrides + overrides = sessionOverridesSchema.parse(parsed) + } catch (err) { + console.warn( + `[supabase-adapter] Failed to parse overrides for scheduled session ${r.id as string}, falling back to undefined:`, + err, + ) + overrides = undefined + } + } + return { + id: r.id as string, + blockWeekId: r.blockWeekId as string, + dayOfWeek: (r.dayOfWeek ?? undefined) as number | undefined, + dayLabel: r.dayLabel as string, + sessionType: sessionTypeSchema.parse(r.sessionType), + sessionTemplateId: r.sessionTemplateId as string, + notes: (r.notes ?? undefined) as string | undefined, + overrides, + } + } catch (err) { + throw new Error( + `Failed to map scheduled session (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) + } + } + + private mapProgramActivation(raw: Record): ProgramActivation { + const r = camelizeKeys(raw) + return { + id: r.id as string, + createdAt: r.createdAt as string, + updatedAt: r.updatedAt as string, + userId: r.userId as string, + programId: r.programId as string, + currentBlockOrdinal: r.currentBlockOrdinal as number, + currentWeekNumber: r.currentWeekNumber as number, + startDate: r.startDate as string, + } + } + + private mapWeekStatus(raw: Record): WeekStatus { + const r = camelizeKeys(raw) + return { + id: r.id as string, + activationId: r.activationId as string, + blockOrdinal: r.blockOrdinal as number, + weekNumber: r.weekNumber as number, + status: weekStatusValueSchema.parse(r.status), + createdAt: r.createdAt as string, + } + } + + private mapShareLink(raw: Record): ShareLink { + const r = camelizeKeys(raw) + return { + id: r.id as string, + token: shareTokenSchema.parse(r.token), + entityType: shareableEntityTypeSchema.parse(r.entityType), + entityId: r.entityId as string, + createdBy: r.createdBy as string, + isActive: r.isActive as boolean, + createdAt: r.createdAt as string, + updatedAt: r.updatedAt as string, + } + } + + private mapEventItem(raw: Record): EventItem { + const r = camelizeKeys(raw) + return { + id: r.id as string, + createdAt: r.createdAt as string, + updatedAt: r.updatedAt as string, + sessionTemplateId: (r.sessionTemplateId ?? undefined) as string | undefined, + workoutLogId: (r.workoutLogId ?? undefined) as string | undefined, + userId: r.userId as string, + name: r.name as string, + category: (r.category ?? undefined) as string | undefined, + quantity: r.quantity as number, + isPacked: r.isPacked as boolean, + sortOrder: r.sortOrder as number, + notes: (r.notes ?? undefined) as string | undefined, + } + } + + private mapConversation(raw: Record, participantUserIds: string[] = []): Conversation { + const r = camelizeKeys(raw) + try { + return conversationSchema.parse({ + id: r.id, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + type: r.type, + title: r.title ?? undefined, + groupId: r.groupId ?? undefined, + participantUserIds, + }) + } catch (err) { + throw new Error( + `Failed to map conversation (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) + } + } + + private mapConversationParticipant(raw: Record): ConversationParticipant { + const r = camelizeKeys(raw) + try { + return conversationParticipantSchema.parse({ + id: r.id, + createdAt: r.joinedAt, + updatedAt: r.lastReadAt ?? r.joinedAt, + conversationId: r.conversationId, + userId: r.userId, + lastReadAt: r.lastReadAt ?? undefined, + isArchived: r.isArchived, + joinedAt: r.joinedAt, + leftAt: r.leftAt ?? undefined, + }) + } catch (err) { + throw new Error( + `Failed to map conversation participant (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) + } + } + + private mapMessage(raw: Record): Message { + const r = camelizeKeys(raw) + try { + return messageSchema.parse({ + id: r.id, + createdAt: r.createdAt, + conversationId: r.conversationId, + senderId: r.senderId ?? undefined, + messageType: r.messageType, + content: r.content ?? undefined, + syncStatus: r.syncStatus ?? undefined, + }) + } catch (err) { + throw new Error( + `Failed to map message (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) + } + } + + private mapMediaAttachment(raw: Record): MediaAttachment { + const r = camelizeKeys(raw) + try { + return { + id: r.id as string, + createdAt: r.createdAt as string, + updatedAt: r.updatedAt as string, + messageId: r.messageId as string, + provider: mediaProviderSchema.parse(r.provider), + providerAssetId: (r.providerAssetId ?? undefined) as string | undefined, + mediaType: mediaTypeSchema.parse(r.mediaType), + originalFilename: (r.originalFilename ?? undefined) as string | undefined, + mimeType: (r.mimeType ?? undefined) as string | undefined, + thumbnailUrl: (r.thumbnailUrl ?? undefined) as string | undefined, + playbackUrl: (r.playbackUrl ?? undefined) as string | undefined, + durationSeconds: (r.durationSeconds ?? undefined) as number | undefined, + fileSizeBytes: (r.fileSizeBytes ?? undefined) as number | undefined, + status: mediaStatusSchema.parse(r.status), + } + } catch (err) { + throw new Error( + `Failed to map media attachment (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) + } + } + // --------------------------------------------------------------------------- // Exercise operations // --------------------------------------------------------------------------- @@ -164,7 +606,7 @@ export class SupabaseAdapter implements DataAdapter { }) if (error) throw error - let exercises = (data as ExerciseRow[]).map(toExercise) + let exercises = (data as unknown as Record[]).map((r) => this.mapExercise(r)) if (filters.category) { exercises = exercises.filter((e) => e.category === filters.category) @@ -208,7 +650,7 @@ export class SupabaseAdapter implements DataAdapter { const { data, error } = await query.order('name') if (error) throw error - return (data as ExerciseRow[]).map(toExercise) + return (data as unknown as Record[]).map((r) => this.mapExercise(r)) } async getExercise(id: string): Promise { @@ -218,7 +660,7 @@ export class SupabaseAdapter implements DataAdapter { .eq('id', id) .maybeSingle() if (error) throw error - return data ? toExercise(data as ExerciseRow) : null + return data ? this.mapExercise(data as unknown as Record) : null } async createExercise( @@ -227,14 +669,22 @@ export class SupabaseAdapter implements DataAdapter { const userId = await this.getCurrentUserId() // RLS policy blocks non-custom inserts from client; force is_custom: true as defense-in-depth const row = { - ...fromExercise(exercise), + name: exercise.name, + aliases: exercise.aliases, + category: exercise.category, + movement_pattern: exercise.movementPattern, + muscle_groups: exercise.muscleGroups, + is_bilateral: exercise.isBilateral, + supports_1rm: exercise.supports1RM, + equipment_required: exercise.equipmentRequired, is_custom: true, + is_public: exercise.isPublic, user_id: userId, } const { data, error } = await this.client.from('exercises').insert(row).select().single() if (error) throw error - return toExercise(data as ExerciseRow) + return this.mapExercise(data as unknown as Record) } // --------------------------------------------------------------------------- @@ -254,7 +704,7 @@ export class SupabaseAdapter implements DataAdapter { const { data, error } = await query if (error) throw error - return (data as WorkoutLogRow[]).map(toWorkoutLog) + return (data as unknown as Record[]).map((r) => this.mapWorkoutLog(r)) } async getWorkoutLogsSummary( @@ -309,7 +759,7 @@ export class SupabaseAdapter implements DataAdapter { } return { - log: toWorkoutLog(row as unknown as WorkoutLogRow), + log: this.mapWorkoutLog(row as unknown as Record), exerciseNames: Array.from(exerciseNameSet), setCount, exerciseCount: exerciseNameSet.size, @@ -324,7 +774,7 @@ export class SupabaseAdapter implements DataAdapter { .eq('id', id) .maybeSingle() if (error) throw error - return data ? toWorkoutLog(data as WorkoutLogRow) : null + return data ? this.mapWorkoutLog(data as unknown as Record) : null } async getWorkoutLogFull(id: string): Promise<{ @@ -347,11 +797,11 @@ export class SupabaseAdapter implements DataAdapter { .eq('workout_log_id', id) .order('ordinal') if (groupError) throw groupError - const groups = (groupData as LoggedActivityGroupRow[]).map(toLoggedActivityGroup) + const groups = (groupData as unknown as Record[]).map((r) => this.mapLoggedActivityGroup(r)) const groupIds = groups.map((g) => g.id) if (groupIds.length === 0) { - return { log: toWorkoutLog(logData as WorkoutLogRow), groups, activities: [], sets: [] } + return { log: this.mapWorkoutLog(logData as unknown as Record), groups, activities: [], sets: [] } } const { data: actData, error: actError } = await this.client @@ -360,11 +810,11 @@ export class SupabaseAdapter implements DataAdapter { .in('logged_group_id', groupIds) .order('ordinal') if (actError) throw actError - const activities = (actData as LoggedActivityRow[]).map(toLoggedActivity) + const activities = (actData as unknown as Record[]).map((r) => this.mapLoggedActivity(r)) const activityIds = activities.map((a) => a.id) if (activityIds.length === 0) { - return { log: toWorkoutLog(logData as WorkoutLogRow), groups, activities, sets: [] } + return { log: this.mapWorkoutLog(logData as unknown as Record), groups, activities, sets: [] } } const { data: setData, error: setError } = await this.client @@ -373,10 +823,10 @@ export class SupabaseAdapter implements DataAdapter { .in('logged_activity_id', activityIds) .order('set_number') if (setError) throw setError - const sets = (setData as LoggedSetRow[]).map(toLoggedSet) + const sets = (setData as unknown as Record[]).map((r) => this.mapLoggedSet(r)) return { - log: toWorkoutLog(logData as WorkoutLogRow), + log: this.mapWorkoutLog(logData as unknown as Record), groups, activities, sets, @@ -386,15 +836,43 @@ export class SupabaseAdapter implements DataAdapter { async createWorkoutLog( log: Omit, ): Promise { - const row = fromWorkoutLog(log) + const row = { + user_id: log.userId, + title: log.title ?? null, + started_at: log.startedAt, + completed_at: log.completedAt ?? null, + session_template_id: log.sessionTemplateId ?? null, + program_context: log.programContext ?? null, + perceived_difficulty: log.perceivedDifficulty ?? null, + bodyweight_at_session: log.bodyweightAtSession ?? null, + overall_notes: log.overallNotes ?? null, + note_tags: log.noteTags ?? [], + event_metadata: log.eventMetadata ?? null, + paused_at: log.pausedAt ?? null, + total_paused_ms: log.totalPausedMs ?? 0, + } const { data, error } = await this.client.from('workout_logs').insert(row).select().single() if (error) throw error - return toWorkoutLog(data as WorkoutLogRow) + return this.mapWorkoutLog(data as unknown as Record) } async updateWorkoutLog(log: WorkoutLog): Promise { - const row = fromWorkoutLog(log) + const row = { + user_id: log.userId, + title: log.title ?? null, + started_at: log.startedAt, + completed_at: log.completedAt ?? null, + session_template_id: log.sessionTemplateId ?? null, + program_context: log.programContext ?? null, + perceived_difficulty: log.perceivedDifficulty ?? null, + bodyweight_at_session: log.bodyweightAtSession ?? null, + overall_notes: log.overallNotes ?? null, + note_tags: log.noteTags ?? [], + event_metadata: log.eventMetadata ?? null, + paused_at: log.pausedAt ?? null, + total_paused_ms: log.totalPausedMs ?? 0, + } const { data, error } = await this.client .from('workout_logs') @@ -403,7 +881,7 @@ export class SupabaseAdapter implements DataAdapter { .select() .single() if (error) throw error - return toWorkoutLog(data as WorkoutLogRow) + return this.mapWorkoutLog(data as unknown as Record) } async deleteWorkoutLog(id: string): Promise { @@ -419,7 +897,14 @@ export class SupabaseAdapter implements DataAdapter { group: Omit, userId: string, ): Promise { - const row = fromLoggedActivityGroup(group, userId) + const row = { + workout_log_id: group.workoutLogId, + user_id: userId, + group_type: group.groupType, + ordinal: group.ordinal, + actual_rounds_completed: group.actualRoundsCompleted ?? null, + completion_time: group.completionTime ?? null, + } const { data, error } = await this.client .from('logged_activity_groups') @@ -427,7 +912,7 @@ export class SupabaseAdapter implements DataAdapter { .select() .single() if (error) throw error - return toLoggedActivityGroup(data as LoggedActivityGroupRow) + return this.mapLoggedActivityGroup(data as unknown as Record) } // --------------------------------------------------------------------------- @@ -438,7 +923,14 @@ export class SupabaseAdapter implements DataAdapter { activity: Omit, userId: string, ): Promise { - const row = fromLoggedActivity(activity, userId) + const row = { + logged_group_id: activity.loggedGroupId, + user_id: userId, + exercise_id: activity.exerciseId, + ordinal: activity.ordinal, + notes: activity.notes ?? null, + note_tags: activity.noteTags ?? [], + } const { data, error } = await this.client .from('logged_activities') @@ -446,7 +938,7 @@ export class SupabaseAdapter implements DataAdapter { .select() .single() if (error) throw error - return toLoggedActivity(data as LoggedActivityRow) + return this.mapLoggedActivity(data as unknown as Record) } // --------------------------------------------------------------------------- @@ -454,15 +946,51 @@ export class SupabaseAdapter implements DataAdapter { // --------------------------------------------------------------------------- async createLoggedSet(set: Omit, userId: string): Promise { - const row = fromLoggedSet(set, userId) + const row = { + logged_activity_id: set.loggedActivityId, + user_id: userId, + set_number: set.setNumber, + set_type: set.setType, + prescribed: set.prescribed ?? null, + actual_reps: set.actualReps ?? null, + actual_weight: set.actualWeight ?? null, + actual_duration: set.actualDuration ?? null, + actual_distance: set.actualDistance ?? null, + actual_pace: set.actualPace ?? null, + actual_heart_rate: set.actualHeartRate ?? null, + ruck_load: set.ruckLoad ?? null, + elevation_gain: set.elevationGain ?? null, + rpe: set.rpe ?? null, + completed: set.completed, + notes: set.notes ?? null, + note_tags: set.noteTags ?? [], + } const { data, error } = await this.client.from('logged_sets').insert(row).select().single() if (error) throw error - return toLoggedSet(data as LoggedSetRow) + return this.mapLoggedSet(data as unknown as Record) } async updateLoggedSet(set: LoggedSet, userId: string): Promise { - const row = fromLoggedSet(set, userId) + const row = { + logged_activity_id: set.loggedActivityId, + user_id: userId, + set_number: set.setNumber, + set_type: set.setType, + prescribed: set.prescribed ?? null, + actual_reps: set.actualReps ?? null, + actual_weight: set.actualWeight ?? null, + actual_duration: set.actualDuration ?? null, + actual_distance: set.actualDistance ?? null, + actual_pace: set.actualPace ?? null, + actual_heart_rate: set.actualHeartRate ?? null, + ruck_load: set.ruckLoad ?? null, + elevation_gain: set.elevationGain ?? null, + rpe: set.rpe ?? null, + completed: set.completed, + notes: set.notes ?? null, + note_tags: set.noteTags ?? [], + } const { data, error } = await this.client .from('logged_sets') @@ -471,7 +999,7 @@ export class SupabaseAdapter implements DataAdapter { .select() .single() if (error) throw error - return toLoggedSet(data as LoggedSetRow) + return this.mapLoggedSet(data as unknown as Record) } async deleteLoggedSet(id: string): Promise { @@ -485,7 +1013,14 @@ export class SupabaseAdapter implements DataAdapter { } async updateLoggedActivity(activity: LoggedActivity, userId: string): Promise { - const row = fromLoggedActivity(activity, userId) + const row = { + logged_group_id: activity.loggedGroupId, + user_id: userId, + exercise_id: activity.exerciseId, + ordinal: activity.ordinal, + notes: activity.notes ?? null, + note_tags: activity.noteTags ?? [], + } const { data, error } = await this.client .from('logged_activities') @@ -494,7 +1029,7 @@ export class SupabaseAdapter implements DataAdapter { .select() .single() if (error) throw error - return toLoggedActivity(data as LoggedActivityRow) + return this.mapLoggedActivity(data as unknown as Record) } async deleteLoggedActivity(id: string): Promise { @@ -511,7 +1046,14 @@ export class SupabaseAdapter implements DataAdapter { group: LoggedActivityGroup, userId: string, ): Promise { - const row = fromLoggedActivityGroup(group, userId) + const row = { + workout_log_id: group.workoutLogId, + user_id: userId, + group_type: group.groupType, + ordinal: group.ordinal, + actual_rounds_completed: group.actualRoundsCompleted ?? null, + completion_time: group.completionTime ?? null, + } const { data, error } = await this.client .from('logged_activity_groups') @@ -520,7 +1062,7 @@ export class SupabaseAdapter implements DataAdapter { .select() .single() if (error) throw error - return toLoggedActivityGroup(data as LoggedActivityGroupRow) + return this.mapLoggedActivityGroup(data as unknown as Record) } async deleteLoggedActivityGroup(id: string): Promise { @@ -544,11 +1086,17 @@ export class SupabaseAdapter implements DataAdapter { .eq('id', userId) .maybeSingle() if (error) throw error - return data ? toUserProfile(data as UserProfileRow) : null + return data ? this.mapUserProfile(data as unknown as Record) : null } async updateUserProfile(profile: Partial & { id: string }): Promise { - const row = fromUserProfile(profile) + const row: Record = { id: profile.id } + if (profile.displayName !== undefined) row.display_name = profile.displayName ?? null + if (profile.preferredUnits !== undefined) row.preferred_units = profile.preferredUnits + if (profile.bodyweight !== undefined) row.bodyweight = profile.bodyweight ?? null + if (profile.trainingAge !== undefined) row.training_age = profile.trainingAge ?? null + if (profile.exerciseMaxes !== undefined) row.exercise_maxes = profile.exerciseMaxes + if (profile.maxReps !== undefined) row.max_reps = profile.maxReps const { data, error } = await this.client .from('user_profiles') @@ -556,13 +1104,19 @@ export class SupabaseAdapter implements DataAdapter { .select() .single() if (error) throw error - return toUserProfile(data as UserProfileRow) + return this.mapUserProfile(data as unknown as Record) } async saveOneRepMax( entry: Omit, ): Promise { - const row = fromOneRepMaxHistory(entry) + const row = { + user_id: entry.userId, + exercise_id: entry.exerciseId, + weight: entry.weight, + estimated: entry.estimated, + recorded_at: entry.recordedAt, + } const { data, error } = await this.client .from('one_rep_max_history') @@ -570,7 +1124,7 @@ export class SupabaseAdapter implements DataAdapter { .select() .single() if (error) throw error - return toOneRepMaxHistory(data as OneRepMaxHistoryRow) + return this.mapOneRepMaxHistory(data as unknown as Record) } // --------------------------------------------------------------------------- @@ -599,8 +1153,8 @@ export class SupabaseAdapter implements DataAdapter { if (error) throw error - const rows = (data ?? []) as unknown as Array<{ gyms: GymRow | null }> - return rows.flatMap((r) => (r.gyms ? [toGym(r.gyms)] : [])) + const rows = (data ?? []) as unknown as Array<{ gyms: Record | null }> + return rows.flatMap((r) => (r.gyms ? [this.mapGym(r.gyms)] : [])) } async listAllGyms(): Promise { @@ -610,14 +1164,14 @@ export class SupabaseAdapter implements DataAdapter { .order('name', { ascending: true }) if (error) throw error - return (data ?? []).map((row) => toGym(row as GymRow)) + return (data ?? []).map((row) => this.mapGym(row as unknown as Record)) } async getGym(gymId: string): Promise { const { data, error } = await this.client.from('gyms').select('*').eq('id', gymId).maybeSingle() if (error) throw error - return data ? toGym(data as GymRow) : null + return data ? this.mapGym(data as unknown as Record) : null } async createGym(input: { name: string }): Promise { @@ -626,21 +1180,23 @@ export class SupabaseAdapter implements DataAdapter { // The RLS insert policy enforces owner_user_id = auth.uid(); we set it // explicitly here as defense in depth and to match the existing // createGroup convention. - const row = fromGym({ + const row = { name: input.name, - ownerUserId: userId, - }) + owner_user_id: userId, + } const { data, error } = await this.client.from('gyms').insert(row).select().single() if (error) throw error - return toGym(data as GymRow) + return this.mapGym(data as unknown as Record) } async updateGym(input: Partial & { id: string }): Promise { // Strip id from the update payload (Postgres enforces primary key) but // use it for the WHERE clause. Owner-only enforcement is RLS-side. const { id, ...rest } = input - const patch = fromGym(rest) + const patch: Record = {} + if (rest.name !== undefined) patch.name = rest.name + if (rest.ownerUserId !== undefined) patch.owner_user_id = rest.ownerUserId const { data, error } = await this.client .from('gyms') @@ -650,7 +1206,7 @@ export class SupabaseAdapter implements DataAdapter { .single() if (error) throw error - return toGym(data as GymRow) + return this.mapGym(data as unknown as Record) } async deleteGym(gymId: string): Promise { @@ -699,7 +1255,7 @@ export class SupabaseAdapter implements DataAdapter { .order('joined_at', { ascending: true }) if (error) throw error - return (data ?? []).map((row) => toGymMember(row as GymMemberRow)) + return (data ?? []).map((row) => this.mapGymMember(row as unknown as Record)) } // --------------------------------------------------------------------------- @@ -727,7 +1283,7 @@ export class SupabaseAdapter implements DataAdapter { // The view exposes nullable columns because Postgres views are typed // optimistically; in practice these are always populated. - const rows = (data ?? []) as GymMemberCountRow[] + const rows = (data ?? []) as unknown as Array<{ gym_id: string | null; member_count: number | null }> return rows.flatMap((r) => r.gym_id == null || r.member_count == null ? [] @@ -754,16 +1310,16 @@ export class SupabaseAdapter implements DataAdapter { throw error } - const row = data as GymInvitationRow + const r = camelizeKeys(data as unknown as Record) return { - id: row.id, - gymId: row.gym_id, - token: row.token, - expiresAt: row.expires_at, - maxUses: row.max_uses, - usesCount: row.uses_count, - createdBy: row.created_by, - createdAt: row.created_at, + id: r.id as string, + gymId: r.gymId as string, + token: r.token as string, + expiresAt: r.expiresAt as string, + maxUses: r.maxUses as number, + usesCount: r.usesCount as number, + createdBy: r.createdBy as string, + createdAt: r.createdAt as string, } } @@ -785,16 +1341,16 @@ export class SupabaseAdapter implements DataAdapter { } return (data ?? []).map((row) => { - const r = row as GymInvitationRow + const r = camelizeKeys(row as unknown as Record) return { - id: r.id, - gymId: r.gym_id, - token: r.token, - expiresAt: r.expires_at, - maxUses: r.max_uses, - usesCount: r.uses_count, - createdBy: r.created_by, - createdAt: r.created_at, + id: r.id as string, + gymId: r.gymId as string, + token: r.token as string, + expiresAt: r.expiresAt as string, + maxUses: r.maxUses as number, + usesCount: r.usesCount as number, + createdBy: r.createdBy as string, + createdAt: r.createdAt as string, } }) } @@ -905,12 +1461,12 @@ export class SupabaseAdapter implements DataAdapter { } if (!data) return null - const row = data as GymOwnershipTransferRow + const r = camelizeKeys(data as unknown as Record) return { - gymId: row.gym_id, - proposedBy: row.proposed_by, - proposedTo: row.proposed_to, - proposedAt: row.proposed_at, + gymId: r.gymId as string, + proposedBy: r.proposedBy as string, + proposedTo: r.proposedTo as string, + proposedAt: r.proposedAt as string, } } @@ -926,7 +1482,7 @@ export class SupabaseAdapter implements DataAdapter { .eq('exercise_id', exerciseId) .order('recorded_at', { ascending: true }) if (error) throw error - return (data as OneRepMaxHistoryRow[]).map(toOneRepMaxHistory) + return (data as unknown as Record[]).map((r) => this.mapOneRepMaxHistory(r)) } async getRecentlyUsedExerciseIds(userId: string, limit = 10): Promise { @@ -985,9 +1541,9 @@ export class SupabaseAdapter implements DataAdapter { logged_group_id: string logged_activity_groups: { workout_log_id: string - workout_logs: WorkoutLogRow + workout_logs: Record } - logged_sets: LoggedSetRow[] + logged_sets: Record[] } const rows = data as unknown as ActivityWithJoins[] @@ -998,13 +1554,13 @@ export class SupabaseAdapter implements DataAdapter { const logId = row.logged_activity_groups.workout_log_id if (!grouped.has(logId)) { grouped.set(logId, { - log: toWorkoutLog(row.logged_activity_groups.workout_logs), + log: this.mapWorkoutLog(row.logged_activity_groups.workout_logs), sets: [], }) } const entry = grouped.get(logId)! for (const setRow of row.logged_sets) { - entry.sets.push(toLoggedSet(setRow)) + entry.sets.push(this.mapLoggedSet(setRow)) } } @@ -1036,7 +1592,7 @@ export class SupabaseAdapter implements DataAdapter { const { data, error } = await query.order('created_at', { ascending: false }) if (error) throw error - return (data as SessionTemplateRow[]).map(toSessionTemplate) + return (data as unknown as Record[]).map((r) => this.mapSessionTemplate(r)) } async getSessionTemplate(id: string): Promise { @@ -1046,7 +1602,7 @@ export class SupabaseAdapter implements DataAdapter { .eq('id', id) .maybeSingle() if (error) throw error - return data ? toSessionTemplate(data as SessionTemplateRow) : null + return data ? this.mapSessionTemplate(data as unknown as Record) : null } async getSessionTemplateFull(id: string): Promise { @@ -1064,10 +1620,10 @@ export class SupabaseAdapter implements DataAdapter { .eq('session_template_id', id) .order('ordinal') if (groupError) throw groupError - const groups = (groupData as ActivityGroupRow[]).map(toActivityGroupFlat) + const groups = (groupData as unknown as Record[]).map((r) => this.mapActivityGroupFlat(r)) const groupIds = groups.map((g) => g.id) - const template = toSessionTemplate(templateData as SessionTemplateRow) + const template = this.mapSessionTemplate(templateData as unknown as Record) if (groupIds.length === 0) { const eventItems = @@ -1086,7 +1642,7 @@ export class SupabaseAdapter implements DataAdapter { .in('activity_group_id', groupIds) .order('ordinal') if (actError) throw actError - const activities = (actData as ActivityRow[]).map(toActivity) + const activities = (actData as unknown as Record[]).map((r) => this.mapActivity(r)) const eventItems = template.category === 'EVENT' ? await this.getEventItems(id, 'template') : [] @@ -1106,7 +1662,19 @@ export class SupabaseAdapter implements DataAdapter { activities: Array> }>, ): Promise { - const templateRow = fromSessionTemplate(template) + const templateRow = { + user_id: template.userId, + name: template.name, + description: template.description ?? null, + category: template.category, + rest_between_groups: template.restBetweenGroups + ? JSON.stringify(template.restBetweenGroups) + : null, + time_cap: template.timeCap ? JSON.stringify(template.timeCap) : null, + scoring: template.scoring, + event_metadata: template.eventMetadata ? JSON.stringify(template.eventMetadata) : null, + is_public: template.isPublic, + } const { data: tData, error: tError } = await this.client .from('session_templates') @@ -1114,13 +1682,24 @@ export class SupabaseAdapter implements DataAdapter { .select() .single() if (tError) throw tError - const createdTemplate = toSessionTemplate(tData as SessionTemplateRow) + const createdTemplate = this.mapSessionTemplate(tData as unknown as Record) const allGroups: Array> = [] const allActivities: Activity[] = [] for (const groupInput of groups) { - const groupRow = fromActivityGroup(groupInput.group, createdTemplate.id) + const groupRow = { + session_template_id: createdTemplate.id, + group_type: groupInput.group.groupType, + ordinal: groupInput.group.ordinal, + rounds: groupInput.group.rounds ?? null, + rest_between_rounds: groupInput.group.restBetweenRounds + ? JSON.stringify(groupInput.group.restBetweenRounds) + : null, + rest_between_activities: groupInput.group.restBetweenActivities + ? JSON.stringify(groupInput.group.restBetweenActivities) + : null, + } const { data: gData, error: gError } = await this.client .from('activity_groups') @@ -1128,11 +1707,17 @@ export class SupabaseAdapter implements DataAdapter { .select() .single() if (gError) throw gError - const createdGroup = toActivityGroupFlat(gData as ActivityGroupRow) + const createdGroup = this.mapActivityGroupFlat(gData as unknown as Record) allGroups.push(createdGroup) for (const actInput of groupInput.activities) { - const actRow = fromActivity(actInput, createdGroup.id) + const actRow = { + activity_group_id: createdGroup.id, + exercise_id: actInput.exerciseId, + ordinal: actInput.ordinal, + set_scheme: JSON.stringify(actInput.setScheme), + notes: actInput.notes ?? null, + } const { data: aData, error: aError } = await this.client .from('activities') @@ -1140,7 +1725,7 @@ export class SupabaseAdapter implements DataAdapter { .select() .single() if (aError) throw aError - allActivities.push(toActivity(aData as ActivityRow)) + allActivities.push(this.mapActivity(aData as unknown as Record)) } } @@ -1160,7 +1745,19 @@ export class SupabaseAdapter implements DataAdapter { activities: Array> }>, ): Promise { - const templateRow = fromSessionTemplate(template) + const templateRow = { + user_id: template.userId, + name: template.name, + description: template.description ?? null, + category: template.category, + rest_between_groups: template.restBetweenGroups + ? JSON.stringify(template.restBetweenGroups) + : null, + time_cap: template.timeCap ? JSON.stringify(template.timeCap) : null, + scoring: template.scoring, + event_metadata: template.eventMetadata ? JSON.stringify(template.eventMetadata) : null, + is_public: template.isPublic, + } const { data: tData, error: tError } = await this.client .from('session_templates') @@ -1169,7 +1766,7 @@ export class SupabaseAdapter implements DataAdapter { .select() .single() if (tError) throw tError - const updatedTemplate = toSessionTemplate(tData as SessionTemplateRow) + const updatedTemplate = this.mapSessionTemplate(tData as unknown as Record) // Delete existing groups (cascade handles activities) const { error: delError } = await this.client @@ -1182,7 +1779,18 @@ export class SupabaseAdapter implements DataAdapter { const allActivities: Activity[] = [] for (const groupInput of groups) { - const groupRow = fromActivityGroup(groupInput.group, template.id) + const groupRow = { + session_template_id: template.id, + group_type: groupInput.group.groupType, + ordinal: groupInput.group.ordinal, + rounds: groupInput.group.rounds ?? null, + rest_between_rounds: groupInput.group.restBetweenRounds + ? JSON.stringify(groupInput.group.restBetweenRounds) + : null, + rest_between_activities: groupInput.group.restBetweenActivities + ? JSON.stringify(groupInput.group.restBetweenActivities) + : null, + } const { data: gData, error: gError } = await this.client .from('activity_groups') @@ -1190,11 +1798,17 @@ export class SupabaseAdapter implements DataAdapter { .select() .single() if (gError) throw gError - const createdGroup = toActivityGroupFlat(gData as ActivityGroupRow) + const createdGroup = this.mapActivityGroupFlat(gData as unknown as Record) allGroups.push(createdGroup) for (const actInput of groupInput.activities) { - const actRow = fromActivity(actInput, createdGroup.id) + const actRow = { + activity_group_id: createdGroup.id, + exercise_id: actInput.exerciseId, + ordinal: actInput.ordinal, + set_scheme: JSON.stringify(actInput.setScheme), + notes: actInput.notes ?? null, + } const { data: aData, error: aError } = await this.client .from('activities') @@ -1202,7 +1816,7 @@ export class SupabaseAdapter implements DataAdapter { .select() .single() if (aError) throw aError - allActivities.push(toActivity(aData as ActivityRow)) + allActivities.push(this.mapActivity(aData as unknown as Record)) } } @@ -1277,7 +1891,7 @@ export class SupabaseAdapter implements DataAdapter { .order('category') .order('sort_order') if (error) throw error - return (data as EventItemRow[]).map(toEventItem) + return (data as unknown as Record[]).map((r) => this.mapEventItem(r)) } async saveEventItem( @@ -1285,10 +1899,20 @@ export class SupabaseAdapter implements DataAdapter { parentId: string, parentType: 'template' | 'log', ): Promise { - const row = fromEventItem(item, parentId, parentType) + const row = { + session_template_id: parentType === 'template' ? parentId : null, + workout_log_id: parentType === 'log' ? parentId : null, + user_id: item.userId, + name: item.name, + category: item.category ?? null, + quantity: item.quantity, + is_packed: item.isPacked, + sort_order: item.sortOrder, + notes: item.notes ?? null, + } const { data, error } = await this.client.from('event_items').insert(row).select().single() if (error) throw error - return toEventItem(data as EventItemRow) + return this.mapEventItem(data as unknown as Record) } async updateEventItem(item: EventItem): Promise { @@ -1306,7 +1930,7 @@ export class SupabaseAdapter implements DataAdapter { .select() .single() if (error) throw error - return toEventItem(data as EventItemRow) + return this.mapEventItem(data as unknown as Record) } async deleteEventItem(itemId: string): Promise { @@ -1322,7 +1946,7 @@ export class SupabaseAdapter implements DataAdapter { .select() .single() if (error) throw error - return toEventItem(data as EventItemRow) + return this.mapEventItem(data as unknown as Record) } async reorderEventItems(items: Array<{ id: string; sortOrder: number }>): Promise { @@ -1357,7 +1981,7 @@ export class SupabaseAdapter implements DataAdapter { const { data, error } = await query.order('created_at', { ascending: false }) if (error) throw error - return (data as ProgramRow[]).map(toProgram) + return (data as unknown as Record[]).map((r) => this.mapProgram(r)) } async getProgramFull(id: string): Promise { @@ -1375,12 +1999,12 @@ export class SupabaseAdapter implements DataAdapter { .eq('program_id', id) .order('ordinal', { ascending: true }) if (blockError) throw blockError - const blocks = (blockData as BlockRow[]).map(toBlock) + const blocks = (blockData as unknown as Record[]).map((r) => this.mapBlock(r)) const blockIds = blocks.map((b) => b.id) if (blockIds.length === 0) { return { - program: toProgram(programData as ProgramRow), + program: this.mapProgram(programData as unknown as Record), blocks: [], blockWeeks: [], scheduledSessions: [], @@ -1393,12 +2017,12 @@ export class SupabaseAdapter implements DataAdapter { .in('block_id', blockIds) .order('week_number', { ascending: true }) if (weekError) throw weekError - const blockWeeks = (weekData as BlockWeekRow[]).map(toBlockWeek) + const blockWeeks = (weekData as unknown as Record[]).map((r) => this.mapBlockWeek(r)) const weekIds = blockWeeks.map((w) => w.id) if (weekIds.length === 0) { return { - program: toProgram(programData as ProgramRow), + program: this.mapProgram(programData as unknown as Record), blocks, blockWeeks: [], scheduledSessions: [], @@ -1410,10 +2034,10 @@ export class SupabaseAdapter implements DataAdapter { .select('*') .in('block_week_id', weekIds) if (sessionError) throw sessionError - const scheduledSessions = (sessionData as ScheduledSessionRow[]).map(toScheduledSession) + const scheduledSessions = (sessionData as unknown as Record[]).map((r) => this.mapScheduledSession(r)) return { - program: toProgram(programData as ProgramRow), + program: this.mapProgram(programData as unknown as Record), blocks, blockWeeks, scheduledSessions, @@ -1434,7 +2058,13 @@ export class SupabaseAdapter implements DataAdapter { const programId = crypto.randomUUID() const programRow = { id: programId, - ...fromProgram(program), + user_id: program.userId, + name: program.name, + description: program.description ?? null, + source: program.source, + duration_weeks: program.durationWeeks ?? null, + is_public: program.isPublic, + created_by: program.createdBy ?? null, } const { error: pError } = await this.client @@ -1448,7 +2078,11 @@ export class SupabaseAdapter implements DataAdapter { const blockId = crypto.randomUUID() const blockRow = { id: blockId, - ...fromBlock(blockEntry.block, programId), + program_id: programId, + name: blockEntry.block.name, + ordinal: blockEntry.block.ordinal, + duration_weeks: blockEntry.block.durationWeeks, + block_type: blockEntry.block.blockType, } const { error: bError } = await this.client.from('blocks').insert(blockRow).select().single() @@ -1458,7 +2092,8 @@ export class SupabaseAdapter implements DataAdapter { const weekId = crypto.randomUUID() const weekRow = { id: weekId, - ...fromBlockWeek(weekEntry.week, blockId), + block_id: blockId, + week_number: weekEntry.week.weekNumber, } const { error: wError } = await this.client @@ -1472,7 +2107,13 @@ export class SupabaseAdapter implements DataAdapter { const sessionId = crypto.randomUUID() const sessionRow = { id: sessionId, - ...fromScheduledSession(sessionInput, weekId), + block_week_id: weekId, + day_of_week: sessionInput.dayOfWeek ?? null, + day_label: sessionInput.dayLabel, + session_type: sessionInput.sessionType, + session_template_id: sessionInput.sessionTemplateId, + notes: sessionInput.notes ?? null, + overrides: sessionInput.overrides ?? null, } const { error: sError } = await this.client @@ -1501,7 +2142,14 @@ export class SupabaseAdapter implements DataAdapter { }> }>, ): Promise { - const { user_id: _, ...updateFields } = fromProgram(program) + const updateFields = { + name: program.name, + description: program.description ?? null, + source: program.source, + duration_weeks: program.durationWeeks ?? null, + is_public: program.isPublic, + created_by: program.createdBy ?? null, + } const { error: pError } = await this.client .from('programs') .update(updateFields) @@ -1520,7 +2168,11 @@ export class SupabaseAdapter implements DataAdapter { const blockId = blockEntry.block.id ?? crypto.randomUUID() const blockRow = { id: blockId, - ...fromBlock(blockEntry.block, program.id), + program_id: program.id, + name: blockEntry.block.name, + ordinal: blockEntry.block.ordinal, + duration_weeks: blockEntry.block.durationWeeks, + block_type: blockEntry.block.blockType, } const { error: bError } = await this.client.from('blocks').insert(blockRow).select().single() @@ -1530,7 +2182,8 @@ export class SupabaseAdapter implements DataAdapter { const weekId = weekEntry.week.id ?? crypto.randomUUID() const weekRow = { id: weekId, - ...fromBlockWeek(weekEntry.week, blockId), + block_id: blockId, + week_number: weekEntry.week.weekNumber, } const { error: wError } = await this.client @@ -1544,7 +2197,13 @@ export class SupabaseAdapter implements DataAdapter { const sessionId = crypto.randomUUID() const sessionRow = { id: sessionId, - ...fromScheduledSession(sessionInput, weekId), + block_week_id: weekId, + day_of_week: sessionInput.dayOfWeek ?? null, + day_label: sessionInput.dayLabel, + session_type: sessionInput.sessionType, + session_template_id: sessionInput.sessionTemplateId, + notes: sessionInput.notes ?? null, + overrides: sessionInput.overrides ?? null, } const { error: sError } = await this.client @@ -1578,7 +2237,7 @@ export class SupabaseAdapter implements DataAdapter { p_group_id: groupId, }) if (error) throw error - return toProgram(data) + return this.mapProgram(data as unknown as Record) } // --------------------------------------------------------------------------- @@ -1685,7 +2344,7 @@ export class SupabaseAdapter implements DataAdapter { .eq('user_id', userId) .maybeSingle() if (error) throw error - return data ? toProgramActivation(data as ProgramActivationRow) : null + return data ? this.mapProgramActivation(data as unknown as Record) : null } async setActiveProgram( @@ -1710,7 +2369,7 @@ export class SupabaseAdapter implements DataAdapter { .select() .single() if (error) throw error - return toProgramActivation(data as ProgramActivationRow) + return this.mapProgramActivation(data as unknown as Record) } async updateActiveProgram( @@ -1737,7 +2396,7 @@ export class SupabaseAdapter implements DataAdapter { .select() .single() if (error) throw error - return toProgramActivation(data as ProgramActivationRow) + return this.mapProgramActivation(data as unknown as Record) } async clearActiveProgram(userId: string): Promise { @@ -1757,7 +2416,7 @@ export class SupabaseAdapter implements DataAdapter { .order('block_ordinal', { ascending: true }) .order('week_number', { ascending: true }) if (error) throw error - return (data as ProgramWeekStatusRow[]).map(toWeekStatus) + return (data as unknown as Record[]).map((row) => this.mapWeekStatus(row)) } async upsertWeekStatuses( @@ -1809,7 +2468,7 @@ export class SupabaseAdapter implements DataAdapter { .eq('created_by', userId) .order('created_at', { ascending: false }) if (error) throw error - return (data as ShareLinkRow[]).map(toShareLink) + return (data as unknown as Record[]).map((row) => this.mapShareLink(row)) } async getShareLinksForEntity( @@ -1823,7 +2482,7 @@ export class SupabaseAdapter implements DataAdapter { .eq('entity_id', entityId) .order('created_at', { ascending: false }) if (error) throw error - return (data as ShareLinkRow[]).map(toShareLink) + return (data as unknown as Record[]).map((row) => this.mapShareLink(row)) } async createShareLink( @@ -1831,11 +2490,16 @@ export class SupabaseAdapter implements DataAdapter { ): Promise { const { data, error } = await this.client .from('share_links') - .insert(fromShareLink(link)) + .insert({ + token: link.token, + entity_type: link.entityType, + entity_id: link.entityId, + created_by: link.createdBy, + }) .select() .single() if (error) throw error - return toShareLink(data as ShareLinkRow) + return this.mapShareLink(data as unknown as Record) } async revokeShareLink(id: string): Promise { @@ -2498,14 +3162,13 @@ export class SupabaseAdapter implements DataAdapter { const userId = await this.getCurrentUserId() // 1. Insert the conversation row - const conversationRow = fromConversation({ - type, - title, - groupId, - }) const { data: convData, error: convError } = await this.client .from('conversations') - .insert(conversationRow) + .insert({ + type, + title: title ?? null, + group_id: groupId ?? null, + }) .select() .single() if (convError) throw convError @@ -2514,13 +3177,13 @@ export class SupabaseAdapter implements DataAdapter { const allParticipantIds = [...new Set([userId, ...participantIds])] for (const pid of allParticipantIds) { const { error: pError } = await this.client.from('conversation_participants').insert({ - conversation_id: (convData as ConversationRow).id, + conversation_id: (convData as { id: string }).id, user_id: pid, }) if (pError) throw pError } - return toConversation(convData as ConversationRow, allParticipantIds) + return this.mapConversation(convData as unknown as Record, allParticipantIds) } async getConversations(): Promise { @@ -2559,8 +3222,8 @@ export class SupabaseAdapter implements DataAdapter { participantsByConv.set(p.conversation_id, list) } - return (data as ConversationRow[]).map((row) => - toConversation(row, participantsByConv.get(row.id) ?? []), + return (data as unknown as Record[]).map((row) => + this.mapConversation(row, participantsByConv.get(row.id as string) ?? []), ) } @@ -2580,8 +3243,8 @@ export class SupabaseAdapter implements DataAdapter { .is('left_at', null) if (pError) throw pError - return toConversation( - data as ConversationRow, + return this.mapConversation( + data as unknown as Record, (participants ?? []).map((p) => p.user_id), ) } @@ -2635,18 +3298,19 @@ export class SupabaseAdapter implements DataAdapter { ): Promise { const userId = await this.getCurrentUserId() - const row = fromMessage({ - conversationId, - senderId: userId, - messageType, - content, - }) - // Remove sync_status -- it is SQLite-only and not a column in the Supabase table - delete row.sync_status - - const { data, error } = await this.client.from('messages').insert(row).select().single() + // sync_status is SQLite-only and not a column in the Supabase table + const { data, error } = await this.client + .from('messages') + .insert({ + conversation_id: conversationId, + sender_id: userId, + message_type: messageType, + content: content ?? null, + }) + .select() + .single() if (error) throw error - return toMessage(data as MessageRow) + return this.mapMessage(data as unknown as Record) } async getMessages( @@ -2666,7 +3330,7 @@ export class SupabaseAdapter implements DataAdapter { const { data, error } = await query if (error) throw error - return (data as MessageRow[]).map(toMessage).reverse() + return (data as unknown as Record[]).map((row) => this.mapMessage(row)).reverse() } async getMessagesSince(conversationId: string, since: string): Promise { @@ -2677,7 +3341,7 @@ export class SupabaseAdapter implements DataAdapter { .gt('created_at', since) .order('created_at', { ascending: true }) if (error) throw error - return (data as MessageRow[]).map(toMessage) + return (data as unknown as Record[]).map((row) => this.mapMessage(row)) } async updateLastRead(conversationId: string): Promise { @@ -2752,7 +3416,7 @@ export class SupabaseAdapter implements DataAdapter { .select() .single() if (error) throw error - return toConversationParticipant(data as ConversationParticipantRow) + return this.mapConversationParticipant(data as unknown as Record) } async leaveConversation(conversationId: string): Promise { @@ -2791,14 +3455,25 @@ export class SupabaseAdapter implements DataAdapter { messageId: string, attachment: Omit, ): Promise { - const row = fromMediaAttachment({ ...attachment, messageId }) const { data, error } = await this.client .from('media_attachments') - .insert(row) + .insert({ + message_id: messageId, + provider: attachment.provider, + provider_asset_id: attachment.providerAssetId ?? null, + media_type: attachment.mediaType, + original_filename: attachment.originalFilename ?? null, + mime_type: attachment.mimeType ?? null, + thumbnail_url: attachment.thumbnailUrl ?? null, + playback_url: attachment.playbackUrl ?? null, + duration_seconds: attachment.durationSeconds ?? null, + file_size_bytes: attachment.fileSizeBytes ?? null, + status: attachment.status, + }) .select() .single() if (error) throw error - return toMediaAttachment(data as MediaAttachmentRow) + return this.mapMediaAttachment(data as unknown as Record) } async getMediaAttachments(messageIds: string[]): Promise { @@ -2809,7 +3484,7 @@ export class SupabaseAdapter implements DataAdapter { .select('*') .in('message_id', messageIds) if (error) throw error - return (data as MediaAttachmentRow[]).map(toMediaAttachment) + return (data as unknown as Record[]).map((row) => this.mapMediaAttachment(row)) } async updateMediaAttachment( @@ -2831,7 +3506,7 @@ export class SupabaseAdapter implements DataAdapter { .select() .single() if (error) throw error - return toMediaAttachment(data as MediaAttachmentRow) + return this.mapMediaAttachment(data as unknown as Record) } } diff --git a/src/lib/tauri-adapter.ts b/src/lib/tauri-adapter.ts index eba14ac3..1c7a9ce3 100644 --- a/src/lib/tauri-adapter.ts +++ b/src/lib/tauri-adapter.ts @@ -53,62 +53,37 @@ import type { GymOwnershipTransfer, RedeemInviteError, } from '@/domain/types' -import type { - ExerciseRow, - WorkoutLogRow, - LoggedActivityGroupRow, - LoggedActivityRow, - LoggedSetRow, - UserProfileRow, - OneRepMaxHistoryRow, - SessionTemplateRow, - ActivityGroupRow, - ActivityRow, - ProgramRow, - BlockRow, - BlockWeekRow, - ScheduledSessionRow, - ProgramActivationRow, - ProgramWeekStatusRow, - AccountabilityGroupRow, - GroupMemberRow, - GroupInviteRow, - DirectConnectionRow, - ConversationRow, - MessageRow, - MediaAttachmentRow, -} from './database.types' +import { z } from 'zod' import { - toExercise, - fromExercise, - toWorkoutLog, - fromWorkoutLog, - toLoggedActivityGroup, - fromLoggedActivityGroup, - toLoggedActivity, - fromLoggedActivity, - toLoggedSet, - fromLoggedSet, - toUserProfile, - fromUserProfile, - toOneRepMaxHistory, - fromOneRepMaxHistory, - toSessionTemplate, - fromSessionTemplate, - toActivityGroupFlat, - fromActivityGroup, - toActivity, - fromActivity, - toProgram, - toBlock, - toBlockWeek, - toScheduledSession, - toProgramActivation, - toWeekStatus, - toConversation, - toMessage, - toMediaAttachment, -} from './data-mapper' + exerciseCategorySchema, + movementPatternSchema, + muscleGroupSpecSchema, + equipmentSchema, + weightSchema, + durationSchema, + distanceSchema, + paceSchema, + programContextSchema, + prescriptionSchema, + groupTypeSchema, + setTypeSchema, + preferredUnitsSchema, + oneRepMaxSchema, + sessionTypeSchema, + scoringTypeSchema, + setSchemeSchema, + programSourceSchema, + blockTypeSchema, + sessionOverridesSchema, + weekStatusValueSchema, + conversationSchema, + messageSchema, + mediaProviderSchema, + mediaTypeSchema, + mediaStatusSchema, + entityId, +} from '@/domain/types' +import { parseJsonOrValue } from './adapter-utils' import { toAccountabilityGroup, toGroupMember, @@ -571,13 +546,7 @@ async function invokeCommand(cmd: string, args: Record): Pro } // --------------------------------------------------------------------------- -// Conversion helpers: Tauri Response -> TS Row types -// -// The existing mapper functions (toExercise, toWorkoutLog, etc.) expect the -// database.types.ts Row shapes. Tauri commands return slightly different shapes: -// - booleans as 0/1 integers -// - JSON columns as raw strings instead of parsed objects -// These helpers bridge that gap. +// Low-level conversion utilities // --------------------------------------------------------------------------- /** Convert an ISO 8601 date string to Unix seconds for Rust commands. */ @@ -641,30 +610,6 @@ function intToBool(value: number | null | undefined, fallback = false): boolean return value !== 0 } -function toExerciseRow(r: TauriExerciseResponse): ExerciseRow { - if (!r.movement_pattern) { - throw new Error( - `Exercise "${r.name}" (${r.id}) has no movement_pattern -- this field is required by the domain model`, - ) - } - return { - id: r.id, - name: r.name, - aliases: parseJson(r.aliases, 'aliases'), - category: r.category, - movement_pattern: r.movement_pattern, - muscle_groups: parseJson(r.muscle_groups, 'muscle_groups'), - is_bilateral: intToBool(r.is_bilateral), - supports_1rm: intToBool(r.supports_1rm), - equipment_required: parseJson(r.equipment_required, 'equipment_required'), - is_custom: intToBool(r.is_custom), - is_public: intToBool(r.is_public), - user_id: null, - created_at: r.created_at ?? new Date().toISOString(), - updated_at: r.updated_at ?? new Date().toISOString(), - } -} - // One-shot warning when pause state is dropped on the Tauri adapter. // Per ADR-013, pause-state persistence to local SQLite is deferred (F018). // The interim behavior is to silently coerce paused_at/total_paused_ms to @@ -677,251 +622,486 @@ function _warnPauseFieldsDroppedOnce(): void { console.warn('[tauri-adapter] Pause state not persisted on mobile (F018/ADR-013 deferred)') } -function toWorkoutLogRow(r: TauriWorkoutLogResponse): WorkoutLogRow { +// --------------------------------------------------------------------------- +// Inline domain mappers: Tauri Response -> Domain types +// +// These functions replace the two-step pipeline (toXxxRow + data-mapper toXxx) +// with a single pass. Each function handles the Tauri-specific concerns +// (int->bool, JSON string parsing) and then maps directly to the domain type. +// --------------------------------------------------------------------------- + +function toExercise(r: TauriExerciseResponse): Exercise { + if (!r.movement_pattern) { + throw new Error( + `Exercise "${r.name}" (${r.id}) has no movement_pattern -- this field is required by the domain model`, + ) + } return { id: r.id, - user_id: requireString(r.user_id, 'user_id'), - title: r.title, - started_at: r.started_at, - completed_at: r.completed_at, - session_template_id: r.session_template_id, - program_context: parseJson(r.program_context, 'program_context'), - perceived_difficulty: r.perceived_difficulty, - bodyweight_at_session: parseJson(r.bodyweight_at_session, 'bodyweight_at_session'), - overall_notes: r.overall_notes, - note_tags: parseNoteTags(r.note_tags, 'workout_logs.note_tags'), - event_metadata: null, // Event features deferred for Tauri offline mode (W-8) - paused_at: null, // Pause state deferred for Tauri offline mode (F018) - total_paused_ms: 0, - created_at: r.created_at ?? new Date().toISOString(), - updated_at: r.updated_at ?? new Date().toISOString(), + createdAt: r.created_at ?? new Date().toISOString(), + updatedAt: r.updated_at ?? new Date().toISOString(), + name: r.name, + aliases: z.array(z.string()).parse(parseJson(r.aliases, 'aliases')), + category: exerciseCategorySchema.parse(r.category), + movementPattern: movementPatternSchema.parse(r.movement_pattern), + muscleGroups: muscleGroupSpecSchema.parse(parseJson(r.muscle_groups, 'muscle_groups')), + isBilateral: intToBool(r.is_bilateral), + supports1RM: intToBool(r.supports_1rm), + equipmentRequired: z + .array(equipmentSchema) + .parse(parseJson(r.equipment_required, 'equipment_required')), + isCustom: intToBool(r.is_custom), + isPublic: intToBool(r.is_public), + } +} + +function fromExercise( + exercise: Omit, +): Record { + return { + name: exercise.name, + aliases: exercise.aliases, + category: exercise.category, + movement_pattern: exercise.movementPattern, + muscle_groups: exercise.muscleGroups, + is_bilateral: exercise.isBilateral, + supports_1rm: exercise.supports1RM, + equipment_required: exercise.equipmentRequired, + is_custom: exercise.isCustom, + is_public: exercise.isPublic, + } +} + +function toWorkoutLog(r: TauriWorkoutLogResponse): WorkoutLog { + const programContext = parseJson(r.program_context, 'program_context') + const bodyweightAtSession = parseJson(r.bodyweight_at_session, 'bodyweight_at_session') + return { + id: r.id, + createdAt: r.created_at ?? new Date().toISOString(), + updatedAt: r.updated_at ?? new Date().toISOString(), + userId: requireString(r.user_id, 'user_id'), + title: r.title ?? undefined, + startedAt: r.started_at, + completedAt: r.completed_at ?? undefined, + sessionTemplateId: r.session_template_id ?? undefined, + programContext: programContext != null ? programContextSchema.parse(programContext) : undefined, + perceivedDifficulty: r.perceived_difficulty ?? undefined, + bodyweightAtSession: + bodyweightAtSession != null ? weightSchema.parse(bodyweightAtSession) : undefined, + overallNotes: r.overall_notes ?? undefined, + noteTags: (() => { + const tags = parseNoteTags(r.note_tags, 'workout_logs.note_tags') + return tags.length > 0 ? tags : undefined + })(), + eventMetadata: undefined, // Event features deferred for Tauri offline mode (W-8) + pausedAt: undefined, // Pause state deferred for Tauri offline mode (F018) + totalPausedMs: 0, + } +} + +function fromWorkoutLog( + log: Omit, +): Record { + return { + user_id: log.userId, + title: log.title ?? null, + started_at: log.startedAt, + completed_at: log.completedAt ?? null, + session_template_id: log.sessionTemplateId ?? null, + program_context: log.programContext ?? null, + perceived_difficulty: log.perceivedDifficulty ?? null, + bodyweight_at_session: log.bodyweightAtSession ?? null, + overall_notes: log.overallNotes ?? null, + note_tags: log.noteTags ?? [], + event_metadata: null, + paused_at: log.pausedAt ?? null, + total_paused_ms: log.totalPausedMs ?? 0, + } +} + +function toLoggedActivityGroup(r: TauriLoggedActivityGroupResponse): LoggedActivityGroup { + const completionTime = parseJson(r.completion_time, 'completion_time') + return { + id: r.id, + workoutLogId: r.workout_log_id, + groupType: groupTypeSchema.parse(r.group_type), + ordinal: r.ordinal, + actualRoundsCompleted: r.actual_rounds_completed ?? undefined, + completionTime: completionTime != null ? durationSchema.parse(completionTime) : undefined, } } -function toLoggedActivityGroupRow(r: TauriLoggedActivityGroupResponse): LoggedActivityGroupRow { +function fromLoggedActivityGroup( + group: Omit, + userId: string, +): Record { return { - id: r.id, - workout_log_id: r.workout_log_id, - user_id: requireString(r.user_id, 'user_id'), - group_type: r.group_type, - ordinal: r.ordinal, - actual_rounds_completed: r.actual_rounds_completed, - completion_time: parseJson(r.completion_time, 'completion_time'), - created_at: r.created_at ?? new Date().toISOString(), - updated_at: r.updated_at ?? new Date().toISOString(), + workout_log_id: group.workoutLogId, + user_id: userId, + group_type: group.groupType, + ordinal: group.ordinal, + actual_rounds_completed: group.actualRoundsCompleted ?? null, + completion_time: group.completionTime ?? null, } } -function toLoggedActivityRow(r: TauriLoggedActivityResponse): LoggedActivityRow { +function toLoggedActivity(r: TauriLoggedActivityResponse): LoggedActivity { + const noteTags = parseNoteTags(r.note_tags, 'logged_activities.note_tags') return { id: r.id, - logged_group_id: r.logged_group_id, - user_id: requireString(r.user_id, 'user_id'), - exercise_id: r.exercise_id, + loggedGroupId: r.logged_group_id, + exerciseId: r.exercise_id, ordinal: r.ordinal, - notes: r.notes, - note_tags: parseNoteTags(r.note_tags, 'logged_activities.note_tags'), - created_at: r.created_at ?? new Date().toISOString(), - updated_at: r.updated_at ?? new Date().toISOString(), + notes: r.notes ?? undefined, + noteTags: noteTags.length > 0 ? noteTags : undefined, } } -function toLoggedSetRow(r: TauriLoggedSetResponse): LoggedSetRow { +function fromLoggedActivity( + activity: Omit, + userId: string, +): Record { + return { + logged_group_id: activity.loggedGroupId, + user_id: userId, + exercise_id: activity.exerciseId, + ordinal: activity.ordinal, + notes: activity.notes ?? null, + note_tags: activity.noteTags ?? [], + } +} + +function toLoggedSet(r: TauriLoggedSetResponse): LoggedSet { + const prescribed = parseJson(r.prescribed, 'prescribed') + const actualWeight = parseJson(r.actual_weight, 'actual_weight') + const actualDuration = parseJson(r.actual_duration, 'actual_duration') + const actualDistance = parseJson(r.actual_distance, 'actual_distance') + const actualPace = parseJson(r.actual_pace, 'actual_pace') + const ruckLoad = parseJson(r.ruck_load, 'ruck_load') + const elevationGain = parseJson(r.elevation_gain, 'elevation_gain') + const noteTags = parseNoteTags(r.note_tags, 'logged_sets.note_tags') return { id: r.id, - logged_activity_id: r.logged_activity_id, - user_id: requireString(r.user_id, 'user_id'), - set_number: r.set_number, - set_type: r.set_type, - prescribed: parseJson(r.prescribed, 'prescribed'), - actual_reps: r.actual_reps, - actual_weight: parseJson(r.actual_weight, 'actual_weight'), - actual_duration: parseJson(r.actual_duration, 'actual_duration'), - actual_distance: parseJson(r.actual_distance, 'actual_distance'), - actual_pace: parseJson(r.actual_pace, 'actual_pace'), - actual_heart_rate: r.actual_heart_rate, - ruck_load: parseJson(r.ruck_load, 'ruck_load'), - elevation_gain: parseJson(r.elevation_gain, 'elevation_gain'), - rpe: r.rpe, + loggedActivityId: r.logged_activity_id, + setNumber: r.set_number, + setType: setTypeSchema.parse(r.set_type), + prescribed: prescribed != null ? prescriptionSchema.parse(prescribed) : undefined, + actualReps: r.actual_reps ?? undefined, + actualWeight: actualWeight != null ? weightSchema.parse(actualWeight) : undefined, + actualDuration: actualDuration != null ? durationSchema.parse(actualDuration) : undefined, + actualDistance: actualDistance != null ? distanceSchema.parse(actualDistance) : undefined, + actualPace: actualPace != null ? paceSchema.parse(actualPace) : undefined, + actualHeartRate: r.actual_heart_rate ?? undefined, + rpe: r.rpe ?? undefined, completed: intToBool(r.completed), - notes: r.notes, - note_tags: parseNoteTags(r.note_tags, 'logged_sets.note_tags'), - created_at: r.created_at ?? new Date().toISOString(), - updated_at: r.updated_at ?? new Date().toISOString(), + notes: r.notes ?? undefined, + noteTags: noteTags.length > 0 ? noteTags : undefined, + ruckLoad: ruckLoad != null ? weightSchema.parse(ruckLoad) : undefined, + elevationGain: elevationGain != null ? distanceSchema.parse(elevationGain) : undefined, } } -function toUserProfileRow(r: TauriUserProfileResponse): UserProfileRow { +function fromLoggedSet(set: Omit, userId: string): Record { + return { + logged_activity_id: set.loggedActivityId, + user_id: userId, + set_number: set.setNumber, + set_type: set.setType, + prescribed: set.prescribed ?? null, + actual_reps: set.actualReps ?? null, + actual_weight: set.actualWeight ?? null, + actual_duration: set.actualDuration ?? null, + actual_distance: set.actualDistance ?? null, + actual_pace: set.actualPace ?? null, + actual_heart_rate: set.actualHeartRate ?? null, + ruck_load: set.ruckLoad ?? null, + elevation_gain: set.elevationGain ?? null, + rpe: set.rpe ?? null, + completed: set.completed, + notes: set.notes ?? null, + note_tags: set.noteTags ?? [], + } +} + +function toUserProfile(r: TauriUserProfileResponse): UserProfile { + const bodyweight = parseJson(r.bodyweight, 'bodyweight') + const trainingAge = parseJson(r.training_age, 'training_age') + const exerciseMaxes = parseJson(r.exercise_maxes, 'exercise_maxes') + const maxReps = parseJson(r.max_reps, 'max_reps') return { id: r.id, - display_name: r.display_name, - preferred_units: r.preferred_units ?? 'IMPERIAL', - bodyweight: parseJson(r.bodyweight, 'bodyweight'), - training_age: parseJson(r.training_age, 'training_age'), - exercise_maxes: parseJson(r.exercise_maxes, 'exercise_maxes'), - max_reps: parseJson(r.max_reps, 'max_reps'), - created_at: r.created_at ?? new Date().toISOString(), - updated_at: r.updated_at ?? new Date().toISOString(), + createdAt: r.created_at ?? new Date().toISOString(), + updatedAt: r.updated_at ?? new Date().toISOString(), + displayName: r.display_name ?? undefined, + preferredUnits: preferredUnitsSchema.parse(r.preferred_units ?? 'IMPERIAL'), + bodyweight: bodyweight != null ? weightSchema.parse(bodyweight) : undefined, + trainingAge: trainingAge != null ? durationSchema.parse(trainingAge) : undefined, + exerciseMaxes: + exerciseMaxes != null ? z.record(entityId, oneRepMaxSchema).parse(exerciseMaxes) : {}, + maxReps: maxReps != null ? z.record(entityId, z.number().int().positive()).parse(maxReps) : {}, } } -function toOneRepMaxHistoryRow(r: TauriOneRepMaxHistoryResponse): OneRepMaxHistoryRow { +function fromUserProfile( + profile: Partial & { id: string }, +): Record { + const row: Record = { id: profile.id } + + if (profile.displayName !== undefined) row.display_name = profile.displayName ?? null + if (profile.preferredUnits !== undefined) row.preferred_units = profile.preferredUnits + if (profile.bodyweight !== undefined) row.bodyweight = profile.bodyweight ?? null + if (profile.trainingAge !== undefined) row.training_age = profile.trainingAge ?? null + if (profile.exerciseMaxes !== undefined) row.exercise_maxes = profile.exerciseMaxes + if (profile.maxReps !== undefined) row.max_reps = profile.maxReps + + return row +} + +function toOneRepMaxHistory(r: TauriOneRepMaxHistoryResponse): OneRepMaxHistory { return { id: r.id, - user_id: r.user_id, - exercise_id: r.exercise_id, - weight: parseJson(r.weight, 'weight'), + createdAt: r.created_at ?? new Date().toISOString(), + userId: r.user_id, + exerciseId: r.exercise_id, + weight: weightSchema.parse(parseJson(r.weight, 'weight')), estimated: intToBool(r.estimated), - recorded_at: r.recorded_at, - created_at: r.created_at ?? new Date().toISOString(), + recordedAt: r.recorded_at, + } +} + +function fromOneRepMaxHistory( + entry: Omit, +): Record { + return { + user_id: entry.userId, + exercise_id: entry.exerciseId, + weight: entry.weight, + estimated: entry.estimated, + recorded_at: entry.recordedAt, } } -function toSessionTemplateRowFromTauri(r: TauriSessionTemplateResponse): SessionTemplateRow { +function toSessionTemplate(r: TauriSessionTemplateResponse): SessionTemplate { if (!r.created_at) console.warn('tauri-adapter: null created_at on session template row', r) if (!r.updated_at) console.warn('tauri-adapter: null updated_at on session template row', r) return { id: r.id, - user_id: r.user_id, + createdAt: r.created_at ?? new Date().toISOString(), + updatedAt: r.updated_at ?? new Date().toISOString(), + userId: r.user_id, name: r.name, - description: r.description, - category: r.category, - rest_between_groups: r.rest_between_groups, - time_cap: r.time_cap, - scoring: r.scoring, - is_public: r.is_public !== 0, - event_metadata: null, // Event features deferred for Tauri offline mode (W-8) - last_assigned_at: r.last_assigned_at ?? null, - created_at: r.created_at ?? new Date().toISOString(), - updated_at: r.updated_at ?? new Date().toISOString(), + description: r.description ?? undefined, + category: sessionTypeSchema.parse(r.category), + restBetweenGroups: + r.rest_between_groups != null + ? durationSchema.parse(parseJsonOrValue(r.rest_between_groups)) + : undefined, + timeCap: r.time_cap != null ? durationSchema.parse(parseJsonOrValue(r.time_cap)) : undefined, + scoring: scoringTypeSchema.parse(r.scoring), + eventMetadata: undefined, // Event features deferred for Tauri offline mode (W-8) + lastAssignedAt: r.last_assigned_at ?? undefined, + isPublic: r.is_public !== 0, + } +} + +function fromSessionTemplate( + template: Omit, +): Record { + return { + user_id: template.userId, + name: template.name, + description: template.description ?? null, + category: template.category, + rest_between_groups: template.restBetweenGroups + ? JSON.stringify(template.restBetweenGroups) + : null, + time_cap: template.timeCap ? JSON.stringify(template.timeCap) : null, + scoring: template.scoring, + event_metadata: template.eventMetadata ? JSON.stringify(template.eventMetadata) : null, + is_public: template.isPublic, } } -function toActivityGroupRowFromTauri(r: TauriActivityGroupResponse): ActivityGroupRow { +function toActivityGroupFlat(r: TauriActivityGroupResponse): Omit { if (!r.created_at) console.warn('tauri-adapter: null created_at on activity group row', r) if (!r.updated_at) console.warn('tauri-adapter: null updated_at on activity group row', r) return { id: r.id, - session_template_id: r.session_template_id, - group_type: r.group_type, + sessionTemplateId: r.session_template_id, + groupType: groupTypeSchema.parse(r.group_type), ordinal: r.ordinal, - rounds: r.rounds, - rest_between_rounds: r.rest_between_rounds, - rest_between_activities: r.rest_between_activities, - created_at: r.created_at ?? new Date().toISOString(), - updated_at: r.updated_at ?? new Date().toISOString(), + rounds: r.rounds ?? undefined, + restBetweenRounds: + r.rest_between_rounds != null + ? durationSchema.parse(parseJsonOrValue(r.rest_between_rounds)) + : undefined, + restBetweenActivities: + r.rest_between_activities != null + ? durationSchema.parse(parseJsonOrValue(r.rest_between_activities)) + : undefined, + } +} + +function fromActivityGroup( + group: Omit | Omit, + templateId?: string, +): Record { + return { + session_template_id: templateId ?? group.sessionTemplateId, + group_type: group.groupType, + ordinal: group.ordinal, + rounds: group.rounds ?? null, + rest_between_rounds: group.restBetweenRounds ? JSON.stringify(group.restBetweenRounds) : null, + rest_between_activities: group.restBetweenActivities + ? JSON.stringify(group.restBetweenActivities) + : null, } } -function toActivityRowFromTauri(r: TauriActivityResponse): ActivityRow { +function toActivity(r: TauriActivityResponse): Activity { if (!r.created_at) console.warn('tauri-adapter: null created_at on activity row', r) if (!r.updated_at) console.warn('tauri-adapter: null updated_at on activity row', r) return { id: r.id, - activity_group_id: r.activity_group_id, - exercise_id: r.exercise_id, + activityGroupId: r.activity_group_id, + exerciseId: r.exercise_id, ordinal: r.ordinal, - set_scheme: r.set_scheme, - notes: r.notes, - created_at: r.created_at ?? new Date().toISOString(), - updated_at: r.updated_at ?? new Date().toISOString(), + setScheme: setSchemeSchema.parse(parseJsonOrValue(r.set_scheme)), + notes: r.notes ?? undefined, } } -function toProgramRowFromTauri(r: TauriProgramResponse): ProgramRow { +function fromActivity( + activity: Omit | Omit, + groupId?: string, +): Record { + return { + activity_group_id: + groupId ?? ('activityGroupId' in activity ? activity.activityGroupId : undefined), + exercise_id: activity.exerciseId, + ordinal: activity.ordinal, + set_scheme: JSON.stringify(activity.setScheme), + notes: activity.notes ?? null, + } +} + +function toProgram(r: TauriProgramResponse): Program { if (!r.created_at) console.warn('tauri-adapter: null created_at on program row', r) if (!r.updated_at) console.warn('tauri-adapter: null updated_at on program row', r) - return { - id: r.id, - user_id: r.user_id, - name: r.name, - description: r.description, - source: r.source, - duration_weeks: r.duration_weeks, - is_public: r.is_public !== 0, - created_by: r.created_by, - created_at: r.created_at ?? new Date().toISOString(), - updated_at: r.updated_at ?? new Date().toISOString(), + try { + return { + id: r.id, + createdAt: r.created_at ?? new Date().toISOString(), + updatedAt: r.updated_at ?? new Date().toISOString(), + userId: r.user_id, + name: r.name, + description: r.description ?? undefined, + source: programSourceSchema.parse(r.source), + durationWeeks: r.duration_weeks ?? undefined, + isPublic: r.is_public !== 0, + createdBy: r.created_by ?? r.user_id, + } + } catch (err) { + throw new Error( + `Failed to map program "${r.name}" (${r.id}): ${err instanceof Error ? err.message : String(err)}`, + ) } } -function toBlockRowFromTauri(r: TauriBlockResponse): BlockRow { +function toBlock(r: TauriBlockResponse): Block { if (!r.created_at) console.warn('tauri-adapter: null created_at on block row', r) if (!r.updated_at) console.warn('tauri-adapter: null updated_at on block row', r) - return { - id: r.id, - program_id: r.program_id, - name: r.name, - ordinal: r.ordinal, - duration_weeks: r.duration_weeks, - block_type: r.block_type, - created_at: r.created_at ?? new Date().toISOString(), - updated_at: r.updated_at ?? new Date().toISOString(), + try { + return { + id: r.id, + programId: r.program_id, + name: r.name, + ordinal: r.ordinal, + durationWeeks: r.duration_weeks, + blockType: blockTypeSchema.parse(r.block_type), + } + } catch (err) { + throw new Error( + `Failed to map block "${r.name}" (${r.id}): ${err instanceof Error ? err.message : String(err)}`, + ) } } -function toBlockWeekRowFromTauri(r: TauriBlockWeekResponse): BlockWeekRow { +function toBlockWeek(r: TauriBlockWeekResponse): BlockWeek { if (!r.created_at) console.warn('tauri-adapter: null created_at on block_week row', r) if (!r.updated_at) console.warn('tauri-adapter: null updated_at on block_week row', r) return { id: r.id, - block_id: r.block_id, - week_number: r.week_number, - created_at: r.created_at ?? new Date().toISOString(), - updated_at: r.updated_at ?? new Date().toISOString(), + blockId: r.block_id, + weekNumber: r.week_number, } } -function toScheduledSessionRowFromTauri(r: TauriScheduledSessionResponse): ScheduledSessionRow { +function toScheduledSession(r: TauriScheduledSessionResponse): ScheduledSession { if (!r.created_at) console.warn('tauri-adapter: null created_at on scheduled_session row', r) if (!r.updated_at) console.warn('tauri-adapter: null updated_at on scheduled_session row', r) - return { - id: r.id, - block_week_id: r.block_week_id, - day_of_week: r.day_of_week, - day_label: r.day_label, - session_type: r.session_type, - session_template_id: r.session_template_id, - notes: r.notes, - overrides: parseJson(r.overrides, 'overrides'), - created_at: r.created_at ?? new Date().toISOString(), - updated_at: r.updated_at ?? new Date().toISOString(), + try { + let overrides: ScheduledSession['overrides'] + if (r.overrides != null) { + try { + const parsed = parseJson(r.overrides, 'overrides') + overrides = sessionOverridesSchema.parse(parsed) + } catch (err) { + console.warn( + `[tauri-adapter] Failed to parse overrides for scheduled session ${r.id}, falling back to undefined:`, + err, + ) + overrides = undefined + } + } + return { + id: r.id, + blockWeekId: r.block_week_id, + dayOfWeek: r.day_of_week ?? undefined, + dayLabel: r.day_label, + sessionType: sessionTypeSchema.parse(r.session_type), + sessionTemplateId: r.session_template_id, + notes: r.notes ?? undefined, + overrides, + } + } catch (err) { + throw new Error( + `Failed to map scheduled session (${r.id}): ${err instanceof Error ? err.message : String(err)}`, + ) } } -function toProgramActivationRowFromTauri(r: TauriProgramActivationResponse): ProgramActivationRow { +function toProgramActivation(r: TauriProgramActivationResponse): ProgramActivation { if (!r.created_at) console.warn('tauri-adapter: null created_at on program_activation row', r) if (!r.updated_at) console.warn('tauri-adapter: null updated_at on program_activation row', r) return { id: r.id, - user_id: r.user_id, - program_id: r.program_id, - current_block_ordinal: r.current_block_ordinal, - current_week_number: r.current_week_number, - start_date: r.start_date, - created_at: r.created_at ?? new Date().toISOString(), - updated_at: r.updated_at ?? new Date().toISOString(), + createdAt: r.created_at ?? new Date().toISOString(), + updatedAt: r.updated_at ?? new Date().toISOString(), + userId: r.user_id, + programId: r.program_id, + currentBlockOrdinal: r.current_block_ordinal, + currentWeekNumber: r.current_week_number, + startDate: r.start_date, } } -function toWeekStatusRowFromTauri(r: TauriWeekStatusResponse): ProgramWeekStatusRow { +function toWeekStatus(r: TauriWeekStatusResponse): WeekStatus { return { id: r.id, - activation_id: r.activation_id, - block_ordinal: r.block_ordinal, - week_number: r.week_number, - status: r.status, - created_at: r.created_at, + activationId: r.activation_id, + blockOrdinal: r.block_ordinal, + weekNumber: r.week_number, + status: weekStatusValueSchema.parse(r.status), + createdAt: r.created_at, } } // --------------------------------------------------------------------------- -// Sharing row converters: Tauri Response -> TS Row types +// Sharing domain mappers: Tauri Response -> Domain types (via sharing-mappers) // --------------------------------------------------------------------------- -function toAccountabilityGroupRowFromTauri( - r: TauriAccountabilityGroupResponse, -): AccountabilityGroupRow { +function toAccountabilityGroupRowFromTauri(r: TauriAccountabilityGroupResponse) { if (!r.created_at || !r.updated_at) console.warn(`[tauri-adapter] Null timestamp in AccountabilityGroup ${r.id}`) return { @@ -936,7 +1116,7 @@ function toAccountabilityGroupRowFromTauri( } } -function toGroupMemberRowFromTauri(r: TauriGroupMemberResponse): GroupMemberRow { +function toGroupMemberRowFromTauri(r: TauriGroupMemberResponse) { if (!r.created_at || !r.updated_at) console.warn(`[tauri-adapter] Null timestamp in GroupMember ${r.id}`) return { @@ -951,7 +1131,7 @@ function toGroupMemberRowFromTauri(r: TauriGroupMemberResponse): GroupMemberRow } } -function toGroupInviteRowFromTauri(r: TauriGroupInviteResponse): GroupInviteRow { +function toGroupInviteRowFromTauri(r: TauriGroupInviteResponse) { if (!r.created_at || !r.updated_at) console.warn(`[tauri-adapter] Null timestamp in GroupInvite ${r.id}`) return { @@ -966,7 +1146,7 @@ function toGroupInviteRowFromTauri(r: TauriGroupInviteResponse): GroupInviteRow } } -function toDirectConnectionRowFromTauri(r: TauriDirectConnectionResponse): DirectConnectionRow { +function toDirectConnectionRowFromTauri(r: TauriDirectConnectionResponse) { if (!r.created_at || !r.updated_at) console.warn(`[tauri-adapter] Null timestamp in DirectConnection ${r.id}`) return { @@ -983,49 +1163,67 @@ function toDirectConnectionRowFromTauri(r: TauriDirectConnectionResponse): Direc } // --------------------------------------------------------------------------- -// Chat row converters: Tauri Response -> TS Row types +// Chat domain mappers: Tauri Response -> Domain types // --------------------------------------------------------------------------- -function toConversationRowFromTauri(r: TauriConversationResponse): ConversationRow { - return { - id: r.id, - type: r.type, - title: r.title, - group_id: r.group_id, - created_at: r.created_at, - updated_at: r.updated_at, +function toConversation(r: TauriConversationResponse, participantUserIds: string[] = []): Conversation { + try { + return conversationSchema.parse({ + id: r.id, + createdAt: r.created_at, + updatedAt: r.updated_at, + type: r.type, + title: r.title ?? undefined, + groupId: r.group_id ?? undefined, + participantUserIds, + }) + } catch (err) { + throw new Error( + `Failed to map conversation (${r.id}): ${err instanceof Error ? err.message : String(err)}`, + ) } } -function toMessageRowFromTauri(r: TauriMessageResponse): MessageRow { - return { - id: r.id, - conversation_id: r.conversation_id, - sender_id: r.sender_id, - message_type: r.message_type, - content: r.content, - created_at: r.created_at, - updated_at: r.updated_at, - sync_status: r.sync_status ?? undefined, +function toMessage(r: TauriMessageResponse): Message { + try { + return messageSchema.parse({ + id: r.id, + createdAt: r.created_at, + conversationId: r.conversation_id, + senderId: r.sender_id ?? undefined, + messageType: r.message_type, + content: r.content ?? undefined, + syncStatus: r.sync_status ?? undefined, + }) + } catch (err) { + throw new Error( + `Failed to map message (${r.id}): ${err instanceof Error ? err.message : String(err)}`, + ) } } -function toMediaAttachmentRowFromTauri(r: TauriMediaAttachmentResponse): MediaAttachmentRow { - return { - id: r.id, - message_id: r.message_id, - provider: r.provider, - provider_asset_id: r.provider_asset_id, - media_type: r.media_type, - original_filename: r.original_filename, - mime_type: r.mime_type, - thumbnail_url: r.thumbnail_url, - playback_url: r.playback_url, - duration_seconds: r.duration_seconds, - file_size_bytes: r.file_size_bytes, - status: r.status, - created_at: r.created_at, - updated_at: r.updated_at, +function toMediaAttachment(r: TauriMediaAttachmentResponse): MediaAttachment { + try { + return { + id: r.id, + createdAt: r.created_at, + updatedAt: r.updated_at, + messageId: r.message_id, + provider: mediaProviderSchema.parse(r.provider), + providerAssetId: r.provider_asset_id ?? undefined, + mediaType: mediaTypeSchema.parse(r.media_type), + originalFilename: r.original_filename ?? undefined, + mimeType: r.mime_type ?? undefined, + thumbnailUrl: r.thumbnail_url ?? undefined, + playbackUrl: r.playback_url ?? undefined, + durationSeconds: r.duration_seconds ?? undefined, + fileSizeBytes: r.file_size_bytes ?? undefined, + status: mediaStatusSchema.parse(r.status), + } + } catch (err) { + throw new Error( + `Failed to map media attachment (${r.id}): ${err instanceof Error ? err.message : String(err)}`, + ) } } @@ -1065,7 +1263,7 @@ export class TauriAdapter implements DataAdapter { filters: rustFilters, }) - let exercises = rows.map((r) => toExercise(toExerciseRow(r))) + let exercises = rows.map((r) => toExercise(r)) // The Rust command does not filter by muscleGroup, so apply client-side // (mirrors SupabaseAdapter's behavior for search queries) @@ -1078,7 +1276,7 @@ export class TauriAdapter implements DataAdapter { async getExercise(id: string): Promise { const row = await invokeCommand('get_exercise', { id }) - return row ? toExercise(toExerciseRow(row)) : null + return row ? toExercise(row) : null } async createExercise( @@ -1086,19 +1284,19 @@ export class TauriAdapter implements DataAdapter { ): Promise { const partial = fromExercise(exercise) const input = { - name: partial.name!, + name: partial.name as string, aliases: partial.aliases != null ? JSON.stringify(partial.aliases) : null, - category: partial.category!, - movement_pattern: partial.movement_pattern ?? null, + category: partial.category as string, + movement_pattern: (partial.movement_pattern as string | undefined) ?? null, muscle_groups: partial.muscle_groups != null ? JSON.stringify(partial.muscle_groups) : null, - is_bilateral: partial.is_bilateral ?? null, - supports_1rm: partial.supports_1rm ?? null, + is_bilateral: (partial.is_bilateral as boolean | undefined) ?? null, + supports_1rm: (partial.supports_1rm as boolean | undefined) ?? null, equipment_required: partial.equipment_required != null ? JSON.stringify(partial.equipment_required) : null, } const row = await invokeCommand('create_exercise', { exercise: input }) - return toExercise(toExerciseRow(row)) + return toExercise(row) } // --------------------------------------------------------------------------- @@ -1110,7 +1308,7 @@ export class TauriAdapter implements DataAdapter { user_id: userId, limit: limit ?? null, }) - return rows.map((r) => toWorkoutLog(toWorkoutLogRow(r))) + return rows.map((r) => toWorkoutLog(r)) } async getWorkoutLogsSummary( @@ -1124,7 +1322,7 @@ export class TauriAdapter implements DataAdapter { }) return summaries.map((s) => ({ - log: toWorkoutLog(toWorkoutLogRow(s.log)), + log: toWorkoutLog(s.log), exerciseNames: s.exercise_names, setCount: s.set_count, exerciseCount: s.exercise_count, @@ -1133,7 +1331,7 @@ export class TauriAdapter implements DataAdapter { async getWorkoutLog(id: string): Promise { const row = await invokeCommand('get_workout_log', { id }) - return row ? toWorkoutLog(toWorkoutLogRow(row)) : null + return row ? toWorkoutLog(row) : null } async getWorkoutLogFull(id: string): Promise<{ @@ -1146,10 +1344,10 @@ export class TauriAdapter implements DataAdapter { if (!full) return null return { - log: toWorkoutLog(toWorkoutLogRow(full.log)), - groups: full.groups.map((g) => toLoggedActivityGroup(toLoggedActivityGroupRow(g))), - activities: full.activities.map((a) => toLoggedActivity(toLoggedActivityRow(a))), - sets: full.sets.map((s) => toLoggedSet(toLoggedSetRow(s))), + log: toWorkoutLog(full.log), + groups: full.groups.map((g) => toLoggedActivityGroup(g)), + activities: full.activities.map((a) => toLoggedActivity(a)), + sets: full.sets.map((s) => toLoggedSet(s)), } } @@ -1161,16 +1359,16 @@ export class TauriAdapter implements DataAdapter { } const partial = fromWorkoutLog(log) const input = { - user_id: partial.user_id!, - title: partial.title ?? null, - started_at: isoToUnixSeconds(partial.started_at!), - completed_at: partial.completed_at ? isoToUnixSeconds(partial.completed_at) : null, - session_template_id: partial.session_template_id ?? null, + user_id: partial.user_id as string, + title: (partial.title as string | undefined) ?? null, + started_at: isoToUnixSeconds(partial.started_at as string), + completed_at: partial.completed_at ? isoToUnixSeconds(partial.completed_at as string) : null, + session_template_id: (partial.session_template_id as string | undefined) ?? null, program_context: partial.program_context != null ? JSON.stringify(partial.program_context) : null, - overall_notes: partial.overall_notes ?? null, + overall_notes: (partial.overall_notes as string | undefined) ?? null, note_tags: JSON.stringify(partial.note_tags ?? []), - perceived_difficulty: partial.perceived_difficulty ?? null, + perceived_difficulty: (partial.perceived_difficulty as number | undefined) ?? null, bodyweight_at_session: partial.bodyweight_at_session != null ? JSON.stringify(partial.bodyweight_at_session) @@ -1178,7 +1376,7 @@ export class TauriAdapter implements DataAdapter { } const row = await invokeCommand('create_workout_log', { log: input }) - return toWorkoutLog(toWorkoutLogRow(row)) + return toWorkoutLog(row) } async updateWorkoutLog(log: WorkoutLog): Promise { @@ -1193,7 +1391,7 @@ export class TauriAdapter implements DataAdapter { note_tags: JSON.stringify(log.noteTags ?? []), perceived_difficulty: log.perceivedDifficulty ?? null, }) - return toWorkoutLog(toWorkoutLogRow(row)) + return toWorkoutLog(row) } async deleteWorkoutLog(id: string): Promise { @@ -1210,10 +1408,10 @@ export class TauriAdapter implements DataAdapter { ): Promise { const partial = fromLoggedActivityGroup(group, userId) const input = { - workout_log_id: partial.workout_log_id!, - group_type: partial.group_type!, - ordinal: partial.ordinal!, - actual_rounds_completed: partial.actual_rounds_completed ?? null, + workout_log_id: partial.workout_log_id as string, + group_type: partial.group_type as string, + ordinal: partial.ordinal as number, + actual_rounds_completed: (partial.actual_rounds_completed as number | undefined) ?? null, completion_time: partial.completion_time != null ? JSON.stringify(partial.completion_time) : null, } @@ -1225,7 +1423,7 @@ export class TauriAdapter implements DataAdapter { user_id: userId, }, ) - return toLoggedActivityGroup(toLoggedActivityGroupRow(row)) + return toLoggedActivityGroup(row) } async updateLoggedActivityGroup( @@ -1235,10 +1433,10 @@ export class TauriAdapter implements DataAdapter { const partial = fromLoggedActivityGroup(group, userId) const input = { id: group.id, - workout_log_id: partial.workout_log_id!, - group_type: partial.group_type!, - ordinal: partial.ordinal!, - actual_rounds_completed: partial.actual_rounds_completed ?? null, + workout_log_id: partial.workout_log_id as string, + group_type: partial.group_type as string, + ordinal: partial.ordinal as number, + actual_rounds_completed: (partial.actual_rounds_completed as number | undefined) ?? null, completion_time: partial.completion_time != null ? JSON.stringify(partial.completion_time) : null, } @@ -1250,7 +1448,7 @@ export class TauriAdapter implements DataAdapter { user_id: userId, }, ) - return toLoggedActivityGroup(toLoggedActivityGroupRow(row)) + return toLoggedActivityGroup(row) } async deleteLoggedActivityGroup(id: string): Promise { @@ -1267,10 +1465,10 @@ export class TauriAdapter implements DataAdapter { ): Promise { const partial = fromLoggedActivity(activity, userId) const input = { - logged_group_id: partial.logged_group_id!, - exercise_id: partial.exercise_id!, - ordinal: partial.ordinal!, - notes: partial.notes ?? null, + logged_group_id: partial.logged_group_id as string, + exercise_id: partial.exercise_id as string, + ordinal: partial.ordinal as number, + notes: (partial.notes as string | undefined) ?? null, note_tags: JSON.stringify(partial.note_tags ?? []), } @@ -1278,17 +1476,17 @@ export class TauriAdapter implements DataAdapter { activity: input, user_id: userId, }) - return toLoggedActivity(toLoggedActivityRow(row)) + return toLoggedActivity(row) } async updateLoggedActivity(activity: LoggedActivity, userId: string): Promise { const partial = fromLoggedActivity(activity, userId) const input = { id: activity.id, - logged_group_id: partial.logged_group_id!, - exercise_id: partial.exercise_id!, - ordinal: partial.ordinal!, - notes: partial.notes ?? null, + logged_group_id: partial.logged_group_id as string, + exercise_id: partial.exercise_id as string, + ordinal: partial.ordinal as number, + notes: (partial.notes as string | undefined) ?? null, note_tags: JSON.stringify(partial.note_tags ?? []), } @@ -1296,7 +1494,7 @@ export class TauriAdapter implements DataAdapter { activity: input, user_id: userId, }) - return toLoggedActivity(toLoggedActivityRow(row)) + return toLoggedActivity(row) } async deleteLoggedActivity(id: string): Promise { @@ -1310,24 +1508,24 @@ export class TauriAdapter implements DataAdapter { async createLoggedSet(set: Omit, userId: string): Promise { const partial = fromLoggedSet(set, userId) const input = { - logged_activity_id: partial.logged_activity_id!, - set_number: partial.set_number!, - set_type: partial.set_type!, + logged_activity_id: partial.logged_activity_id as string, + set_number: partial.set_number as number, + set_type: partial.set_type as string, prescribed: partial.prescribed != null ? JSON.stringify(partial.prescribed) : null, - actual_reps: partial.actual_reps ?? null, + actual_reps: (partial.actual_reps as number | undefined) ?? null, actual_weight: partial.actual_weight != null ? JSON.stringify(partial.actual_weight) : null, actual_duration: partial.actual_duration != null ? JSON.stringify(partial.actual_duration) : null, actual_distance: partial.actual_distance != null ? JSON.stringify(partial.actual_distance) : null, actual_pace: partial.actual_pace != null ? JSON.stringify(partial.actual_pace) : null, - actual_heart_rate: partial.actual_heart_rate ?? null, + actual_heart_rate: (partial.actual_heart_rate as number | undefined) ?? null, ruck_load: partial.ruck_load != null ? JSON.stringify(partial.ruck_load) : null, elevation_gain: partial.elevation_gain != null ? JSON.stringify(partial.elevation_gain) : null, - rpe: partial.rpe ?? null, - completed: partial.completed ?? null, - notes: partial.notes ?? null, + rpe: (partial.rpe as number | undefined) ?? null, + completed: (partial.completed as boolean | undefined) ?? null, + notes: (partial.notes as string | undefined) ?? null, note_tags: JSON.stringify(partial.note_tags ?? []), } @@ -1335,31 +1533,31 @@ export class TauriAdapter implements DataAdapter { set: input, user_id: userId, }) - return toLoggedSet(toLoggedSetRow(row)) + return toLoggedSet(row) } async updateLoggedSet(set: LoggedSet, userId: string): Promise { const partial = fromLoggedSet(set, userId) const input = { id: set.id, - logged_activity_id: partial.logged_activity_id!, - set_number: partial.set_number!, - set_type: partial.set_type!, + logged_activity_id: partial.logged_activity_id as string, + set_number: partial.set_number as number, + set_type: partial.set_type as string, prescribed: partial.prescribed != null ? JSON.stringify(partial.prescribed) : null, - actual_reps: partial.actual_reps ?? null, + actual_reps: (partial.actual_reps as number | undefined) ?? null, actual_weight: partial.actual_weight != null ? JSON.stringify(partial.actual_weight) : null, actual_duration: partial.actual_duration != null ? JSON.stringify(partial.actual_duration) : null, actual_distance: partial.actual_distance != null ? JSON.stringify(partial.actual_distance) : null, actual_pace: partial.actual_pace != null ? JSON.stringify(partial.actual_pace) : null, - actual_heart_rate: partial.actual_heart_rate ?? null, + actual_heart_rate: (partial.actual_heart_rate as number | undefined) ?? null, ruck_load: partial.ruck_load != null ? JSON.stringify(partial.ruck_load) : null, elevation_gain: partial.elevation_gain != null ? JSON.stringify(partial.elevation_gain) : null, - rpe: partial.rpe ?? null, - completed: partial.completed ?? null, - notes: partial.notes ?? null, + rpe: (partial.rpe as number | undefined) ?? null, + completed: (partial.completed as boolean | undefined) ?? null, + notes: (partial.notes as string | undefined) ?? null, note_tags: JSON.stringify(partial.note_tags ?? []), } @@ -1367,7 +1565,7 @@ export class TauriAdapter implements DataAdapter { set: input, user_id: userId, }) - return toLoggedSet(toLoggedSetRow(row)) + return toLoggedSet(row) } async deleteLoggedSet(id: string): Promise { @@ -1382,7 +1580,7 @@ export class TauriAdapter implements DataAdapter { const row = await invokeCommand('get_user_profile', { user_id: userId, }) - return row ? toUserProfile(toUserProfileRow(row)) : null + return row ? toUserProfile(row) : null } async updateUserProfile(profile: Partial & { id: string }): Promise { @@ -1391,9 +1589,9 @@ export class TauriAdapter implements DataAdapter { // command and the SQLite migration on the Tauri side drop it in lockstep // (Wave 3b). We do not pass it here. const input = { - id: partial.id!, - display_name: partial.display_name ?? null, - preferred_units: partial.preferred_units ?? null, + id: partial.id as string, + display_name: (partial.display_name as string | undefined) ?? null, + preferred_units: (partial.preferred_units as string | undefined) ?? null, bodyweight: partial.bodyweight != null ? JSON.stringify(partial.bodyweight) : null, training_age: partial.training_age != null ? JSON.stringify(partial.training_age) : null, exercise_maxes: @@ -1404,7 +1602,7 @@ export class TauriAdapter implements DataAdapter { const row = await invokeCommand('update_user_profile', { profile: input, }) - return toUserProfile(toUserProfileRow(row)) + return toUserProfile(row) } async saveOneRepMax( @@ -1412,13 +1610,13 @@ export class TauriAdapter implements DataAdapter { ): Promise { const partial = fromOneRepMaxHistory(entry) const row = await invokeCommand('save_one_rep_max', { - user_id: partial.user_id!, - exercise_id: partial.exercise_id!, + user_id: partial.user_id as string, + exercise_id: partial.exercise_id as string, weight: JSON.stringify(partial.weight), - estimated: partial.estimated ?? null, - recorded_at: isoToUnixSeconds(partial.recorded_at!), + estimated: (partial.estimated as boolean | undefined) ?? null, + recorded_at: isoToUnixSeconds(partial.recorded_at as string), }) - return toOneRepMaxHistory(toOneRepMaxHistoryRow(row)) + return toOneRepMaxHistory(row) } // --------------------------------------------------------------------------- @@ -1430,7 +1628,7 @@ export class TauriAdapter implements DataAdapter { user_id: userId, exercise_id: exerciseId, }) - return rows.map((r) => toOneRepMaxHistory(toOneRepMaxHistoryRow(r))) + return rows.map((r) => toOneRepMaxHistory(r)) } async getRecentlyUsedExerciseIds(userId: string, limit = 10): Promise { @@ -1452,8 +1650,8 @@ export class TauriAdapter implements DataAdapter { }) return results.map((r) => ({ - log: toWorkoutLog(toWorkoutLogRow(r.log)), - sets: r.sets.map((s) => toLoggedSet(toLoggedSetRow(s))), + log: toWorkoutLog(r.log), + sets: r.sets.map((s) => toLoggedSet(s)), })) } @@ -1538,10 +1736,10 @@ export class TauriAdapter implements DataAdapter { }>('create_workout_log_full', { input, user_id: userId }) return { - log: toWorkoutLog(toWorkoutLogRow(result.log)), - groups: result.groups.map((g) => toLoggedActivityGroup(toLoggedActivityGroupRow(g))), - activities: result.activities.map((a) => toLoggedActivity(toLoggedActivityRow(a))), - sets: result.sets.map((s) => toLoggedSet(toLoggedSetRow(s))), + log: toWorkoutLog(result.log), + groups: result.groups.map((g) => toLoggedActivityGroup(g)), + activities: result.activities.map((a) => toLoggedActivity(a)), + sets: result.sets.map((s) => toLoggedSet(s)), } } @@ -1565,7 +1763,7 @@ export class TauriAdapter implements DataAdapter { user_id: userId, }) - let templates = rows.map((r) => toSessionTemplate(toSessionTemplateRowFromTauri(r))) + let templates = rows.map((r) => toSessionTemplate(r)) // Apply client-side filters for searchQuery and category if (filters?.searchQuery) { @@ -1587,7 +1785,7 @@ export class TauriAdapter implements DataAdapter { const row = await invokeCommand('get_session_template', { id, }) - return row ? toSessionTemplate(toSessionTemplateRowFromTauri(row)) : null + return row ? toSessionTemplate(row) : null } async getSessionTemplateFull(id: string): Promise { @@ -1597,9 +1795,9 @@ export class TauriAdapter implements DataAdapter { if (!full) return null return { - template: toSessionTemplate(toSessionTemplateRowFromTauri(full.template)), - groups: full.groups.map((g) => toActivityGroupFlat(toActivityGroupRowFromTauri(g))), - activities: full.activities.map((a) => toActivity(toActivityRowFromTauri(a))), + template: toSessionTemplate(full.template), + groups: full.groups.map((g) => toActivityGroupFlat(g)), + activities: full.activities.map((a) => toActivity(a)), eventItems: [], } } @@ -1613,33 +1811,33 @@ export class TauriAdapter implements DataAdapter { ): Promise { const partial = fromSessionTemplate(template) const input = { - user_id: partial.user_id ?? null, - name: partial.name!, - description: partial.description ?? null, - category: partial.category!, - rest_between_groups: partial.rest_between_groups ?? null, - time_cap: partial.time_cap ?? null, - scoring: partial.scoring ?? null, - is_public: partial.is_public ?? false, + user_id: (partial.user_id as string | undefined) ?? null, + name: partial.name as string, + description: (partial.description as string | undefined) ?? null, + category: partial.category as string, + rest_between_groups: (partial.rest_between_groups as string | undefined) ?? null, + time_cap: (partial.time_cap as string | undefined) ?? null, + scoring: (partial.scoring as string | undefined) ?? null, + is_public: (partial.is_public as boolean | undefined) ?? false, } const groupsInput = groups.map((g) => { const gPartial = fromActivityGroup(g.group) return { group: { - group_type: gPartial.group_type!, - ordinal: gPartial.ordinal!, - rounds: gPartial.rounds ?? null, - rest_between_rounds: gPartial.rest_between_rounds ?? null, - rest_between_activities: gPartial.rest_between_activities ?? null, + group_type: gPartial.group_type as string, + ordinal: gPartial.ordinal as number, + rounds: (gPartial.rounds as number | undefined) ?? null, + rest_between_rounds: (gPartial.rest_between_rounds as string | undefined) ?? null, + rest_between_activities: (gPartial.rest_between_activities as string | undefined) ?? null, }, activities: g.activities.map((a) => { const aPartial = fromActivity(a) return { - exercise_id: aPartial.exercise_id!, - ordinal: aPartial.ordinal!, - set_scheme: aPartial.set_scheme!, - notes: aPartial.notes ?? null, + exercise_id: aPartial.exercise_id as string, + ordinal: aPartial.ordinal as number, + set_scheme: aPartial.set_scheme as string, + notes: (aPartial.notes as string | undefined) ?? null, } }), } @@ -1651,9 +1849,9 @@ export class TauriAdapter implements DataAdapter { }) return { - template: toSessionTemplate(toSessionTemplateRowFromTauri(result.template)), - groups: result.groups.map((g) => toActivityGroupFlat(toActivityGroupRowFromTauri(g))), - activities: result.activities.map((a) => toActivity(toActivityRowFromTauri(a))), + template: toSessionTemplate(result.template), + groups: result.groups.map((g) => toActivityGroupFlat(g)), + activities: result.activities.map((a) => toActivity(a)), eventItems: [], } } @@ -1668,14 +1866,14 @@ export class TauriAdapter implements DataAdapter { const partial = fromSessionTemplate(template) const input = { id: template.id, - user_id: partial.user_id ?? null, - name: partial.name!, - description: partial.description ?? null, - category: partial.category!, - rest_between_groups: partial.rest_between_groups ?? null, - time_cap: partial.time_cap ?? null, - scoring: partial.scoring ?? null, - is_public: partial.is_public ?? false, + user_id: (partial.user_id as string | undefined) ?? null, + name: partial.name as string, + description: (partial.description as string | undefined) ?? null, + category: partial.category as string, + rest_between_groups: (partial.rest_between_groups as string | undefined) ?? null, + time_cap: (partial.time_cap as string | undefined) ?? null, + scoring: (partial.scoring as string | undefined) ?? null, + is_public: (partial.is_public as boolean | undefined) ?? false, } const groupsInput = groups.map((g) => { @@ -1683,19 +1881,19 @@ export class TauriAdapter implements DataAdapter { return { group: { id: g.group.id || null, - group_type: gPartial.group_type!, - ordinal: gPartial.ordinal!, - rounds: gPartial.rounds ?? null, - rest_between_rounds: gPartial.rest_between_rounds ?? null, - rest_between_activities: gPartial.rest_between_activities ?? null, + group_type: gPartial.group_type as string, + ordinal: gPartial.ordinal as number, + rounds: (gPartial.rounds as number | undefined) ?? null, + rest_between_rounds: (gPartial.rest_between_rounds as string | undefined) ?? null, + rest_between_activities: (gPartial.rest_between_activities as string | undefined) ?? null, }, activities: g.activities.map((a) => { const aPartial = fromActivity(a) return { - exercise_id: aPartial.exercise_id!, - ordinal: aPartial.ordinal!, - set_scheme: aPartial.set_scheme!, - notes: aPartial.notes ?? null, + exercise_id: aPartial.exercise_id as string, + ordinal: aPartial.ordinal as number, + set_scheme: aPartial.set_scheme as string, + notes: (aPartial.notes as string | undefined) ?? null, } }), } @@ -1707,9 +1905,9 @@ export class TauriAdapter implements DataAdapter { }) return { - template: toSessionTemplate(toSessionTemplateRowFromTauri(result.template)), - groups: result.groups.map((g) => toActivityGroupFlat(toActivityGroupRowFromTauri(g))), - activities: result.activities.map((a) => toActivity(toActivityRowFromTauri(a))), + template: toSessionTemplate(result.template), + groups: result.groups.map((g) => toActivityGroupFlat(g)), + activities: result.activities.map((a) => toActivity(a)), eventItems: [], } } @@ -1805,7 +2003,7 @@ export class TauriAdapter implements DataAdapter { user_id: userId, }) - let programs = rows.map((r) => toProgram(toProgramRowFromTauri(r))) + let programs = rows.map((r) => toProgram(r)) // Apply client-side filters for searchQuery and source if (filters?.searchQuery) { @@ -1827,12 +2025,10 @@ export class TauriAdapter implements DataAdapter { const result = await invokeCommand('get_program_full', { id }) if (!result) return null return { - program: toProgram(toProgramRowFromTauri(result.program)), - blocks: result.blocks.map((r) => toBlock(toBlockRowFromTauri(r))), - blockWeeks: result.block_weeks.map((r) => toBlockWeek(toBlockWeekRowFromTauri(r))), - scheduledSessions: result.scheduled_sessions.map((r) => - toScheduledSession(toScheduledSessionRowFromTauri(r)), - ), + program: toProgram(result.program), + blocks: result.blocks.map((r) => toBlock(r)), + blockWeeks: result.block_weeks.map((r) => toBlockWeek(r)), + scheduledSessions: result.scheduled_sessions.map((r) => toScheduledSession(r)), } } @@ -1885,12 +2081,10 @@ export class TauriAdapter implements DataAdapter { const result = await invokeCommand('create_program_full', input) return { - program: toProgram(toProgramRowFromTauri(result.program)), - blocks: result.blocks.map((r) => toBlock(toBlockRowFromTauri(r))), - blockWeeks: result.block_weeks.map((r) => toBlockWeek(toBlockWeekRowFromTauri(r))), - scheduledSessions: result.scheduled_sessions.map((r) => - toScheduledSession(toScheduledSessionRowFromTauri(r)), - ), + program: toProgram(result.program), + blocks: result.blocks.map((r) => toBlock(r)), + blockWeeks: result.block_weeks.map((r) => toBlockWeek(r)), + scheduledSessions: result.scheduled_sessions.map((r) => toScheduledSession(r)), } } @@ -1943,12 +2137,10 @@ export class TauriAdapter implements DataAdapter { const result = await invokeCommand('update_program_full', input) return { - program: toProgram(toProgramRowFromTauri(result.program)), - blocks: result.blocks.map((r) => toBlock(toBlockRowFromTauri(r))), - blockWeeks: result.block_weeks.map((r) => toBlockWeek(toBlockWeekRowFromTauri(r))), - scheduledSessions: result.scheduled_sessions.map((r) => - toScheduledSession(toScheduledSessionRowFromTauri(r)), - ), + program: toProgram(result.program), + blocks: result.blocks.map((r) => toBlock(r)), + blockWeeks: result.block_weeks.map((r) => toBlockWeek(r)), + scheduledSessions: result.scheduled_sessions.map((r) => toScheduledSession(r)), } } @@ -1967,7 +2159,7 @@ export class TauriAdapter implements DataAdapter { member_id: memberId, group_id: groupId, }) - return toProgram(toProgramRowFromTauri(row)) + return toProgram(row) } // --------------------------------------------------------------------------- @@ -1978,7 +2170,7 @@ export class TauriAdapter implements DataAdapter { const row = await invokeCommand('get_active_program', { user_id: userId, }) - return row ? toProgramActivation(toProgramActivationRowFromTauri(row)) : null + return row ? toProgramActivation(row) : null } async setActiveProgram( @@ -1991,7 +2183,7 @@ export class TauriAdapter implements DataAdapter { program_id: programId, start_date: startDate ?? null, }) - return toProgramActivation(toProgramActivationRowFromTauri(row)) + return toProgramActivation(row) } async updateActiveProgram( @@ -2004,7 +2196,7 @@ export class TauriAdapter implements DataAdapter { current_week_number: updates.currentWeekNumber ?? null, start_date: updates.startDate ?? null, }) - return toProgramActivation(toProgramActivationRowFromTauri(row)) + return toProgramActivation(row) } async clearActiveProgram(userId: string): Promise { @@ -2019,7 +2211,7 @@ export class TauriAdapter implements DataAdapter { const rows = await invokeCommand('get_week_statuses', { activation_id: activationId, }) - return rows.map((r) => toWeekStatus(toWeekStatusRowFromTauri(r))) + return rows.map((r) => toWeekStatus(r)) } async upsertWeekStatuses( @@ -2030,7 +2222,7 @@ export class TauriAdapter implements DataAdapter { activation_id: activationId, statuses, }) - return rows.map((r) => toWeekStatus(toWeekStatusRowFromTauri(r))) + return rows.map((r) => toWeekStatus(r)) } async deleteWeekStatuses( @@ -2523,7 +2715,7 @@ export class TauriAdapter implements DataAdapter { }, }) const createUserIds = result.participants.filter((p) => p.left_at == null).map((p) => p.user_id) - return toConversation(toConversationRowFromTauri(result.conversation), createUserIds) + return toConversation(result.conversation, createUserIds) } async getConversations(): Promise { @@ -2532,7 +2724,7 @@ export class TauriAdapter implements DataAdapter { }) return results.map((r) => { const userIds = r.participants.filter((p) => p.left_at == null).map((p) => p.user_id) - return toConversation(toConversationRowFromTauri(r.conversation), userIds) + return toConversation(r.conversation, userIds) }) } @@ -2543,7 +2735,7 @@ export class TauriAdapter implements DataAdapter { ) if (!result) return null const userIds = result.participants.filter((p) => p.left_at == null).map((p) => p.user_id) - return toConversation(toConversationRowFromTauri(result.conversation), userIds) + return toConversation(result.conversation, userIds) } async findDirectConversation(otherUserId: string): Promise { @@ -2553,7 +2745,7 @@ export class TauriAdapter implements DataAdapter { ) if (!result) return null const userIds = result.participants.filter((p) => p.left_at == null).map((p) => p.user_id) - return toConversation(toConversationRowFromTauri(result.conversation), userIds) + return toConversation(result.conversation, userIds) } async sendMessage( @@ -2569,7 +2761,7 @@ export class TauriAdapter implements DataAdapter { content: content ?? null, }, }) - return toMessage(toMessageRowFromTauri(row)) + return toMessage(row) } async getMessages( @@ -2584,7 +2776,7 @@ export class TauriAdapter implements DataAdapter { before, limit: options.limit, }) - return rows.map((r) => toMessage(toMessageRowFromTauri(r))) + return rows.map((r) => toMessage(r)) } async getMessagesSince(conversationId: string, since: string): Promise { @@ -2593,7 +2785,7 @@ export class TauriAdapter implements DataAdapter { conversation_id: conversationId, since: sinceEpoch, }) - return rows.map((r) => toMessage(toMessageRowFromTauri(r))) + return rows.map((r) => toMessage(r)) } async updateLastRead(conversationId: string): Promise { @@ -2658,7 +2850,7 @@ export class TauriAdapter implements DataAdapter { status: attachment.status, }, }) - return toMediaAttachment(toMediaAttachmentRowFromTauri(row)) + return toMediaAttachment(row) } async getMediaAttachments(messageIds: string[]): Promise { @@ -2667,7 +2859,7 @@ export class TauriAdapter implements DataAdapter { const rows = await invokeCommand('get_media_attachments', { message_ids: messageIds, }) - return rows.map((r) => toMediaAttachment(toMediaAttachmentRowFromTauri(r))) + return rows.map((r) => toMediaAttachment(r)) } async updateMediaAttachment( From 7a7b9ec3339db6f2543804221b6d3a2cad8b5522 Mon Sep 17 00:00:00 2001 From: Robert Hamilton Date: Sun, 12 Apr 2026 20:14:54 -0500 Subject: [PATCH 2/4] fix(adapters): resolve P17 review findings from PR #107 Critical: mapExercise now reads raw['supports_1rm'] directly -- camelizeKeys regex /_([a-z])/g skips digit-adjacent underscores, so supports_1rm was never camelized and supports1RM silently evaluated to undefined on every Supabase exercise read. - parseJsonOrValue: add required column param + try/catch for context-rich errors - supabase-adapter: add try/catch to 15 mapper methods missing it; add console.error to mapProgram/mapBlock catch blocks; remove misleading pre-check console.warn in mapGym/mapGymMember; fix unsafe (err as Error).message cast at both gym catch sites; update all 6 parseJsonOrValue call sites - tauri-adapter: intToBool now requires field param and logs console.warn on null fallback (resolves TODO P14-021); revert 4 parseJsonOrValue call sites to local parseJson helper; standardize 16 console.warn prefix to [tauri-adapter] Review: Context/Reviews/0017-pr107-camelcase-conv-2026-04-12.md (P17-001 through P17-006, P17-011, P17-012) --- src/lib/adapter-utils.ts | 14 +- src/lib/supabase-adapter.ts | 574 ++++++++++++++++++++++-------------- src/lib/tauri-adapter.ts | 83 +++--- 3 files changed, 406 insertions(+), 265 deletions(-) diff --git a/src/lib/adapter-utils.ts b/src/lib/adapter-utils.ts index 8ffb8d14..b8addf38 100644 --- a/src/lib/adapter-utils.ts +++ b/src/lib/adapter-utils.ts @@ -33,7 +33,17 @@ export function camelizeKeys(obj: Record): Record): Exercise { const r = camelizeKeys(raw) - return { - id: r.id as string, - createdAt: r.createdAt as string, - updatedAt: r.updatedAt as string, - name: r.name as string, - aliases: z.array(z.string()).parse(r.aliases), - category: exerciseCategorySchema.parse(r.category), - movementPattern: movementPatternSchema.parse(r.movementPattern), - muscleGroups: muscleGroupSpecSchema.parse(r.muscleGroups), - isBilateral: r.isBilateral as boolean, - supports1RM: r.supports1Rm as boolean, - equipmentRequired: z.array(equipmentSchema).parse(r.equipmentRequired), - isCustom: r.isCustom as boolean, - isPublic: r.isPublic as boolean, + try { + return { + id: r.id as string, + createdAt: r.createdAt as string, + updatedAt: r.updatedAt as string, + name: r.name as string, + aliases: z.array(z.string()).parse(r.aliases), + category: exerciseCategorySchema.parse(r.category), + movementPattern: movementPatternSchema.parse(r.movementPattern), + muscleGroups: muscleGroupSpecSchema.parse(r.muscleGroups), + isBilateral: r.isBilateral as boolean, + supports1RM: raw['supports_1rm'] as boolean, + equipmentRequired: z.array(equipmentSchema).parse(r.equipmentRequired), + isCustom: r.isCustom as boolean, + isPublic: r.isPublic as boolean, + } + } catch (err) { + console.error('[supabase-adapter] Failed to map exercise:', err, raw) + throw new Error( + `Failed to map exercise (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) } } private mapWorkoutLog(raw: Record): WorkoutLog { const r = camelizeKeys(raw) - return { - id: r.id as string, - createdAt: r.createdAt as string, - updatedAt: r.updatedAt as string, - userId: r.userId as string, - title: (r.title ?? undefined) as string | undefined, - startedAt: r.startedAt as string, - completedAt: (r.completedAt ?? undefined) as string | undefined, - sessionTemplateId: (r.sessionTemplateId ?? undefined) as string | undefined, - programContext: - r.programContext != null - ? programContextSchema.parse(r.programContext) - : undefined, - perceivedDifficulty: (r.perceivedDifficulty ?? undefined) as number | undefined, - bodyweightAtSession: - r.bodyweightAtSession != null - ? weightSchema.parse(r.bodyweightAtSession) - : undefined, - overallNotes: (r.overallNotes ?? undefined) as string | undefined, - noteTags: - Array.isArray(r.noteTags) && (r.noteTags as unknown[]).length > 0 - ? (r.noteTags as string[]) - : undefined, - eventMetadata: - r.eventMetadata != null - ? eventMetadataSchema.parse(r.eventMetadata) - : undefined, - pausedAt: (r.pausedAt ?? undefined) as string | undefined, - totalPausedMs: (r.totalPausedMs ?? 0) as number, + try { + return { + id: r.id as string, + createdAt: r.createdAt as string, + updatedAt: r.updatedAt as string, + userId: r.userId as string, + title: (r.title ?? undefined) as string | undefined, + startedAt: r.startedAt as string, + completedAt: (r.completedAt ?? undefined) as string | undefined, + sessionTemplateId: (r.sessionTemplateId ?? undefined) as string | undefined, + programContext: + r.programContext != null ? programContextSchema.parse(r.programContext) : undefined, + perceivedDifficulty: (r.perceivedDifficulty ?? undefined) as number | undefined, + bodyweightAtSession: + r.bodyweightAtSession != null ? weightSchema.parse(r.bodyweightAtSession) : undefined, + overallNotes: (r.overallNotes ?? undefined) as string | undefined, + noteTags: + Array.isArray(r.noteTags) && (r.noteTags as unknown[]).length > 0 + ? (r.noteTags as string[]) + : undefined, + eventMetadata: + r.eventMetadata != null ? eventMetadataSchema.parse(r.eventMetadata) : undefined, + pausedAt: (r.pausedAt ?? undefined) as string | undefined, + totalPausedMs: (r.totalPausedMs ?? 0) as number, + } + } catch (err) { + console.error('[supabase-adapter] Failed to map workout_log:', err, raw) + throw new Error( + `Failed to map workout_log (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) } } private mapLoggedActivityGroup(raw: Record): LoggedActivityGroup { const r = camelizeKeys(raw) - return { - id: r.id as string, - workoutLogId: r.workoutLogId as string, - groupType: groupTypeSchema.parse(r.groupType), - ordinal: r.ordinal as number, - actualRoundsCompleted: (r.actualRoundsCompleted ?? undefined) as number | undefined, - completionTime: - r.completionTime != null - ? durationSchema.parse(r.completionTime) - : undefined, + try { + return { + id: r.id as string, + workoutLogId: r.workoutLogId as string, + groupType: groupTypeSchema.parse(r.groupType), + ordinal: r.ordinal as number, + actualRoundsCompleted: (r.actualRoundsCompleted ?? undefined) as number | undefined, + completionTime: + r.completionTime != null ? durationSchema.parse(r.completionTime) : undefined, + } + } catch (err) { + console.error('[supabase-adapter] Failed to map logged_activity_group:', err, raw) + throw new Error( + `Failed to map logged_activity_group (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) } } private mapLoggedActivity(raw: Record): LoggedActivity { const r = camelizeKeys(raw) - return { - id: r.id as string, - loggedGroupId: r.loggedGroupId as string, - exerciseId: r.exerciseId as string, - ordinal: r.ordinal as number, - notes: (r.notes ?? undefined) as string | undefined, - noteTags: - Array.isArray(r.noteTags) && (r.noteTags as unknown[]).length > 0 - ? (r.noteTags as string[]) - : undefined, + try { + return { + id: r.id as string, + loggedGroupId: r.loggedGroupId as string, + exerciseId: r.exerciseId as string, + ordinal: r.ordinal as number, + notes: (r.notes ?? undefined) as string | undefined, + noteTags: + Array.isArray(r.noteTags) && (r.noteTags as unknown[]).length > 0 + ? (r.noteTags as string[]) + : undefined, + } + } catch (err) { + console.error('[supabase-adapter] Failed to map logged_activity:', err, raw) + throw new Error( + `Failed to map logged_activity (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) } } private mapLoggedSet(raw: Record): LoggedSet { const r = camelizeKeys(raw) - return { - id: r.id as string, - loggedActivityId: r.loggedActivityId as string, - setNumber: r.setNumber as number, - setType: setTypeSchema.parse(r.setType), - prescribed: - r.prescribed != null ? prescriptionSchema.parse(r.prescribed) : undefined, - actualReps: (r.actualReps ?? undefined) as number | undefined, - actualWeight: - r.actualWeight != null ? weightSchema.parse(r.actualWeight) : undefined, - actualDuration: - r.actualDuration != null ? durationSchema.parse(r.actualDuration) : undefined, - actualDistance: - r.actualDistance != null ? distanceSchema.parse(r.actualDistance) : undefined, - actualPace: - r.actualPace != null ? paceSchema.parse(r.actualPace) : undefined, - actualHeartRate: (r.actualHeartRate ?? undefined) as number | undefined, - rpe: (r.rpe ?? undefined) as number | undefined, - completed: r.completed as boolean, - notes: (r.notes ?? undefined) as string | undefined, - noteTags: - Array.isArray(r.noteTags) && (r.noteTags as unknown[]).length > 0 - ? (r.noteTags as string[]) - : undefined, - ruckLoad: r.ruckLoad != null ? weightSchema.parse(r.ruckLoad) : undefined, - elevationGain: - r.elevationGain != null ? distanceSchema.parse(r.elevationGain) : undefined, + try { + return { + id: r.id as string, + loggedActivityId: r.loggedActivityId as string, + setNumber: r.setNumber as number, + setType: setTypeSchema.parse(r.setType), + prescribed: r.prescribed != null ? prescriptionSchema.parse(r.prescribed) : undefined, + actualReps: (r.actualReps ?? undefined) as number | undefined, + actualWeight: r.actualWeight != null ? weightSchema.parse(r.actualWeight) : undefined, + actualDuration: + r.actualDuration != null ? durationSchema.parse(r.actualDuration) : undefined, + actualDistance: + r.actualDistance != null ? distanceSchema.parse(r.actualDistance) : undefined, + actualPace: r.actualPace != null ? paceSchema.parse(r.actualPace) : undefined, + actualHeartRate: (r.actualHeartRate ?? undefined) as number | undefined, + rpe: (r.rpe ?? undefined) as number | undefined, + completed: r.completed as boolean, + notes: (r.notes ?? undefined) as string | undefined, + noteTags: + Array.isArray(r.noteTags) && (r.noteTags as unknown[]).length > 0 + ? (r.noteTags as string[]) + : undefined, + ruckLoad: r.ruckLoad != null ? weightSchema.parse(r.ruckLoad) : undefined, + elevationGain: r.elevationGain != null ? distanceSchema.parse(r.elevationGain) : undefined, + } + } catch (err) { + console.error('[supabase-adapter] Failed to map logged_set:', err, raw) + throw new Error( + `Failed to map logged_set (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) } } private mapUserProfile(raw: Record): UserProfile { const r = camelizeKeys(raw) - return { - id: r.id as string, - createdAt: r.createdAt as string, - updatedAt: r.updatedAt as string, - displayName: (r.displayName ?? undefined) as string | undefined, - preferredUnits: preferredUnitsSchema.parse(r.preferredUnits), - bodyweight: - r.bodyweight != null ? weightSchema.parse(r.bodyweight) : undefined, - trainingAge: - r.trainingAge != null ? durationSchema.parse(r.trainingAge) : undefined, - exerciseMaxes: - r.exerciseMaxes != null - ? z.record(entityId, oneRepMaxSchema).parse(r.exerciseMaxes) - : {}, - maxReps: - r.maxReps != null - ? z.record(entityId, z.number().int().positive()).parse(r.maxReps) - : {}, + try { + return { + id: r.id as string, + createdAt: r.createdAt as string, + updatedAt: r.updatedAt as string, + displayName: (r.displayName ?? undefined) as string | undefined, + preferredUnits: preferredUnitsSchema.parse(r.preferredUnits), + bodyweight: r.bodyweight != null ? weightSchema.parse(r.bodyweight) : undefined, + trainingAge: r.trainingAge != null ? durationSchema.parse(r.trainingAge) : undefined, + exerciseMaxes: + r.exerciseMaxes != null ? z.record(entityId, oneRepMaxSchema).parse(r.exerciseMaxes) : {}, + maxReps: + r.maxReps != null ? z.record(entityId, z.number().int().positive()).parse(r.maxReps) : {}, + } + } catch (err) { + console.error('[supabase-adapter] Failed to map user_profile:', err, raw) + throw new Error( + `Failed to map user_profile (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) } } private mapGym(raw: Record): Gym { const r = camelizeKeys(raw) - if (r.id == null || r.name == null || r.ownerUserId == null) { - console.warn('[supabase-adapter] mapGym: missing required fields', raw) - } try { return gymSchema.parse({ id: r.id, @@ -269,15 +290,14 @@ export class SupabaseAdapter implements DataAdapter { }) } catch (err) { console.error('[supabase-adapter] Failed to map gym row:', err, raw) - throw new Error(`Failed to map gym row (id=${r.id as string}): ${(err as Error).message}`) + throw new Error( + `Failed to map gym row (id=${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) } } private mapGymMember(raw: Record): GymMember { const r = camelizeKeys(raw) - if (r.gymId == null || r.userId == null) { - console.warn('[supabase-adapter] mapGymMember: missing required fields', raw) - } try { return gymMemberSchema.parse({ gymId: r.gymId, @@ -287,81 +307,121 @@ export class SupabaseAdapter implements DataAdapter { } catch (err) { console.error('[supabase-adapter] Failed to map gym_member row:', err, raw) throw new Error( - `Failed to map gym_member row (gym=${r.gymId as string}, user=${r.userId as string}): ${(err as Error).message}`, + `Failed to map gym_member row (gym=${r.gymId as string}, user=${r.userId as string}): ${err instanceof Error ? err.message : String(err)}`, ) } } - private mapOneRepMaxHistory(raw: Record): OneRepMaxHistory { const r = camelizeKeys(raw) - return { - id: r.id as string, - createdAt: r.createdAt as string, - userId: r.userId as string, - exerciseId: r.exerciseId as string, - weight: weightSchema.parse(r.weight), - estimated: r.estimated as boolean, - recordedAt: r.recordedAt as string, + try { + return { + id: r.id as string, + createdAt: r.createdAt as string, + userId: r.userId as string, + exerciseId: r.exerciseId as string, + weight: weightSchema.parse(r.weight), + estimated: r.estimated as boolean, + recordedAt: r.recordedAt as string, + } + } catch (err) { + console.error('[supabase-adapter] Failed to map one_rep_max_history:', err, raw) + throw new Error( + `Failed to map one_rep_max_history (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) } } private mapSessionTemplate(raw: Record): SessionTemplate { const r = camelizeKeys(raw) - return { - id: r.id as string, - createdAt: r.createdAt as string, - updatedAt: r.updatedAt as string, - userId: r.userId as string, - name: r.name as string, - description: (r.description ?? undefined) as string | undefined, - category: sessionTypeSchema.parse(r.category), - restBetweenGroups: - r.restBetweenGroups != null - ? durationSchema.parse(parseJsonOrValue(r.restBetweenGroups as string | object)) - : undefined, - timeCap: - r.timeCap != null - ? durationSchema.parse(parseJsonOrValue(r.timeCap as string | object)) - : undefined, - scoring: scoringTypeSchema.parse(r.scoring), - eventMetadata: - r.eventMetadata != null - ? eventMetadataSchema.parse(parseJsonOrValue(r.eventMetadata as string | object)) - : undefined, - lastAssignedAt: (r.lastAssignedAt ?? undefined) as string | undefined, - isPublic: r.isPublic as boolean, + try { + return { + id: r.id as string, + createdAt: r.createdAt as string, + updatedAt: r.updatedAt as string, + userId: r.userId as string, + name: r.name as string, + description: (r.description ?? undefined) as string | undefined, + category: sessionTypeSchema.parse(r.category), + restBetweenGroups: + r.restBetweenGroups != null + ? durationSchema.parse( + parseJsonOrValue(r.restBetweenGroups as string | object, 'restBetweenGroups'), + ) + : undefined, + timeCap: + r.timeCap != null + ? durationSchema.parse(parseJsonOrValue(r.timeCap as string | object, 'timeCap')) + : undefined, + scoring: scoringTypeSchema.parse(r.scoring), + eventMetadata: + r.eventMetadata != null + ? eventMetadataSchema.parse( + parseJsonOrValue(r.eventMetadata as string | object, 'eventMetadata'), + ) + : undefined, + lastAssignedAt: (r.lastAssignedAt ?? undefined) as string | undefined, + isPublic: r.isPublic as boolean, + } + } catch (err) { + console.error('[supabase-adapter] Failed to map session_template:', err, raw) + throw new Error( + `Failed to map session_template (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) } } private mapActivityGroupFlat(raw: Record): Omit { const r = camelizeKeys(raw) - return { - id: r.id as string, - sessionTemplateId: r.sessionTemplateId as string, - groupType: groupTypeSchema.parse(r.groupType), - ordinal: r.ordinal as number, - rounds: (r.rounds ?? undefined) as number | undefined, - restBetweenRounds: - r.restBetweenRounds != null - ? durationSchema.parse(parseJsonOrValue(r.restBetweenRounds as string | object)) - : undefined, - restBetweenActivities: - r.restBetweenActivities != null - ? durationSchema.parse(parseJsonOrValue(r.restBetweenActivities as string | object)) - : undefined, + try { + return { + id: r.id as string, + sessionTemplateId: r.sessionTemplateId as string, + groupType: groupTypeSchema.parse(r.groupType), + ordinal: r.ordinal as number, + rounds: (r.rounds ?? undefined) as number | undefined, + restBetweenRounds: + r.restBetweenRounds != null + ? durationSchema.parse( + parseJsonOrValue(r.restBetweenRounds as string | object, 'restBetweenRounds'), + ) + : undefined, + restBetweenActivities: + r.restBetweenActivities != null + ? durationSchema.parse( + parseJsonOrValue( + r.restBetweenActivities as string | object, + 'restBetweenActivities', + ), + ) + : undefined, + } + } catch (err) { + console.error('[supabase-adapter] Failed to map activity_group_flat:', err, raw) + throw new Error( + `Failed to map activity_group_flat (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) } } private mapActivity(raw: Record): Activity { const r = camelizeKeys(raw) - return { - id: r.id as string, - activityGroupId: r.activityGroupId as string, - exerciseId: r.exerciseId as string, - ordinal: r.ordinal as number, - setScheme: setSchemeSchema.parse(parseJsonOrValue(r.setScheme as string | object)), - notes: (r.notes ?? undefined) as string | undefined, + try { + return { + id: r.id as string, + activityGroupId: r.activityGroupId as string, + exerciseId: r.exerciseId as string, + ordinal: r.ordinal as number, + setScheme: setSchemeSchema.parse( + parseJsonOrValue(r.setScheme as string | object, 'setScheme'), + ), + notes: (r.notes ?? undefined) as string | undefined, + } + } catch (err) { + console.error('[supabase-adapter] Failed to map activity:', err, raw) + throw new Error( + `Failed to map activity (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) } } @@ -381,6 +441,7 @@ export class SupabaseAdapter implements DataAdapter { createdBy: (r.createdBy ?? r.userId) as string, } } catch (err) { + console.error('[supabase-adapter] Failed to map program:', err, raw) throw new Error( `Failed to map program "${r.name as string}" (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, ) @@ -399,6 +460,7 @@ export class SupabaseAdapter implements DataAdapter { blockType: blockTypeSchema.parse(r.blockType), } } catch (err) { + console.error('[supabase-adapter] Failed to map block:', err, raw) throw new Error( `Failed to map block "${r.name as string}" (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, ) @@ -407,10 +469,17 @@ export class SupabaseAdapter implements DataAdapter { private mapBlockWeek(raw: Record): BlockWeek { const r = camelizeKeys(raw) - return { - id: r.id as string, - blockId: r.blockId as string, - weekNumber: r.weekNumber as number, + try { + return { + id: r.id as string, + blockId: r.blockId as string, + weekNumber: r.weekNumber as number, + } + } catch (err) { + console.error('[supabase-adapter] Failed to map block_week:', err, raw) + throw new Error( + `Failed to map block_week (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) } } @@ -420,8 +489,7 @@ export class SupabaseAdapter implements DataAdapter { let overrides: ScheduledSession['overrides'] if (r.overrides != null) { try { - const parsed = - typeof r.overrides === 'string' ? JSON.parse(r.overrides) : r.overrides + const parsed = typeof r.overrides === 'string' ? JSON.parse(r.overrides) : r.overrides overrides = sessionOverridesSchema.parse(parsed) } catch (err) { console.warn( @@ -450,63 +518,94 @@ export class SupabaseAdapter implements DataAdapter { private mapProgramActivation(raw: Record): ProgramActivation { const r = camelizeKeys(raw) - return { - id: r.id as string, - createdAt: r.createdAt as string, - updatedAt: r.updatedAt as string, - userId: r.userId as string, - programId: r.programId as string, - currentBlockOrdinal: r.currentBlockOrdinal as number, - currentWeekNumber: r.currentWeekNumber as number, - startDate: r.startDate as string, + try { + return { + id: r.id as string, + createdAt: r.createdAt as string, + updatedAt: r.updatedAt as string, + userId: r.userId as string, + programId: r.programId as string, + currentBlockOrdinal: r.currentBlockOrdinal as number, + currentWeekNumber: r.currentWeekNumber as number, + startDate: r.startDate as string, + } + } catch (err) { + console.error('[supabase-adapter] Failed to map program_activation:', err, raw) + throw new Error( + `Failed to map program_activation (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) } } private mapWeekStatus(raw: Record): WeekStatus { const r = camelizeKeys(raw) - return { - id: r.id as string, - activationId: r.activationId as string, - blockOrdinal: r.blockOrdinal as number, - weekNumber: r.weekNumber as number, - status: weekStatusValueSchema.parse(r.status), - createdAt: r.createdAt as string, + try { + return { + id: r.id as string, + activationId: r.activationId as string, + blockOrdinal: r.blockOrdinal as number, + weekNumber: r.weekNumber as number, + status: weekStatusValueSchema.parse(r.status), + createdAt: r.createdAt as string, + } + } catch (err) { + console.error('[supabase-adapter] Failed to map week_status:', err, raw) + throw new Error( + `Failed to map week_status (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) } } private mapShareLink(raw: Record): ShareLink { const r = camelizeKeys(raw) - return { - id: r.id as string, - token: shareTokenSchema.parse(r.token), - entityType: shareableEntityTypeSchema.parse(r.entityType), - entityId: r.entityId as string, - createdBy: r.createdBy as string, - isActive: r.isActive as boolean, - createdAt: r.createdAt as string, - updatedAt: r.updatedAt as string, + try { + return { + id: r.id as string, + token: shareTokenSchema.parse(r.token), + entityType: shareableEntityTypeSchema.parse(r.entityType), + entityId: r.entityId as string, + createdBy: r.createdBy as string, + isActive: r.isActive as boolean, + createdAt: r.createdAt as string, + updatedAt: r.updatedAt as string, + } + } catch (err) { + console.error('[supabase-adapter] Failed to map share_link:', err, raw) + throw new Error( + `Failed to map share_link (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) } } private mapEventItem(raw: Record): EventItem { const r = camelizeKeys(raw) - return { - id: r.id as string, - createdAt: r.createdAt as string, - updatedAt: r.updatedAt as string, - sessionTemplateId: (r.sessionTemplateId ?? undefined) as string | undefined, - workoutLogId: (r.workoutLogId ?? undefined) as string | undefined, - userId: r.userId as string, - name: r.name as string, - category: (r.category ?? undefined) as string | undefined, - quantity: r.quantity as number, - isPacked: r.isPacked as boolean, - sortOrder: r.sortOrder as number, - notes: (r.notes ?? undefined) as string | undefined, + try { + return { + id: r.id as string, + createdAt: r.createdAt as string, + updatedAt: r.updatedAt as string, + sessionTemplateId: (r.sessionTemplateId ?? undefined) as string | undefined, + workoutLogId: (r.workoutLogId ?? undefined) as string | undefined, + userId: r.userId as string, + name: r.name as string, + category: (r.category ?? undefined) as string | undefined, + quantity: r.quantity as number, + isPacked: r.isPacked as boolean, + sortOrder: r.sortOrder as number, + notes: (r.notes ?? undefined) as string | undefined, + } + } catch (err) { + console.error('[supabase-adapter] Failed to map event_item:', err, raw) + throw new Error( + `Failed to map event_item (${r.id as string}): ${err instanceof Error ? err.message : String(err)}`, + ) } } - private mapConversation(raw: Record, participantUserIds: string[] = []): Conversation { + private mapConversation( + raw: Record, + participantUserIds: string[] = [], + ): Conversation { const r = camelizeKeys(raw) try { return conversationSchema.parse({ @@ -797,11 +896,18 @@ export class SupabaseAdapter implements DataAdapter { .eq('workout_log_id', id) .order('ordinal') if (groupError) throw groupError - const groups = (groupData as unknown as Record[]).map((r) => this.mapLoggedActivityGroup(r)) + const groups = (groupData as unknown as Record[]).map((r) => + this.mapLoggedActivityGroup(r), + ) const groupIds = groups.map((g) => g.id) if (groupIds.length === 0) { - return { log: this.mapWorkoutLog(logData as unknown as Record), groups, activities: [], sets: [] } + return { + log: this.mapWorkoutLog(logData as unknown as Record), + groups, + activities: [], + sets: [], + } } const { data: actData, error: actError } = await this.client @@ -810,11 +916,18 @@ export class SupabaseAdapter implements DataAdapter { .in('logged_group_id', groupIds) .order('ordinal') if (actError) throw actError - const activities = (actData as unknown as Record[]).map((r) => this.mapLoggedActivity(r)) + const activities = (actData as unknown as Record[]).map((r) => + this.mapLoggedActivity(r), + ) const activityIds = activities.map((a) => a.id) if (activityIds.length === 0) { - return { log: this.mapWorkoutLog(logData as unknown as Record), groups, activities, sets: [] } + return { + log: this.mapWorkoutLog(logData as unknown as Record), + groups, + activities, + sets: [], + } } const { data: setData, error: setError } = await this.client @@ -1283,7 +1396,10 @@ export class SupabaseAdapter implements DataAdapter { // The view exposes nullable columns because Postgres views are typed // optimistically; in practice these are always populated. - const rows = (data ?? []) as unknown as Array<{ gym_id: string | null; member_count: number | null }> + const rows = (data ?? []) as unknown as Array<{ + gym_id: string | null + member_count: number | null + }> return rows.flatMap((r) => r.gym_id == null || r.member_count == null ? [] @@ -1620,7 +1736,9 @@ export class SupabaseAdapter implements DataAdapter { .eq('session_template_id', id) .order('ordinal') if (groupError) throw groupError - const groups = (groupData as unknown as Record[]).map((r) => this.mapActivityGroupFlat(r)) + const groups = (groupData as unknown as Record[]).map((r) => + this.mapActivityGroupFlat(r), + ) const groupIds = groups.map((g) => g.id) const template = this.mapSessionTemplate(templateData as unknown as Record) @@ -1642,7 +1760,9 @@ export class SupabaseAdapter implements DataAdapter { .in('activity_group_id', groupIds) .order('ordinal') if (actError) throw actError - const activities = (actData as unknown as Record[]).map((r) => this.mapActivity(r)) + const activities = (actData as unknown as Record[]).map((r) => + this.mapActivity(r), + ) const eventItems = template.category === 'EVENT' ? await this.getEventItems(id, 'template') : [] @@ -2017,7 +2137,9 @@ export class SupabaseAdapter implements DataAdapter { .in('block_id', blockIds) .order('week_number', { ascending: true }) if (weekError) throw weekError - const blockWeeks = (weekData as unknown as Record[]).map((r) => this.mapBlockWeek(r)) + const blockWeeks = (weekData as unknown as Record[]).map((r) => + this.mapBlockWeek(r), + ) const weekIds = blockWeeks.map((w) => w.id) if (weekIds.length === 0) { @@ -2034,7 +2156,9 @@ export class SupabaseAdapter implements DataAdapter { .select('*') .in('block_week_id', weekIds) if (sessionError) throw sessionError - const scheduledSessions = (sessionData as unknown as Record[]).map((r) => this.mapScheduledSession(r)) + const scheduledSessions = (sessionData as unknown as Record[]).map((r) => + this.mapScheduledSession(r), + ) return { program: this.mapProgram(programData as unknown as Record), @@ -3330,7 +3454,9 @@ export class SupabaseAdapter implements DataAdapter { const { data, error } = await query if (error) throw error - return (data as unknown as Record[]).map((row) => this.mapMessage(row)).reverse() + return (data as unknown as Record[]) + .map((row) => this.mapMessage(row)) + .reverse() } async getMessagesSince(conversationId: string, since: string): Promise { diff --git a/src/lib/tauri-adapter.ts b/src/lib/tauri-adapter.ts index 1c7a9ce3..4a25bec9 100644 --- a/src/lib/tauri-adapter.ts +++ b/src/lib/tauri-adapter.ts @@ -83,7 +83,6 @@ import { mediaStatusSchema, entityId, } from '@/domain/types' -import { parseJsonOrValue } from './adapter-utils' import { toAccountabilityGroup, toGroupMember, @@ -604,9 +603,13 @@ function requireString(value: string | null | undefined, field: string): string * parameter is a safety net for fields where null genuinely means "unset" * (e.g. is_bilateral, supports_1rm, is_custom). */ -// TODO(P14-021): pass field name and log on fallback -function intToBool(value: number | null | undefined, fallback = false): boolean { - if (value == null) return fallback +function intToBool(value: number | null | undefined, field: string, fallback = false): boolean { + if (value == null) { + console.warn( + `[tauri-adapter] ${field}: expected int (0/1), got null -- using fallback ${String(fallback)}`, + ) + return fallback + } return value !== 0 } @@ -645,13 +648,13 @@ function toExercise(r: TauriExerciseResponse): Exercise { category: exerciseCategorySchema.parse(r.category), movementPattern: movementPatternSchema.parse(r.movement_pattern), muscleGroups: muscleGroupSpecSchema.parse(parseJson(r.muscle_groups, 'muscle_groups')), - isBilateral: intToBool(r.is_bilateral), - supports1RM: intToBool(r.supports_1rm), + isBilateral: intToBool(r.is_bilateral, 'is_bilateral'), + supports1RM: intToBool(r.supports_1rm, 'supports_1rm'), equipmentRequired: z .array(equipmentSchema) .parse(parseJson(r.equipment_required, 'equipment_required')), - isCustom: intToBool(r.is_custom), - isPublic: intToBool(r.is_public), + isCustom: intToBool(r.is_custom, 'is_custom'), + isPublic: intToBool(r.is_public, 'is_public'), } } @@ -793,7 +796,7 @@ function toLoggedSet(r: TauriLoggedSetResponse): LoggedSet { actualPace: actualPace != null ? paceSchema.parse(actualPace) : undefined, actualHeartRate: r.actual_heart_rate ?? undefined, rpe: r.rpe ?? undefined, - completed: intToBool(r.completed), + completed: intToBool(r.completed, 'completed'), notes: r.notes ?? undefined, noteTags: noteTags.length > 0 ? noteTags : undefined, ruckLoad: ruckLoad != null ? weightSchema.parse(ruckLoad) : undefined, @@ -842,9 +845,7 @@ function toUserProfile(r: TauriUserProfileResponse): UserProfile { } } -function fromUserProfile( - profile: Partial & { id: string }, -): Record { +function fromUserProfile(profile: Partial & { id: string }): Record { const row: Record = { id: profile.id } if (profile.displayName !== undefined) row.display_name = profile.displayName ?? null @@ -864,7 +865,7 @@ function toOneRepMaxHistory(r: TauriOneRepMaxHistoryResponse): OneRepMaxHistory userId: r.user_id, exerciseId: r.exercise_id, weight: weightSchema.parse(parseJson(r.weight, 'weight')), - estimated: intToBool(r.estimated), + estimated: intToBool(r.estimated, 'estimated'), recordedAt: r.recorded_at, } } @@ -882,8 +883,8 @@ function fromOneRepMaxHistory( } function toSessionTemplate(r: TauriSessionTemplateResponse): SessionTemplate { - if (!r.created_at) console.warn('tauri-adapter: null created_at on session template row', r) - if (!r.updated_at) console.warn('tauri-adapter: null updated_at on session template row', r) + if (!r.created_at) console.warn('[tauri-adapter] null created_at on session template row', r) + if (!r.updated_at) console.warn('[tauri-adapter] null updated_at on session template row', r) return { id: r.id, createdAt: r.created_at ?? new Date().toISOString(), @@ -894,9 +895,10 @@ function toSessionTemplate(r: TauriSessionTemplateResponse): SessionTemplate { category: sessionTypeSchema.parse(r.category), restBetweenGroups: r.rest_between_groups != null - ? durationSchema.parse(parseJsonOrValue(r.rest_between_groups)) + ? durationSchema.parse(parseJson(r.rest_between_groups, 'rest_between_groups')) : undefined, - timeCap: r.time_cap != null ? durationSchema.parse(parseJsonOrValue(r.time_cap)) : undefined, + timeCap: + r.time_cap != null ? durationSchema.parse(parseJson(r.time_cap, 'time_cap')) : undefined, scoring: scoringTypeSchema.parse(r.scoring), eventMetadata: undefined, // Event features deferred for Tauri offline mode (W-8) lastAssignedAt: r.last_assigned_at ?? undefined, @@ -923,8 +925,8 @@ function fromSessionTemplate( } function toActivityGroupFlat(r: TauriActivityGroupResponse): Omit { - if (!r.created_at) console.warn('tauri-adapter: null created_at on activity group row', r) - if (!r.updated_at) console.warn('tauri-adapter: null updated_at on activity group row', r) + if (!r.created_at) console.warn('[tauri-adapter] null created_at on activity group row', r) + if (!r.updated_at) console.warn('[tauri-adapter] null updated_at on activity group row', r) return { id: r.id, sessionTemplateId: r.session_template_id, @@ -933,11 +935,11 @@ function toActivityGroupFlat(r: TauriActivityGroupResponse): Omit Domain types // --------------------------------------------------------------------------- -function toConversation(r: TauriConversationResponse, participantUserIds: string[] = []): Conversation { +function toConversation( + r: TauriConversationResponse, + participantUserIds: string[] = [], +): Conversation { try { return conversationSchema.parse({ id: r.id, From 8dbcecb0918c1da6851e83adf40c8757d031bda2 Mon Sep 17 00:00:00 2001 From: Robert Hamilton Date: Sun, 12 Apr 2026 20:14:58 -0500 Subject: [PATCH 3/4] docs(review): mark P17 resolved; add test tasks to backlog All 8 [FIX] findings resolved. 4 [TASK] findings (test coverage gaps) added to Context/Backlog/Ideas.md as the P17 adapter test coverage entry. Review: Context/Reviews/0017-pr107-camelcase-conv-2026-04-12.md --- Context/Backlog/Ideas.md | 28 +++ .../0017-pr107-camelcase-conv-2026-04-12.md | 205 ++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 Context/Reviews/0017-pr107-camelcase-conv-2026-04-12.md diff --git a/Context/Backlog/Ideas.md b/Context/Backlog/Ideas.md index 1a0ba93d..73f56da3 100644 --- a/Context/Backlog/Ideas.md +++ b/Context/Backlog/Ideas.md @@ -5,6 +5,19 @@ skill. Prioritize via the backlog-prioritize skill. +## P17 Review: Adapter test coverage gaps (PR #107) + +**Added:** 2026-04-12 +**Source:** `Context/Reviews/0017-pr107-camelcase-conv-2026-04-12.md` +**Priority:** Medium + +| # | File | Improvement | Status | +| ------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| P17-007 | `src/lib/__tests__/adapter-utils.test.ts` | New file: unit tests for `camelizeKeys` and `parseJsonOrValue` -- already-camelCase passthrough, digit-adjacent keys, multiple/leading underscores, object passthrough, error on malformed JSON | Open | +| P17-008 | `src/lib/__tests__/supabase-adapter.test.ts` | Add `mapScheduledSession` malformed-JSON fallback test: fixture with bad JSON in `overrides`, assert `result.overrides === undefined` and `console.warn` called | Open | +| P17-009 | `src/lib/__tests__/supabase-adapter.test.ts` | Fix JSONB fixtures in `sessionTemplateRow` and `templateActivityRow` to pass pre-parsed objects (not `JSON.stringify`) to exercise the object-passthrough branch of `parseJsonOrValue` | Open | +| P17-010 | `src/lib/__tests__/supabase-adapter.test.ts` | Add `supports1RM` assertion to `getExercises` happy-path test (line 301); add fixture value and assert it round-trips correctly | Open | + ## ~~Chat Data Layer Refinements (PR #33 review suggestions)~~ (Resolved 2026-04-05) **Added:** 2026-04-02 @@ -64,6 +77,21 @@ The `OneRmManagement` component was removed from the profile page during the res **Added:** 2026-04-05 **Resolution:** Reordered sidebar nav into logical groups: core tools, reference, social. Commit `1b359d2`. +### Exercise completion signal during active workout + +**Added:** 2026-04-12 +**Source:** User feedback +**Priority:** Medium + +Two related UX gaps in the workout logger: + +1. **"Done with this exercise" affordance** -- No explicit way to mark an exercise block as finished mid-workout. Users who finish squats before the planned sets are complete (injury, RPE ceiling, time) have no way to signal they're moving on. Options: a "Mark complete" button per exercise block, a long-press gesture, or a swipe action that collapses the block and flags it as done. + +2. **Trailing auto-populated set ambiguity** -- When the logger auto-populates a new set row, it's unclear whether exiting the exercise (or ending the workout) will count that empty row as a logged set or discard it. Users are left guessing whether their volume is being over-counted. Need either: discard empty trailing sets on exit, or show explicit "incomplete set" vs "complete set" state that is visible before saving. + +**Related:** `src/routes/_authenticated/log.$workoutId.tsx`, workout logger state, set auto-population logic +**Area:** Workout logger UX + ### Split log.$workoutId route into three components **Added:** 2026-04-06 **Context:** `src/routes/_authenticated/log.$workoutId.tsx` is ~800 lines with three distinct rendering paths (event workout, strength workout, post-workout summary) toggled by conditional branches. Splitting into separate route components would clarify ownership, shrink the file, and isolate state hoisting per path. diff --git a/Context/Reviews/0017-pr107-camelcase-conv-2026-04-12.md b/Context/Reviews/0017-pr107-camelcase-conv-2026-04-12.md new file mode 100644 index 00000000..cef2508a --- /dev/null +++ b/Context/Reviews/0017-pr107-camelcase-conv-2026-04-12.md @@ -0,0 +1,205 @@ +# PR Review: chore/camelcase-conv → main + +**Date:** 2026-04-12 +**Feature:** N/A (refactor chore — see `Context/QuickPlans/2026-04-12-remove-data-mapper.md`) +**Branch:** chore/camelcase-conv +**PR:** #107 — "refactor(adapters): eliminate data-mapper layer" +**Reviewers:** pr-review-toolkit (code-reviewer, silent-failure-hunter, pr-test-analyzer) +**Status:** ✅ Resolved + +## Summary + +12 findings across 2 categories (8 [FIX], 4 [TASK]). One critical data bug (exercises lose +`supports1RM` on all Supabase reads), one high-severity error-handling gap (`parseJsonOrValue` +silent failures), and a cluster of missing error handling that violates project conventions in +the new inlined mapper methods. Four missing test tasks were also identified: the new +`adapter-utils.ts` utilities have zero unit tests, and three specific adapter-test gaps were +left by deleting the data-mapper test suite. + +## Findings + +### Fix-Now + +#### [FIX] P17-001: `supports_1rm` camelization produces wrong key — exercises lose `supports1RM` +- **File:** `src/lib/adapter-utils.ts:18`, `src/lib/supabase-adapter.ts:131` +- **Severity:** Critical +- **Detail:** `snakeToCamel` regex `/_([a-z])/g` only matches underscores before lowercase + letters, not digits. `supports_1rm` → `supports1Rm` (lowercase `m`). The domain type field + is `supports1RM`. At `supabase-adapter.ts:131`, `r.supports1Rm as boolean` evaluates to + `undefined` cast silently to boolean. Every exercise returned by `getExercises`, + `getExercise`, and `createExercise` via SupabaseAdapter has `supports1RM: undefined`. + The 1RM tracking tab shows no exercises; set scheme gating is broken. Tauri adapter is + unaffected (reads `r.supports_1rm` from the typed struct directly, bypassing camelizeKeys). + **Fix:** Read `raw['supports_1rm']` directly in `mapExercise`, or fix regex to + `/_([a-z0-9])/g` AND update the read key to `r.supports1Rm` to match what the regex + actually produces. +- **Status:** ✅ Fixed +- **Resolution:** `mapExercise` now reads `raw['supports_1rm']` directly, bypassing camelizeKeys for this digit-adjacent column. + +#### [FIX] P17-002: `parseJsonOrValue` throws bare `SyntaxError` with no column context +- **File:** `src/lib/adapter-utils.ts:37-38` +- **Severity:** High +- **Detail:** `parseJsonOrValue` calls `JSON.parse(value)` without a try/catch. Malformed + JSONB in any of 9 call sites across both adapters produces an unattributed + `SyntaxError: Unexpected token X at position Y` — no column name, no entity type, no raw + value. The Tauri adapter already has a local `parseJson(value, column)` helper that catches + and rethrows with context. The new shared utility regresses this for the 4 Tauri call sites + that switched to it (`toSessionTemplate:897`, `toActivityGroupFlat:936,940`, `toActivity:969`). + **Fix:** Add `column: string` param and wrap with try/catch that rethrows with column name + and raw value preview. All 9 call sites must pass the column name. +- **Status:** ✅ Fixed +- **Resolution:** `parseJsonOrValue` now requires `column: string` param and wraps `JSON.parse` in try/catch with column name + raw value preview. All 6 Supabase call sites updated. The 4 Tauri call sites were reverted to the local `parseJson` helper (see P17-006). + +#### [FIX] P17-003: `intToBool` null coercion triggers without `console.warn` (acknowledged TODO) +- **File:** `src/lib/tauri-adapter.ts:607-611` +- **Severity:** Medium +- **Detail:** Project rule (error-handling.md §Adapter Boundary Fallbacks) requires + `console.warn` at warn level when a safety-net coercion triggers for fields expected to be + non-nullable. `intToBool` silently returns `false` for null inputs, including for `completed` + (a field the inline comment says "should always provide a value" from Rust). A null `completed` + makes in-progress workouts appear to have zero completions. The TODO `P14-021` acknowledges + this but the refactor was the opportunity to fix it. + **Fix:** Add `field: string` param; log `console.warn('[tauri-adapter] ${field}: expected + int (0/1), got null -- using fallback ${fallback}')` before returning fallback. +- **Status:** ✅ Fixed +- **Resolution:** `intToBool` now requires `field: string` param and logs `console.warn('[tauri-adapter] ...)` when null fallback triggers. TODO comment removed. All 10 call sites updated with field names. + +#### [FIX] P17-004: `mapGym`/`mapGymMember` pre-check `console.warn` implies tolerance but throws +- **File:** `src/lib/supabase-adapter.ts:259-273`, `src/lib/supabase-adapter.ts:278-292` +- **Severity:** Medium +- **Detail:** `if (r.id == null || ...) console.warn('[supabase-adapter] mapGym: missing required + fields', raw)` fires, then falls through to `gymSchema.parse()` which throws, caught and + re-logged as `console.error`. Two log lines for one root cause; the warn implies the code is + tolerating the missing field, but it is not. Developers debugging a production Sentry trace + have misleading signal. + **Fix:** Remove the pre-check warn. The catch block already logs `console.error` with `raw`. + Or: throw directly in the pre-check (no warn), skipping the Zod parse. +- **Status:** ✅ Fixed +- **Resolution:** Removed pre-check `console.warn` blocks from both `mapGym` and `mapGymMember`. The catch block's `console.error` is the sole log signal on failure. + +#### [FIX] P17-005: 15 supabase-adapter mapper methods lack try/catch around Zod parse +- **File:** `src/lib/supabase-adapter.ts:119-507` +- **Severity:** High +- **Detail:** `mapExercise`, `mapWorkoutLog`, `mapLoggedActivityGroup`, `mapLoggedActivity`, + `mapLoggedSet`, `mapUserProfile`, `mapSessionTemplate`, `mapActivityGroupFlat`, `mapActivity`, + `mapOneRepMaxHistory`, `mapBlockWeek`, `mapProgramActivation`, `mapWeekStatus`, `mapShareLink`, + `mapEventItem` all call Zod `parse()` without try/catch. A `ZodError` propagates with only + Zod's internal field path — no entity type, no entity ID, no raw row. `mapProgram`, + `mapBlock`, and `mapScheduledSession` do have try/catch (though without `console.error` before + rethrow). Inconsistent coverage means malformed DB rows are untraceable in production. + **Fix:** Wrap each mapper in try/catch that logs `console.error('[supabase-adapter] Failed to + map :', err, raw)` and rethrows with entity type + ID context. Match the pattern + already used by `mapProgram`. +- **Status:** ✅ Fixed +- **Resolution:** All 15 methods now wrapped in try/catch with `console.error('[supabase-adapter] Failed to map :', err, raw)` + rethrow. `mapProgram` and `mapBlock` also updated to add `console.error` to their existing catch blocks. + +#### [FIX] P17-006: tauri-adapter `toSessionTemplate`/`toActivityGroupFlat`/`toActivity` use `parseJsonOrValue` instead of local `parseJson` +- **File:** `src/lib/tauri-adapter.ts:897`, `src/lib/tauri-adapter.ts:936`, `src/lib/tauri-adapter.ts:940`, `src/lib/tauri-adapter.ts:969` +- **Severity:** Medium +- **Detail:** The tauri-adapter defines a local `parseJson(value, column)` helper (line 558) + specifically to produce context-rich errors. These four call sites import `parseJsonOrValue` + from `adapter-utils` instead, bypassing column-name context. All other Tauri mapper functions + that parse JSON columns use the local `parseJson` correctly. This inconsistency means these + four columns (`rest_between_groups`, `time_cap`, `rest_between_rounds`, `rest_between_activities`, + `set_scheme`) produce bare `SyntaxError` on bad data. + **Fix:** Replace `parseJsonOrValue(r.x)` with `parseJson(r.x, 'x')` at all four call sites. + If P17-002 is fixed to add a `column` param, `parseJsonOrValue` and `parseJson` can unify. +- **Status:** ✅ Fixed +- **Resolution:** All 4 call sites replaced with local `parseJson(r.x, 'x')`. The `parseJsonOrValue` import removed from tauri-adapter.ts. + +### Missing Tasks + +#### [TASK] P17-007: Add unit tests for `camelizeKeys` and `parseJsonOrValue` in `adapter-utils.test.ts` +- **File:** `src/lib/__tests__/` (new file: `adapter-utils.test.ts`) +- **Severity:** High +- **Detail:** Both utilities in `adapter-utils.ts` have zero unit tests. `camelizeKeys` is the + foundational transform used by every Supabase adapter mapper. Key behaviors that must be + verified: already-camelCase passthrough, null/undefined values, keys adjacent to digits + (the `supports_1rm` case specifically), multiple consecutive underscores, leading underscores. + `parseJsonOrValue` must verify: string path (`JSON.parse`), object-passthrough, and error + behavior on malformed JSON. These are the two highest-value tests to add given the surface + area they protect. +- **Status:** 📋 Task created +- **Resolution:** Added to `Context/Backlog/Ideas.md` as P17-007. + +#### [TASK] P17-008: Add `mapScheduledSession` overrides malformed-JSON fallback test +- **File:** `src/lib/__tests__/supabase-adapter.test.ts` +- **Severity:** Medium +- **Detail:** `mapScheduledSession` (supabase-adapter.ts:417) wraps the `overrides` column parse + in an inner try/catch that degrades to `undefined` + `console.warn`. The deleted + `data-mapper.test.ts` (lines 2082-2110) verified this fallback explicitly with two tests: + one for malformed JSON string, one for schema-invalid JSON. No remaining test exercises the + catch branch. If the fallback is removed or the warn is dropped, it is undetectable. + **Fix:** Add a `scheduledSessionRow` fixture variant with malformed JSON in `overrides`, assert + `result.overrides === undefined` and that `console.warn` was called. +- **Status:** 📋 Task created +- **Resolution:** Added to `Context/Backlog/Ideas.md` as P17-008. + +#### [TASK] P17-009: Fix Supabase adapter JSONB test fixtures to pass pre-parsed objects +- **File:** `src/lib/__tests__/supabase-adapter.test.ts` +- **Severity:** Medium +- **Detail:** `sessionTemplateRow` (line 166) and `templateActivityRow` (line 193) pass + `JSON.stringify({...})` for JSONB columns (`rest_between_groups`, `time_cap`, `set_scheme`). + PostgREST returns these columns as already-parsed objects, not strings. The existing tests + exercise the string branch of `parseJsonOrValue` — the wrong code path for Supabase. + If `parseJsonOrValue`'s object-passthrough branch ever broke, no Supabase adapter test + would catch it. + **Fix:** Update fixtures to pass pre-parsed objects: `rest_between_groups: { seconds: 120 }` + rather than `JSON.stringify({ seconds: 120 })`. Add a second variant with strings to retain + coverage of the Tauri string path. +- **Status:** 📋 Task created +- **Resolution:** Added to `Context/Backlog/Ideas.md` as P17-009. + +#### [TASK] P17-010: Assert `supports1RM` in `getExercises` Supabase adapter happy-path test +- **File:** `src/lib/__tests__/supabase-adapter.test.ts:301` +- **Severity:** Medium +- **Detail:** The `getExercises` happy-path test asserts on `id`, `name`, `category`, + `movementPattern`, and `muscleGroups` but not on `supports1RM`, `isBilateral`, `isCustom`, + or `isPublic`. The `supports_1rm` camelization issue (P17-001) would have been caught here + had this assertion existed. The Tauri adapter test does assert `result[0].supports1RM === true` + (line 588), providing protection for the Tauri path but not the Supabase path. + **Fix:** Add `supports1RM: true` (or false) to the exercise fixture and assert it in the + `getExercises` result. +- **Status:** 📋 Task created +- **Resolution:** Added to `Context/Backlog/Ideas.md` as P17-010. + +#### [FIX] P17-011: `console.warn` calls in tauri-adapter missing bracketed `[module-name]` prefix +- **File:** `src/lib/tauri-adapter.ts` (multiple warn call sites) +- **Severity:** Low +- **Detail:** Multiple `console.warn` calls in tauri-adapter use the form `'tauri-adapter: ...'` + without the bracketed prefix `[tauri-adapter]` that the project error-handling convention + requires. The `parseNoteTags` and `invokeCommand` catch blocks use the correct format; the + null-timestamp warn calls in the to-domain mapper functions do not. Makes production log traces + harder to grep. + **Fix:** Standardize all warn calls to `console.warn('[tauri-adapter] ...')`. +- **Status:** ✅ Fixed +- **Resolution:** All 16 `console.warn('tauri-adapter: ...)` calls updated to `console.warn('[tauri-adapter] ...')` bracket format. + +#### [FIX] P17-012: `(err as Error).message` unsafe cast in `mapGym`/`mapGymMember` catch blocks +- **File:** `src/lib/supabase-adapter.ts:272`, `src/lib/supabase-adapter.ts:290` +- **Severity:** Low +- **Detail:** `throw new Error(\`...: ${(err as Error).message}\`)` — if `err` is not an `Error` + instance (e.g. a plain string or a `PostgrestError`), `.message` is `undefined`, producing + `"Failed to map gym row (id=abc): undefined"`. Other mappers in the same file already use the + guarded form `err instanceof Error ? err.message : String(err)` correctly. + **Fix:** Replace `(err as Error).message` with `err instanceof Error ? err.message : String(err)` + at both catch sites. +- **Status:** ✅ Fixed +- **Resolution:** Both catch blocks in `mapGym` and `mapGymMember` updated to use guarded `err instanceof Error ? err.message : String(err)`. + +## Resolution Checklist +- [x] All [FIX] findings resolved +- [x] All [TASK] findings added to backlog (no Steps.md — chore branch) +- [x] All [ADR] findings have ADRs created or dismissed (none) +- [x] All [RULE] findings applied or dismissed (none) +- [ ] Review verified by review-verify agent + +## Resolution Summary +**Resolved at:** 2026-04-12 +**Session:** review-resolve for PR #107 camelcase-conv adapter refactor + +| Category | Total | Resolved | +|---|---|---| +| [FIX] | 8 | 8 | +| [TASK] | 4 | 4 (added to backlog) | +| **Total** | **12** | **12** | From 73305170087a74c4d53ce28b0f93ea1621e57595 Mon Sep 17 00:00:00 2001 From: Robert Hamilton Date: Sun, 12 Apr 2026 20:30:30 -0500 Subject: [PATCH 4/4] docs(review): mark P17 verified by review-verify agent --- Context/Reviews/0017-pr107-camelcase-conv-2026-04-12.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Context/Reviews/0017-pr107-camelcase-conv-2026-04-12.md b/Context/Reviews/0017-pr107-camelcase-conv-2026-04-12.md index cef2508a..339be0da 100644 --- a/Context/Reviews/0017-pr107-camelcase-conv-2026-04-12.md +++ b/Context/Reviews/0017-pr107-camelcase-conv-2026-04-12.md @@ -192,7 +192,7 @@ left by deleting the data-mapper test suite. - [x] All [TASK] findings added to backlog (no Steps.md — chore branch) - [x] All [ADR] findings have ADRs created or dismissed (none) - [x] All [RULE] findings applied or dismissed (none) -- [ ] Review verified by review-verify agent +- [x] Review verified by review-verify agent ## Resolution Summary **Resolved at:** 2026-04-12