From 2ab8e53e0cfdac4dbbca81ea6dfa076e83012a94 Mon Sep 17 00:00:00 2001 From: Considus Date: Fri, 17 Jul 2026 23:31:53 +0100 Subject: [PATCH] Fix SNOOZED marker on the UIKit timeline (D-058/D-060 regression) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The label lane picks its word from `isSnoozed ? "SNOOZED" : "OVERDUE"`, but the rewritten timeline never wired snooze through: `TimelineReadCell` didn't pass isSnoozed and `UIKitTimeline` never received the snoozed set — so every snoozed overdue Take read "OVERDUE". Snooze isn't derivable from the Take (it lives in `vm.snoozedReminderIDs`), which is exactly why the rebuild dropped it — found while re-anchoring the Feature Register after the M7 merge. Thread `snoozedIDs` from DailiesView → UIKitTimeline → the cells, with its OWN reconfigure trigger: a snooze doesn't change the Take's value, so the value-diff that reconfigures cells couldn't see it (same shape as the month-filter restyle). Diff the set instead. Owner-verified on device: a snoozed Take now reads SNOOZED, and flips the moment it's snoozed without needing another edit to that Take. (The reorder-coverage + register-anchor work from the same local pass is already on main via PR #131 — its `reorderTarget` extraction and test file are byte-identical to what that PR landed, so nothing to add here.) Co-Authored-By: Claude Opus 4.8 --- .../UIKitTimeline/UIKitTimeline.swift | 30 +++++++++++++++++-- Catchlight/UI/Views/DailiesView.swift | 3 ++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Catchlight/UI/Components/UIKitTimeline/UIKitTimeline.swift b/Catchlight/UI/Components/UIKitTimeline/UIKitTimeline.swift index 07fa0a9..f887515 100644 --- a/Catchlight/UI/Components/UIKitTimeline/UIKitTimeline.swift +++ b/Catchlight/UI/Components/UIKitTimeline/UIKitTimeline.swift @@ -35,6 +35,11 @@ struct UIKitTimeline: UIViewControllerRepresentable { var activeMonthKey: String? = nil /// Tap a month divider → filter to that creation month; tap the lit one again → clear. var onToggleMonthFilter: (String) -> Void = { _ in } + /// Takes whose overdue reminder is SNOOZED — their label lane reads "SNOOZED" instead of + /// "OVERDUE" (D-058/D-060). Not derivable from the Take: snooze lives in the view model + /// (`vm.snoozedReminderIDs`), which is exactly why the rebuild lost it (see the VC's + /// `snoozedIDs`). + var snoozedIDs: Set = [] /// Top inset so content clears the pinned heading + Obie zone; bottom for the dock. var topInset: CGFloat = 0 var bottomInset: CGFloat = 0 @@ -67,6 +72,7 @@ struct UIKitTimeline: UIViewControllerRepresentable { vc.cardGap = cardGap vc.activeMonthKey = activeMonthKey vc.onToggleMonthFilter = onToggleMonthFilter + vc.snoozedIDs = snoozedIDs vc.topInset = topInset vc.bottomInset = bottomInset vc.onToggleDone = onToggleDone @@ -86,6 +92,7 @@ struct UIKitTimeline: UIViewControllerRepresentable { vc.cardGap = cardGap vc.activeMonthKey = activeMonthKey vc.onToggleMonthFilter = onToggleMonthFilter + vc.snoozedIDs = snoozedIDs vc.topInset = topInset vc.bottomInset = bottomInset vc.onToggleDone = onToggleDone @@ -110,6 +117,11 @@ struct TimelineReadCell: View { let take: Take let spineX: CGFloat let cardGap: CGFloat + /// Overdue reminder currently SNOOZED → the label lane reads "SNOOZED" not "OVERDUE" + /// (D-058/D-060). It can't be read off the Take — snooze lives in the view model — so it has + /// to be threaded down. The rebuild simply never passed it, and every snoozed Take on the new + /// timeline said OVERDUE (found 2026-07-16 while re-anchoring the Feature Register). + var isSnoozed: Bool = false /// Iris tap → the CGPoint is the Iris centre in WINDOW (global) coords, so the /// host can bloom the Focus-ring fan in place (matches `RootView`'s full-screen /// `ignoresSafeArea` overlay space). Long-press → toggle Obie. Both reuse the @@ -137,7 +149,7 @@ struct TimelineReadCell: View { var body: some View { ZStack(alignment: .topLeading) { - TakeCardSurface(take: take, linksInteractive: false) // card + TakeCardSurface(take: take, isSnoozed: isSnoozed, linksInteractive: false) // card .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .onTapGesture { onTapText(take) } .contextMenu { menuItems } @@ -262,6 +274,8 @@ struct TimelineSwipeCell: View { let take: Take let spineX: CGFloat let cardGap: CGFloat + /// Threaded straight through to `TimelineReadCell` (see its `isSnoozed`). + var isSnoozed: Bool = false @Bindable var swipeState: TimelineSwipeState let onToggleDone: (Take) -> Void let onDelete: (Take) -> Void @@ -291,6 +305,7 @@ struct TimelineSwipeCell: View { centersActionLabel: true // centre glyph/label in the revealed button, not hugged to the screen edge ) { offset in TimelineReadCell(take: take, spineX: spineX, cardGap: cardGap, + isSnoozed: isSnoozed, onTapCircle: onTapCircle, onLongPressCircle: onLongPressCircle, onToggleDone: onToggleDone, onDelete: onDelete, onSetImportant: onSetImportant, onMakeObie: onMakeObie, @@ -384,6 +399,10 @@ final class UIKitTimelineViewController: UIViewController, UIGestureRecognizerDe private var lastSpineX: CGFloat = -1 // density/spine change → reconfigure all private var lastCardGap: CGFloat = -1 private var lastActiveMonthKey: String?? // month-filter change → restyle the dividers + /// Snoozed Take ids (from the host's view model). A snooze does NOT change the `Take`, so the + /// value-diff reconfigure below can't see it — it needs its own trigger, like the month filter. + var snoozedIDs: Set = [] + private var lastSnoozedIDs: Set? private var groupTitles: [String: String] = [:] @objc private func handleBackgroundTap() { onTapBackground() } @@ -446,6 +465,7 @@ final class UIKitTimelineViewController: UIViewController, UIGestureRecognizerDe guard let take = self.takesByID[id] else { return } cell.contentConfiguration = UIHostingConfiguration { TimelineSwipeCell(take: take, spineX: self.spineX, cardGap: self.cardGap, + isSnoozed: self.snoozedIDs.contains(id), swipeState: self.swipeState, onToggleDone: { self.onToggleDone($0) }, onDelete: { self.onDelete($0) }, @@ -549,12 +569,18 @@ final class UIKitTimelineViewController: UIViewController, UIGestureRecognizerDe // an explicit reconfigure or the lit state never appears. let monthFilterChanged = lastActiveMonthKey != .some(activeMonthKey) lastActiveMonthKey = .some(activeMonthKey) + // Snoozing doesn't touch a Take's VALUE (it lives in the view model), so the value-diff + // below would never restyle the card — the lane would keep saying OVERDUE. Diff the SET. + let snoozeChanged = lastSnoozedIDs != nil && lastSnoozedIDs != snoozedIDs + let snoozeAffected = (lastSnoozedIDs ?? []).symmetricDifference(snoozedIDs) + lastSnoozedIDs = snoozedIDs let current = dataSource.snapshot() let toApply: [TimelineRow] if layoutChanged { toApply = current.itemIdentifiers } else { - let changedIDs = Set(takesByID.compactMap { id, take in previousTakes[id] != take ? id : nil }) + var changedIDs = Set(takesByID.compactMap { id, take in previousTakes[id] != take ? id : nil }) + if snoozeChanged { changedIDs.formUnion(snoozeAffected) } toApply = current.itemIdentifiers.filter { row in switch row { case .take(let id): return changedIDs.contains(id) diff --git a/Catchlight/UI/Views/DailiesView.swift b/Catchlight/UI/Views/DailiesView.swift index 9a8f7fe..0d2e4e9 100644 --- a/Catchlight/UI/Views/DailiesView.swift +++ b/Catchlight/UI/Views/DailiesView.swift @@ -968,6 +968,9 @@ struct DailiesView: View { onToggleMonthFilter: { key in withAnimation(.easeInOut(duration: 0.2)) { ui.toggleMonthFilter(key) } }, + // Snoozed overdue Takes read "SNOOZED" rather than "OVERDUE" in the label lane + // (D-058/D-060). Not derivable from the Take, so it has to be handed over. + snoozedIDs: vm.snoozedReminderIDs, topInset: newTimelineTopInset, bottomInset: CatchlightLayout.dockClearance + deviceBottomInset, onToggleDone: { take in