Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Strand/Data/IntelligenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2177,7 +2177,8 @@ final class IntelligenceEngine: ObservableObject {
updated.append(WorkoutRow(
startTs: row.startTs, endTs: row.endTs, sport: row.sport, source: row.source,
durationS: row.durationS, energyKcal: energyKcal, avgHr: s.avgHr, maxHr: s.maxHr,
strain: s.strain, distanceM: row.distanceM, zonesJSON: row.zonesJSON, notes: row.notes))
strain: s.strain, distanceM: row.distanceM, zonesJSON: row.zonesJSON, notes: row.notes,
steps: row.steps))
}
if !updated.isEmpty { _ = try? await store.upsertWorkouts(updated, deviceId: deviceId) }
}
Expand Down
5 changes: 3 additions & 2 deletions Strand/Data/Repository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2486,7 +2486,7 @@ final class Repository: ObservableObject {
return WorkoutRow(startTs: row.startTs, endTs: row.endTs, sport: row.sport,
source: row.source, durationS: row.durationS, energyKcal: row.energyKcal,
avgHr: r.avg, maxHr: newMax, strain: newStrain, distanceM: row.distanceM,
zonesJSON: row.zonesJSON, notes: row.notes)
zonesJSON: row.zonesJSON, notes: row.notes, steps: row.steps)
}
}

Expand Down Expand Up @@ -2569,7 +2569,8 @@ final class Repository: ObservableObject {
let manual = WorkoutRow(startTs: row.startTs, endTs: row.endTs, sport: trimmed, source: "manual",
durationS: row.durationS, energyKcal: row.energyKcal,
avgHr: row.avgHr, maxHr: row.maxHr, strain: row.strain,
distanceM: row.distanceM, zonesJSON: row.zonesJSON, notes: row.notes)
distanceM: row.distanceM, zonesJSON: row.zonesJSON, notes: row.notes,
steps: row.steps)
_ = try? await store.upsertWorkouts([manual], deviceId: deviceId)
_ = try? await store.deleteWorkouts(deviceId: computedDeviceId, sport: "detected",
from: row.startTs, to: row.startTs)
Expand Down
15 changes: 8 additions & 7 deletions Strand/Data/WorkoutSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -320,19 +320,20 @@ enum WorkoutSource: Equatable {

// MARK: - Building / preserving rows

/// Carry the captured fields the add/edit sheet does NOT expose (maxHr, strain, zonesJSON, notes)
/// over from the row being edited. A v1.67 live-tracked session has real captured strain/maxHr;
/// rebuilding the row from the sheet's inputs alone would silently wipe them on an edit. No-op for a
/// fresh add (`old == nil`). `distanceM` is NOW a sheet field (#1195), so it comes from the freshly
/// built `row` (the sheet pre-fills it from the edited row, so an untouched field preserves the
/// captured GPS distance and a cleared one clears it) rather than being force-carried from `old`.
/// Carry the captured fields the add/edit sheet does NOT expose (maxHr, strain, zonesJSON, notes,
/// steps) over from the row being edited. A v1.67 live-tracked session has real captured
/// strain/maxHr; rebuilding the row from the sheet's inputs alone would silently wipe them on an
/// edit. No-op for a fresh add (`old == nil`). `distanceM` is NOW a sheet field (#1195), so it comes
/// from the freshly built `row` (the sheet pre-fills it from the edited row, so an untouched field
/// preserves the captured GPS distance and a cleared one clears it) rather than being force-carried
/// from `old`. `steps` (#1058) has no sheet field at all, so it is carried like the others (#1444).
static func preservingCaptured(_ row: WorkoutRow, from old: WorkoutRow?) -> WorkoutRow {
guard let old else { return row }
return WorkoutRow(startTs: row.startTs, endTs: row.endTs, sport: row.sport,
source: row.source, durationS: row.durationS,
energyKcal: row.energyKcal, avgHr: row.avgHr,
maxHr: old.maxHr, strain: old.strain, distanceM: row.distanceM,
zonesJSON: old.zonesJSON, notes: old.notes)
zonesJSON: old.zonesJSON, notes: old.notes, steps: old.steps)
}

/// Build a retroactive manual workout (source "manual", persisted under the strap deviceId by the
Expand Down
2 changes: 1 addition & 1 deletion Strand/Screens/WorkoutsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1584,7 +1584,7 @@ struct WorkoutsView: View {
WorkoutRow(startTs: row.startTs, endTs: row.endTs, sport: WorkoutSource.displaySport(row.sport),
source: "manual", durationS: row.durationS, energyKcal: row.energyKcal,
avgHr: row.avgHr, maxHr: row.maxHr, strain: row.strain, distanceM: row.distanceM,
zonesJSON: row.zonesJSON, notes: row.notes)
zonesJSON: row.zonesJSON, notes: row.notes, steps: row.steps)
}

/// #796 - the per-session Effort cell label: the stored 0-100 strain mapped to the user's Effort scale
Expand Down
16 changes: 14 additions & 2 deletions StrandTests/WorkoutSourceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,17 @@ final class WorkoutSourceTests: XCTestCase {
XCTAssertNil(WorkoutSource.preservingCaptured(cleared, from: old).distanceM)
}

/// #1444: per-session steps (#1058) has NO sheet field, so an edit rebuilds the row without it and
/// the merged row used to come back with `steps` nil — silently wiping a value the user never saw
/// and never touched, just by renaming the sport. Kotlin twin:
/// `preservingCaptured_carriesStepsFromOld`.
func testPreservingCapturedCarriesStepsFromOld() {
let old = fullRow(start: 100, end: 3700, sport: "Running", source: "manual", steps: 4_200)
let edited = fullRow(start: 100, end: 3700, sport: "Trail Running", source: "manual")
XCTAssertNil(edited.steps, "the sheet cannot supply steps, so the rebuilt row starts without it")
XCTAssertEqual(WorkoutSource.preservingCaptured(edited, from: old).steps, 4_200)
}

func testPreservingCapturedCarriesUnexposedFieldsOnEdit() {
// The sheet rebuilds a row from its 5 inputs; an edit must keep the original's captured
// maxHr/strain (a live-tracked session has real values the sheet never shows).
Expand All @@ -345,10 +356,11 @@ final class WorkoutSourceTests: XCTestCase {

private func fullRow(start: Int, end: Int, sport: String, source: String,
avgHr: Int? = nil, kcal: Double? = nil, dist: Double? = nil,
strain: Double? = nil, maxHr: Int? = nil, notes: String? = nil) -> WorkoutRow {
strain: Double? = nil, maxHr: Int? = nil, notes: String? = nil,
steps: Int? = nil) -> WorkoutRow {
WorkoutRow(startTs: start, endTs: end, sport: sport, source: source,
durationS: Double(end - start), energyKcal: kcal, avgHr: avgHr, maxHr: maxHr,
strain: strain, distanceM: dist, zonesJSON: nil, notes: notes)
strain: strain, distanceM: dist, zonesJSON: nil, notes: notes, steps: steps)
}

func testFilterInactiveWhenEmptyPassesEverythingUntouched() {
Expand Down
5 changes: 5 additions & 0 deletions android/app/src/main/java/com/noop/ui/WorkoutEditing.kt
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,11 @@ object WorkoutEditing {
zonesJSON = old.zonesJSON,
notes = old.notes,
routePolyline = old.routePolyline,
// #1444: `row` here is the sheet-built row, NOT the stored one, so copy() alone does not
// carry this — buildManualRow has no steps input and leaves it null. Per-session steps
// (#1058) is a captured field the sheet never exposes, so it is restored from `old` like
// the rest. Twin of Swift WorkoutSource.preservingCaptured.
steps = old.steps,
)
}

Expand Down
14 changes: 14 additions & 0 deletions android/app/src/test/java/com/noop/ui/WorkoutEditingTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,20 @@ class WorkoutEditingTest {
assertNull(WorkoutEditing.preservingCaptured(cleared, old).distanceM)
}

/**
* #1444: per-session steps (#1058) has NO sheet field, so buildManualRow leaves it null and copy()
* carries the NEW row's null rather than the stored value — copy() only protects a field when the
* row being copied IS the stored one. Renaming the sport silently wiped a value the user never saw
* and never touched. Twin of Swift `testPreservingCapturedCarriesStepsFromOld`.
*/
@Test
fun preservingCaptured_carriesStepsFromOld() {
val old = row("my-whoop", 100, 3700, "Running", "manual").copy(steps = 4_200)
val edited = row("my-whoop", 100, 3700, "Trail Running", "manual")
assertNull(edited.steps) // the sheet cannot supply steps
assertEquals(4_200, WorkoutEditing.preservingCaptured(edited, old).steps)
}

@Test
fun preservingCaptured_carriesUnexposedFieldsOnEdit() {
val old = row("my-whoop", 100, 3700, "Workout", "manual", avgHr = 130, maxHr = 175, strain = 13.5)
Expand Down
Loading