Hide empty tabs, badge the app icon, and notify about data issues (Where)#65
Conversation
…ere) Hide the Resolve and Elsewhere tabs when they have no content, but only once the user navigates away — while a tab is selected it stays put so resolving the last issue never bounces you off it. The app icon badge now folds the current-year data-issue count into the missing-day backlog, and a new local notification (gated by an issueAlertsEnabled preference, with a Settings toggle) nags about unresolved issues, mirroring the daily-summary scheduler/reconciler pair. DayJournal now invalidates the scanner and re-reconciles the badge and alert after every write, including dismiss/restore, which previously committed without re-badging. Co-authored-by: Cursor <cursoragent@cursor.com>
| /// view-model tests that need a controller without touching | ||
| /// `UNUserNotificationCenter`. Reports unauthorized so the UI's "denied" | ||
| /// affordances stay exercisable. | ||
| public struct NoopDataIssueAlertScheduler: DataIssueAlertScheduling { |
There was a problem hiding this comment.
I am once again asking for this to be @_spi(Testing)'d
There was a problem hiding this comment.
Done — it's now @_spi(Testing) behind #if DEBUG, matching the ScriptedStoreRemoteChangeSource testing-hook convention (it's only used by tests, never previews — PreviewSupport takes the production default). Consumers now @_spi(Testing) import WhereCore. (3492a3a)
| } | ||
| } | ||
|
|
||
| public func reconcile(enabled: Bool, time: ReminderTime, body: String) async { |
There was a problem hiding this comment.
Documented the concrete reconcile(...) — the disable/authorization-clear and the leave-correct-else-rebuild (incl. stray-duplicate) behavior. (3492a3a)
|
|
||
| /// Count of unresolved issues for `year`, for headless callers (the app-icon | ||
| /// badge, the issue-alert notification) that don't have the UI's | ||
| /// `RegionRanking` on hand. Derives `primaryRegions` from the year's own |
There was a problem hiding this comment.
Does this mean we're duplicating logic at all?
There was a problem hiding this comment.
No ranking logic is duplicated: the "primary" rule lives in one place — Region.primaryRegions(in:count:) — and both RegionRanking (the Primary/Elsewhere split) and this headless count call it, so the badge/notification count can't drift from what the Resolve tab shows. I tightened the doc to say this explicitly.
The one thing that is repeated is the report read: currentIssueCount reads the year report to rank regions, and issues(...) reads it again on a cache miss. That's deliberate — the hot badge path (ReminderReconciler.reconcile) already reuses its in-hand report and calls issues(...) with Region.primaryRegions(...) directly to avoid the second read; currentIssueCount is the convenience for the cold notification path only, and the doc now points callers-with-a-report at the direct route. (3492a3a)
…eduler Mark NoopDataIssueAlertScheduler @_spi(Testing) behind #if DEBUG per the testing-hook convention (it's used only by tests, not previews) and import it via @_spi(Testing) where consumed. Document the concrete reconcile(...) on the UserNotification scheduler, and clarify in currentIssueCount's doc that the "primary" rule is the shared Region.primaryRegions helper (also used by RegionRanking), so no ranking logic is duplicated. Co-authored-by: Cursor <cursoragent@cursor.com>
…e-notify Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # Where/WhereCore/Sources/WhereServices.swift # Where/WhereCore/Tests/WhereLogTests.swift
New hideEmptyTabs preference (default true, so the tab bar stays focused out of the box) with a Settings > Tabs toggle. When off, MainTabs keeps both the Elsewhere and Resolve tabs permanently visible instead of hiding them once they have nothing to show. Mirrored as an observable on YearReportModel the way driftThreshold is, so flipping it re-evaluates the tab bar immediately. Co-authored-by: Cursor <cursoragent@cursor.com>
…e-notify Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # Where/WhereCore/Sources/Resources/Localizable.xcstrings # Where/WhereCore/Tests/WhereLogTests.swift
Summary
Makes the tab bar and app-icon badge reflect "things that need you," and adds a notification for unresolved data issues — plus a setting to keep the conditional tabs always visible.
MainTabs.swift): theTabViewuses aselectionbinding +TabIDenum. Elsewhere and Resolve appear when they have content or are the currently selected tab, so resolving the last issue (or emptying Elsewhere) never bounces you off the tab you're on — the "All clear" state stays visible and the tab drops out the next time you switch tabs.hideEmptyTabspreference (mirrored observably onYearReportModel, likedriftThreshold) with a Settings → Tabs toggle. Turning it off keeps the Elsewhere and Resolve tabs permanently in the tab bar instead of hiding them when empty.ReminderReconciler+LoggingReminderSchedulerdisabled-branch now applies the passed badge instead of clearing to 0). Per design this may double-count missing days, which appear in both.DataIssueAlertScheduling+UserNotificationDataIssueAlertScheduler+NoopDataIssueAlertScheduler(SPI-gated, test-only), driven byDataIssueAlertReconciler.issueAlertsEnabledpreference (default on) gating both the notification and the data-issue portion of the badge, independent ofremindersEnabled, with a Settings toggle (RemindersSettingsModel,SettingsView, strings + xcstrings) and a launch/foreground config step (WhereSession,WhereLaunch).DataIssueScanner.currentIssueCount(...)derives primary regions internally via the sharedRegion.primaryRegions(in:count:)helper thatRegionRankingalso reuses, so the headless count matches the Resolve tab's ranking (no ranking logic is duplicated; only the cold notification path re-reads the report — the hot badge path inReminderReconcilerreuses its in-hand report).DayJournalnow invalidates the scanner and re-reconciles both the badge and the alert after every write, includingdismissIssue/restoreIssue(which previously committed without re-badging).Test plan
WhereCoreTests— newDataIssueAlertReconcilerTestsandUserNotificationDataIssueAlertSchedulerTests; updatedReminderReconcilerTests(combined badge + reminders-off badge),DayJournalTests,WhereServicesTests,WhereLogTests.WhereUITests— newhideEmptyTabsDefaultsOnAndPersistsAcrossModels; updatedRemindersSettingsModelTests(issue-alert toggle),WhereLaunchTests(new launch step), plusNoopDataIssueAlertSchedulerinjected across session/reset/tracking tests.WhereCoreTests+WhereUITestsbundles pass on the iOS 17 simulator viatuist test Stuff-iOS-Tests.Notes for reviewers
MissingDaysDetectorflags the whole elapsed current year regardless of primary regions — so an empty/cleared store mid-year still has a missing-days issue. They now use genuinely-zero-issue scenarios (an empty store at the very start of the year, and a resolve-by-dismissing-every-issue flow).NoopDataIssueAlertScheduleris@_spi(Testing)behind#if DEBUG(per the testing-hook convention), the concretereconcile(...)is documented, and thecurrentIssueCountdoc clarifies the shared-helper (non-duplication) design.maintwice along the way (#64manual-entry audit trail,#66RegionKit extraction); theWhereLog.Categorycount now lands at 19, andReminderReconciler'sRegion.primaryRegionsuse picked upimport RegionKit.Made with Cursor