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 @@ -242,6 +242,10 @@ extension RecoveryScorer {

// MARK: - Plain-English verdicts (no fabricated numbers; direction only)

// Every returned literal is also a runtime localization key consumed by ChargeBreakdownFormat.
// When adding or rewording a verdict, add the identical key to Strand's Localizable.xcstrings;
// Tools/test_home_i18n.py enforces that complete engine-to-catalog contract.

static func hrvVerdict(value: Double, baseline: Double, saturationDetected: Bool = false) -> String {
if value > baseline { return "above baseline, supporting recovery" }
if value < baseline {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,18 @@ public enum IllnessSignalEngine {
case alreadyUnwell // user logged feeling unwell — "rest up", not a scare
}

/// Semantic presentation state. The UI localizes this enum; it never has to infer meaning from
/// the engine's English notification sentence.
public enum Message: String, Equatable, Sendable, Codable {
case learningBaseline, alreadyUnwellAgree, alreadyUnwell, normal, suppressed, mild, raised
}

/// Locale-free confounder identity for presentation. `suppressedBy` remains the compatible
/// human-readable payload used by existing callers; new UIs should localize these typed values.
public enum SuppressionReason: String, Equatable, Sendable, Codable {
case alcohol, stress, sauna, hardOrLateWorkout, travel
}

public struct Result: Equatable, Sendable {
/// 0–100 composite anomaly score (post-dampening for the suppressed level so the surface matches).
public let score: Double
Expand All @@ -119,15 +131,20 @@ public enum IllnessSignalEngine {
public let firedSignals: [String]
/// Named confounders that were present and damped/explained the score, e.g. "alcohol", "travel".
public let suppressedBy: [String]
public let suppressionReasons: [SuppressionReason]
/// Count of signals over the firing threshold (corroboration), regardless of level.
public let signalCount: Int
/// One-line non-clinical copy, terminating in the shipped not-a-diagnosis framing where it raises.
public let copy: String
public let message: Message?

public init(score: Double, level: Level, firedSignals: [String], suppressedBy: [String],
signalCount: Int, copy: String) {
signalCount: Int, copy: String, message: Message? = nil,
suppressionReasons: [SuppressionReason] = []) {
self.score = score; self.level = level; self.firedSignals = firedSignals
self.suppressedBy = suppressedBy; self.signalCount = signalCount; self.copy = copy
self.message = message
self.suppressionReasons = suppressionReasons
}
}

Expand Down Expand Up @@ -170,7 +187,8 @@ public enum IllnessSignalEngine {
if !context.baselineTrusted {
return Result(score: score, level: .quiet, firedSignals: firedSignals,
suppressedBy: [], signalCount: signalCount,
copy: "Still learning your baseline - keeping an eye out.")
copy: "Still learning your baseline - keeping an eye out.",
message: .learningBaseline)
}

// Already-unwell path: the user told us. Switch from "early warning" to a gentle "rest up" and
Expand All @@ -181,24 +199,30 @@ public enum IllnessSignalEngine {
? "Rest up - you logged feeling unwell, and your numbers agree. \(disclaimerTail)"
: "Rest up - you logged feeling unwell. Take it easy today. \(disclaimerTail)"
return Result(score: score, level: .alreadyUnwell, firedSignals: firedSignals,
suppressedBy: [], signalCount: signalCount, copy: copy)
suppressedBy: [], signalCount: signalCount, copy: copy,
message: agreeing ? .alreadyUnwellAgree : .alreadyUnwell)
}

// Corroboration + magnitude gate: need ≥ 2 firing signals and a mild-or-better composite, else quiet.
guard signalCount >= minCorroboratingSignals, score >= mildThreshold else {
return Result(score: score, level: .quiet, firedSignals: firedSignals,
suppressedBy: [], signalCount: signalCount,
copy: "Nothing notable - your signals look like your normal range.")
copy: "Nothing notable - your signals look like your normal range.",
message: .normal)
}

// Confounder suppression — the differentiating part. Collect every present behaviour/travel tag
// that offers a plainer explanation; if any are present, dampen the score and downgrade.
var suppressedBy: [String] = []
if context.alcohol { suppressedBy.append("alcohol") }
if context.stress { suppressedBy.append("stress") }
if context.sauna { suppressedBy.append("sauna") }
if context.hardOrLateWorkout { suppressedBy.append("a hard or late workout") }
if context.travelPhaseJump { suppressedBy.append("travel") }
var suppressionReasons: [SuppressionReason] = []
if context.alcohol { suppressedBy.append("alcohol"); suppressionReasons.append(.alcohol) }
if context.stress { suppressedBy.append("stress"); suppressionReasons.append(.stress) }
if context.sauna { suppressedBy.append("sauna"); suppressionReasons.append(.sauna) }
if context.hardOrLateWorkout {
suppressedBy.append("a hard or late workout")
suppressionReasons.append(.hardOrLateWorkout)
}
if context.travelPhaseJump { suppressedBy.append("travel"); suppressionReasons.append(.travel) }

let signalsPhrase = firedSignals.isEmpty ? "Some signals are up" : firedSignals.joined(separator: ", ")

Expand All @@ -208,22 +232,23 @@ public enum IllnessSignalEngine {
let copy = "Some signals are up (\(signalsPhrase)), but you logged \(reason) - likely that, "
+ "not illness. \(disclaimerTail)"
return Result(score: dampened, level: .suppressed, firedSignals: firedSignals,
suppressedBy: suppressedBy, signalCount: signalCount, copy: copy)
suppressedBy: suppressedBy, signalCount: signalCount, copy: copy,
message: .suppressed, suppressionReasons: suppressionReasons)
}

// No confounder. Mild stays in the detail view; a strong composite raises.
if score < raiseThreshold {
let copy = "A few signals are mildly up (\(signalsPhrase)). Nothing alarming - worth a calmer "
+ "day. \(disclaimerTail)"
return Result(score: score, level: .mild, firedSignals: firedSignals,
suppressedBy: [], signalCount: signalCount, copy: copy)
suppressedBy: [], signalCount: signalCount, copy: copy, message: .mild)
}

let ruledOut = "no alcohol or travel logged"
let copy = "Heads-up - your body looks strained. \(signalsPhrase). With \(ruledOut), consider "
+ "taking it easy. \(disclaimerTail)"
return Result(score: score, level: .raised, firedSignals: firedSignals,
suppressedBy: [], signalCount: signalCount, copy: copy)
suppressedBy: [], signalCount: signalCount, copy: copy, message: .raised)
}

// MARK: - Helpers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,24 @@ public enum ReadinessEngine {
case good, neutral, watch, bad
}

/// Locale-free numeric evidence. Clients format and localize it at the display boundary.
public enum Evidence: Sendable, Equatable {
case metric(value: Double, baseline: Double, unit: String, decimals: Int)
case trainingLoad(acute: Double, chronic: Double)
case monotony(Double)
}

public struct Signal: Sendable, Equatable {
public let key: String // "hrv" | "rhr" | "respRate" | "acwr" | "monotony"
public let label: String // short human label
public let evidence: String?
public let evidenceData: Evidence?
public let detail: String // one-line plain-English read
public let flag: Flag
public init(key: String, label: String, evidence: String? = nil, detail: String, flag: Flag) {
public init(key: String, label: String, evidence: String? = nil,
evidenceData: Evidence? = nil, detail: String, flag: Flag) {
self.key = key; self.label = label; self.evidence = evidence
self.detail = detail; self.flag = flag
self.evidenceData = evidenceData; self.detail = detail; self.flag = flag
}
}

Expand Down Expand Up @@ -180,10 +189,12 @@ public enum ReadinessEngine {
if z >= 2.0 {
signals.append(Signal(key: "respRate", label: "Respiratory rate",
evidence: evidence(value: rr, baseline: m, unit: "rpm", decimals: 1),
evidenceData: .metric(value: rr, baseline: m, unit: "rpm", decimals: 1),
detail: "up vs baseline - sometimes an early sign of getting sick", flag: .bad))
} else if z >= 1.5 {
signals.append(Signal(key: "respRate", label: "Respiratory rate",
evidence: evidence(value: rr, baseline: m, unit: "rpm", decimals: 1),
evidenceData: .metric(value: rr, baseline: m, unit: "rpm", decimals: 1),
detail: "slightly raised vs baseline", flag: .watch))
}
}
Expand All @@ -209,6 +220,7 @@ public enum ReadinessEngine {
if mono >= 2.0 {
signals.append(Signal(key: "monotony", label: "Training variety",
evidence: "monotony \(String(format: "%.1f", mono))",
evidenceData: .monotony(mono),
detail: "low - similar strain every day raises strain/illness risk", flag: .watch))
}
}
Expand Down Expand Up @@ -271,6 +283,8 @@ public enum ReadinessEngine {
return Signal(key: key, label: label,
evidence: evidence(value: v, baseline: logDomain ? exp(m) : m,
unit: unit, decimals: decimals),
evidenceData: .metric(value: v, baseline: logDomain ? exp(m) : m,
unit: unit, decimals: decimals),
detail: text, flag: flag)
}

Expand All @@ -281,18 +295,22 @@ public enum ReadinessEngine {
case ..<0.8:
return Signal(key: "acwr", label: "Training load",
evidence: evidence,
evidenceData: .trainingLoad(acute: acute, chronic: chronic),
detail: "ramping down (acute:chronic \(pct)) - room to build", flag: .watch)
case 0.8..<1.3:
return Signal(key: "acwr", label: "Training load",
evidence: evidence,
evidenceData: .trainingLoad(acute: acute, chronic: chronic),
detail: "in the sweet spot (acute:chronic \(pct))", flag: .good)
case 1.3..<1.5:
return Signal(key: "acwr", label: "Training load",
evidence: evidence,
evidenceData: .trainingLoad(acute: acute, chronic: chronic),
detail: "building fast (acute:chronic \(pct)) - watch fatigue", flag: .watch)
default:
return Signal(key: "acwr", label: "Training load",
evidence: evidence,
evidenceData: .trainingLoad(acute: acute, chronic: chronic),
detail: "spiking (acute:chronic \(pct)) - higher injury risk", flag: .bad)
}
}
Expand Down
19 changes: 10 additions & 9 deletions Packages/StrandDesign/Sources/StrandDesign/DayNavBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public struct DayNavBar: View {
private let onSelect: (Int) -> Void

@State private var showingPicker = false
@Environment(\.locale) private var locale

/// `today` is the caller's LOGICAL day (the same anchor the rest of Today uses, rolling at 04:00),
/// so every label here counts back from it. Passing it in instead of reading `Date()` keeps the
Expand All @@ -40,7 +41,11 @@ public struct DayNavBar: View {
switch selectedOffset {
case 0: return "Today"
case 1: return "Yesterday"
default: return "\(Self.dayFmt.string(from: selectedDay))"
default:
let formattedDay = selectedDay.formatted(
.dateTime.weekday(.abbreviated).day().month(.abbreviated).locale(locale)
)
return LocalizedStringKey(formattedDay)
}
}

Expand All @@ -67,7 +72,9 @@ public struct DayNavBar: View {
// On today the label already reads "Today"; the full date would just duplicate the
// header, so it's shown only once you've navigated to another day (for orientation).
if selectedOffset > 0 {
Text(Self.fullDateFmt.string(from: selectedDay))
Text(selectedDay.formatted(
.dateTime.day().month(.abbreviated).year().locale(locale)
))
.font(StrandFont.captionNumber)
.foregroundStyle(StrandPalette.accent)
.lineLimit(1)
Expand All @@ -83,7 +90,7 @@ public struct DayNavBar: View {
.overlay(blockShape.strokeBorder(StrandPalette.hairline, lineWidth: 1))
}
.buttonStyle(.plain)
.accessibilityLabel("Pick a date")
.accessibilityLabel(Text("Pick a date", bundle: .module))
.popover(isPresented: $showingPicker) {
datePickerPopover
}
Expand Down Expand Up @@ -143,11 +150,5 @@ public struct DayNavBar: View {
#endif
}

private static let dayFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEE d MMM"; f.locale = Locale(identifier: "en_US_POSIX"); return f
}()
private static let fullDateFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "d MMM yyyy"; f.locale = Locale(identifier: "en_US_POSIX"); return f
}()
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -4197,6 +4197,70 @@
}
}
},
"Pick a date": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Datum auswählen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Pick a date"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Elegir una fecha"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Choisir une date"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Scegli una data"
}
},
"pt-PT": {
"stringUnit": {
"state": "translated",
"value": "Selecionar uma data"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Выбрать дату"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "选择日期"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "選擇日期"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Wybierz datę"
}
}
}
},
"Previous day": {
"localizations": {
"de": {
Expand Down
Loading
Loading