From 87f03749f17873e38c2f8f03dd051c338fb2cb56 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:19:38 +0200 Subject: [PATCH] cleanup: remove private code nothing uses 707 lines of private declarations with no reader left, across 11 files. Swift's private is file-scoped, so each was checked by name within its own file, and the compiler confirms none was still needed. Most of it is Settings' WHOOP 5/MG research card (fiveMGCard) and what only it used: the ECG raw-data gate readouts, the deep-data (R22) button state, the capture exports and their @AppStorage mirrors. #1709 removed the card's only call site (`if showFiveMGControls { fiveMGCard }`) on 29 Aug when the IMU capture sessions replaced it, and left the body behind. Their UserDefaults keys and the features behind them are untouched; only unreachable UI code goes. The rest are small leftovers: helpers, a formatter, a colour, a placeholder button and a first-run card on Today that nothing renders. Co-Authored-By: Claude Opus 5.5 --- Strand/Collect/RawHistoryArchive.swift | 4 - Strand/Screens/BiofeedbackController.swift | 7 - Strand/Screens/FullDayChartView.swift | 2 - Strand/Screens/IntervalTimerView.swift | 8 - Strand/Screens/LiveView.swift | 3 - Strand/Screens/LiveWorkoutView.swift | 16 - Strand/Screens/SettingsView.swift | 553 --------------------- Strand/Screens/SleepView.swift | 8 - Strand/Screens/TodayView.swift | 98 ---- Strand/Screens/TrendsView.swift | 6 - Strand/Screens/WeeklyDigestView.swift | 2 - 11 files changed, 707 deletions(-) diff --git a/Strand/Collect/RawHistoryArchive.swift b/Strand/Collect/RawHistoryArchive.swift index 6ba7365b3b..e323724b4c 100644 --- a/Strand/Collect/RawHistoryArchive.swift +++ b/Strand/Collect/RawHistoryArchive.swift @@ -137,10 +137,6 @@ struct RawHistoryArchive { /// kept as distinct buckets (their layouts differ despite the shared version number). private struct VersionKey: Hashable { let family: String; let version: Int } - private func versionKey(family: DeviceFamily, version: Int) -> VersionKey { - VersionKey(family: family.rawValue, version: version) - } - /// Durably append `frames` as JSONL. `trim`/`family` tag each line so the corpus is replayable. /// Empty input is a no-op success. See `Result` for the ack contract. /// diff --git a/Strand/Screens/BiofeedbackController.swift b/Strand/Screens/BiofeedbackController.swift index 5659d3446a..adb475718c 100644 --- a/Strand/Screens/BiofeedbackController.swift +++ b/Strand/Screens/BiofeedbackController.swift @@ -115,13 +115,6 @@ final class BiofeedbackController: ObservableObject { model.buzz(loops: UInt8(clamping: loops), gate: HapticPrefs.breathing) } - /// Schedule one cancellable buzz at `offsetMs` from now (the asyncAfter walk, cancellable on stop). - private func scheduleBuzz(loops: Int, afterMs: Int) { - let item = DispatchWorkItem { [weak self] in self?.fireBuzz(loops: loops) } - pending.append(item) - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(afterMs), execute: item) - } - private func startSecondTimer() { elapsedSeconds = 0 secondTimer = Timer.publish(every: 1, on: .main, in: .common) diff --git a/Strand/Screens/FullDayChartView.swift b/Strand/Screens/FullDayChartView.swift index 6f0b277bbc..85b567ef17 100644 --- a/Strand/Screens/FullDayChartView.swift +++ b/Strand/Screens/FullDayChartView.swift @@ -66,8 +66,6 @@ struct FullDayChartView: View { /// The visible window the chart's gestures mutate. nil → full day (the chart falls back to `dayBounds`). @State private var zoomDomain: ClosedRange? = nil @State private var loading = true - /// Bumped on every settled zoom/metric change so the re-read task re-runs at the new resolution. - @State private var reloadTick = 0 /// The full clamp the zoom window can never escape — the selected calendar day. private var dayBounds: ClosedRange { diff --git a/Strand/Screens/IntervalTimerView.swift b/Strand/Screens/IntervalTimerView.swift index bd98b06f3f..f62d3a9086 100644 --- a/Strand/Screens/IntervalTimerView.swift +++ b/Strand/Screens/IntervalTimerView.swift @@ -86,14 +86,6 @@ struct IntervalTimerView: View { } } - private var phaseTone: StrandTone { - switch phase { - case .work: return .accent - case .rest: return .neutral - case .done: return .positive - } - } - private var isFinished: Bool { phase == .done } // MARK: Body diff --git a/Strand/Screens/LiveView.swift b/Strand/Screens/LiveView.swift index 3602bd7a96..c3017ddeff 100644 --- a/Strand/Screens/LiveView.swift +++ b/Strand/Screens/LiveView.swift @@ -979,9 +979,6 @@ private struct LivePhysiology: View { /// Oura ring actively streaming live HR — trusted stream without a WHOOP bond (see LiveView.ringStreaming). private var ringStreaming: Bool { live.connected && live.streamingLiveHR } - /// The liquid heart pink (matches LiquidThread's default + the mockup #ff6b81). - private let liquidHeart = Color(.sRGB, red: 1, green: 107 / 255, blue: 129 / 255, opacity: 1) - var body: some View { VStack(alignment: .leading, spacing: NoopMetrics.space4) { HStack(alignment: .firstTextBaseline) { diff --git a/Strand/Screens/LiveWorkoutView.swift b/Strand/Screens/LiveWorkoutView.swift index f1a8b06799..a57a3364d3 100644 --- a/Strand/Screens/LiveWorkoutView.swift +++ b/Strand/Screens/LiveWorkoutView.swift @@ -437,22 +437,6 @@ struct LiveWorkoutView: View { .accessibilityLabel(Text("Delete")) } - private var activeSportName: String { - model.activeWorkout?.sport ?? WorkoutCatalog.defaultSportName - } - - private var workoutTypeGlassButton: some View { - Button { - // Placeholder — sport-type picker lands later; chrome and sizing stay put. - } label: { - WorkoutTypeIcon(workoutType: activeSportName, size: 22, weight: .semibold) - .frame(width: Self.bottomControlDiameter, height: Self.bottomControlDiameter) - .contentShape(Circle()) - } - .nativeLiquidGlassWorkoutControl() - .accessibilityLabel(Text("\(WorkoutSource.displaySport(activeSportName)) workout")) - } - // MARK: - Helpers /// Delegates to the shared clock. This carried its own `%d:%02d` with NO hour roll-over, so a diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index 2a104575e4..8aa168bad8 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -40,12 +40,6 @@ struct SettingsView: View { @State private var showOversizeRestoreConfirm = false @State private var oversizeRestoreMessage = "" - /// Opt-in WHOOP 5/MG protocol experiments (off by default). See [PuffinExperiment]. - @AppStorage(PuffinExperiment.defaultsKey) private var puffinExperiments = false - - /// Opt-in WHOOP 5/MG raw-frame capture to a file (off by default). See [PuffinFrameRecorder]. - @AppStorage(PuffinFrameRecorder.enabledKey) private var puffinCapture = false - /// Opt-in WHOOP 5/MG "R22" deep-data unlock (off by default) — the one probe that writes a /// persistent feature flag to the strap. See [PuffinExperiment.deepDataKey]. (#174) @AppStorage(PuffinExperiment.deepDataKey) private var deepDataEnabled = false @@ -57,86 +51,16 @@ struct SettingsView: View { /// connected, and a write to bonded hardware is not something a toggle should do unannounced. @State private var confirmingDeepDataDisable = false - /// Opt-in "Broadcast heart rate" (off by default) — makes the strap advertise its HR as a standard - /// BLE sensor for Garmin/Zwift/gym kit. See [PuffinExperiment.broadcastHrKey]. (#181) - @AppStorage(PuffinExperiment.broadcastHrKey) private var broadcastHrEnabled = false - - /// #891 opt-in: writes the device-config key `enable_raw_data_w_ecg` on an attested WHOOP MG. A - /// persistent strap write, so it gets its own deliberate switch like #174 and #181. - /// See [PuffinExperiment.ecgRawDataKey]. - @AppStorage(PuffinExperiment.ecgRawDataKey) private var ecgRawDataEnabled = false - /// #103 opt-in: surfaces the WHOOP 5/MG `spo2_candidate_82` nightly mean in the Blood Oxygen tile /// as a "strap estimate (unverified)" fallback when no calibrated `spo2Pct` exists. Display-only — /// writes nothing to the strap. See [PuffinExperiment.spo2CandidateDisplayKey]. @AppStorage(PuffinExperiment.spo2CandidateDisplayKey) private var spo2CandidateDisplayEnabled = false - /// #463 opt-in: score the intraday stress timeline against a PERSONAL cross-day baseline - /// (`.baselineRelative`) instead of the day's own calm hours. Default off — the r≈0.6 margin is - /// single-subject so far. Display-only; never feeds recovery/illness. See - /// [PuffinExperiment.stressPersonalBaselineKey]. - @AppStorage(PuffinExperiment.stressPersonalBaselineKey) private var stressPersonalBaselineEnabled = false /// #1545 opt-in: score Effort with Banister's exponential TRIMP instead of Edwards' heart-rate zones. /// Default OFF — it re-scores the whole window against a different recipe. See /// [PuffinExperiment.banisterEffortKey]. @AppStorage(PuffinExperiment.banisterEffortKey) private var banisterEffortEnabled = false - /// True when the connected strap has positively attested itself a WHOOP MG. The variant is published as - /// its label string (`LiveState.whoop5Variant`); "MG" is `Whoop5Variant.mg.label`. nil / not-yet- - /// identified / a plain 5.0 is not an MG. - private var ecgVariantIsMG: Bool { live.whoop5Variant == Whoop5Variant.mg.label } - - /// The #891 ECG-gate buttons need the same encrypted bond the R22 writes do (a config write over the - /// live-HR-only link silently fails, #269) AND a strap that has positively attested itself an MG. Not - /// wear-gated: this stores a value, it does not start an on-wrist stream. - private var ecgGateReady: Bool { - #if os(macOS) - return false - #else - return live.encryptedBond && ecgVariantIsMG - #endif - } - - /// The reason line under the #891 buttons. Each case names the ONE thing that is missing. - private var ecgGateReason: String { - #if os(macOS) - return String(localized: "The ECG gate needs an iPhone or Android. A Mac can't form the encrypted bond a 5/MG requires.") - #else - if !live.encryptedBond { - return String(localized: "Needs the full encrypted bond: close the official WHOOP app and pair the strap to NOOP first (a live-HR-only link can't carry a config write).") - } - if !ecgVariantIsMG { - // A nil / non-MG variant lands here too, and deliberately: an unattested strap is not an MG. - return String(localized: "Waiting for your strap to identify itself as an MG. Only a WHOOP MG has ECG electrodes, so NOOP won't write this key to anything else.") - } - return String(localized: "One tap writes the key; NOOP then reads it back off the strap and reports the value it actually stores — the write's own \"success\" is not treated as proof.") - #endif - } - - /// Icon per read-back verdict. Only a confirmed read-back gets the success mark. - private func ecgGateIcon(_ v: EcgRawDataGateReport.Verdict) -> String { - switch v { - case .confirmed: return "checkmark.seal.fill" - case .unchanged: return "xmark.seal.fill" - case .pending: return "ellipsis" - default: return "questionmark.circle" - } - } - - /// Tint per read-back verdict. Anything that isn't a confirmed read-back is never shown as positive. - private func ecgGateTint(_ v: EcgRawDataGateReport.Verdict) -> Color { - switch v { - case .confirmed: return StrandPalette.statusPositive - case .unchanged: return StrandPalette.statusWarning - default: return StrandPalette.textSecondary - } - } - - /// WHOOP MG ECG ("Labrador") experiment. Unlocks the gated, user-initiated ECG probe on the Devices - /// card. Default off; with it off the four ECG opcodes are dropped by the command allowlist, so no - /// ECG byte can reach a strap. See [PuffinExperiment.ecgKey]. - @AppStorage(PuffinExperiment.ecgKey) private var ecgEnabled = false - /// Opt-in "Continuous HRV capture" (off by default) — holds the dense realtime stream armed 24/7 so /// the strap banks beat-to-beat R-R for better overnight HRV/recovery/sleep, at a battery cost. /// See [PuffinExperiment.keepRealtimeForDataKey]. @@ -273,21 +197,12 @@ struct SettingsView: View { private var distanceSystemBinding: Binding { Binding(get: { distanceUnitSystem.rawValue }, set: { distanceSystemRaw = $0 }) } - private var temperatureUnit: TemperatureUnit { - UnitPrefs.resolveTemperature(system: unitSystem, override: temperatureRaw) - } /// Raw-sensor CSV export (experimental diagnostic, #308/#276/#322). Holds the last-written file so /// macOS can "Reveal in Finder" after a share, mirroring the puffin-capture export. @State private var rawCsvBusy = false @State private var lastRawCsvURL: URL? - /// #646/#651: the "Export raw + log" button's zip build now runs off the main actor, so a second tap - /// mid-export would fire a second `exportPair` — two staged zips, two save panels / stacked share - /// sheets (see the `present(activityItems:)` #455 comment). Same disable-while-busy guard as - /// `rawCsvBusy` above. - @State private var rawAndLogBusy = false - /// Passive WHOOP 5/MG optical experiment: the picker writes local timestamp markers into the /// durable deep-buffer JSONL. It never calls a BLE write path. @State private var showOpticalPhasePicker = false @@ -2018,390 +1933,6 @@ struct SettingsView: View { } } - /// The R22 "send enable sequence" button is structurally impossible on macOS — a Mac can't form the - /// encrypted bond a 5/MG needs to accept the write (BLEManager forms a live-HR-only link there), so it - /// stays disabled regardless of bond/wear state (#587). On iOS/Android it gates on the real bond + wear. - private var deepDataButtonDisabled: Bool { - #if os(macOS) - return true - #else - return !live.encryptedBond || !live.worn - #endif - } - - /// The reason line under the R22 button. macOS gets an explicit "needs an iPhone/Android" message - /// rather than the misleading "needs the full encrypted bond" one (a Mac can never get that bond). - private var deepDataButtonReason: String { - #if os(macOS) - return String(localized: "Deep data (R22) needs an iPhone or Android. A Mac can't form the encrypted bond a 5/MG requires.") - #else - if !live.encryptedBond { - return String(localized: "Needs the full encrypted bond: close the official WHOOP app and pair the strap to NOOP first (a live-HR-only link can't carry the unlock).") - } - return live.worn - ? String(localized: "Wear the strap, tap once, then let it sync and share your strap log.") - : String(localized: "Put the strap on first. The deep stream is on-wrist only.") - #endif - } - - /// How many flags the enable sequence actually writes. Read from the sequence rather than restated, so - /// the card cannot drift from it again — it said "15" for the whole life of the 16-flag sequence (#174). - private var r22FlagCount: Int { Whoop5Config.enableR22Sequence.count } - - /// The disable button is gated like the enable one MINUS the wear check: the on-wrist requirement exists - /// because the R22 *stream* is on-wrist gated, and nothing about clearing a stored flag needs the strap - /// worn. Making a user strap it back on to turn a feature off would be the wrong trade. - private var deepDataDisableButtonDisabled: Bool { - #if os(macOS) - return true - #else - return !live.encryptedBond || live.r22DisableReport == BLEManager.deviceConfigProbeWaiting - #endif - } - - /// The reason line under the disable button. Says what the run will do and, crucially, what it can and - /// cannot establish — the off value is inferred from the sibling namespace, so the read-back is the - /// evidence, and a cleared flag is still not the same as observed behaviour reverting. - private var deepDataDisableButtonReason: String { - #if os(macOS) - return String(localized: "Turning deep data back off needs an iPhone or Android. A Mac can't form the encrypted bond a 5/MG requires.") - #else - if !live.encryptedBond { - return String(localized: "Needs the full encrypted bond: close the official WHOOP app and pair the strap to NOOP first (a live-HR-only link can't carry the write).") - } - return String(localized: "Writes the off value to all 16 flags and reads each one back, so you see what the strap stores rather than just that it acked. The off value is inferred from the flag NOOP already turns off this way for Garmin broadcast — it has never been seen on an R22 flag, so NOOP tries one flag first and stops if the strap refuses it. Clearing the flags is not the same as watching the deep records stop: check that by syncing afterwards.") - #endif - } - - private var fiveMGCard: some View { - SettingsSection( - icon: "flask.fill", - title: "Experimental · WHOOP 5 / MG", - blurb: "Normal WHOOP 5/MG recording and history sync are supported. These remaining controls are developer experiments for unmapped protocol features and now live in Test Centre." - ) { - VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { - Toggle(isOn: $puffinExperiments) { - Text("Try WHOOP 5/MG protocol probes") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - } - .toggleStyle(.switch) - .tint(StrandPalette.accent) - Text("On a 5/MG connection NOOP will send a puffin realtime-stream request after the handshake, and log what comes back. If you have a 5/MG strap, turning this on and sharing your strap log helps map the protocol. No effect on WHOOP 4.0.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - - rowDivider - - // MARK: R22 deep-data unlock — the one probe that writes to the strap. - Toggle(isOn: $deepDataEnabled) { - Text("Unlock WHOOP 5/MG deep data (R22)") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - } - .toggleStyle(.switch) - .tint(StrandPalette.accent) - // #174: turning the switch OFF used to write nothing — it only hid the enable button, so the - // strap kept every flag the enable sequence set while the UI implied it had been undone. - // Now it offers the real undo. Turning it ON still writes nothing until the button is tapped. - .onChangeCompat(of: deepDataEnabled) { on in if !on { confirmingDeepDataDisable = true } } - Text("Legacy R22 feature-flag experiment. The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not required for normal WHOOP 5/MG support or for the Raw Data Collector. It writes persistent strap settings and may do nothing on your firmware.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - - if deepDataEnabled { - NoopButton("Send enable sequence to strap", systemImage: "bolt.badge.automatic", kind: .primary) { - model.ble.enableWhoop5DeepData() - } - .disabled(deepDataButtonDisabled) - Text(deepDataButtonReason) - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - - // #174: the undo. Offered whenever the flags may be set — which is any time the opt-in - // has been on, not only right after a send, because the flags persist across launches - // and the app has no record of what a previous install wrote. - NoopButton("Turn deep data back off", systemImage: "bolt.slash", kind: .secondary) { - model.ble.disableWhoop5DeepData() - } - .disabled(deepDataDisableButtonDisabled) - Text(deepDataDisableButtonReason) - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - - // Live R22 telemetry (#174): proof of what the strap is doing right now. - // The threshold and the number are both driven off the sequence itself — they were - // hardcoded to 15 while the sequence carried 16, so the card declared success one flag - // early and named a count that had already drifted. - if live.r22FlagsAccepted > 0 { - Label(live.r22FlagsAccepted >= r22FlagCount - ? "Strap accepted all \(r22FlagCount) R22 flags" - : "Strap accepted \(live.r22FlagsAccepted)/\(r22FlagCount) R22 flags…", - systemImage: live.r22FlagsAccepted >= r22FlagCount ? "checkmark.seal.fill" : "ellipsis") - .font(StrandFont.caption) - .foregroundStyle(live.r22FlagsAccepted >= r22FlagCount ? StrandPalette.statusPositive : StrandPalette.textSecondary) - } - if live.deepPacketsThisSession > 0 { - Label(live.deepPacketsThisSession == 1 - ? "1 type-0x2F historical-offload frame seen outside our sync. These are history (e.g. another app pulling the strap's backlog), not a live R22 stream (#494)." - : "\(live.deepPacketsThisSession) type-0x2F historical-offload frames seen outside our sync. These are history (e.g. another app pulling the strap's backlog), not a live R22 stream (#494).", - systemImage: "clock.arrow.circlepath") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textSecondary) - } else if live.r22FlagsAccepted >= r22FlagCount { - Text("Flags accepted, but the enable sequence doesn't start a separate live stream. The deep records arrive as part of the normal history sync (#494).") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - } - - } - - // #174: the disable run's per-key result. Shown verbatim because the interesting part is - // the read-back table, not a green tick — a write that acked SUCCESS but did not move - // the stored value renders here as "unchanged", which is the case worth seeing. - // - // OUTSIDE the `if deepDataEnabled` block on purpose. The commonest way to reach a disable - // run is flipping the switch OFF and confirming, which means the pref is already false while - // the run is walking its plan — so nesting this inside that block hid the progress line and - // the whole read-back table for exactly the run a user is most likely to start. The report - // is about what is on the STRAP, which outlives the app's opt-in: it stays legible (and - // dismissable) whatever the switch says. - if let report = live.r22DisableReport { - if report == BLEManager.deviceConfigProbeWaiting { - Label("Clearing R22 flags and reading each one back\u{2026}", systemImage: "ellipsis") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textSecondary) - } else { - Text(report) - .font(StrandFont.caption.monospaced()) - .foregroundStyle(StrandPalette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - .textSelection(.enabled) - NoopButton("Dismiss disable report", systemImage: "xmark", kind: .secondary) { - model.ble.clearR22DisableReport() - } - } - } - - rowDivider - - // MARK: Broadcast HR — make the strap a standard BLE HR sensor (Garmin/Zwift/gym). - Toggle(isOn: $broadcastHrEnabled) { - Text("Broadcast strap HR (Garmin/ANT)") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - } - .toggleStyle(.switch) - .tint(StrandPalette.accent) - .onChangeCompat(of: broadcastHrEnabled) { on in model.ble.setBroadcastHr(on) } - Text("Makes your WHOOP 5.0/MG advertise its heart rate as a standard Bluetooth HR sensor, so a Garmin (Edge/watch), Zwift or gym equipment can use it during a workout. Applied on the next connection (and immediately if connected); writes the strap's whoop_live_hr_in_adv_ind_pkt flag. Reversible. iPhone-side only. A Mac can't write to a 5/MG.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - - // #573: leaving broadcast on keeps the strap radio advertising continuously, which drains - // the strap faster — make that visible and persistent so it isn't left on by accident. - if broadcastHrEnabled { - HStack(alignment: .top, spacing: 8) { - Image(systemName: "antenna.radiowaves.left.and.right") - .foregroundStyle(StrandPalette.statusWarning) - .accessibilityHidden(true) - Text("Broadcast HR is ON. Your strap is advertising its heart rate continuously, which keeps its radio hot and drains the battery faster. Turn it off when you're not using it with another device.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.statusWarning) - .fixedSize(horizontal: false, vertical: true) - } - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) - } - - // MARK: #463 Personal daytime-stress baseline — score today's timeline vs a personal - // cross-day baseline instead of the day's own calm hours. Off by default. - Divider().overlay(StrandPalette.hairline) - - Toggle(isOn: $stressPersonalBaselineEnabled) { - Text("Stress: personal daytime baseline") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - } - .toggleStyle(.switch) - .tint(StrandPalette.accent) - Text("Scores your hour-by-hour stress timeline against YOUR own cross-day baseline (how your days usually run, Oura-style) instead of the day's own calm hours. Needs a few worn days; until then it stays on the default. The high-stress cutoff is tuned from a single-subject reference so far, so it's an alternative lens rather than the default. HR-only, and it never feeds recovery or illness scoring. Off by default.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - - - // MARK: #891 ECG raw-data gate — the second device-config key this app may write, MG-only. - Divider().overlay(StrandPalette.hairline) - - Toggle(isOn: $ecgRawDataEnabled) { - Text("ECG raw-data gate (WHOOP MG only)") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - } - .toggleStyle(.switch) - .tint(StrandPalette.accent) - Text("Your strap listed its own device-config keys, and one of them is enable_raw_data_w_ecg. On an MG with no ECG subscription it reads '0' — while all three ECG commands answer SUCCESS and send no data at all (#891). This is the leading guess for what's holding ECG shut. Nobody knows whether flipping it actually produces ECG data: finding out is the point, and \"still nothing\" is a useful answer worth posting to #891.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - - if ecgRawDataEnabled { - HStack(alignment: .top, spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(StrandPalette.statusWarning) - .accessibilityHidden(true) - Text("This writes a setting that STAYS ON YOUR STRAP until you change it back — it isn't an app preference, and closing NOOP won't undo it. \"Turn gate off\" below writes '0' again, in one tap. Only this one key is ever written; the other six your strap listed are never touched.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.statusWarning) - .fixedSize(horizontal: false, vertical: true) - } - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) - - NoopButton("Turn gate on (write '1')", systemImage: "waveform.path.ecg", kind: .primary) { - model.ble.setEcgRawDataGate(true) - } - .disabled(!ecgGateReady) - NoopButton("Turn gate off (write '0')", systemImage: "arrow.uturn.backward", kind: .secondary) { - model.ble.setEcgRawDataGate(false) - } - .disabled(!ecgGateReady) - Text(ecgGateReason) - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - - // The read-back — the ONLY thing reported as a result. The write's own ack is in the - // strap log for the record and is deliberately not surfaced as an outcome here. - if let report = live.ecgRawDataGate { - Label(report.summary, systemImage: ecgGateIcon(report.verdict)) - .font(StrandFont.caption) - .foregroundStyle(ecgGateTint(report.verdict)) - .fixedSize(horizontal: false, vertical: true) - } - } - - Divider().overlay(StrandPalette.hairline) - - // MARK: WHOOP MG ECG (Labrador) — MG-only, writes ECG control commands. NOT medical. - Toggle(isOn: $ecgEnabled) { - Text("WHOOP MG ECG capture (experimental)") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - } - .toggleStyle(.switch) - .tint(StrandPalette.accent) - // Turning the switch off also tells the strap to stop, so a stream can't be left running - // by a user who simply flips the toggle back. `ecgStopCapture` is deliberately reachable - // with the opt-in already off (see BLEManager.ecgStopOverride). When the strap isn't a - // connected MG the send can't happen — the Devices "Stop" control then stays offered via - // `ecgMayBeRunning` so there is still a route once the link is back. - // `reportsResult: false`: switching a setting off must not pop the Devices result sheet. - .onChangeCompat(of: ecgEnabled) { on in if !on { model.ecgStopCapture(reportsResult: false) } } - Text("The WHOOP MG has ECG electrodes in its clasp. This unlocks a gated, hand-run probe on the Devices screen that asks the strap to start its ECG subsystem and logs whatever comes back. MG only — a plain WHOOP 5.0 has no electrodes, and NOOP will refuse to send unless your strap identifies itself as an MG. Nobody has confirmed a strap honours these commands, so it may simply do nothing. Turn on “Record puffin frames to a file” below first if you want a complete byte-level capture to share.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - - if ecgEnabled { - HStack(alignment: .top, spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(StrandPalette.statusWarning) - .accessibilityHidden(true) - Text("NOOP is not a medical device and this is not an ECG test. Anything the strap reports here — including any heart-rhythm classification it happens to send — is unvalidated instrumentation for protocol research, not a measurement and not a diagnosis. Never use it to make a decision about your health. If you have symptoms or are worried about your heart, talk to a doctor.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.statusWarning) - .fixedSize(horizontal: false, vertical: true) - } - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) - } - - Toggle(isOn: $puffinCapture) { - Text("Record puffin frames to a file") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - } - .toggleStyle(.switch) - .tint(StrandPalette.accent) - Text("Saves every raw 5/MG frame (with a timestamp and the live heart rate) to a JSON file you can share to help map the biometric layout. This only records frames the strap already sent (it never writes to your strap), so it is safe to leave on. Export the file and attach it to a protocol-mapping issue.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - - if puffinCapture { - rowDivider - Text("Optical block experiment") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - Text("Mark the start of each physical phase while wearing or handling the strap. NOOP aligns the marker to the timestamp inside delayed history buffers, then the offline analyzer compares block activation, header bytes and raw ADC changes. It does not assume a wavelength or calculate SpO₂/BP.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - HStack(spacing: NoopMetrics.space3) { - NoopButton("Mark phase…", systemImage: "flag.fill", kind: .primary) { - showOpticalPhasePicker = true - } - NoopButton("Export experiment…", systemImage: "square.and.arrow.up", kind: .secondary) { - exportOpticalExperiment() - } - Spacer(minLength: 0) - } - if !opticalPhaseStatus.isEmpty { - Text(opticalPhaseStatus) - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textSecondary) - } - } - - if live.puffinCaptureCount > 0 { - Text(live.puffinCaptureCount == 1 - ? "1 frame captured this session." - : "\(live.puffinCaptureCount) frames captured this session.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textSecondary) - HStack(spacing: NoopMetrics.space3) { - NoopButton("Export frames…", systemImage: "square.and.arrow.up", kind: .primary) { - exportPuffinCaptures() - } - - #if os(macOS) - NoopButton("Reveal in Finder", systemImage: "folder", kind: .secondary) { - revealPuffinCaptures() - } - #endif - Spacer(minLength: 0) - } - // One-tap "matched pair" export (#510): hands a reporter BOTH the raw capture file - // and the strap log together (timestamped, same minute) so a protocol-mapping issue - // arrives with the frames AND the context that produced them. - Button { - exportRawAndLog() - } label: { - if rawAndLogBusy { - HStack(spacing: NoopMetrics.space1 + 2) { - ProgressView().controlSize(.small) - Text("Exporting…") - } - } else { - Label("Export raw + log", systemImage: "square.and.arrow.up.on.square") - } - } - .buttonStyle(NoopButtonStyle(.secondary)) - .disabled(rawAndLogBusy) - Text("Saves the raw capture and the strap log together as a matched pair. Attach both to a protocol-mapping issue.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - } - /// SpO2 candidate display (#103/queue-11a) — split out of `fiveMGCard` (2026-08-23): the toggle's /// own copy has covered Oura since `89c8533b` ("Blood Oxygen: strap estimate (WHOOP 5/MG, Oura)"), /// but it stayed nested inside the WHOOP-5/MG-only card, gated by `showFiveMGControls` — so an @@ -2545,36 +2076,6 @@ struct SettingsView: View { } } - /// Flush the in-flight capture, then copy it to a user-chosen location (save panel on macOS) or - /// hand it to the system share sheet (iOS). - private func exportPuffinCaptures() { - Task { @MainActor in - await model.ble.flushPuffinCaptures() - guard let src = live.puffinCaptureURL else { return } - // Suggest a friendly, timestamped name so a reporter saving several captures gets sortable, - // non-colliding files (#510) — e.g. noop-raw-capture-260617-1042.json. - let suggested = FileExport.timestampedName("noop-raw-capture", ext: "json") - #if os(macOS) - let panel = NSSavePanel() - panel.allowedContentTypes = [.json] - panel.nameFieldStringValue = suggested - panel.canCreateDirectories = true - guard panel.runModal() == .OK, let dest = panel.url else { return } - let fm = FileManager.default - do { - if fm.fileExists(atPath: dest.path) { try fm.removeItem(at: dest) } - try fm.copyItem(at: src, to: dest) - } catch { - backupAlertTitle = String(localized: "Export failed") - backupAlertMessage = error.localizedDescription - showBackupAlert = true - } - #else - FileExport.exportFile(at: src, suggestedName: suggested) - #endif - } - } - private func markOpticalPhase(_ phase: PuffinOpticalExperimentPhase) { if model.ble.markWhoop5OpticalPhase(phase) { opticalPhaseStatus = String(localized: "Marked: \(phase.displayName)") @@ -2583,61 +2084,7 @@ struct SettingsView: View { } } - /// Export the durable JSONL used by the optical comparison CLI. Closing its append handle first - /// makes the user-selected copy complete; logging reopens lazily on the next buffer or marker. - private func exportOpticalExperiment() { - guard let src = model.ble.whoop5OpticalExperimentURL() else { - backupAlertTitle = String(localized: "Nothing to export") - backupAlertMessage = String(localized: "No WHOOP 5/MG deep buffers or phase markers have been recorded yet.") - showBackupAlert = true - return - } - FileExport.exportFile( - at: src, - suggestedName: FileExport.timestampedName("noop-whoop5-optical-experiment", ext: "jsonl")) - } - - /// One-tap matched-pair export (#510): export the raw puffin capture AND the strap log together, - /// both stamped with the same `yyMMdd-HHmm` minute so they're obviously a pair. Reuses the existing - /// export utilities — `FileExport.exportPair` shares both files in one iOS share sheet, and saves - /// each via its own NSSavePanel on macOS (no new file plumbing). - /// - /// #646/#651: `exportPair` is now async (its file read + zip build run off the main actor), so this - /// launches it in a `Task` — the button action itself stays synchronous from the caller's perspective. - /// `rawAndLogBusy` disables the button for the duration: without it a second tap mid-export fires a - /// second `exportPair` (two staged zips, two save panels / stacked share sheets). - private func exportRawAndLog() { - rawAndLogBusy = true - Task { @MainActor in - // `defer` so the flag is cleared on ANY exit (#961 follow-up), including cancellation. It - // cleared correctly before, but only because `exportPair` is non-throwing — the guard should - // not depend on that. Otherwise the button stays disabled behind a spinner that never stops. - defer { rawAndLogBusy = false } - // #652: `flushPuffinCaptures` is async now (encode+write moved off the main actor), so await - // it here — the file must be current before we read `puffinCaptureURL` to export it. - await model.ble.flushPuffinCaptures() - guard let capture = live.puffinCaptureURL else { - backupAlertTitle = String(localized: "Nothing to export") - backupAlertMessage = String(localized: "No raw capture has been recorded yet this session.") - showBackupAlert = true - return - } - let stamp = FileExport.timestamp() - await FileExport.exportPair( - file: capture, fileSuggestedName: "noop-raw-capture-\(stamp).json", - text: live.exportableLogText(), textSuggestedName: "noop-strap-log-\(stamp).txt") - } - } - #if os(macOS) - /// Flush, then reveal the capture file in Finder so the user can grab it directly. - private func revealPuffinCaptures() { - Task { @MainActor in - await model.ble.flushPuffinCaptures() - guard let url = live.puffinCaptureURL else { return } - NSWorkspace.shared.activateFileViewerSelecting([url]) - } - } #endif private var backupCard: some View { diff --git a/Strand/Screens/SleepView.swift b/Strand/Screens/SleepView.swift index a0cb7d0034..598051b980 100644 --- a/Strand/Screens/SleepView.swift +++ b/Strand/Screens/SleepView.swift @@ -2224,14 +2224,6 @@ struct SleepView: View { return stages.total > 0 ? (stages, intervals) : nil } - /// yyyy-MM-dd → Date (en_US_POSIX, UTC), per task spec. - private static let dayParser: DateFormatter = { - let f = DateFormatter() - f.locale = Locale(identifier: "en_US_POSIX") - f.timeZone = TimeZone(identifier: "UTC") - f.dateFormat = "yyyy-MM-dd" - return f - }() } /// Original atmospheric night hero — photographic moonlit lake plus lightweight static depth layers. diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index 0fd6604e49..f218821308 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -1711,69 +1711,6 @@ struct TodayView: View { // MARK: First-run scoring-guide card (one-time, dismissible) - /// "New here?", a single, dismissible card that points first-time users at the guide. Tapping the - /// card opens the guide; the ✕ closes it. Either action sets `scoringGuideCardSeen`, so it shows - /// once and never again. Follows the in-flow, never-modal card pattern. - private var scoringGuideFirstRunCard: some View { - NoopCard { - HStack(alignment: .top, spacing: 14) { - Image(systemName: "sparkles") - .font(.system(size: 18)) - .foregroundStyle(StrandPalette.accent) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 4) { - Text("New here?") - .font(StrandFont.headline) - .foregroundStyle(StrandPalette.textPrimary) - Text("See how Charge, Effort and Rest are calculated, and how they differ from WHOOP.") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - Button { - scoringGuideCardSeen = true - showGuideTop = true - } label: { - Label("How your scores work", systemImage: "arrow.right") - .font(StrandFont.subhead) - } - .buttonStyle(.plain) - .foregroundStyle(StrandPalette.accent) - .padding(.top, 2) - } - Spacer(minLength: 0) - Button { - // Dismiss INTO the Updates inbox (restorable), rather than permanently hiding. - withAnimation(StrandMotion.interactive) { - dismissTodayCard( - id: "newHere", - title: String(localized: "New here?"), - message: String(localized: "How Charge, Effort and Rest are calculated, and how they differ from WHOOP.") - ) - } - } label: { - Image(systemName: "xmark") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(StrandPalette.textTertiary) - .padding(6) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel("Dismiss") - } - // The whole card is tappable as the primary action; the ✕ stops the tap from also firing. - .contentShape(Rectangle()) - .onTapGesture { - #if os(iOS) - StrandHaptic.selection.play() - #endif - scoringGuideCardSeen = true - showGuideTop = true - } - } - // Press-down feedback for the tappable card surface. - .strandPressable() - } - // MARK: Readiness, on-device training-readiness synthesis (HRV / resting-HR / load). /// S4: Readiness now lives behind the Charge-ring tap (in `chargeBreakdownSheet`), not as a standalone @@ -3168,14 +3105,6 @@ struct TodayView: View { return hrvInsightDetail(d, score: score) } - /// The Synthesis status colour, keyed on the carried prior recovery when carrying, else today's. - private func synthesisCardColor(score: Double?) -> Color { - if let rec = lastScoredRecoveryDay?.recovery { - return StrandPalette.recoveryColor(rec) - } - return score.map { StrandPalette.recoveryColor($0) } ?? StrandPalette.textTertiary - } - /// Screen-4 insight headline, when the HRV baseline is established, the gold "primed" read /// keyed on how far today's HRV sits above/below the learned baseline ("HRV 12% over baseline"); /// otherwise the recovery-state word. Purely a re-presentation of the existing recovery + HRV @@ -5276,27 +5205,6 @@ struct TodayView: View { } } - private var dateLine: String { - // The selected day's date when navigated; today's banked-row date (or today) at offset 0. - if selectedDayOffset == 0, let day = repo.today?.day, let date = Self.dayParser.date(from: day) { - return date.formatted( - .dateTime.weekday(.wide).day().month(.wide).locale(AppLanguage.activeLocale) - ) - } - return selectedLogicalDay.formatted( - .dateTime.weekday(.wide).day().month(.wide).locale(AppLanguage.activeLocale) - ) - } - - /// Hero title that names the selected day, "Today's"/"Yesterday's"/"Day's" Synthesis. - private var synthesisTitle: LocalizedStringKey { - switch selectedDayOffset { - case 0: return "Today’s Synthesis" - case 1: return "Yesterday’s Synthesis" - default: return "Synthesis" - } - } - /// Section overline naming the selected day, "Today"/"Yesterday"/"EEE d MMM". private var selectedDayOverline: String { switch selectedDayOffset { @@ -5357,12 +5265,6 @@ struct TodayView: View { } } - private func ringSupporting(_ d: DailyMetric?) -> String { - let hrv = d?.avgHrv.map { String(localized: "\(Int($0.rounded())) ms") } ?? " - ms" - let rhr = d?.restingHr.map { "\($0)" } ?? "—" - return String(localized: "HRV \(hrv) · RHR \(rhr)") - } - private func sleepValue(_ d: DailyMetric?) -> String { guard let m = d?.totalSleepMin else { return "—" } let h = Int(m) / 60, mm = Int(m) % 60 diff --git a/Strand/Screens/TrendsView.swift b/Strand/Screens/TrendsView.swift index 4876863ec6..8f68bb601b 100644 --- a/Strand/Screens/TrendsView.swift +++ b/Strand/Screens/TrendsView.swift @@ -83,12 +83,6 @@ struct TrendsView: View { // MARK: Window selection (relative to the LATEST day, with auto-expand) - /// The latest recorded day across all history (anchors every window). - private var latestDay: Date? { - guard let d = repo.days.last?.day else { return nil } - return date(d) - } - /// Days for a given range, taken RELATIVE TO TODAY (the phone's local date) — not the latest /// recorded day, which on a stale import anchored W/M/3M to months-old data so it looked current /// (issue #23). Empty short windows auto-widen (see `resolve`), so old imports surface under a diff --git a/Strand/Screens/WeeklyDigestView.swift b/Strand/Screens/WeeklyDigestView.swift index c64962391d..10d97e777f 100644 --- a/Strand/Screens/WeeklyDigestView.swift +++ b/Strand/Screens/WeeklyDigestView.swift @@ -146,8 +146,6 @@ struct WeeklyDigestContent: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize #endif - /// Display order: the two daily scores first, then the nightly signals. - private static let order: [WeeklyMetric] = [.charge, .effort, .rest, .hrv, .rhr] /// The three headline 0–100 scores shown as domain summaries. private static let scoreOrder: [WeeklyMetric] = [.charge, .effort, .rest]