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
47 changes: 33 additions & 14 deletions Strand/Screens/CardioModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ final class CardioModel: ObservableObject {
distanceM: 0, energyKcal: 0, effort: nil,
sessionsWithDistance: 0, bySport: [])
@Published private(set) var typicalMinutes: ClosedRange<Double>?
@Published private(set) var load: LoadTrend?
/// The selected week's cardio lane, read exactly as Training Load reads it.
@Published private(set) var lane: TrainingLoadModel.Lane?
@Published private(set) var laneRatios: [TrainingLoadModel.RatioPoint] = []
@Published private(set) var weekCharge: Double?
/// The displayed week's time in each heart-rate zone. Nil when no zone set is known yet, or when
/// nothing that week carried a trace complete enough to bin.
Expand All @@ -76,6 +78,11 @@ final class CardioModel: ObservableObject {
private var fusedVisible: [UnifiedTrainingSession] = []
/// Sessions another record already described, so the zone split counts those minutes once.
private var duplicateSessionIds: Set<String> = []
/// The lane reads every training session, strength included, over the history window plus the
/// lookback a reading needs, so the oldest selectable week still compares like Training Load does.
private var laneSessions: [UnifiedTrainingSession] = []
private var laneResolution = TrainingCardioLoadResolution()
private var laneSeries: TrainingLoadLanes.CardioSeries?

// The selected sport
@Published private(set) var sportHistory: [CardioSessionMetrics] = []
Expand All @@ -100,7 +107,8 @@ final class CardioModel: ObservableObject {
private struct WeekBundle: Sendable {
let week: CardioWeekSummary
let typical: ClosedRange<Double>?
let load: LoadTrend?
let lane: TrainingLoadModel.Lane?
let laneRatios: [TrainingLoadModel.RatioPoint]
let zones: CardioZoneSplit?
}

Expand All @@ -115,6 +123,8 @@ final class CardioModel: ObservableObject {
// differently apart instead of hiding them.
let visible = fusion.sessions.filter { $0.kind != .strength }
let cardio = await repo.cardioLoads(for: visible)
let laneFusion = await repo.trainingSessions(days: range.days + TrainingLoadLanes.lookbackDays)
let laneResolution = await repo.cardioLoads(for: laneFusion.sessions)
Comment on lines +126 to +127
fusedVisible = visible
duplicateSessionIds = cardio.duplicateSessionIds
let rows = visible.map(\.row)
Expand All @@ -126,6 +136,14 @@ final class CardioModel: ObservableObject {
let enduranceStarts = Set(visible.filter { $0.kind == .endurance || $0.kind == .multisport }
.map { $0.row.startTs })

let laneSeries = await Task.detached(priority: .userInitiated) {
TrainingLoadLanes.cardioSeries(sessions: laneFusion.sessions, resolution: laneResolution,
tzOffsetSeconds: offset)
}.value
self.laneSessions = laneFusion.sessions
self.laneResolution = laneResolution
self.laneSeries = laneSeries

let prepared = await Task.detached(priority: .userInitiated) { () -> ([CardioSessionMetrics], [SportChoice]) in
let sessions = CardioSession.sessions(rows, tzOffsetSeconds: offset,
cardioLoadByStart: loadByStart)
Expand Down Expand Up @@ -180,18 +198,26 @@ final class CardioModel: ObservableObject {
}

let anchor = weekAnchorDay
let endDate = weekEndDate
let all = sessions
let offset = tzOffset
let laneDay = TrainingLoadLanes.readingDay(monday: monday, today: Repository.localDayKey(Date()))
let laneSessions = self.laneSessions
let laneResolution = self.laneResolution
let laneSeries = self.laneSeries

// Outside the detached task: binning zones is an async read on the repository, and its result
// travels into the bundle as a finished value so the week's cache holds it too.
let zones = await weekZoneSplit(repo: repo, monday: monday, sunday: sunday)
let bundle = await Task.detached(priority: .userInitiated) { () -> WeekBundle in
let lane = laneSeries.map {
TrainingLoadLanes.cardioLane(sessions: laneSessions, resolution: laneResolution, series: $0,
through: laneDay, tzOffsetSeconds: offset)
}
return WeekBundle(week: CardioSession.week(containing: anchor, sessions: all),
typical: CardioSession.typicalWeeklyMinutes(all, endingBefore: anchor),
load: CardioSession.cardioLoadTrend(all, asOf: endDate,
tzOffsetSeconds: offset),
lane: lane,
laneRatios: TrainingLoadLanes.ratios(strengthByDay: nil, cardio: laneSeries,
through: laneDay),
zones: zones)
}.value

Expand Down Expand Up @@ -219,7 +245,8 @@ final class CardioModel: ObservableObject {
private func apply(_ bundle: WeekBundle) {
week = bundle.week
typicalMinutes = bundle.typical
load = bundle.load
lane = bundle.lane
laneRatios = bundle.laneRatios
zoneSplit = bundle.zones
}

Expand Down Expand Up @@ -282,14 +309,6 @@ final class CardioModel: ObservableObject {
WeeklyDigestEngine.addDays(Repository.localDayKey(Date()), weekOffset * 7)
}

var weekEndDate: Date {
guard let monday = WeeklyDigestEngine.mondayOfWeek(containing: weekAnchorDay),
let sunday = WeightSeries.date(forDay: WeeklyDigestEngine.addDays(monday, 6)) else {
return Date()
}
return min(sunday, Date())
}

var minWeekOffset: Int {
guard let earliest = sessions.map(\.day).min(),
let earliestMon = WeeklyDigestEngine.mondayOfWeek(containing: earliest),
Expand Down
4 changes: 2 additions & 2 deletions Strand/Screens/CardioView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ struct CardioView: View {

@ViewBuilder
private var loadTile: some View {
let load = model.load
let load = model.lane?.trend
tile(icon: "chart.bar.fill",
label: String(localized: "Load trend"),
value: load.map { signedPercent($0.percentChange) } ?? "—",
Expand Down Expand Up @@ -904,7 +904,7 @@ struct CardioView: View {
if model.week.distanceM > 0 {
parts.append(String(format: "%.1f km", model.week.distanceM / 1000))
}
if let load = model.load {
if let load = model.lane?.trend {
parts.append(String(format: "cardio load %+.0f%% vs own 28-day level", load.percentChange))
}
if let sport = model.selectedSport, let line = model.paceTrend {
Expand Down
29 changes: 23 additions & 6 deletions Strand/Screens/StrengthModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ final class StrengthModel: ObservableObject {
mondayKey: "", sessionCount: 0, workingSetCount: 0, volumeLoadKg: 0,
setsByMuscle: [:], secondarySetsByMuscle: [:], unattributedSetCount: 0)
@Published private(set) var typicalBands: [HevyMuscleGroup: ClosedRange<Double>] = [:]
@Published private(set) var strengthLoad: LoadTrend?
/// The selected week's strength lane, read exactly as Training Load reads it.
@Published private(set) var lane: TrainingLoadModel.Lane?
@Published private(set) var laneRatios: [TrainingLoadModel.RatioPoint] = []
@Published private(set) var weekStimulus: [HevyMuscleGroup: Double] = [:]
@Published private(set) var typicalWeek: [HevyMuscleGroup: Double] = [:]
@Published private(set) var weekCharge: Double?
Expand Down Expand Up @@ -138,6 +140,9 @@ final class StrengthModel: ObservableObject {
private var index = MuscleStimulus.SessionStimulusIndex(workouts: [], templates: [:])
/// The wearer's weigh-ins, for pricing bodyweight work at the body that performed it.
private var bodyweight = BodyweightTimeline(points: [])
/// Workouts over the history window plus the lane lookback, and their weighted sets per day.
private var laneWorkouts: [HevyWorkout] = []
private var laneByDay: [String: Double] = [:]
/// Week-scoped results, keyed by the week's Monday. The bands and the usual week depend on nothing
/// the stepper changes except this key, so stepping back and forward again is free.
private var weekCache: [String: WeekBundle] = [:]
Expand All @@ -147,7 +152,8 @@ final class StrengthModel: ObservableObject {
private struct WeekBundle: Sendable {
let week: StrengthSession.WeekSummary
let bands: [HevyMuscleGroup: ClosedRange<Double>]
let load: LoadTrend?
let lane: TrainingLoadModel.Lane
let laneRatios: [TrainingLoadModel.RatioPoint]
let stimulus: [HevyMuscleGroup: Double]
let typical: [HevyMuscleGroup: Double]
let balance: [StrengthBalance.Reading]
Expand All @@ -168,10 +174,12 @@ final class StrengthModel: ObservableObject {

async let historyRead = repo.resolvedStrengthHistory(days: historyDays)
async let fusedRead = repo.trainingSessions(days: historyDays)
async let laneHistoryRead = repo.resolvedStrengthHistory(days: historyDays + TrainingLoadLanes.lookbackDays)
let history = await historyRead
let sessions = history.workouts
let catalogue = history.templates
let fused = await fusedRead
let laneWorkouts = await laneHistoryRead.workouts
let observations = ((try? await store.muscleRecoveryFeedback()) ?? []).compactMap { row in
MuscleRecovery.Feeling(rawValue: row.feeling).map {
MuscleRecovery.Observation(group: row.muscleGroup, ts: row.ts, feeling: $0)
Expand Down Expand Up @@ -210,10 +218,13 @@ final class StrengthModel: ObservableObject {
ratedShare: index.total().ratedShare,
choices: StrengthSession.exerciseFrequency(sessions)
.map { ExerciseChoice(templateId: $0.templateId, sessions: $0.sessions) },
unmapped: history.unmappedExerciseTitles)
unmapped: history.unmappedExerciseTitles,
laneByDay: TrainingLoadLanes.strengthByDay(laneWorkouts, tzOffsetSeconds: offset))
}.value

index = prepared.index
self.laneWorkouts = laneWorkouts
laneByDay = prepared.laneByDay
bodyweight = BodyweightTimeline(points: weighIns)
weekCache.removeAll()

Expand Down Expand Up @@ -307,6 +318,7 @@ final class StrengthModel: ObservableObject {
let ratedShare: Double
let choices: [ExerciseChoice]
let unmapped: [String]
let laneByDay: [String: Double]
}

// MARK: - The week
Expand All @@ -333,7 +345,9 @@ final class StrengthModel: ObservableObject {
}

let anchor = weekAnchorDay
let endDate = weekEndDate
let laneDay = TrainingLoadLanes.readingDay(monday: monday, today: Repository.localDayKey(Date()))
let laneWorkouts = self.laneWorkouts
let laneByDay = self.laneByDay
let sessions = workouts
let catalogue = templates
let offset = tzOffset
Expand Down Expand Up @@ -364,7 +378,9 @@ final class StrengthModel: ObservableObject {
week: week,
bands: StrengthSession.typicalWeeklySets(sessions, templates: catalogue,
endingBefore: anchor, tzOffsetSeconds: offset),
load: StrengthSession.strengthLoadTrend(sessions, asOf: endDate, tzOffsetSeconds: offset),
lane: TrainingLoadLanes.strengthLane(workouts: laneWorkouts, byDay: laneByDay, through: laneDay,
tzOffsetSeconds: offset),
laneRatios: TrainingLoadLanes.ratios(strengthByDay: laneByDay, cardio: nil, through: laneDay),
stimulus: index.week(containing: anchor).byMuscle,
typical: MuscleStimulus.typicalWeeklyStimulus(index: index, endingBefore: anchor),
balance: StrengthBalance.readings(setsByMuscle: week.setsByMuscle),
Expand All @@ -383,7 +399,8 @@ final class StrengthModel: ObservableObject {
private func apply(_ bundle: WeekBundle) {
week = bundle.week
typicalBands = bundle.bands
strengthLoad = bundle.load
lane = bundle.lane
laneRatios = bundle.laneRatios
weekStimulus = bundle.stimulus
typicalWeek = bundle.typical
balance = bundle.balance
Expand Down
4 changes: 2 additions & 2 deletions Strand/Screens/StrengthView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ struct StrengthView: View {
/// team-sport distance research that never covered set counts. The ratio is still there on
/// `LoadTrend` for anything that needs it.
private var strengthLoadTile: some View {
let load = model.strengthLoad
let load = model.lane?.trend
return tile(icon: "chart.bar.fill",
label: String(localized: "Strength load"),
value: load.map { signedPercent($0.percentChange) } ?? "—",
Expand Down Expand Up @@ -1821,7 +1821,7 @@ struct StrengthView: View {
?? "\(row.group.label) \(row.sets)" }
.joined(separator: ", ")
if !muscles.isEmpty { parts.append("working sets — " + muscles) }
if let load = model.strengthLoad {
if let load = model.lane?.trend {
// The coach gets the same framing the tile shows: effort-weighted sets against this
// person's own recent level, as a percentage. Handing it a bare ratio invited it to
// quote 0.8–1.3 bands that were never validated on set counts.
Expand Down
Loading
Loading