Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -565,3 +565,81 @@ public enum CardioSession {
}
}
}

/// One sport's part of a week's measured cardio load.
public struct CardioSportLoadShare: Equatable, Sendable {
public let sport: String
public let modality: CardioModality
public let load: Double
/// Of the week's measured load, 0…1.
public let share: Double
}

extension CardioSession {
private static func inWeek(containing anchorDay: String,
_ sessions: [CardioSessionMetrics]) -> [CardioSessionMetrics] {
guard let monday = WeeklyDigestEngine.mondayOfWeek(containing: anchorDay) else { return [] }
let sunday = WeeklyDigestEngine.addDays(monday, 6)
return sessions.filter { $0.day >= monday && $0.day <= sunday }
}

/// Each sport's share of the week's measured load, largest first. Sessions without a measured load
/// are left out of both sides, so a share never mixes measured and unmeasured work.
public static func loadShareBySport(inWeekContaining anchorDay: String,
sessions: [CardioSessionMetrics]) -> [CardioSportLoadShare] {
var bySport: [String: (modality: CardioModality, load: Double)] = [:]
for session in inWeek(containing: anchorDay, sessions) {
guard let load = session.cardioLoad, load.isFinite, load > 0 else { continue }
bySport[session.sport, default: (session.modality, 0)].load += load
}
let total = bySport.values.reduce(0) { $0 + $1.load }
guard total > 0 else { return [] }
return bySport.map { CardioSportLoadShare(sport: $0.key, modality: $0.value.modality,
load: $0.value.load, share: $0.value.load / total) }
.sorted { $0.load != $1.load ? $0.load > $1.load : $0.sport < $1.sport }
}

/// One sport's pace over the week: total moving time over total distance, in seconds per kilometre,
/// from the sessions that recorded both. Nil when none did.
public static func weeklyPaceSecPerKm(sport: String, inWeekContaining anchorDay: String,
sessions: [CardioSessionMetrics]) -> Double? {
var seconds = 0.0, metres = 0.0
for session in inWeek(containing: anchorDay, sessions)
where session.sport.caseInsensitiveCompare(sport) == .orderedSame {
guard let duration = session.durationS, duration > 0,
let distance = session.distanceM, distance > 0 else { continue }
seconds += duration
metres += distance
}
guard metres > 0 else { return nil }
return seconds / (metres / 1000)
}

/// The week's average heart rate, weighted by each session's duration, over sessions carrying both.
public static func weeklyAverageHr(inWeekContaining anchorDay: String,
sessions: [CardioSessionMetrics]) -> Double? {
var beats = 0.0, seconds = 0.0
for session in inWeek(containing: anchorDay, sessions) {
guard let hr = session.avgHr, hr > 0, let duration = session.durationS, duration > 0 else { continue }
beats += Double(hr) * duration
seconds += duration
}
return seconds > 0 ? beats / seconds : nil
}

/// The median weekly average heart rate over the preceding `weeks` weeks that have one. Nil below
/// three such weeks, the same floor the usual weekly minutes use.
public static func typicalWeeklyAverageHr(_ sessions: [CardioSessionMetrics],
endingBefore anchorDay: String,
weeks: Int = 8) -> Double? {
guard let thisMonday = WeeklyDigestEngine.mondayOfWeek(containing: anchorDay) else { return nil }
var values: [Double] = []
var monday = WeeklyDigestEngine.addDays(thisMonday, -7)
for _ in 0..<max(weeks, 1) {
if let value = weeklyAverageHr(inWeekContaining: monday, sessions: sessions) { values.append(value) }
monday = WeeklyDigestEngine.addDays(monday, -7)
}
guard values.count >= 3 else { return nil }
return StrengthSession.percentile(values.sorted(), 0.5)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,53 @@ final class CardioModalityUnitTests: XCTestCase {
XCTAssertEqual(CardioModality.cycling.readout, .speed)
}
}

extension CardioSessionTests {
// MARK: - Week summaries for the load screen

private func week(_ rows: [WorkoutRow], loads: [Int: Double] = [:]) -> [CardioSessionMetrics] {
CardioSession.sessions(rows, tzOffsetSeconds: 0, cardioLoadByStart: loads)
}

func testLoadShareCountsOnlyMeasuredLoadInTheWeek() {
let run1 = Self.ts("2026-09-14"), run2 = Self.ts("2026-09-16"), ride = Self.ts("2026-09-15")
let unmeasured = Self.ts("2026-09-17"), lastWeek = Self.ts("2026-09-10")
let sessions = week([row("Running", at: run1, km: 10), row("Running", at: run2, km: 8),
row("Cycling", at: ride, km: 30), row("Rowing", at: unmeasured),
row("Cycling", at: lastWeek, km: 40)],
loads: [run1: 90, run2: 60, ride: 50, lastWeek: 500])
let shares = CardioSession.loadShareBySport(inWeekContaining: "2026-09-17", sessions: sessions)
XCTAssertEqual(shares.map(\.sport), ["Running", "Cycling"])
XCTAssertEqual(shares.map(\.load), [150, 50])
XCTAssertEqual(shares.map(\.share), [0.75, 0.25])
XCTAssertTrue(CardioSession.loadShareBySport(inWeekContaining: "2026-09-17",
sessions: week([row("Rowing", at: unmeasured)])).isEmpty)
}

func testWeeklyPaceIsTotalTimeOverTotalDistanceForThatSport() {
let sessions = week([row("Running", at: Self.ts("2026-09-14"), minutes: 50, km: 10),
row("Running", at: Self.ts("2026-09-16"), minutes: 30, km: 5),
row("Running", at: Self.ts("2026-09-17"), minutes: 40, km: nil),
row("Swimming", at: Self.ts("2026-09-15"), minutes: 40, km: 2)])
let run = CardioSession.weeklyPaceSecPerKm(sport: "running", inWeekContaining: "2026-09-17", sessions: sessions)
XCTAssertEqual(try XCTUnwrap(run), 80 * 60 / 15, accuracy: 1e-9, "the run without a distance is left out")
let swim = CardioSession.weeklyPaceSecPerKm(sport: "Swimming", inWeekContaining: "2026-09-17", sessions: sessions)
XCTAssertEqual(try XCTUnwrap(swim) / 10, 120, accuracy: 1e-9, "2:00 per 100 m")
XCTAssertNil(CardioSession.weeklyPaceSecPerKm(sport: "Cycling", inWeekContaining: "2026-09-17", sessions: sessions))
}

func testWeeklyHeartRateIsWeightedByDurationAndComparedWithEarlierWeeks() {
var rows = [row("Running", at: Self.ts("2026-09-14"), minutes: 60, avgHr: 150),
row("Walking", at: Self.ts("2026-09-15"), minutes: 30, avgHr: 120),
row("Cycling", at: Self.ts("2026-09-16"), minutes: 45, avgHr: nil)]
let current = CardioSession.weeklyAverageHr(inWeekContaining: "2026-09-16", sessions: week(rows))
XCTAssertEqual(try XCTUnwrap(current), 140, accuracy: 1e-9)
XCTAssertNil(CardioSession.typicalWeeklyAverageHr(week(rows), endingBefore: "2026-09-16"))

for (day, hr) in [("2026-09-07", 130), ("2026-08-31", 136), ("2026-08-24", 132)] {
rows.append(row("Running", at: Self.ts(day), minutes: 45, avgHr: hr))
}
let typical = CardioSession.typicalWeeklyAverageHr(week(rows), endingBefore: "2026-09-16")
XCTAssertEqual(try XCTUnwrap(typical), 132, accuracy: 1e-9)
}
}
124 changes: 124 additions & 0 deletions Packages/StrandDesign/Sources/StrandDesign/Palette.swift
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,130 @@ public enum StrandPalette {
/// 3-stop gauge ramp: calm → balanced → high.
public static var stressGradient: Gradient { Gradient(colors: [stressDeep, stressColor, stressBright]) }

// MARK: - Lane colours — Strength / Cardio (redesign)
//
// Teal for Strength: orange, red and yellow are status colours and green is Charge, and a lane must
// never read as a status. `LaneColorTests` pins the hue and contrast margins.

/// The raw hex table backing the lane tokens below, keyed by `chartStyle`.
enum LaneColorTable {
/// A light/dark hex pair, kept as raw strings so `LaneColorTests` can parse them directly (a
/// SwiftUI `Color` built from a dynamic provider cannot be read back).
struct Hex {
let light: String
let dark: String
}

/// The six lane swatches for one chart style, each lane as deep → color → bright.
struct Style {
let strengthDeep: Hex
let strengthColor: Hex
let strengthBright: Hex
let cardioDeep: Hex
let cardioColor: Hex
let cardioBright: Hex
}

static let signature = Style(
strengthDeep: Hex(light: "#064E53", dark: "#086E74"),
strengthColor: Hex(light: "#0A8F97", dark: "#1ED6E0"),
strengthBright: Hex(light: "#0FB3BC", dark: "#7CEBF0"),
cardioDeep: Hex(light: "#291268", dark: "#3A1A93"),
cardioColor: Hex(light: "#440CDF", dark: "#7D51F6"),
cardioBright: Hex(light: "#622FEE", dark: "#A98CF8")
)
static let titanium = Style(
strengthDeep: Hex(light: "#064A50", dark: "#0A6A72"),
strengthColor: Hex(light: "#0B8791", dark: "#22C7D6"),
strengthBright: Hex(light: "#12A6B2", dark: "#7FE3EC"),
cardioDeep: Hex(light: "#2E1763", dark: "#41218C"),
cardioColor: Hex(light: "#4B1BBB", dark: "#7A4CE6"),
cardioBright: Hex(light: "#662FE4", dark: "#A788F2")
)
static let classic = Style(
strengthDeep: Hex(light: "#074A4E", dark: "#0B6B70"),
strengthColor: Hex(light: "#0D858C", dark: "#25C4CC"),
strengthBright: Hex(light: "#14A2AA", dark: "#83E0E5"),
cardioDeep: Hex(light: "#2A1763", dark: "#3C218C"),
cardioColor: Hex(light: "#431BBB", dark: "#734CE6"),
cardioBright: Hex(light: "#5D2FE4", dark: "#A288F2")
)
static let health = Style(
strengthDeep: Hex(light: "#064C52", dark: "#0A7078"),
strengthColor: Hex(light: "#0A8C96", dark: "#1FD3E0"),
strengthBright: Hex(light: "#0FAEB9", dark: "#7FEAF1"),
cardioDeep: Hex(light: "#211268", dark: "#2E1A93"),
cardioColor: Hex(light: "#2F0CDF", dark: "#6C51F6"),
cardioBright: Hex(light: "#4F2FEE", dark: "#9E8CF8")
)
static let aurora = Style(
strengthDeep: Hex(light: "#1F4648", dark: "#2E6466"),
strengthColor: Hex(light: "#3E8384", dark: "#5FB3B3"),
strengthBright: Hex(light: "#4F9FA0", dark: "#9ACFCE"),
cardioDeep: Hex(light: "#351F51", dark: "#4D2D76"),
cardioColor: Hex(light: "#5A3091", dark: "#8757C7"),
cardioBright: Hex(light: "#723EB6", dark: "#A883D8")
)
static let sunset = Style(
strengthDeep: Hex(light: "#074A50", dark: "#0B6C74"),
strengthColor: Hex(light: "#0C8891", dark: "#22C9D4"),
strengthBright: Hex(light: "#13A7B1", dark: "#82E4EA"),
cardioDeep: Hex(light: "#36156A", dark: "#4A1F8F"),
cardioColor: Hex(light: "#5A15C6", dark: "#7F48EE"),
cardioBright: Hex(light: "#7431EA", dark: "#A884F5")
)
static let forest = Style(
strengthDeep: Hex(light: "#1C4545", dark: "#2A6566"),
strengthColor: Hex(light: "#357B7A", dark: "#4FA9A8"),
strengthBright: Hex(light: "#449897", dark: "#8CC9C8"),
cardioDeep: Hex(light: "#311F51", dark: "#482D76"),
cardioColor: Hex(light: "#543091", dark: "#8057C7"),
cardioBright: Hex(light: "#6A3EB6", dark: "#A283D8")
)

static func style(_ chartStyle: ChartStyle) -> Style {
switch chartStyle {
case .signature: return signature
case .titanium: return titanium
case .classic: return classic
case .health: return health
case .aurora: return aurora
case .sunset: return sunset
case .forest: return forest
}
}
}

/// Strength lane identity: electric teal in every style.
public static var strengthDeep: Color {
let hex = LaneColorTable.style(chartStyle).strengthDeep
return Color(light: hex.light, dark: hex.dark)
}
public static var strengthColor: Color {
let hex = LaneColorTable.style(chartStyle).strengthColor
return Color(light: hex.light, dark: hex.dark)
}
public static var strengthBright: Color {
let hex = LaneColorTable.style(chartStyle).strengthBright
return Color(light: hex.light, dark: hex.dark)
}
public static var strengthGradient: Gradient { Gradient(colors: [strengthDeep, strengthBright]) }

/// Cardio lane identity: electric violet-indigo in every style.
public static var cardioDeep: Color {
let hex = LaneColorTable.style(chartStyle).cardioDeep
return Color(light: hex.light, dark: hex.dark)
}
public static var cardioColor: Color {
let hex = LaneColorTable.style(chartStyle).cardioColor
return Color(light: hex.light, dark: hex.dark)
}
public static var cardioBright: Color {
let hex = LaneColorTable.style(chartStyle).cardioBright
return Color(light: hex.light, dark: hex.dark)
}
public static var cardioGradient: Gradient { Gradient(colors: [cardioDeep, cardioBright]) }

// MARK: Scenic background (NEW) — detail-screen hero gradient + starfield.
/// Radial canvas: lit center → deep edge. Used by `ScenicHeroBackground` (warm-lit on light).
public static let scenicCenter = Color(light: "#FBF6EA", dark: "#1C2128")
Expand Down
Loading
Loading