From ab82c29b36b7b91f7fde5d8f420ddb46a82b7eb3 Mon Sep 17 00:00:00 2001 From: Joshua Hoblitt Date: Thu, 16 Jul 2026 16:09:33 -0700 Subject: [PATCH 1/2] feat: Add optional per-set power tracking (model, plan-diff, stats) Records peak power (watts) per completed set for equipment with a power readout (e.g. Keiser trainers). Adds an optional power field on RecordedSet and an opt-in trackPower flag on the weighted exercise blueprint, both additive within schema v3. Propagates the toggle through the plan diff, preserves it when filling AI-generated plans, and adds a max-power-per-session stat series. Co-Authored-By: Claude Opus 4.8 --- app/src/i18n/en.json | 6 + app/src/models/ai-models.spec.ts | 31 +++++ app/src/models/ai-models.ts | 1 + app/src/models/blueprint-diff.spec.ts | 27 ++++ app/src/models/blueprint-diff.ts | 31 +++++ app/src/models/blueprint-models/index.spec.ts | 31 +++++ app/src/models/blueprint-models/index.ts | 8 +- .../generated/program-blueprint.schema.json | 119 ++++++++++++++--- app/src/models/plan-file.spec.ts | 1 + .../models/session-models/__test__/helpers.ts | 9 +- .../recorded-weighted-exercise.spec.ts | 126 ++++++++++++++++++ .../recorded-weighted-exercise.ts | 29 +++- .../storage/versions/latest/blueprint.ts | 6 + .../models/storage/versions/latest/session.ts | 6 + .../export-plaintext-effects.spec.ts.snap | 2 + app/src/store/stats/calculate-stats.spec.ts | 55 ++++++++ app/src/store/stats/calculate-stats.ts | 28 ++++ app/src/store/stats/index.ts | 8 ++ docs/schemas/ai-plan/AiPlan.json | 4 + .../program-blueprint/ProgramBlueprint.json | 4 + docs/schemas/workout-worker/RecordedSet.json | 4 + .../WeightedExerciseBlueprint.json | 4 + .../reference/ProgramBlueprint.json | 4 + .../scripts/validate-plan.mjs | 17 ++- 24 files changed, 530 insertions(+), 31 deletions(-) diff --git a/app/src/i18n/en.json b/app/src/i18n/en.json index a1f9c8d70..988bb086c 100644 --- a/app/src/i18n/en.json +++ b/app/src/i18n/en.json @@ -1,6 +1,7 @@ { "ai.planner.subtitle": "Use AI to generate an entire program suited to your goals", "ai.planner.title": "AI Planner", + "exercise.power.label": "Power", "exercise.progressive_overload.label": "Progressive Overload", "ai.restart_chat.button": "Restart chat", "ai.share_program.button": "Share a program", @@ -83,6 +84,7 @@ "exercise.reps.label": "Reps", "exercise.resistance.label": "Resistance", "exercise.rest_between_sets.label": "Rest between sets", + "exercise.select_power.title": "Max Power", "exercise.select_reps.title": "Select Reps", "exercise.set_number.label": "Set {number}", "exercise.sets.label": "Sets", @@ -95,6 +97,7 @@ "exercise.track_incline.label": "Track Incline", "exercise.track_resistance.label": "Track Resistance", "exercise.track_time.label": "Track Time", + "exercise.track_power.label": "Track Power", "exercise.track_weight.label": "Track Weight", "exercise.track_steps.label": "Track Steps", "exercise.type.label": "Exercise type", @@ -210,6 +213,7 @@ "generic.retry.button": "Retry", "generic.review.label": "review", "generic.save.button": "Save", + "generic.skip.button": "Skip", "generic.search.button": "Search", "generic.share.button": "Share", "generic.share_link.button": "Share link", @@ -280,6 +284,7 @@ "plan.diff.session_notes.label": "Session notes", "plan.diff.sets.label": "Sets", "plan.diff.superset.label": "Superset", + "plan.diff.track_power.label": "Power", "plan.diff.target.label": "Target", "plan.diff.track_distance.label": "Distance", "plan.diff.track_duration.label": "Duration", @@ -398,6 +403,7 @@ "stats.stats.title": "Stats", "stats.exercise.overview.title": "Overview", "stats.exercise.max_weight.title": "Max Weight", + "stats.exercise.max_power.title": "Max Power", "stats.exercise.1rm_progress.title": "1RM", "stats.exercise.volume_per_workout.title": "Volume Per Workout", "stats.exercise.current_weight.label": "Current weight", diff --git a/app/src/models/ai-models.spec.ts b/app/src/models/ai-models.spec.ts index cce621714..f61630aff 100644 --- a/app/src/models/ai-models.spec.ts +++ b/app/src/models/ai-models.spec.ts @@ -14,6 +14,7 @@ import { WeightedExerciseBlueprint, } from '@/models/blueprint-models'; import { AnyVersionAiPlanJSON } from '@/models/storage/versions/any'; +import { WeightedExerciseBlueprintJSON } from '@/models/storage/versions/latest'; import { toBigNumberJSON, toDurationJSON, toLocalDateJSON } from '@/models/storage/versions/libs'; import { DeepPartial } from '@/utils/types'; @@ -233,6 +234,36 @@ describe('aiPlanFromJSON', () => { expect(exercise.progressiveOverload).toBeInstanceOf(IncreaseAllEvenlyProgressiveOverload); expect((exercise.progressiveOverload as IncreaseAllEvenlyProgressiveOverload).amount.toNumber()).toBe(5); }); + + it('preserves trackPower when streamed in, and defaults to false when absent', () => { + const withPower = firstExercise({ + version: 3, + name: 'PPL', + blueprint: { + sessions: [ + { + exercises: [ + { + type: 'WeightedExerciseBlueprint', + name: 'Squat', + trackPower: true, + } as DeepPartial, + ], + }, + ], + }, + }) as WeightedExerciseBlueprint; + + expect(withPower.trackPower).toBe(true); + + const withoutPower = firstExercise({ + version: 3, + name: 'PPL', + blueprint: { sessions: [{ exercises: [{ name: 'Bench' }] }] }, + }) as WeightedExerciseBlueprint; + + expect(withoutPower.trackPower).toBe(false); + }); }); describe('progressive overload', () => { diff --git a/app/src/models/ai-models.ts b/app/src/models/ai-models.ts index 3cba1cae1..66f1c262b 100644 --- a/app/src/models/ai-models.ts +++ b/app/src/models/ai-models.ts @@ -156,6 +156,7 @@ function fillWeightedExercise(partial: DeepPartial { sets: CardioExerciseSetBlueprint[] = [createCardioSet()], ): CardioExerciseBlueprint => new CardioExerciseBlueprint(name, sets, '', ''); + it('detects, labels, and applies a trackPower-only change', () => { + const oldExercise = createWeightedExercise('Bench Press'); + const newExercise = oldExercise.with({ trackPower: true }); + const original = new SessionBlueprint('Push Day', [oldExercise], ''); + const modified = new SessionBlueprint('Push Day', [newExercise], ''); + + const diff = diffSessionBlueprints(original, modified); + + expect(diff.hasChanges).toBe(true); + expect(diff.modifiedExercises).toHaveLength(1); + const changes = diff.modifiedExercises[0]!.changes; + expect(changes).toHaveLength(1); + expect(changes[0]).toEqual( + expect.objectContaining({ + kind: 'exerciseTrackPower', + oldValue: false, + newValue: true, + }), + ); + expect(getChangeLabelKey(changes[0]!)).toEqual({ + key: 'plan.diff.track_power.label', + }); + + const result = applySessionBlueprintDiff(original, diff); + expect((result.exercises[0] as WeightedExerciseBlueprint).trackPower).toBe(true); + }); + describe('session-level changes', () => { it('should detect session name change', () => { const original = new SessionBlueprint('Workout A', [], ''); diff --git a/app/src/models/blueprint-diff.ts b/app/src/models/blueprint-diff.ts index d4f9019d8..d9f3c5059 100644 --- a/app/src/models/blueprint-diff.ts +++ b/app/src/models/blueprint-diff.ts @@ -139,6 +139,15 @@ interface ExerciseSupersetChange extends BaseChange { newValue: boolean; } +interface ExerciseTrackPowerChange extends BaseChange { + kind: 'exerciseTrackPower'; + type: 'modified'; + exerciseName: string; + exerciseIndex: number; + oldValue: boolean; + newValue: boolean; +} + interface ExerciseNotesChange extends BaseChange { kind: 'exerciseNotes'; type: 'modified'; @@ -228,6 +237,7 @@ type ExerciseFieldChange = | ExerciseWeightIncreaseChange | ExerciseRestChange | ExerciseSupersetChange + | ExerciseTrackPowerChange | ExerciseNotesChange | ExerciseLinkChange | ExerciseTargetChange @@ -477,6 +487,18 @@ function diffWeightedExercises( }); } + if (oldEx.trackPower !== newEx.trackPower) { + changes.push({ + id: generateChangeId(), + kind: 'exerciseTrackPower', + type: 'modified', + exerciseName, + exerciseIndex, + oldValue: oldEx.trackPower, + newValue: newEx.trackPower, + }); + } + if (oldEx.notes !== newEx.notes) { changes.push({ id: generateChangeId(), @@ -910,6 +932,9 @@ export function applySessionBlueprintDiff(original: SessionBlueprint, diff: Sess .with({ kind: 'exerciseSuperset' }, (c) => exercise instanceof WeightedExerciseBlueprint ? exercise.with({ supersetWithNext: c.newValue }) : exercise, ) + .with({ kind: 'exerciseTrackPower' }, (c) => + exercise instanceof WeightedExerciseBlueprint ? exercise.with({ trackPower: c.newValue }) : exercise, + ) .with({ kind: 'exerciseNotes' }, (c) => exercise.with({ notes: c.newValue })) .with({ kind: 'exerciseLink' }, (c) => exercise.with({ link: c.newValue })) .with({ kind: 'exerciseTarget' }, (c) => { @@ -1078,6 +1103,9 @@ export function getChangeDescription(t: UseTranslateResult['t'], change: DiffCha .with({ kind: 'exerciseSuperset' }, (c) => t(c.newValue ? 'plan.diff.generic_enabled.body' : 'plan.diff.generic_disabled.body'), ) + .with({ kind: 'exerciseTrackPower' }, (c) => + t(c.newValue ? 'plan.diff.generic_enabled.body' : 'plan.diff.generic_disabled.body'), + ) .with({ kind: 'exerciseNotes' }, () => t('plan.diff.generic_updated.body')) .with({ kind: 'exerciseLink' }, () => t('plan.diff.generic_updated.body')) .with({ kind: 'exerciseTarget' }, () => t('plan.diff.generic_updated.body')) @@ -1143,6 +1171,9 @@ export function getChangeLabelKey(change: DiffChange): TranslatableString { .with({ kind: 'exerciseSuperset' }, () => ({ key: 'plan.diff.superset.label', })) + .with({ kind: 'exerciseTrackPower' }, () => ({ + key: 'plan.diff.track_power.label', + })) .with({ kind: 'exerciseNotes' }, () => ({ key: 'plan.diff.notes.label', })) diff --git a/app/src/models/blueprint-models/index.spec.ts b/app/src/models/blueprint-models/index.spec.ts index 2b10343e5..c51456089 100644 --- a/app/src/models/blueprint-models/index.spec.ts +++ b/app/src/models/blueprint-models/index.spec.ts @@ -563,3 +563,34 @@ describe('WeightedExerciseBlueprint rep schemes', () => { }); }); }); + +describe('WeightedExerciseBlueprint.trackPower', () => { + it('defaults to false', () => { + expect(WeightedExerciseBlueprint.empty().trackPower).toBe(false); + }); + + it('defaults to false when absent from JSON', () => { + const json = WeightedExerciseBlueprint.empty().toJSON(); + delete (json as { trackPower?: boolean }).trackPower; + expect(WeightedExerciseBlueprint.fromJSON(json).trackPower).toBe(false); + }); + + it('round-trips through JSON', () => { + const blueprint = WeightedExerciseBlueprint.empty().with({ trackPower: true }); + expect(WeightedExerciseBlueprint.fromJSON(blueprint.toJSON()).trackPower).toBe(true); + }); + + it('participates in equality', () => { + const off = WeightedExerciseBlueprint.empty(); + const on = off.with({ trackPower: true }); + expect(off.equals(on)).toBe(false); + expect(on.equals(off.with({ trackPower: true }))).toBe(true); + }); + + it('with() sets and clears the flag', () => { + const on = WeightedExerciseBlueprint.empty().with({ trackPower: true }); + expect(on.trackPower).toBe(true); + expect(on.with({ trackPower: false }).trackPower).toBe(false); + expect(on.with({ sets: 5 }).trackPower).toBe(true); + }); +}); diff --git a/app/src/models/blueprint-models/index.ts b/app/src/models/blueprint-models/index.ts index 300f3a68c..cb9229033 100644 --- a/app/src/models/blueprint-models/index.ts +++ b/app/src/models/blueprint-models/index.ts @@ -570,6 +570,7 @@ export class WeightedExerciseBlueprint { readonly supersetWithNext: boolean, readonly notes: string, readonly link: string, + readonly trackPower: boolean = false, ) {} static empty() { @@ -582,6 +583,7 @@ export class WeightedExerciseBlueprint { false, '', '', + false, ); } @@ -595,6 +597,7 @@ export class WeightedExerciseBlueprint { json.supersetWithNext, json.notes, json.link, + json.trackPower ?? false, ); } @@ -652,7 +655,8 @@ export class WeightedExerciseBlueprint { this.restBetweenSets.failureRest.equals(other.restBetweenSets.failureRest) && this.supersetWithNext === other.supersetWithNext && this.notes === other.notes && - this.link === other.link + this.link === other.link && + this.trackPower === other.trackPower ); } @@ -667,6 +671,7 @@ export class WeightedExerciseBlueprint { supersetWithNext: this.supersetWithNext, notes: this.notes, link: this.link, + trackPower: this.trackPower, }; } @@ -680,6 +685,7 @@ export class WeightedExerciseBlueprint { other.supersetWithNext ?? this.supersetWithNext, other.notes ?? this.notes, other.link ?? this.link, + other.trackPower ?? this.trackPower, ); } } diff --git a/app/src/models/generated/program-blueprint.schema.json b/app/src/models/generated/program-blueprint.schema.json index 9ce18c339..7d5950f0a 100644 --- a/app/src/models/generated/program-blueprint.schema.json +++ b/app/src/models/generated/program-blueprint.schema.json @@ -20,7 +20,12 @@ "$ref": "#/definitions/LocalDate" } }, - "required": ["version", "name", "sessions", "lastEdited"], + "required": [ + "version", + "name", + "sessions", + "lastEdited" + ], "definitions": { "SessionBlueprint": { "type": "object", @@ -42,14 +47,21 @@ "type": "string" } }, - "required": ["version", "name", "exercises", "notes"] + "required": [ + "version", + "name", + "exercises", + "notes" + ] }, "ExerciseBlueprint": { "type": "object", "discriminator": { "propertyName": "type" }, - "required": ["type"], + "required": [ + "type" + ], "oneOf": [ { "$ref": "#/definitions/WeightedExerciseBlueprint" @@ -92,6 +104,10 @@ }, "progressiveOverload": { "$ref": "#/definitions/ProgressiveOverload" + }, + "trackPower": { + "type": "boolean", + "description": "When true, the app prompts for and records the peak power (watts) of each completed set, for equipment with a power readout (e.g. Keiser functional trainers)." } }, "required": [ @@ -111,7 +127,9 @@ "discriminator": { "propertyName": "type" }, - "required": ["type"], + "required": [ + "type" + ], "oneOf": [ { "$ref": "#/definitions/FixedRepsConfig" @@ -135,7 +153,10 @@ "type": "integer" } }, - "required": ["type", "reps"] + "required": [ + "type", + "reps" + ] }, "RangeRepsConfig": { "type": "object", @@ -151,7 +172,11 @@ "type": "integer" } }, - "required": ["type", "min", "max"] + "required": [ + "type", + "min", + "max" + ] }, "PerSetRepsConfig": { "type": "object", @@ -168,7 +193,10 @@ "description": "One target per set; length matches `sets`. It's important to note that while the model allows for max and min to be specified, the UI does not, so it is VERY important that the min and max values are the same when using this mode" } }, - "required": ["type", "targets"] + "required": [ + "type", + "targets" + ] }, "RepsTarget": { "type": "object", @@ -180,7 +208,10 @@ "type": "integer" } }, - "required": ["min", "max"] + "required": [ + "min", + "max" + ] }, "Rest": { "type": "object", @@ -196,7 +227,11 @@ "description": "Rest taken after a set where the user failed to hit their target reps." } }, - "required": ["minRest", "maxRest", "failureRest"] + "required": [ + "minRest", + "maxRest", + "failureRest" + ] }, "Duration": { "type": "string", @@ -207,7 +242,9 @@ "discriminator": { "propertyName": "type" }, - "required": ["type"], + "required": [ + "type" + ], "oneOf": [ { "$ref": "#/definitions/NoProgressiveOverload" @@ -228,7 +265,9 @@ "const": "NoProgressiveOverload" } }, - "required": ["type"], + "required": [ + "type" + ], "description": "Used when the user does not want progressive overload at all. Potentially with bodyweight exercises." }, "IncreaseAllEvenlyProgressiveOverload": { @@ -242,12 +281,17 @@ "$ref": "#/definitions/BigNumber" } }, - "required": ["type", "amount"], + "required": [ + "type", + "amount" + ], "description": "The standard \"increase every set across the board\" progressive overload. Usually 2.5kg, or 5lb." }, "BigNumber": { "type": "string", - "examples": ["1.23"], + "examples": [ + "1.23" + ], "format": "decimal" }, "IncreaseLowestSetProgressiveOverload": { @@ -262,10 +306,19 @@ }, "increaseStrategy": { "type": "string", - "enum": ["first", "middle", "last", "all"] + "enum": [ + "first", + "middle", + "last", + "all" + ] } }, - "required": ["type", "amount", "increaseStrategy"], + "required": [ + "type", + "amount", + "increaseStrategy" + ], "description": "A more complex progressive overload which allows the user to increase only a single set, or all sets which have the lowest weight.\n\nA user might want to increase the middle weight for exercises where going up across the board would be too much e.g. lateral raises, or other shoulder exercises" }, "CardioExerciseBlueprint": { @@ -291,7 +344,13 @@ "type": "string" } }, - "required": ["type", "name", "sets", "notes", "link"] + "required": [ + "type", + "name", + "sets", + "notes", + "link" + ] }, "CardioExerciseSetBlueprint": { "type": "object", @@ -337,7 +396,9 @@ "discriminator": { "propertyName": "type" }, - "required": ["type"], + "required": [ + "type" + ], "oneOf": [ { "$ref": "#/definitions/TimeCardioTarget" @@ -358,7 +419,10 @@ "$ref": "#/definitions/Duration" } }, - "required": ["type", "value"] + "required": [ + "type", + "value" + ] }, "DistanceCardioTarget": { "type": "object", @@ -371,7 +435,10 @@ "$ref": "#/definitions/Distance" } }, - "required": ["type", "value"] + "required": [ + "type", + "value" + ] }, "Distance": { "type": "object", @@ -383,15 +450,23 @@ "$ref": "#/definitions/DistanceUnit" } }, - "required": ["value", "unit"] + "required": [ + "value", + "unit" + ] }, "DistanceUnit": { "type": "string", - "enum": ["metre", "yard", "mile", "kilometre"] + "enum": [ + "metre", + "yard", + "mile", + "kilometre" + ] }, "LocalDate": { "type": "string", "format": "date" } } -} +} \ No newline at end of file diff --git a/app/src/models/plan-file.spec.ts b/app/src/models/plan-file.spec.ts index 6e9cf27ef..ac6e95874 100644 --- a/app/src/models/plan-file.spec.ts +++ b/app/src/models/plan-file.spec.ts @@ -31,6 +31,7 @@ const validBlueprint: ProgramBlueprintJSON = { notes: '', link: '', progressiveOverload: { type: 'IncreaseAllEvenlyProgressiveOverload', amount: '2.5' as BigNumberJSON }, + trackPower: false, }, ], }, diff --git a/app/src/models/session-models/__test__/helpers.ts b/app/src/models/session-models/__test__/helpers.ts index ce6df2f37..7e7013dbd 100644 --- a/app/src/models/session-models/__test__/helpers.ts +++ b/app/src/models/session-models/__test__/helpers.ts @@ -81,8 +81,13 @@ export function makeSession( return new Session(uuid(), new SessionBlueprint('Test', exercises, ''), recorded, date, undefined, undefined); } -export function filledPotentialSet(reps: number, time: OffsetDateTime, weight = new Weight(100, 'kilograms')) { - return new PotentialSet(new RecordedSet(reps, time), weight); +export function filledPotentialSet( + reps: number, + time: OffsetDateTime, + weight = new Weight(100, 'kilograms'), + power?: number, +) { + return new PotentialSet(new RecordedSet(reps, time, power), weight); } // Helper functions to match the C# test structure diff --git a/app/src/models/session-models/recorded-weighted-exercise.spec.ts b/app/src/models/session-models/recorded-weighted-exercise.spec.ts index 2e1fbb9e4..ded2cf36a 100644 --- a/app/src/models/session-models/recorded-weighted-exercise.spec.ts +++ b/app/src/models/session-models/recorded-weighted-exercise.spec.ts @@ -315,3 +315,129 @@ describe('RecordedWeightedExercise JSON', () => { expect(filled.equals(empty)).toBe(false); }); }); + +describe('RecordedSet.power', () => { + it('defaults power to undefined', () => { + expect(new RecordedSet(10, tick()).power).toBeUndefined(); + }); + + it('stores power and handles it in with()', () => { + const set = new RecordedSet(10, tick(), 312); + expect(set.power).toBe(312); + expect(set.with({ repsCompleted: 9 }).power).toBe(312); + expect(set.with({ power: 400 }).power).toBe(400); + expect(set.with({ power: undefined }).power).toBeUndefined(); + }); + + it('round-trips power through JSON and defaults to undefined when absent', () => { + const time = tick(); + const withPower = new RecordedSet(8, time, 250); + const roundTripped = RecordedSet.fromJSON(withPower.toJSON()); + expect(roundTripped.equals(withPower)).toBe(true); + expect(roundTripped.power).toBe(250); + + const withoutPower = new RecordedSet(8, time); + expect(withoutPower.toJSON().power).toBeUndefined(); + expect(RecordedSet.fromJSON(withoutPower.toJSON()).power).toBeUndefined(); + }); + + it('includes power in equality', () => { + const time = tick(); + const a = new RecordedSet(10, time, 312); + expect(a.equals(new RecordedSet(10, time, 312))).toBe(true); + expect(a.equals(new RecordedSet(10, time, 300))).toBe(false); + expect(a.equals(new RecordedSet(10, time))).toBe(false); + }); +}); + +describe('RecordedWeightedExercise power operations', () => { + function makeExercise() { + return new RecordedWeightedExercise( + makeWeightedBlueprint(), + [ + filledPotentialSet(10, tick(), undefined, 250), + filledPotentialSet(10, tick(), undefined, 312), + new PotentialSet(undefined, new Weight(100, 'kilograms')), + ], + undefined, + ); + } + + it('withPower sets power on a completed set', () => { + const result = makeExercise().withPower(0, 400); + expect(result.getSet(0).set?.power).toBe(400); + expect(result.getSet(1).set?.power).toBe(312); + }); + + it('withPower(undefined) clears power but keeps the completed set', () => { + const result = makeExercise().withPower(1, undefined); + expect(result.getSet(1).set?.power).toBeUndefined(); + expect(result.getSet(1).set?.repsCompleted).toBe(10); + }); + + it('withPower is a no-op on an uncompleted set', () => { + const exercise = makeExercise(); + const result = exercise.withPower(2, 400); + expect(result.getSet(2).set).toBeUndefined(); + expect(result.equals(exercise)).toBe(true); + }); + + it('withRepCount preserves existing power when editing reps', () => { + const result = makeExercise().withRepCount(1, 8, tick()); + expect(result.getSet(1).set?.repsCompleted).toBe(8); + expect(result.getSet(1).set?.power).toBe(312); + }); + + it('withRepCount(undefined) clears the whole set including power', () => { + expect(makeExercise().withRepCount(1, undefined, tick()).getSet(1).set).toBeUndefined(); + }); + + it('withCycledRepCount decrement preserves power', () => { + const result = makeExercise().withCycledRepCount(1, tick()); + expect(result.getSet(1).set?.repsCompleted).toBe(9); + expect(result.getSet(1).set?.power).toBe(312); + }); + + it('withNothingCompleted drops power with the set', () => { + const result = makeExercise().withNothingCompleted(); + expect(result.potentialSets.every((x) => x.set === undefined)).toBe(true); + }); + + it('maxPower returns the max across sets, or undefined when none recorded', () => { + expect(makeExercise().maxPower).toBe(312); + const noPower = new RecordedWeightedExercise(makeWeightedBlueprint(), [filledPotentialSet(10, tick())], undefined); + expect(noPower.maxPower).toBeUndefined(); + }); + + it('latestRecordedPower returns the power of the most recently completed set that has one', () => { + const exercise = new RecordedWeightedExercise( + makeWeightedBlueprint(), + [ + filledPotentialSet(10, tick(), undefined, 250), + filledPotentialSet(10, tick(), undefined, 312), + filledPotentialSet(10, tick()), + ], + undefined, + ); + expect(exercise.latestRecordedPower).toBe(312); + expect(makeExercise().withPower(1, undefined).latestRecordedPower).toBe(250); + const noPower = new RecordedWeightedExercise(makeWeightedBlueprint(), [filledPotentialSet(10, tick())], undefined); + expect(noPower.latestRecordedPower).toBeUndefined(); + }); + + it('latestRecordedPower uses completion time, not array order', () => { + const t1 = tick(); + const t2 = tick(); + const t3 = tick(); + const exercise = new RecordedWeightedExercise( + makeWeightedBlueprint(), + [ + filledPotentialSet(10, t3, undefined, 300), + filledPotentialSet(10, t1, undefined, 400), + filledPotentialSet(10, t2, undefined, 312), + ], + undefined, + ); + expect(exercise.latestRecordedPower).toBe(300); + }); +}); diff --git a/app/src/models/session-models/recorded-weighted-exercise.ts b/app/src/models/session-models/recorded-weighted-exercise.ts index db9a99e4b..ce3a85f9a 100644 --- a/app/src/models/session-models/recorded-weighted-exercise.ts +++ b/app/src/models/session-models/recorded-weighted-exercise.ts @@ -107,7 +107,7 @@ export class RecordedWeightedExercise { withRepCount(setIndex: number, reps: number | undefined, time: OffsetDateTime): RecordedWeightedExercise { return this.withSet(setIndex, (s) => s.with({ - set: reps === undefined ? undefined : new RecordedSet(reps, time), + set: reps === undefined ? undefined : new RecordedSet(reps, time, s.set?.power), }), ); } @@ -136,6 +136,10 @@ export class RecordedWeightedExercise { .exhaustive(); } + withPower(setIndex: number, power: number | undefined): RecordedWeightedExercise { + return this.withSet(setIndex, (s) => (s.set ? s.with({ set: s.set.with({ power }) }) : s)); + } + toJSON(): RecordedWeightedExerciseJSON { return { type: 'RecordedWeightedExercise', @@ -156,6 +160,18 @@ export class RecordedWeightedExercise { ); } + get maxPower(): number | undefined { + const powers = this.potentialSets.map((x) => x.set?.power).filter((x): x is number => x !== undefined); + return powers.length ? Math.max(...powers) : undefined; + } + + get latestRecordedPower(): number | undefined { + return Enumerable.from(this.potentialSets) + .where((x) => x.set?.power !== undefined) + .orderByDescending((x) => x.set?.completionDateTime, TemporalComparer) + .firstOrDefault()?.set?.power; + } + get totalWeightLifted(): Weight { return this.potentialSets.reduce( (accum, set) => accum.plus(set.weight.multipliedBy(set.set?.repsCompleted ?? 0)), @@ -215,10 +231,11 @@ export class RecordedSet { constructor( readonly repsCompleted: number, readonly completionDateTime: OffsetDateTime, + readonly power?: number, ) {} static fromJSON(json: RecordedSetJSON): RecordedSet { - return new RecordedSet(json.repsCompleted, fromOffsetDateTimeJSON(json.completionDateTime)); + return new RecordedSet(json.repsCompleted, fromOffsetDateTimeJSON(json.completionDateTime), json.power); } equals(other: RecordedSet | undefined): boolean { @@ -228,13 +245,18 @@ export class RecordedSet { if (other === this) { return true; } - return this.repsCompleted === other.repsCompleted && this.completionDateTime.equals(other.completionDateTime); + return ( + this.repsCompleted === other.repsCompleted && + this.completionDateTime.equals(other.completionDateTime) && + this.power === other.power + ); } with(other: Partial): RecordedSet { return new RecordedSet( 'repsCompleted' in other ? other.repsCompleted! : this.repsCompleted, 'completionDateTime' in other ? other.completionDateTime! : this.completionDateTime, + 'power' in other ? other.power : this.power, ); } @@ -242,6 +264,7 @@ export class RecordedSet { return { repsCompleted: this.repsCompleted, completionDateTime: toOffsetDateTimeJSON(this.completionDateTime), + power: this.power, }; } } diff --git a/app/src/models/storage/versions/latest/blueprint.ts b/app/src/models/storage/versions/latest/blueprint.ts index 36b93003b..cfc51796d 100644 --- a/app/src/models/storage/versions/latest/blueprint.ts +++ b/app/src/models/storage/versions/latest/blueprint.ts @@ -89,6 +89,12 @@ export interface WeightedExerciseBlueprintJSON { */ link: string; progressiveOverload: ProgressiveOverloadJSON; + /** + * When true, the app prompts for and records the peak power (watts) of + * each completed set, for equipment with a power readout + * (e.g. Keiser functional trainers). + */ + trackPower?: boolean; } export interface RepsTargetJSON { diff --git a/app/src/models/storage/versions/latest/session.ts b/app/src/models/storage/versions/latest/session.ts index 3468ef698..f05bf7a16 100644 --- a/app/src/models/storage/versions/latest/session.ts +++ b/app/src/models/storage/versions/latest/session.ts @@ -55,6 +55,12 @@ export interface RecordedSetJSON { */ repsCompleted: number; completionDateTime: OffsetDateTimeJSON; + /** + * Peak power in whole watts achieved during the set, as reported by + * equipment with a power readout (e.g. Keiser functional trainers). + * @asType integer + */ + power?: number | undefined; } export interface PotentialSetJSON { diff --git a/app/src/store/settings/__snapshots__/export-plaintext-effects.spec.ts.snap b/app/src/store/settings/__snapshots__/export-plaintext-effects.spec.ts.snap index 70c17ab11..570f391f0 100644 --- a/app/src/store/settings/__snapshots__/export-plaintext-effects.spec.ts.snap +++ b/app/src/store/settings/__snapshots__/export-plaintext-effects.spec.ts.snap @@ -48,6 +48,7 @@ exports[`export-plaintext-effects > addExportPlaintextEffects — JSON > JSON ou }, "sets": 3, "supersetWithNext": false, + "trackPower": false, "type": "WeightedExerciseBlueprint", }, "potentialSets": [ @@ -114,6 +115,7 @@ exports[`export-plaintext-effects > addExportPlaintextEffects — JSON > JSON ou }, "sets": 3, "supersetWithNext": false, + "trackPower": false, "type": "WeightedExerciseBlueprint", }, "potentialSets": [ diff --git a/app/src/store/stats/calculate-stats.spec.ts b/app/src/store/stats/calculate-stats.spec.ts index 989c021a8..099a95d52 100644 --- a/app/src/store/stats/calculate-stats.spec.ts +++ b/app/src/store/stats/calculate-stats.spec.ts @@ -324,4 +324,59 @@ describe('calculateStats', () => { expect(result.averageSessionLength.toMinutes()).toBe(44); }); }); + + describe('max power statistics', () => { + function makePoweredSession(date: LocalDate, powers: (number | undefined)[]): Session { + const blueprint = makeBlueprint('Chest Press', powers.length, 10); + const sessionBlueprint = makeSessionBlueprint('Keiser Day', [blueprint]); + const baseTime = makeOffset(date); + const potentialSets = powers.map( + (power, i) => + new PotentialSet(new RecordedSet(10, baseTime.plusSeconds(i * 60), power), new Weight(40, 'kilograms')), + ); + const exercise = new RecordedWeightedExercise(blueprint, potentialSets, undefined); + return new Session('session-' + date.toString(), sessionBlueprint, [exercise], date, undefined, undefined); + } + + const range: LocalDateRange = { + from: LocalDate.of(2025, 4, 1), + to: LocalDate.of(2025, 4, 30), + }; + + it('collects best power per session over time', () => { + const stats = calculateStats( + [ + makePoweredSession(LocalDate.of(2025, 4, 7), [250, 312, 290]), + makePoweredSession(LocalDate.of(2025, 4, 14), [280, 330, undefined]), + ], + 'kilograms', + range, + ); + + const exerciseStats = stats.weightedExerciseStats.find((x) => x.exerciseName === 'Chest Press')!; + const power = exerciseStats.maxPowerPerSessionStatistics!; + expect(power.statistics.map((x) => x.value)).toEqual([312, 330]); + expect(power.maxValue).toBe(330); + expect(power.minValue).toBe(312); + expect(power.currentValue).toBe(330); + }); + + it('skips sessions with no power and is undefined when the exercise never has power', () => { + const stats = calculateStats( + [ + makePoweredSession(LocalDate.of(2025, 4, 7), [undefined, undefined, undefined]), + makePoweredSession(LocalDate.of(2025, 4, 14), [undefined, 300, undefined]), + makeSession(LocalDate.of(2025, 4, 21), 'Squat', 100), + ], + 'kilograms', + range, + ); + + const chestPress = stats.weightedExerciseStats.find((x) => x.exerciseName === 'Chest Press')!; + expect(chestPress.maxPowerPerSessionStatistics!.statistics.map((x) => x.value)).toEqual([300]); + + const squat = stats.weightedExerciseStats.find((x) => x.exerciseName === 'Squat')!; + expect(squat.maxPowerPerSessionStatistics).toBeUndefined(); + }); + }); }); diff --git a/app/src/store/stats/calculate-stats.ts b/app/src/store/stats/calculate-stats.ts index 1ad259f9f..8f3198754 100644 --- a/app/src/store/stats/calculate-stats.ts +++ b/app/src/store/stats/calculate-stats.ts @@ -8,6 +8,7 @@ import { OptionalStatisticOverTime, RepsBreakdownStatistics, TimeTrackedStatistic, + NumericStatisticOverTime, WeightedExerciseStatistics, WeightedStatisticOverTime, } from '@/store/stats'; @@ -113,6 +114,7 @@ export function calculateStats( maxWeightStatistics: TimeTrackedStatistic[]; max1RMStatistics: TimeTrackedStatistic[]; totalVolumeStatistics: TimeTrackedStatistic[]; + maxPowerStatistics: TimeTrackedStatistic[]; repsStatistics: RepsBreakdownStatistics; latestTime: OffsetDateTime; } @@ -130,6 +132,7 @@ export function calculateStats( max1RMStatistics: [], repsStatistics: { breakdown: {} }, totalVolumeStatistics: [], + maxPowerStatistics: [], latestTime: OffsetDateTime.MIN, }); } @@ -185,6 +188,13 @@ export function calculateStats( .filter((x) => x.set) .reduce((accum, set) => set.weight.multipliedBy(set.set!.repsCompleted).plus(accum), Weight.NIL), }); + const maxPower = ex.maxPower; + if (maxPower !== undefined) { + exerciseStats.maxPowerStatistics.push({ + dateTime: lastSet.set!.completionDateTime, + value: maxPower, + }); + } } } @@ -198,6 +208,9 @@ export function calculateStats( maxLiftedPerSessionStatistics, max1RMPerSessionStatistics, totalVolumeStatistics: unsortedStatsToWeightedStatisticOverTime(ex.totalVolumeStatistics), + maxPowerPerSessionStatistics: ex.maxPowerStatistics.length + ? unsortedStatsToNumericStatisticOverTime(ex.maxPowerStatistics) + : undefined, repsStatistics: ex.repsStatistics, } satisfies WeightedExerciseStatistics; }); @@ -299,3 +312,18 @@ function unsortedStatsToWeightedStatisticOverTime( minValue: min, }; } + +function unsortedStatsToNumericStatisticOverTime( + unsortedStats: TimeTrackedStatistic[], +): NumericStatisticOverTime { + const statistics = Enumerable.from(unsortedStats) + .orderBy((x) => x.dateTime.toString()) + .toArray(); + const values = statistics.map((x) => x.value); + return { + statistics, + currentValue: values.at(-1) ?? 0, + maxValue: values.length ? Math.max(...values) : 0, + minValue: values.length ? Math.min(...values) : 0, + }; +} diff --git a/app/src/store/stats/index.ts b/app/src/store/stats/index.ts index 10e68aa12..0fa451f29 100644 --- a/app/src/store/stats/index.ts +++ b/app/src/store/stats/index.ts @@ -38,6 +38,7 @@ export interface WeightedExerciseStatistics { maxLiftedPerSessionStatistics: WeightedStatisticOverTime; max1RMPerSessionStatistics: WeightedStatisticOverTime; totalVolumeStatistics: WeightedStatisticOverTime; + maxPowerPerSessionStatistics: NumericStatisticOverTime | undefined; repsStatistics: RepsBreakdownStatistics; } @@ -49,6 +50,13 @@ export interface WeightedStatisticOverTime { minValue: Weight; } +export interface NumericStatisticOverTime { + statistics: TimeTrackedStatistic[]; + currentValue: number; + maxValue: number; + minValue: number; +} + export interface OptionalStatisticOverTime { title: string; statistics: OptionalTimeTrackedStatistic[]; diff --git a/docs/schemas/ai-plan/AiPlan.json b/docs/schemas/ai-plan/AiPlan.json index 66d058681..88f776294 100644 --- a/docs/schemas/ai-plan/AiPlan.json +++ b/docs/schemas/ai-plan/AiPlan.json @@ -130,6 +130,10 @@ }, "progressiveOverload": { "$ref": "#/definitions/ProgressiveOverload" + }, + "trackPower": { + "type": "boolean", + "description": "When true, the app prompts for and records the peak power (watts) of each completed set, for equipment with a power readout (e.g. Keiser functional trainers)." } }, "required": [ diff --git a/docs/schemas/program-blueprint/ProgramBlueprint.json b/docs/schemas/program-blueprint/ProgramBlueprint.json index 46c8a8c39..7d5950f0a 100644 --- a/docs/schemas/program-blueprint/ProgramBlueprint.json +++ b/docs/schemas/program-blueprint/ProgramBlueprint.json @@ -104,6 +104,10 @@ }, "progressiveOverload": { "$ref": "#/definitions/ProgressiveOverload" + }, + "trackPower": { + "type": "boolean", + "description": "When true, the app prompts for and records the peak power (watts) of each completed set, for equipment with a power readout (e.g. Keiser functional trainers)." } }, "required": [ diff --git a/docs/schemas/workout-worker/RecordedSet.json b/docs/schemas/workout-worker/RecordedSet.json index 74a7f8ae6..d1d53e343 100644 --- a/docs/schemas/workout-worker/RecordedSet.json +++ b/docs/schemas/workout-worker/RecordedSet.json @@ -7,6 +7,10 @@ }, "completionDateTime": { "$ref": "./OffsetDateTime.json" + }, + "power": { + "type": "integer", + "description": "Peak power in whole watts achieved during the set, as reported by equipment with a power readout (e.g. Keiser functional trainers)." } }, "required": [ diff --git a/docs/schemas/workout-worker/WeightedExerciseBlueprint.json b/docs/schemas/workout-worker/WeightedExerciseBlueprint.json index 3c88dd626..1c4a95213 100644 --- a/docs/schemas/workout-worker/WeightedExerciseBlueprint.json +++ b/docs/schemas/workout-worker/WeightedExerciseBlueprint.json @@ -32,6 +32,10 @@ }, "progressiveOverload": { "$ref": "./ProgressiveOverload.json" + }, + "trackPower": { + "type": "boolean", + "description": "When true, the app prompts for and records the peak power (watts) of each completed set, for equipment with a power readout (e.g. Keiser functional trainers)." } }, "required": [ diff --git a/plugins/liftlog-plan-builder/skills/create-liftlog-plan/reference/ProgramBlueprint.json b/plugins/liftlog-plan-builder/skills/create-liftlog-plan/reference/ProgramBlueprint.json index 46c8a8c39..7d5950f0a 100644 --- a/plugins/liftlog-plan-builder/skills/create-liftlog-plan/reference/ProgramBlueprint.json +++ b/plugins/liftlog-plan-builder/skills/create-liftlog-plan/reference/ProgramBlueprint.json @@ -104,6 +104,10 @@ }, "progressiveOverload": { "$ref": "#/definitions/ProgressiveOverload" + }, + "trackPower": { + "type": "boolean", + "description": "When true, the app prompts for and records the peak power (watts) of each completed set, for equipment with a power readout (e.g. Keiser functional trainers)." } }, "required": [ diff --git a/plugins/liftlog-plan-builder/skills/create-liftlog-plan/scripts/validate-plan.mjs b/plugins/liftlog-plan-builder/skills/create-liftlog-plan/scripts/validate-plan.mjs index def0d4014..82dce349c 100644 --- a/plugins/liftlog-plan-builder/skills/create-liftlog-plan/scripts/validate-plan.mjs +++ b/plugins/liftlog-plan-builder/skills/create-liftlog-plan/scripts/validate-plan.mjs @@ -1131,12 +1131,23 @@ var require_validate_schema = __commonJS({ errors = vErrors.length; } } + if (data.trackPower !== void 0) { + if (typeof data.trackPower !== "boolean") { + const err16 = { instancePath: instancePath + "/trackPower", schemaPath: "#/properties/trackPower/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }; + if (vErrors === null) { + vErrors = [err16]; + } else { + vErrors.push(err16); + } + errors++; + } + } } else { - const err16 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + const err17 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; if (vErrors === null) { - vErrors = [err16]; + vErrors = [err17]; } else { - vErrors.push(err16); + vErrors.push(err17); } errors++; } From 25297645b4d88f9b20bcbb0cd9fc0d13c8b762dc Mon Sep 17 00:00:00 2001 From: Joshua Hoblitt Date: Thu, 16 Jul 2026 16:09:53 -0700 Subject: [PATCH 2/2] feat: Add power tracking UI A Track Power toggle in the weighted exercise editor; a skippable watts dialog on set completion (and via the long-press rep dialog); watts shown on set tiles and summary chips; and a Max Power chart on the exercise stats page. All gated on the per-exercise trackPower flag. Co-Authored-By: Claude Opus 4.8 --- .../stats/expanded-weighted-exercise.tsx | 6 ++ .../foundation/editors/power-dialog.tsx | 77 +++++++++++++++++ .../presentation/stats/power-line-chart.tsx | 84 +++++++++++++++++++ .../presentation/summary/exercise-summary.tsx | 7 ++ .../workout-editor/progressive-overload.tsx | 1 + .../weighted-exercise-editor.tsx | 8 ++ .../weighted/potential-set-counter.tsx | 14 +++- ...potential-sets-addition-actions-dialog.tsx | 43 ++++++++-- .../workout/weighted/weighted-exercise.tsx | 32 ++++++- 9 files changed, 258 insertions(+), 14 deletions(-) create mode 100644 app/src/components/presentation/foundation/editors/power-dialog.tsx create mode 100644 app/src/components/presentation/stats/power-line-chart.tsx diff --git a/app/src/app/(tabs)/stats/expanded-weighted-exercise.tsx b/app/src/app/(tabs)/stats/expanded-weighted-exercise.tsx index 2f6f188b0..675d86d5c 100644 --- a/app/src/app/(tabs)/stats/expanded-weighted-exercise.tsx +++ b/app/src/app/(tabs)/stats/expanded-weighted-exercise.tsx @@ -6,6 +6,7 @@ import { SingleValueStatisticsGrid } from '@/components/presentation/stats/singl import { TimePeriodSelector } from '@/components/presentation/stats/time-period-selector'; import { TitledSection } from '@/components/presentation/stats/titled-section'; import { WeightBarChart } from '@/components/presentation/stats/weight-bar-chart'; +import { PowerLineChart } from '@/components/presentation/stats/power-line-chart'; import { WeightLineChart } from '@/components/presentation/stats/weight-line-chart'; import { spacing, useAppTheme } from '@/hooks/useAppTheme'; import { useAppSelector, useAppSelectorWithArg } from '@/store'; @@ -65,6 +66,11 @@ function LoadedStatsFilled({ stats }: { stats: WeightedExerciseStatistics }) { + {stats.maxPowerPerSessionStatistics && ( + + + + )} diff --git a/app/src/components/presentation/foundation/editors/power-dialog.tsx b/app/src/components/presentation/foundation/editors/power-dialog.tsx new file mode 100644 index 000000000..f3f0e58ee --- /dev/null +++ b/app/src/components/presentation/foundation/editors/power-dialog.tsx @@ -0,0 +1,77 @@ +import { spacing } from '@/hooks/useAppTheme'; +import { T } from '@tolgee/react'; +import { useEffect, useState } from 'react'; +import { View } from 'react-native'; +import Button from '@/components/presentation/foundation/gesture-wrappers/button'; +import { Dialog, Portal, TextInput, useTheme } from 'react-native-paper'; +import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; + +interface PowerDialogProps { + open: boolean; + power: number | undefined; + placeholder: number | undefined; + onClose: () => void; + updatePower: (power: number | undefined) => void; +} + +export default function PowerDialog(props: PowerDialogProps) { + const theme = useTheme(); + const [text, setText] = useState(props.power?.toString() ?? ''); + + useEffect(() => { + setText(props.power?.toString() ?? ''); + }, [props.open, props.power]); + + const parsed = Number(text); + const isValid = !text || (Number.isInteger(parsed) && parsed >= 0); + + const onSaveClick = () => { + if (!isValid) { + return; + } + props.updatePower(text ? parsed : undefined); + props.onClose(); + }; + + return ( + props.open && ( + + + + + + + + + } + style={{ backgroundColor: theme.colors.elevation.level3 }} + /> + + + + + + + + + + ) + ); +} diff --git a/app/src/components/presentation/stats/power-line-chart.tsx b/app/src/components/presentation/stats/power-line-chart.tsx new file mode 100644 index 000000000..e7bdf4997 --- /dev/null +++ b/app/src/components/presentation/stats/power-line-chart.tsx @@ -0,0 +1,84 @@ +import { NumericStatisticOverTime } from '@/store/stats'; +import { LineChart, lineDataItem } from 'react-native-gifted-charts'; +import { View } from 'react-native'; +import { spacing, useAppTheme } from '@/hooks/useAppTheme'; +import { useEffect, useState } from 'react'; +import { lineGraphProps } from '@/components/presentation/stats/line-graph-props'; +import { useFormatDate } from '@/hooks/useFormatDate'; +import { Text } from 'react-native-paper'; + +export function PowerLineChart({ + statistics: { statistics, maxValue, minValue }, +}: { + statistics: NumericStatisticOverTime; +}) { + const formatDate = useFormatDate(); + const { colors } = useAppTheme(); + const points: lineDataItem[] = statistics.map((stat): lineDataItem => { + const label = formatDate(stat.dateTime.toLocalDate(), { + day: 'numeric', + month: 'short', + }); + return { + value: stat.value, + label, + focusedDataPointLabelComponent: () => , + }; + }); + const [width, setWidth] = useState(0); + // On android the area chart renders poorly unless it is delayed until after initial render + const [areaChart, setAreaChart] = useState(false); + useEffect(() => { + setAreaChart(!!width); + }, [width]); + return ( + setWidth(e.nativeEvent.layout.width)}> + + + ); +} + +function FocusedDatapointLabelComponent(props: { value: number; label: string }) { + const { colors } = useAppTheme(); + return ( + + {props.label} + {props.value.toFixed(0)} W + + ); +} diff --git a/app/src/components/presentation/summary/exercise-summary.tsx b/app/src/components/presentation/summary/exercise-summary.tsx index 19393c33f..b1dc7a3ee 100644 --- a/app/src/components/presentation/summary/exercise-summary.tsx +++ b/app/src/components/presentation/summary/exercise-summary.tsx @@ -82,6 +82,11 @@ function FilledChips(props: { exercise: RecordedExercise; showWeight: boolean }) ) : undefined} + {chip.power !== undefined ? ( + + {chip.power} W + + ) : undefined} )); } @@ -188,6 +193,7 @@ interface WeightAndRepsChipData { repsCompleted: number | undefined; repTarget: number; weight: Weight; + power: number | undefined; } interface PotentialSetChipData { @@ -201,6 +207,7 @@ function getWeightAndRepsChips(exercise: RecordedWeightedExercise): WeightAndRep repsCompleted: set.set?.repsCompleted, repTarget: exercise.blueprint.repsTargetForSet(index).max, weight: set.weight, + power: set.set?.power, })); } diff --git a/app/src/components/presentation/workout-editor/progressive-overload.tsx b/app/src/components/presentation/workout-editor/progressive-overload.tsx index d96b2b0ff..cc26a04af 100644 --- a/app/src/components/presentation/workout-editor/progressive-overload.tsx +++ b/app/src/components/presentation/workout-editor/progressive-overload.tsx @@ -211,6 +211,7 @@ function DummySet(props: { maxReps: number; set: PotentialSet }) { {}} onUpdateReps={() => {}} onUpdateWeight={() => {}} diff --git a/app/src/components/presentation/workout-editor/weighted-exercise-editor.tsx b/app/src/components/presentation/workout-editor/weighted-exercise-editor.tsx index 5b965b9da..f6b4c20bf 100644 --- a/app/src/components/presentation/workout-editor/weighted-exercise-editor.tsx +++ b/app/src/components/presentation/workout-editor/weighted-exercise-editor.tsx @@ -122,6 +122,14 @@ export function WeightedExerciseEditor({ testID="exercise-superset" onValueChange={(supersetWithNext) => updateExercise({ supersetWithNext })} />, + updateExercise({ trackPower })} + />, void; onUpdateWeight: (weight: Weight, applyTo: WeightAppliesTo) => void; - onUpdateReps: (reps: number | undefined) => void; + onUpdateReps: (reps: number | undefined, power: number | undefined) => void; } export default function PotentialSetCounter(props: PotentialSetCounterProps) { @@ -148,6 +149,14 @@ export default function PotentialSetCounter(props: PotentialSetCounterProps) { + {props.trackPower && ( + + {props.set.set?.power !== undefined ? `${props.set.set.power} W` : '– W'} + + )} props.onUpdateReps(reps)} + showPower={props.trackPower} + updateRepCount={(reps, power) => props.onUpdateReps(reps, power)} close={() => setIsRepsDialogOpen(false)} /> diff --git a/app/src/components/presentation/workout/weighted/potential-sets-addition-actions-dialog.tsx b/app/src/components/presentation/workout/weighted/potential-sets-addition-actions-dialog.tsx index 442b68851..f986b53a2 100644 --- a/app/src/components/presentation/workout/weighted/potential-sets-addition-actions-dialog.tsx +++ b/app/src/components/presentation/workout/weighted/potential-sets-addition-actions-dialog.tsx @@ -1,4 +1,4 @@ -import { useAppTheme } from '@/hooks/useAppTheme'; +import { spacing, useAppTheme } from '@/hooks/useAppTheme'; import { PotentialSet } from '@/models/session-models'; import { T } from '@tolgee/react'; import { useEffect, useState } from 'react'; @@ -12,7 +12,8 @@ interface PotentialSetAdditionalActionsDialogProps { open: boolean; set: PotentialSet; repTarget: number; - updateRepCount: (reps: number | undefined) => void; + showPower: boolean; + updateRepCount: (reps: number | undefined, power: number | undefined) => void; close: () => void; } @@ -22,23 +23,33 @@ export default function PotentialSetAdditionalActionsDialog({ set, updateRepCount, repTarget, + showPower, }: PotentialSetAdditionalActionsDialogProps) { const { colors } = useAppTheme(); const originalReps = set?.set?.repsCompleted; + const originalPower = set?.set?.power; const [repCountText, setRepCountText] = useState(originalReps?.toString() ?? ''); + const [powerText, setPowerText] = useState(originalPower?.toString() ?? ''); const parsedRepCount = Number(repCountText); const isValid = !repCountText || (Number.isInteger(parsedRepCount) && parsedRepCount >= 0); + const parsedPower = Number(powerText); + const isPowerValid = !powerText || (Number.isInteger(parsedPower) && parsedPower >= 0); useEffect(() => { - setRepCountText(originalReps?.toString() ?? ''); - }, [originalReps]); + if (open) { + setRepCountText(originalReps?.toString() ?? ''); + setPowerText(originalPower?.toString() ?? ''); + } + }, [open, originalReps, originalPower]); + + const powerValue = () => (powerText && isPowerValid ? parsedPower : undefined); const save = () => { - if (!isValid) { + if (!isValid || !isPowerValid) { return; } - updateRepCount(repCountText ? parsedRepCount : undefined); + updateRepCount(repCountText ? parsedRepCount : undefined, powerValue()); close(); }; return ( @@ -65,10 +76,11 @@ export default function PotentialSetAdditionalActionsDialog({ {i}} onPress={() => { setRepCountText(i.toString()); - updateRepCount(i); + updateRepCount(i, powerValue()); close(); }} /> @@ -80,15 +92,28 @@ export default function PotentialSetAdditionalActionsDialog({ icon={'close'} onPress={() => { setRepCountText(''); - updateRepCount(undefined); + setPowerText(''); + updateRepCount(undefined, undefined); close(); }} /> + {showPower && ( + } + inputMode="numeric" + value={powerText} + selectTextOnFocus + error={!isPowerValid} + onChangeText={setPowerText} + right={} + style={{ marginTop: spacing[2] }} + /> + )} - diff --git a/app/src/components/presentation/workout/weighted/weighted-exercise.tsx b/app/src/components/presentation/workout/weighted/weighted-exercise.tsx index a6ba5307e..b93818939 100644 --- a/app/src/components/presentation/workout/weighted/weighted-exercise.tsx +++ b/app/src/components/presentation/workout/weighted/weighted-exercise.tsx @@ -1,4 +1,5 @@ import PotentialSetCounter from '@/components/presentation/workout/weighted/potential-set-counter'; +import PowerDialog from '@/components/presentation/foundation/editors/power-dialog'; import { spacing } from '@/hooks/useAppTheme'; import { RecordedWeightedExercise } from '@/models/session-models'; import { useState } from 'react'; @@ -25,8 +26,9 @@ interface WeightedExerciseProps { export default function WeightedExercise(props: WeightedExerciseProps) { const { updateExercise, timeProvider, resetSetTimer } = props; const { recordedExercise } = props; - useState(false); + const [powerDialogIndex, setPowerDialogIndex] = useState(undefined); + const trackPower = recordedExercise.blueprint.trackPower; const setToStartNext = recordedExercise.potentialSets.findIndex((x) => !x.set); return ( @@ -46,6 +48,7 @@ export default function WeightedExercise(props: WeightedExerciseProps) { isReadonly={props.isReadonly} key={index} repsTarget={recordedExercise.blueprint.repsTargetForSet(index)} + trackPower={trackPower} onTap={() => { const previousSet = set.set; const newSet = recordedExercise.withCycledRepCount(index, timeProvider()).getSet(index).set; @@ -55,6 +58,9 @@ export default function WeightedExercise(props: WeightedExerciseProps) { if (!previousSet || !newSet) { resetSetTimer(); } + if (trackPower && !previousSet && newSet) { + setPowerDialogIndex(index); + } }} previousRepCount={ props.previousRecordedExercises @@ -65,8 +71,14 @@ export default function WeightedExercise(props: WeightedExerciseProps) { ) .at(0)?.potentialSets[index]?.set?.repsCompleted } - onUpdateReps={(reps) => { - updateExercise((ex) => ex.withRepCount(index, reps, timeProvider())); + onUpdateReps={(reps, power) => { + updateExercise((ex) => { + let next = ex.withRepCount(index, reps, timeProvider()); + if (trackPower) { + next = next.withPower(index, reps === undefined ? undefined : power); + } + return next; + }); resetSetTimer(); }} onUpdateWeight={(w, applyTo) => updateExercise((ex) => ex.withWeight(index, w, applyTo))} @@ -76,6 +88,20 @@ export default function WeightedExercise(props: WeightedExerciseProps) { /> ))} + setPowerDialogIndex(undefined)} + updatePower={(power) => { + const index = powerDialogIndex; + if (index !== undefined) { + updateExercise((ex) => ex.withPower(index, power)); + } + }} + /> ); }