Skip to content

Where: Liquid Glass tabbed UI (Primary / Elsewhere / Settings)#9

Merged
kyleve merged 22 commits into
mainfrom
where-swiftui-tabs
May 29, 2026
Merged

Where: Liquid Glass tabbed UI (Primary / Elsewhere / Settings)#9
kyleve merged 22 commits into
mainfrom
where-swiftui-tabs

Conversation

@kyleve
Copy link
Copy Markdown
Owner

@kyleve kyleve commented May 29, 2026

Summary

Turns the WhereUI stub (RootView was just Text("Where")) into a full SwiftUI front-end driven by the existing WhereController, with a Liquid Glass tab bar, a stays timeline, manual backfill, and background-tracking controls.

  • Liquid Glass tab bar (RootView) hosting three tabs — Primary / Elsewhere / Settings — via the iOS 26 Tab API with tab-bar minimize-on-scroll. A single WhereModel is built at launch by an AppDelegate (@UIApplicationDelegateAdaptor) and injected through the environment; RootView.task still calls start() for an idempotent foreground refresh.
  • Primary tab: top regions by days for the selected year as prominent .glassEffect RegionSummaryCards in a GlassEffectContainer, with a "Where have you been in {year}?" hero, big day-count headlines, calendar-derived progress, and loading/empty/error states. A toolbar Timeline button (alongside the year picker) opens a chronological list of continuous stays ("California Jan 1 – Feb 3, NYC Feb 3 – Mar 10, …").
  • Elsewhere tab: every non-primary region as compact cards sharing the same component.
  • Settings tab: a live location-status indicator (LocationStatusRow), a background-tracking toggle reconciled against real authorization + persisted intent, context-aware Grant access / Open Settings buttons, manual day entry (single day or an inclusive date range), and a confirmed destructive erase-year.
  • Supporting: WhereModel (@MainActor @Observable), pure RegionRanking split, PresenceTimeline/RegionStint grouping, location-themed RegionStyle, shared YearSelector, in-memory PreviewSupport, a fully localized string catalog (Localizable.xcstrings via a Strings helper, plural-aware), and centralized layout literals (UIConstants).

Key decisions

  • "Time spent" = day counts (YearReport.totals); no new domain duration model.
  • Primary vs Secondary is dynamic by rank: the top 2 regions by days are Primary; .other is always Secondary.
  • Tracking reflects reality, not the last tap: effective tracking is persisted intent AND Always authorization, reconciled on launch and on every authorization change.

Enabling / supporting changes

  • WhereCore location surface: new domain-level LocationAuthorizationStatus; LocationSource gains authorizationUpdates + currentAuthorization(); CoreLocationSource maps CLAuthorizationStatus, drives a non-blocking two-step (When-In-Use → Always) permission flow, and coalesces concurrent permission requests.
  • WhereController: addManualDays(from:through:) range backfill (extracted into a tested Date.calendarDays(through:in:)), authorization passthroughs, and a restartable GPS lifecycle (the stream consumer outlives a stopGPS() pause).
  • App wiring: AppDelegate bootstraps the controller/CLLocationManager early so CoreLocation can relaunch the app into the background after termination; new public SwiftDataStore.make(storage:) factory (the @ModelActor init isn't reachable from WhereUI).
  • Info.plist: NSLocationWhenInUseUsageDescription + NSLocationAlwaysAndWhenInUseUsageDescription on the Where target so the permission prompt doesn't crash.
  • Tooling: AGENTS.md now requires a feature branch before committing plan work; explicit Tuist WhereTests / WhereUITests schemes so each bundle is runnable on its own (Tuist's autogeneration only covered them via the aggregate Stuff-Workspace scheme).

Review feedback addressed

Human review (resolved): all user-facing strings moved into the catalog; layout literals centralized in UIConstants; region-card year length derived from Calendar; the year rendered with a grouping-free number format.

Bot review (resolved):

  • Restartable location streamstopGPS() no longer cancels the single-consumer AsyncStream; pausing then resuming tracking keeps recording instead of silently dropping samples.
  • Coalesced permission requests — overlapping requestPermission() calls no longer overwrite (and strand) the pending continuation.
  • Stale-year guardWhereModel.refresh() discards out-of-order results so a slow older fetch can't render under the new year's label.
  • Manual-save errors surfacedsetManualDay/setManualDays throw; the entry form stays open with an inline alert instead of dismissing as if it saved.
  • Smarter empty state — Primary distinguishes "nothing tracked" from "tracked, but nothing in a headline region" (and points to Elsewhere).

Notes / out of scope

  • Skipped the optional accent-color asset catalog (per-region tints already carry the theming).
  • Per-sample/manual-day listing + deletion, evidence UI, true duration tracking, and a macOS target are intentionally out of scope.
  • The permission-coalescing fix isn't unit-tested: CoreLocationSource's prompt flow needs a real CLLocationManager and isn't reachable through the ScriptedLocationSource seam.

Test plan

  • mise exec -- tuist test Stuff-Workspace --no-selective-testing — full suite green, including:
    • WhereControllerTests incl. trackingResumesAfterPauseWithoutDroppingSamples and the GPS retry/drain flow
    • WhereModelTrackingTests (launch reconciliation across authorization states, persisted intent, live updates)
    • WhereModelRefreshTests (deterministic stale-year race + manual-save failures)
    • RegionRankingTests, PresenceTimelineTests, StringsTests (catalog wiring + plurals), DateCalendarDaysTests, LocationAuthorizationTests
    • ScreenHostingTests (Primary incl. elsewhere-only state, Elsewhere, Settings, Timeline mount) + rootViewBuilds
  • ./swiftformat --lint clean
  • mise exec -- tuist test WhereUITests / tuist test WhereTests run each bundle on its own (new explicit schemes)

Made with Cursor

kyleve and others added 9 commits May 29, 2026 10:38
The @ModelActor-generated init(modelContainer:) is not reachable from
other modules, so WhereUI had no supported way to build a production
(.localOnly/.cloudKit) store. Add a public make(storage:) factory
mirroring inMemory(), plus a round-trip test.

Co-authored-by: Cursor <cursoragent@cursor.com>
…iews

Groundwork for the tabbed UI (no behavior change yet):
- WhereModel (@mainactor @observable) wraps WhereController and exposes
  year selection, report loading, manual-day entry, GPS, and clear-year.
- RegionRanking purely splits a YearReport into primary (top regions by
  days, excluding .other) and secondary.
- RegionStyle gives each Region a symbol/emoji/tint for whimsy.
- PreviewSupport seeds in-memory fixtures for #Preview and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
RootView now hosts three tabs (Primary / Elsewhere / Settings) in an
iOS 26 TabView with tab-bar minimize-on-scroll, owns the single
WhereModel, builds the live controller in .task, and injects the model
via the environment. Primary/Secondary/Settings land as thin stubs that
are fleshed out in the following steps.

Co-authored-by: Cursor <cursoragent@cursor.com>
PrimaryView shows the top regions (by days) for the selected year as
prominent glass RegionSummaryCards inside a GlassEffectContainer, with a
playful hero header, day-count headlines, loading/empty/error states, and
a shared YearSelector in the toolbar.

Co-authored-by: Cursor <cursoragent@cursor.com>
SecondaryView lists every non-primary region (ranking.secondary) for the
selected year as compact glass cards, sharing RegionSummaryCard and the
YearSelector, with loading/empty/error states and a light "just passing
through" caption for brief stays.

Co-authored-by: Cursor <cursoragent@cursor.com>
SettingsView gates location permission (with an Open-Settings alert on
denial), a background-tracking toggle wired to WhereController GPS, a
push to ManualDayEntryView for asserting/overriding a day's regions, and
a confirmed destructive erase of the selected year's data.

Co-authored-by: Cursor <cursoragent@cursor.com>
CoreLocation's requestAlwaysAuthorization requires both
NSLocationWhenInUseUsageDescription and
NSLocationAlwaysAndWhenInUseUsageDescription, so add them to the Where
target's infoPlist; without them the Settings "Grant location access"
button would crash on launch of the permission prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>
Adds pure unit tests for RegionRanking (top-N primary, .other always
secondary, zero-day drop, tie-breaking, empty year, configurable count)
and hosted smoke tests that mount Primary/Secondary/Settings with seeded
preview data via WhereTesting.show.

Co-authored-by: Cursor <cursoragent@cursor.com>
Codify branching as a pre-commit gate in "Working on plans" so plan
to-dos never land directly on main/master by accident.

Co-authored-by: Cursor <cursoragent@cursor.com>
@kyleve
Copy link
Copy Markdown
Owner Author

kyleve commented May 29, 2026

@cursoragent review

@cursor
Copy link
Copy Markdown

cursor Bot commented May 29, 2026

Findings:

  1. Tracking toggle can show “on” without usable location permission
    Where/WhereUI/Sources/Settings/SettingsView.swift:99-109, Where/WhereUI/Sources/Model/WhereModel.swift:124-127
    The toggle calls startTracking() directly, and startTracking() sets isTracking = true after controller.startGPS() without first requesting/verifying Always authorization. If permission is denied/not determined, the UI can indicate tracking is active while CoreLocation won’t produce background samples. The toggle path should request/check permission and only set isTracking after success, surfacing the existing Settings alert on failure.

  2. Year switching can temporarily display stale data under the new year label
    Where/WhereUI/Sources/Model/WhereModel.swift:85-96, Where/WhereUI/Sources/Primary/PrimaryView.swift:23-37
    select(year:) updates selectedYear before refresh(), but refresh() leaves the old report in place while loading. Because the views only show the loading state when report == nil, the previous year’s ranking can be rendered with the newly selected year in the header until the fetch completes.

Verification:

  • Attempted ./swiftformat --lint && mise exec -- tuist test --no-selective-testing; it failed immediately because mise is not installed/on PATH in this cloud environment. No code changes were made.
Open in Web Open in Cursor 

kyleve and others added 2 commits May 29, 2026 11:13
Add WhereController.addManualDays(from:through:regions:), which writes a
whole inclusive day range in one perform transaction, surface it through
WhereModel.setManualDays, and give ManualDayEntryView a Single day / Date
range mode with From/Through pickers and a live day count. Covered by new
WhereController range tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
Introduce PresenceTimeline, which folds a YearReport's DayPresence into
maximal consecutive RegionStint runs (e.g. California Jan 1 - Feb 3, then
New York Feb 3 - Mar 10, sharing the transition day). Surface it from a
new navbar button on PrimaryView that presents PresenceTimelineView: a
chronological list of region / date-range / duration rows. Covered by
PresenceTimeline unit tests plus a hosted smoke test.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread Where/WhereCore/Sources/WhereController.swift Outdated
Comment thread Where/WhereUI/Sources/Primary/PresenceTimelineView.swift Outdated
Comment thread Where/WhereUI/Sources/Primary/PrimaryView.swift Outdated
Comment thread Where/WhereUI/Sources/Primary/PrimaryView.swift Outdated
Comment thread Where/WhereUI/Sources/Primary/PrimaryView.swift Outdated
Comment thread Where/WhereUI/Sources/Primary/RegionSummaryCard.swift
Comment thread Where/WhereUI/Sources/Secondary/SecondaryView.swift Outdated
Comment thread Where/WhereUI/Sources/Settings/SettingsView.swift Outdated
kyleve and others added 8 commits May 29, 2026 11:58
Addresses PR review on addManualDays: move the per-day range
enumeration into Date.calendarDays(through:in:) in WhereCore and cover
it directly with unit tests, leaving the controller transaction thin.

Co-authored-by: Cursor <cursoragent@cursor.com>
Addresses cursor[bot] review:
- startTracking() now confirms/requests location permission and only
  flips isTracking once GPS is actually running, surfacing the Settings
  alert (and leaving the toggle off) on denial.
- select(year:) drops the previous report so views show their loading
  state instead of rendering last year's data under the new year label.

Co-authored-by: Cursor <cursoragent@cursor.com>
Addresses PR review: replace the hardcoded 365 with
WhereModel.daysInSelectedYear (365/366 from Calendar) so the ambient
progress bar scales correctly in leap years. The card default stays 365
for previews only.

Co-authored-by: Cursor <cursoragent@cursor.com>
Addresses PR review: introduce UIConstants (Spacings/Padding/
CornerRadius/Size) and replace the hardcoded spacing, padding,
corner-radius, and element-size numbers across the region cards,
Primary, Elsewhere, and the timeline rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
Addresses PR review: add Resources/Localizable.xcstrings to WhereUI
(wired via Package.swift resources) and a Strings helper that resolves
every user-facing string through bundle: .module. Counts use catalog
plural variations; years are formatted with a grouping-free number
style ("2026", not "2,026") to address the formatter feedback on
Elsewhere. Adds StringsTests to lock in the wiring.

Co-authored-by: Cursor <cursoragent@cursor.com>
Adds a Sendable LocationAuthorizationStatus and surfaces it through
LocationSource (currentAuthorization() + an authorizationUpdates stream)
and WhereController, so the UI can reflect real authorization and react
to Settings-app changes. CoreLocationSource now requests When-In-Use
first then nudges the (iOS-deferred) Always upgrade without blocking,
and broadcasts every authorization change. ScriptedLocationSource gains
a configurable status + emitAuthorization for tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
Fixes two bugs and adds the requested indicator:
- "Track in the background" no longer reads off on every launch — the
  model now syncs authorization, persists the user's tracking intent,
  and resumes GPS when Always is granted (start() previously never
  started GPS at all).
- "Grant location access" now produces visible feedback: the model
  reflects the authorization result and the button is context-aware
  (Grant only when it can help; Open Settings when denied/limited).
- Adds a LocationStatusRow to the Settings location section summarizing
  authorization + whether tracking is actually running, kept live via
  WhereController.authorizationUpdates().

New strings go through the catalog; WhereModelTrackingTests covers the
launch reconciliation and live-update paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
Move controller/CLLocationManager creation off a SwiftUI .task and onto
an app-launch hook so CoreLocation can deliver significant-change /
visit events when the app is relaunched into the background after
termination (a view's .task isn't a reliable hook with no UI).

- WhereModel.bootstrap(): synchronous controller (and CLLocationManager)
  creation; start() now calls it.
- AppDelegate (UIApplicationDelegateAdaptor) owns the single WhereModel,
  bootstraps synchronously in didFinishLaunching, then re-registers
  monitoring / reconciles tracking; RootView takes the injected model.

Co-authored-by: Cursor <cursoragent@cursor.com>
@kyleve
Copy link
Copy Markdown
Owner Author

kyleve commented May 29, 2026

@cursoragent review

@kyleve
Copy link
Copy Markdown
Owner Author

kyleve commented May 29, 2026

@codex review

@cursor
Copy link
Copy Markdown

cursor Bot commented May 29, 2026

Findings:

  1. Manual entry reports success even when the save fails
    Where/WhereUI/Sources/Settings/ManualDayEntryView.swift:143-157, Where/WhereUI/Sources/Model/WhereModel.swift:196-213
    save() always dismisses after calling setManualDay / setManualDays, but the model swallows persistence errors by only setting loadState = .failed. A SwiftData/CloudKit failure would close the form as if the backfill worked, then surface an unrelated global error later. The model should return/throw save failure so the form can stay open and show inline feedback.

  2. Primary tab can say “No travels logged” when data exists only in .other
    Where/WhereUI/Sources/Primary/PrimaryView.swift:51-52, Where/WhereUI/Sources/Model/RegionRanking.swift:37-47
    .other is intentionally excluded from primary, so a user whose only tracked days are outside bundled regions gets the Primary empty state even though ranking.secondary and trackedDayCount have data. The empty-state condition/copy should distinguish “no data” from “no primary regions.”

  3. Concurrent permission requests can leak/hang a continuation
    Where/WhereCore/Sources/Location/CoreLocationSource.swift:80-104
    A second .notDetermined requestPermission() call overwrites pendingPermissionContinuation before the first is resumed. Rapid taps or overlapping toggle/grant tasks could leave the earlier task suspended indefinitely. Consider coalescing in-flight permission requests.

Verification:

  • ./swiftformat --lint still cannot run in this environment because mise is not installed/on PATH.
  • No local code changes were made.
Open in Web Open in Cursor 

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f1c0871a53

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

guard let controller else { return }
loadState = .loading
do {
report = try await controller.yearReport(for: selectedYear)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Guard refresh results against stale year selections

When the user picks two years quickly from YearSelector, the two Task { await model.select(year:) } calls can overlap because WhereModel is reentrant while awaiting controller.yearReport. If the earlier fetch returns after the later one, this unconditional assignment can install a report for the old year while selectedYear already shows the new year, so the UI displays mismatched totals; capture the requested year and discard results that no longer match (or serialize/cancel in-flight refreshes).

Useful? React with 👍 / 👎.

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.

Posted by an AI agent on kyleve's behalf.

Fixed in 5f4e8b0. WhereModel.refresh() now captures the requested year and discards any result that arrives after a newer select(year:) has moved the selection on, so an out-of-order fetch can't install stale totals under the new year's label. Covered by WhereModelRefreshTests.staleYearFetchDoesNotOverwriteNewerSelection, which gates the first fetch behind the second to force the reentrancy race deterministically.

Comment thread Where/WhereUI/Sources/Model/WhereModel.swift
kyleve and others added 2 commits May 29, 2026 14:02
Addresses two concurrency findings from PR review bots:

- stopGPS() previously cancelled the task iterating the single
  AsyncStream from LocationSource, which terminates the stream. A
  later startGPS() then iterated an already-finished stream and
  dropped every subsequent sample, so toggling tracking off and back
  on (without relaunching) silently stopped recording. The ingest task
  is now created once and kept alive for the controller's lifetime;
  stopGPS() only pauses the underlying monitoring (tracked via a new
  isMonitoring flag) and the task is torn down on deinit.

- A second .notDetermined requestPermission() call overwrote the single
  pending continuation, permanently stranding the first caller. Pending
  waiters are now coalesced into a list: only the first drives the
  system prompt and all resume together on the authorization callback.

Adds a regression test proving tracking survives a pause/resume.

Co-authored-by: Cursor <cursoragent@cursor.com>
Addresses three PR review findings in the SwiftUI layer:

- WhereModel.refresh() assigned its result unconditionally. Because the
  model is reentrant while awaiting yearReport, a rapid second
  select(year:) could let a slower older fetch install its report under
  the newer year's label. refresh() now captures the requested year and
  discards results that no longer match the selection. (Deterministic
  regression test gates the first fetch behind the second.)

- Manual day entry always dismissed and reported success: setManualDay /
  setManualDays swallowed persistence errors into loadState, closing the
  form as if the backfill worked. These now throw; ManualDayEntryView
  keeps the form open and shows the error in an inline alert, and the
  global load state is left untouched.

- The Primary tab showed "No travels logged" whenever no region ranked
  as primary, even when days existed only in .other. It now distinguishes
  "nothing tracked" from "tracked, but nothing in a headline region" and
  points the user at the Elsewhere tab.

Adds string-catalog entries (with plural variations) and tests for each.

Co-authored-by: Cursor <cursoragent@cursor.com>
@kyleve
Copy link
Copy Markdown
Owner Author

kyleve commented May 29, 2026

Posted by an AI agent on kyleve's behalf.

Addressed the open review-bot findings in 6d5bbca (WhereCore) and 5f4e8b0 (WhereUI):

  1. Manual entry reported success even when the save failedWhereModel.setManualDay / setManualDays now throw instead of swallowing persistence errors into loadState. ManualDayEntryView keeps the form open on failure and shows the error in an inline alert; the global load state is left untouched. (5f4e8b0, tests failedManualSaveThrowsAndLeavesLoadStateAlone / failedManualRangeSaveThrows.)

  2. Primary tab said "No travels logged" when data existed only in .other — the empty state now distinguishes "nothing tracked" from "tracked, but nothing in a headline region" and points the user at the Elsewhere tab. (5f4e8b0, tests onlyOtherDaysLeavePrimaryEmptyButRankingNonEmpty + a hosting smoke test.)

  3. Concurrent permission requests could strand a continuationCoreLocationSource no longer overwrites a single pending continuation; overlapping requestPermission() callers are coalesced into a waiter list (only the first drives the system prompt) and all resume together on the authorization callback. (6d5bbca.)

The two codex P2 findings (stale-year race and the non-restartable location stream) are addressed in the same commits and answered in their threads. ./swiftformat --lint and the full Stuff-Workspace test suite pass locally.

Tuist's autogeneration emits standalone schemes for StuffCoreTests and
WhereCoreTests but, for this graph, not for WhereTests or WhereUITests —
they were only reachable via the aggregate Stuff-Workspace scheme, which
builds and tests everything. Declaring the two schemes explicitly lets
`tuist test WhereTests` / `tuist test WhereUITests` run a single bundle.

Tooling-only change; no production code affected.

Co-authored-by: Cursor <cursoragent@cursor.com>
@kyleve kyleve merged commit e3990cd into main May 29, 2026
2 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