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
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