From 2bb3d45161b152c7d24d21bc9d1ada7647e5a91d Mon Sep 17 00:00:00 2001 From: DX23876 <176692557+DX23876@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:21:48 +0200 Subject: [PATCH 1/2] fix(today): start the journal card's load, so the card can appear JournalReminderCard attached its .task to a Group whose only child is the card itself, shown once `loggedDays` is loaded. A Group hands its modifiers to its children, and before the first load there is no child, so the task never ran, `loggedDays` stayed nil and the card never appeared on either Today screen, whatever the reminder toggle said. The content now sits in a VStack that exists whenever the reminder is on, so the load always starts; with the reminder off the card still renders nothing. Reproduced on the iOS simulator with demo data (card missing, then present after the change). Android is not affected: JournalReminderCard there loads in a LaunchedEffect after its early return, which always runs. --- Strand/Screens/JournalReminderCard.swift | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/Strand/Screens/JournalReminderCard.swift b/Strand/Screens/JournalReminderCard.swift index 464d34a095..3d72ae4a03 100644 --- a/Strand/Screens/JournalReminderCard.swift +++ b/Strand/Screens/JournalReminderCard.swift @@ -30,15 +30,20 @@ struct JournalReminderCard: View { private static let stripDays = 7 var body: some View { - Group { - if reminderEnabled, let logged = loggedDays { - card(logged) + if reminderEnabled { + // A VStack, not a Group: a Group hands its modifiers to its children, and before the first load + // there is no child, so the `.task` below never ran, `loggedDays` stayed nil and the card never + // appeared. The stack is a view of its own even while empty, so the load always starts. + VStack(spacing: 0) { + if let logged = loggedDays { + card(logged) + } + } + // Re-read whenever a sync bumps refreshSeq or the toggle flips (mirrors AutoWorkoutCard's task + // id), so the strip and the "logged today" state stay current after the user logs and comes back. + .task(id: JournalReminderLoadKey(seq: repo.refreshSeq, enabled: reminderEnabled)) { + await reload() } - } - // Re-read whenever a sync bumps refreshSeq or the toggle flips (mirrors AutoWorkoutCard's task id), - // so the strip and the "logged today" state stay current after the user logs and comes back. - .task(id: JournalReminderLoadKey(seq: repo.refreshSeq, enabled: reminderEnabled)) { - await reload() } } From 3875c374ae30addc18e4cc4ce94342c11ce60045 Mon Sep 17 00:00:00 2001 From: DX23876 <176692557+DX23876@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:56:46 +0200 Subject: [PATCH 2/2] fix(today): load the cycle card's period starts, so logged history can show it `MenstrualCycleHomeCard` is shown when cycle awareness applies, when the toggle is on, or when period starts are logged. The starts were loaded by a `.task` on a `Group` whose only child is the card, so with awareness off and the toggle off there was no child, the task never ran, `starts` stayed empty and the third condition could never become true. That empty state is the steady state for most users, so a placeholder would hold a slot and its spacing in the Today stack. The load now runs in `PeriodStartsLoader`, the same shape as the auto-detect card's loader. Also drop `JournalReminderLoadKey`: the task only exists while the reminder is on, so its `enabled` field was constant and the seq alone is the id. Analysis migration required: no --- Strand/Screens/JournalReminderCard.swift | 13 +++----- Strand/Screens/SkinTempCardsView.swift | 42 +++++++++++++++++++++--- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/Strand/Screens/JournalReminderCard.swift b/Strand/Screens/JournalReminderCard.swift index 3d72ae4a03..47922b8ccb 100644 --- a/Strand/Screens/JournalReminderCard.swift +++ b/Strand/Screens/JournalReminderCard.swift @@ -39,9 +39,10 @@ struct JournalReminderCard: View { card(logged) } } - // Re-read whenever a sync bumps refreshSeq or the toggle flips (mirrors AutoWorkoutCard's task - // id), so the strip and the "logged today" state stay current after the user logs and comes back. - .task(id: JournalReminderLoadKey(seq: repo.refreshSeq, enabled: reminderEnabled)) { + // Re-read whenever a sync bumps refreshSeq, so the strip and the "logged today" state stay current + // after the user logs and comes back. Flipping the toggle removes and recreates this view, which + // starts a fresh task on its own, so the toggle needs no place in the id. + .task(id: repo.refreshSeq) { await reload() } } @@ -146,9 +147,3 @@ struct JournalReminderCard: View { } } } - -/// Reload key: a sync (seq) or toggle flip re-reads completion. Mirrors `AutoWorkoutLoadKey`. -private struct JournalReminderLoadKey: Equatable { - let seq: Int - let enabled: Bool -} diff --git a/Strand/Screens/SkinTempCardsView.swift b/Strand/Screens/SkinTempCardsView.swift index 7852ad3ca0..243b2f3a7d 100644 --- a/Strand/Screens/SkinTempCardsView.swift +++ b/Strand/Screens/SkinTempCardsView.swift @@ -286,14 +286,25 @@ struct MenstrualCycleHomeCard: View { /// The user's "not for me" opt-out (#hide-cycle): when set, the card is suppressed entirely, /// reversibly from Automations. USER-controlled, never age-based. @AppStorage(AppModel.cycleAwarenessHiddenKey) private var cycleHidden = false - @State private var starts: [String] = [] + /// Logged period starts, loaded outside the view tree (see `PeriodStartsLoader` for why a `.task` here + /// could never start while the card was hidden). + @StateObject private var loader = PeriodStartsLoader() @State private var showingTracker = false + private var starts: [String] { loader.starts } + private var shouldShow: Bool { !cycleHidden && (profile.cycleAwarenessApplies || cycleEnabled || !starts.isEmpty) } var body: some View { + // Re-read whenever a period start is logged or deleted. The body re-runs on that (it observes `repo`), + // and the loader ignores an unchanged seq. + let _ = loader.request(seq: repo.cycleTrackingSeq) { [repo, model, cycleEnabled] in + let starts = await repo.periodStarts() + if cycleEnabled, model.cyclePhase == nil { await model.refreshV5Signals() } + return starts + } Group { if shouldShow { NoopCard(tint: StrandPalette.restColor) { @@ -375,10 +386,6 @@ struct MenstrualCycleHomeCard: View { .accessibilityElement(children: .contain) } } - .task(id: repo.cycleTrackingSeq) { - starts = await repo.periodStarts() - if cycleEnabled, model.cyclePhase == nil { await model.refreshV5Signals() } - } .sheet(isPresented: $showingTracker) { Group { if let result = model.cyclePhase { @@ -430,6 +437,31 @@ struct MenstrualCycleHomeCard: View { } } +/// Loads the logged period starts for `MenstrualCycleHomeCard` outside the view tree. One reason to show +/// the card is that starts exist, and SwiftUI never runs a `.task` attached to a `Group` with no child, so +/// with cycle awareness off and the toggle off the load never started and the card never appeared. That +/// empty state is permanent for most users, so an always-present placeholder would hold a slot in the +/// Today stack and add its spacing; the view only reports the current seq here and this object loads. +@MainActor +final class PeriodStartsLoader: ObservableObject { + @Published private(set) var starts: [String] = [] + private var lastSeq: Int? + private var load: Task? + + /// Called from the card's body on every pass; starts a load only when the seq changed. Publishes + /// later, from the task, never during the view update. + func request(seq: Int, load fetch: @escaping @MainActor () async -> [String]) { + guard seq != lastSeq else { return } + lastSeq = seq + load?.cancel() + load = Task { [weak self] in + let next = await fetch() + guard !Task.isCancelled, let self else { return } + self.starts = next + } + } +} + // MARK: - Cycle tracker detail /// Local-only logged-period detail. It deliberately records only cycle day 1: the engine needs an