Restructure Where navigation into Locations / Your Year / Settings#119
Conversation
Drop the hideEmptyTabs preference, its YearReportModel mirror, the Appearance settings toggle, associated strings/catalog entries, and the covering test. Groundwork for the navigation restructure, which folds Elsewhere into Locations and turns Resolve into a conditional toolbar item so the setting has nothing left to gate. MainTabs keeps hiding the now-unconditional Elsewhere/Resolve tabs by content only until it is rewritten.
Split the calendar/attachments/logged-days screens from their sheet chrome so the new segmented tabs can host them directly: - CalendarView becomes a thin sheet wrapper (NavigationStack + Done + region/year title) around a new chrome-free CalendarContentView that keeps the month-grid load and the day->timeline push. - EvidenceListView and LoggedDaysView drop their NavigationStack, Done, and title, keeping their + toolbar, filter, navigationDestination, and nested add/edit sheets so they merge into a host tab's bar. - SecondaryView is renamed ElsewhereView (folder Secondary -> Elsewhere) and loses its NavigationStack; it is now pushed within the Locations stack. Previews wrap the chrome-free views in a NavigationStack. Groundwork only; PrimaryView still presents these as sheets until the Locations rework. PresenceTimelineView (the timeline sheet shell) is kept for now and removed once PrimaryView stops using it.
A reusable segmented-control container the Your Year and Your Data tabs will share: a segmented Picker over a String-backed SegmentedItem enum whose selected segment persists across launches via @AppStorage (keyed by a typed SegmentStoreKey), driving a content builder. Segment swaps animate with a Reduce-Motion-aware directional slide, timed by a new motion.segmentTransition stylesheet token.
YearView hosts the calendar and timeline for the selected year in one SegmentedTabContainer (Calendar | Timeline), so the two views of the same year live on one screen with the segment remembered across launches. The on-device activity summary moves here as a toolbar item (sparkles) presenting RecentActivitySummaryView. Adds the tab/segment strings it needs. Not yet wired into MainTabs.
YourDataView hosts the evidence archive and the hand-logged-days list in one SegmentedTabContainer (Attachments | Logged Days), remembered across launches. Each segment's content view brings its own + toolbar item, so the add action follows the active segment. Not yet wired into MainTabs.
Rename PrimaryView to LocationsView and reshape it into the new hub: - Drop the recent-activity/timeline/calendar/evidence/logged-days toolbar sheets (they now live in Your Year / Your Data), keeping the region-card focused-calendar sheet. - Fold Elsewhere in at the bottom as a conditional ElsewhereSummaryCard (a new glass entry card, its own ElsewhereCardStyle stylesheet token) that pushes ElsewhereView — shown only when there are secondary regions. - Add a Resolve toolbar button that appears only while dataIssueCount > 0, badged with the count, presenting ResolutionView as a sheet (which gains a Done button). Delete the now-unused PresenceTimelineView sheet shell (file renamed to PresenceTimelineList.swift). MainTabs still lists the old Elsewhere and Resolve tabs until its rewrite; tests/previews updated to the new names.
Replace the conditional Primary/Elsewhere/Resolve/Settings bar with a fixed Locations / Your Year / Your Data / Settings bar. Elsewhere and Resolve are no longer tabs (folded into Locations), so all the content-conditional visibility, sticky-selection, and tab-badge logic is gone. New TabID cases + labels + SF Symbols.
- Prune the now-dead tab.primary / tab.elsewhere / timeline.done strings and catalog entries; add tab.locations / tab.year / tab.data, the segment picker labels, the Attachments segment label, and the Elsewhere card subtitle to the catalog. - Add hosting tests for YearView, YourDataView, CalendarContentView, and ElsewhereSummaryCard; retarget the renamed Primary/Secondary/timeline tests; add SegmentedTabContainerTests covering persisted-selection restore across launches. - Update the WhereSession doc comment: the data-issue count now drives the Locations Resolve toolbar badge rather than a Resolve tab.
- Remove the Locations tab's forced dark colorScheme, dark toolbar scheme, and elevated dark gradient (they also leaked into the pushed ElsewhereView). The tab now follows system appearance. - In the elsewhere-only state (tracked days but none in a headline region), surface a 'See Elsewhere' link to ElsewhereView and drop the copy that pointed at the now-removed Elsewhere tab.
When the selected year isn't the current one, the Your Year and Your Data tab titles gain a grouping-free year suffix (e.g. "Your Year · 2024"), so which year is on screen is never ambiguous; the current year keeps the plain title.
Keep every segment mounted (only the selected one visible, hit-testable, and exposed to VoiceOver) and slide between them via a container-width offset, instead of re-identing the content on each switch. Switching now preserves each segment's state — Logged Days filter, calendar/list scroll, loaded data — rather than tearing it down and reloading. The content builder now also reports whether a segment is selected; EvidenceListView and LoggedDaysView use it to contribute their + toolbar item only while active, so the hidden-but-mounted segment doesn't add a duplicate. Segmented controls also get a per-tab accessibility id.
- ElsewhereSummaryCard shows only the region count (not a summed day total, which double-counted multi-region days). - Refresh doc comments that still referenced the removed Primary/Elsewhere tabs (CardStyle, RegionSummaryCard, YearTotalsWidgetView). - Drop the now-unused report property from AppearanceSettingsView and its call sites/preview. - (SegmentedTabContainer already gained per-tab accessibility ids.)
Replace the in-content segmented container with a segmented control in the navigation bar (Liquid Glass) that switches between the calendar and timeline, crossfading between them, alongside the activity-summary button. Drops the year-suffixed title (the control owns the bar) and the YearSegment type.
… Your Data tab Remove the Your Data tab and instead surface its screens from Settings: a new top group holds Attachments, Logged Days, and Regions (Regions moved here from the tracking group). Adds .attachments/.loggedDays settings destinations (EvidenceListView/LoggedDaysView now conform to SettingsSection so they're searchable and routable); both regain their navigation title and lose the segment-only isActive gating. MainTabs is now three tabs (Locations, Your Year, Settings).
With Your Year using a toolbar toggle and Your Data gone, nothing uses SegmentedTabContainer — remove it (and SegmentStoreKey, SegmentedItem, its test, and the motion.segmentTransition token). Prune the dead tab/ segment strings (tab.data, the year/data forYear titles, the segment picker labels) and add catalog entries for the new Settings Data rows; refresh the SettingsView doc comment.
Place the + on the right (topBarTrailing) on both the Attachments and Logged Days screens.
Move the activity-summary button and the calendar/timeline segmented control to the leading side of the bar, switch the control to icons (calendar / timeline glyphs), and title the page after the selected view (Calendar or Timeline).
Keep the activity-summary button on the left; put the calendar/timeline control on the right.
The calendar auto-scrolls under the bar while the timeline starts at the top, so switching animated the bar's scroll-edge material in/out (a toolbar fade). Pin the nav bar background visible so it's consistent across both views.
The SettingsDestination doc said 'eight' groups; there are now nine (attachments + logged days added). Also remove the empty Sources/Data directory left after deleting YourDataView.
Switch tabBarMinimizeBehavior from .onScrollDown to .never.
The ScrollView's onAppear re-fired scrollToCurrentMonth on every reappearance — including returning to the Your Year tab — snapping the grid back to today and losing the user's scroll position. Guard it on the year already scrolled so it jumps once per year; a report-year switch rebuilds the content and scrolls the new year afresh.
Replace the top-bar segmented control with a Photos-style Liquid Glass pill floating at the bottom-center (above the tab bar), reframing the switch as zooming between the year overview (timeline) and month detail (calendar). The selected segment slides between options; the page title still tracks the mode and the activity-summary button stays in the bar.
Replace the small region dots under each day number with a full-width region-colored bar (split into equal segments on multi-region days), so contiguous stays read as horizontal color bands across a week. Adds regionBarHeight / regionBarCornerRadius CalendarStyle tokens (pinned in WhereStylesheetTests); the month footer still uses dots for its tally.
Replace the full-width region bar with region-presence dots (restored, bumped to 8pt) plus a subtle region-tinted pill drawn behind contiguous same-region days. The pill's true ends round fully; where a run spills across a week boundary the edge rounds subtly (continuationRadius) to imply it carries on, and same-row neighbours bleed half the grid gap so a stay reads as one connected shape. Swaps the regionBar* tokens for a RegionBand group and a dayDotSize token (pinned in WhereStylesheetTests).
Bump the stay pill's terminal-end radius (10 -> 14) so run ends read as fuller rounded caps. On multi-region days, overlap the region dots by 2pt into a cluster, each with a background-colored rim so they read as distinct coins. Adds dayDotOverlap / dayDotStrokeWidth tokens (pinned in WhereStylesheetTests).
Reduce the day-number-to-dots gap to 0 (dots sit snug beneath the date) and give the stay pill vertical breathing room by hugging the padded content, so the pill no longer butts against the bottom of the dots. Adds dayNumberDotSpacing; retunes regionBand.verticalInset (3 -> 4, now content padding). Pinned in WhereStylesheetTests.
Apply the background-colored stroke to all region dots, not just the overlapping multi-region cluster, for a consistent look.
…re bottom gap - Use a single selection capsule that follows the selected segment via matchedGeometryEffect(isSource:) so it slides instead of fading. - Fill the selection with Color.primary (black in light, white in dark), with system-background text for contrast. - Enlarge the segment icons (.imageScale(.large)). - Increase the pill's bottom padding so it clears the tab bar.
The .glassEffect wrapped the selection capsule, frosting the black to grey. Split the glass into its own background layer behind the selection capsule so Color.primary reads as real black (white in dark mode).
Bump the pill's inner padding (xxSmall -> small) so the selection capsule has more breathing room from the glass control's edges.
Tapping a day no longer pushes the timeline (the timeline is now a first-class Your Year segment). Drops the day cell buttons and the timelineTarget navigation destination, the now-unused scrollToMonth plumbing in PresenceTimelineList, and the dead timeline.title string.
Pin each segment label to its intrinsic width (.fixedSize) so it can't truncate while the segment frame animates, and use a non-overshooting .smooth spring so the sliding capsule no longer squishes.
Add a footerDividerSpacing token (8pt) so the region tally rows don't butt up against the divider under each month. Pinned in WhereStylesheetTests.
Bring back .snappy (smooth animated only one direction). The label's .fixedSize already prevents the truncation that prompted the swap, so snappy is safe to keep.
Float the pill over the scrolling content (content now scrolls under it, with a measured bottom content inset so the last rows clear it) and add a bottom blur band — an ultraThinMaterial masked by a vertical gradient (clear a bit above the pill, full at the bottom) — so the content dissolves into blur toward the screen bottom. Fade height derives from the measured pill height and bottom safe area.
Make the fade band taller (start the blur earlier above the pill) and move the gradient's full-blur stop up to 0.5 so it's solid well before the bottom.
Revert to the plain floating pill in a safeAreaInset (content stops above it); remove the bottom fade, the scroll-under contentMargins, and the geometry measurements it needed.
Replace the ScrollViewReader + onAppear + async scrollTo (which scrolled to the current month a frame or two after the grid appeared, causing a visible jump when switching to the tab) with .scrollPosition(id:). The target is seeded before the months are published, so the grid opens already scrolled to the current month; it then just tracks scrolling. Still guarded to seed once per year, preserving position across tab switches.
.scrollPosition couldn't jump to an off-screen lazy month on first layout (it stayed on January). Go back to ScrollViewReader.scrollTo but keep the grid hidden (opacity 0) until it's scrolled into place, then reveal — so the calendar appears already at the current month instead of visibly jumping. Still guarded once per year to preserve scroll across tab switches.
…d future Give every month a subtle grey card background (not just the current one), make the current-month highlight a bit bluer (accent 0.08 -> 0.14), and dim months entirely in the future (opacity 0.55) so it's clear they haven't happened. Future is derived in the view from the report's reference month. Adds month.background / month.futureOpacity tokens (pinned in WhereStylesheetTests).
Instead of rendering all future months, drop everything after the next month and render that next month as a 'teaser': clipped to a short peek height (so the grid can only scroll partway into it), dimmed, and faded out over that height with a gradient mask. Past years (no current month) still show every month. Replaces the blanket future-month dim with a teaserPeekHeight token.
Lighten every month's background (0.05 -> 0.03) and give each card a 2pt darker border. The current month gets a 4pt accent border plus a matching accent outer glow (shadow). Adds border/borderWidth/currentBorder/ currentBorderWidth/currentGlow/currentGlowRadius tokens (pinned in WhereStylesheetTests).
Use a gentler multi-stop gradient (70% at mid, 25% at 0.85, clear at the bottom) so the peek eases out gradually instead of zeroing out so fast.
Drop the month corner radius 22 -> 21, bump the current month's glow opacity (0.4 -> 0.55), and lighten the current month's fill (0.14 -> 0.1) and border (accent -> accent 0.7).
Reduce currentMonthHighlight back to accent 0.08 (its original value) — the accent border and glow now carry the emphasis, so the bluer fill was too much.
Remove the accent-tinted outer glow on the current month (and its now-unused currentGlow/currentGlowRadius stylesheet tokens). Extract the next-month teaser clip + fade into a single TeaserPeek modifier keyed off one height, so the gradient reaches fully transparent exactly where the content is clipped — no hard cutoff line.
| /// Spacing inside a day cell (the number over its dots). | ||
| /// Diameter of a region-presence dot under a day number (a touch larger | ||
| /// than the footer dots so days scan easily). | ||
| var dayDotSize: CGFloat |
There was a problem hiding this comment.
There's a lot of "day" items here. These should be grouped into one nested type. Don't we cover this in AGENTS.md?
There was a problem hiding this comment.
Posted by an AI agent on kyleve's behalf.
Done in b62fbce — folded the flat day* scalars, the today/unresolved markers, and the evidence badge into a nested DayStyle (calendar.day.*), per the AGENTS.md "group large flat types into sub-structs" rule. Footer dotSize and regionBand stay on CalendarStyle. Values unchanged.
| /// Subtle wash behind every month, so each reads as its own card. | ||
| var background: Color | ||
| /// Border around every month card (a touch darker than `background`). | ||
| var border: Color |
There was a problem hiding this comment.
We have past/current/future here in terms of past months / current month / future months. Let's group the styling for a month into a single type and then reuse it.
There was a problem hiding this comment.
Posted by an AI agent on kyleve's behalf.
Done in c530e2f — replaced the parallel background/border/borderWidth (all months) and currentMonthHighlight/currentBorder/currentBorderWidth (current) with a single nested MonthStyle.Card { fill, border, borderWidth }, exposed as plain (past/future) and current. MonthGridView now picks one Card by isCurrentMonth and reads both from it instead of ternary-ing each property. Values unchanged.
| var futureOpacity: Double | ||
| /// Height the next month is clipped to — a peek the grid can only | ||
| /// scroll partway into, faded out over it. | ||
| var teaserPeekHeight: CGFloat |
There was a problem hiding this comment.
futurePeekHeight? But also, can't we only determine this at view time? How would we figure this out in the stylesheet given it relies on layout?
There was a problem hiding this comment.
Posted by an AI agent on kyleve's behalf.
Renamed to futurePeekHeight in d2fc2c5.
On the substantive point: you're right that a fixed 150 is an arbitrary clip, not a layout-derived value. I'm taking that on next as a follow-up commit — computing the peek at view time as a fraction of the measured next-month height (via onGeometryChange), so it actually shows a proportion of the month rather than a magic constant. Will follow up here when it lands.
There was a problem hiding this comment.
Posted by an AI agent on kyleve's behalf.
Landed the view-time version in 9c6dd8f. futurePeekHeight is gone; the stylesheet now carries a dimensionless futurePeekFraction (0.5). TeaserPeek measures the next month's natural rendered height (fixedSize + onGeometryChange, decoupled from the clamped frame so the measurement can't feed back) and clips to naturalHeight * fraction, staying hidden until measured so the full month never flashes. So the stylesheet keeps only the style choice and the layout-dependent pixel height is derived where the layout is actually known.
Fold the flat day* scalars, today/unresolved markers, and the evidence badge on CalendarStyle into a nested DayStyle (calendar.day.*), per the AGENTS.md 'group large flat types into sub-structs' rule. Pure regrouping — values unchanged; footer dotSize and regionBand stay on CalendarStyle.
Replace MonthStyle's parallel background/border/borderWidth (all months) and
currentMonthHighlight/currentBorder/currentBorderWidth (current) with a single
nested Card { fill, border, borderWidth } exposed as plain (past/future) and
current. MonthGridView now picks one Card by isCurrentMonth and reads both from
it instead of ternary-ing each property. Pure regrouping — values unchanged.
Match the past/current/future month vocabulary. Name-only change.
Replace the fixed futurePeekHeight (150pt) with a dimensionless futurePeekFraction (0.5). TeaserPeek measures the next month's natural rendered height (fixedSize + onGeometryChange, decoupled from the clamped frame so it can't loop) and clips to naturalHeight * fraction, hidden until measured so the full month never flashes. The stylesheet keeps only the style choice (the fraction); the layout-dependent pixel height is derived where the layout is known.
Summary
Restructures the Where app's top-level navigation, both in the UI and in how the structure is represented in code. The old four tabs (Primary, Elsewhere, Resolve, Settings) become three: Locations, Your Year, and Settings.
Elsewhere and Resolve are no longer tabs, so the
hideEmptyTabspreference (which only existed to hide them) is removed.Calendar visual pass
Alongside the restructure, the calendar in Your Year got a polish pass:
Notable changes
CalendarContentView, andEvidenceListView/LoggedDaysViewwithout their ownNavigationStack) so the calendar/timeline can be hosted by Your Year and the evidence/logged-days screens can be pushed from Settings;CalendarViewremains a thin sheet wrapper for the region-focused calendar.SecondaryView→ElsewhereView(folderSecondary→Elsewhere); the timeline sheet shell is gone (PresenceTimelineView→PresenceTimelineList).EvidenceListViewandLoggedDaysViewnow conform toSettingsSection(searchable + routable) and are reached via new.attachments/.loggedDayssettings destinations in a new topSettingsListSection.ResolutionViewis presented as a sheet from Locations and gains a Done button.ElsewhereCardStylestylesheet token for the Elsewhere entry card; the card shows a region count (not a summed day total, which would double-count multi-region days). New calendar month/day tokens (backgrounds, borders, current-month border, teaser peek height, day-dot sizing) pinned inWhereStylesheetTests.Modeling / conventions
MainTabsTabIDis nowlocations/year/settings; tab and row strings live inStrings.swift+ the string catalog, with dead entries pruned.SettingsDestinationstays registered in the search catalog and in exactly one list section (guarded bySettingsSearchTests).WhereStylesheetTests, one commit per step.History / iteration
Earlier revisions of this branch introduced a fourth "Your Data" tab and a generic
SegmentedTabContainer(persisted, animated segment switching), and Your Year briefly used a navigation-bar segmented control. After seeing them in the UI those were reverted: Your Year now uses the bottom floating pill, the data screens moved into Settings, and the container was deleted. The commit history shows the full progression.Testing
./swiftformat --lintclean.WhereUITestsbundle (covering all changed views, strings, stylesheet, and settings search) passes on iPhone 17 / iOS 26.2, and the whole workspace builds.StuffTestHostbootstrap crash (a simulator/test-host flake, not an assertion) andWhereResetTests.resetReturnsToOnboardingWithDataErased()is separately flaky (passes in isolation); both are unrelated to this change. CI's fresh runner is the authoritative full-suite check.