Skip to content

Hide empty tabs, badge the app icon, and notify about data issues (Where)#65

Merged
kyleve merged 5 commits into
mainfrom
where-issue-tabs-badge-notify
Jul 8, 2026
Merged

Hide empty tabs, badge the app icon, and notify about data issues (Where)#65
kyleve merged 5 commits into
mainfrom
where-issue-tabs-badge-notify

Conversation

@kyleve

@kyleve kyleve commented Jul 6, 2026

Copy link
Copy Markdown
Owner

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.

  • Conditional tabs (MainTabs.swift): the TabView uses a selection binding + TabID enum. 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.
  • "Hide empty tabs" setting (default on): a new hideEmptyTabs preference (mirrored observably on YearReportModel, like driftThreshold) 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.
  • App-icon badge now = missing-day backlog (when reminders are enabled) + current-year data-issue count (when issue alerts are enabled). Issues badge the icon even when logging reminders are off (ReminderReconciler + LoggingReminderScheduler disabled-branch now applies the passed badge instead of clearing to 0). Per design this may double-count missing days, which appear in both.
  • New local notification for unresolved data issues, fired at the evening reminder time and cleared when the count hits 0 — a mirror of the daily-summary trio: DataIssueAlertScheduling + UserNotificationDataIssueAlertScheduler + NoopDataIssueAlertScheduler (SPI-gated, test-only), driven by DataIssueAlertReconciler.
  • New issueAlertsEnabled preference (default on) gating both the notification and the data-issue portion of the badge, independent of remindersEnabled, with a Settings toggle (RemindersSettingsModel, SettingsView, strings + xcstrings) and a launch/foreground config step (WhereSession, WhereLaunch).
  • Headless issue count: DataIssueScanner.currentIssueCount(...) derives primary regions internally via the shared Region.primaryRegions(in:count:) helper that RegionRanking also 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 in ReminderReconciler reuses its in-hand report).
  • Freshness wiring: DayJournal now invalidates the scanner and re-reconciles both the badge and the alert after every write, including dismissIssue/restoreIssue (which previously committed without re-badging).

Test plan

  • WhereCoreTests — new DataIssueAlertReconcilerTests and UserNotificationDataIssueAlertSchedulerTests; updated ReminderReconcilerTests (combined badge + reminders-off badge), DayJournalTests, WhereServicesTests, WhereLogTests.
  • WhereUITests — new hideEmptyTabsDefaultsOnAndPersistsAcrossModels; updated RemindersSettingsModelTests (issue-alert toggle), WhereLaunchTests (new launch step), plus NoopDataIssueAlertScheduler injected across session/reset/tracking tests.
  • Full WhereCoreTests + WhereUITests bundles pass on the iOS 17 simulator via tuist test Stuff-iOS-Tests.
  • Manual on device/simulator: enable issue alerts, seed an issue → verify the icon badges and the notification is delivered; resolve/dismiss it → verify both clear. Toggle "Hide empty tabs" off → verify Elsewhere/Resolve stay visible when empty.

Notes for reviewers

  • Two "no issues" reconciler tests were rewritten after they exposed that MissingDaysDetector flags 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).
  • Review feedback addressed: NoopDataIssueAlertScheduler is @_spi(Testing) behind #if DEBUG (per the testing-hook convention), the concrete reconcile(...) is documented, and the currentIssueCount doc clarifies the shared-helper (non-duplication) design.
  • Merged latest main twice along the way (#64 manual-entry audit trail, #66 RegionKit extraction); the WhereLog.Category count now lands at 19, and ReminderReconciler's Region.primaryRegions use picked up import RegionKit.

Made with Cursor

…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 {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am once again asking for this to be @_spi(Testing)'d

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Document pls

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean we're duplicating logic at all?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

kyleve and others added 4 commits July 7, 2026 14:50
…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
@kyleve kyleve enabled auto-merge (squash) July 8, 2026 00:14
@kyleve kyleve merged commit 26ac3e3 into main Jul 8, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant