Skip to content

Progress Rail: redesigned in-trip panel - #153

Merged
aaronbrethorst merged 4 commits into
mainfrom
new-design
Jul 29, 2026
Merged

Progress Rail: redesigned in-trip panel#153
aaronbrethorst merged 4 commits into
mainfrom
new-design

Conversation

@aaronbrethorst

@aaronbrethorst aaronbrethorst commented Jul 29, 2026

Copy link
Copy Markdown
Member

Summary

  • Replaces the horizontal paged directions view with the Progress Rail: the whole trip as one vertical timeline (time gutter · rail marks · content), implementing the In-Trip Panel Claude Design. Built on a two-cursor model — currentLeg advances only with the clock via a pure, unit-tested TripProgress engine; focusedLeg follows taps and the tip-detent ‹ › stepper, with a Back-to-now pill to re-tether.
  • New trip anatomy at every detent: pinned destination header ("Arrive at X" + big arrival time, violet + struck-through schedule when running late), tinted now-card (boarding countdown, stop-code reference line, riding stop ladder), pre-trip "Leave in…" banner, and a glanceable 160pt tip bar with a leg-proportional segmented progress bar. Leg now decodes departureDelay/arrivalDelay (REST + GraphQL) feeding the fixed status vocabulary (On time / n min late / n min early / Scheduled). 47 new strings localized across all 13 locales.
  • Explicit trip lifecycle: the sheet opens at medium pre-trip with Start Trip (and an escape-hatch End Trip); starting collapses to the tip with a haptic and map refocus; Hide / End Trip are available at medium and large once underway. Accessibility: rows are combined elements, the time gutter moves inline at accessibility type sizes, and auto-advance posts a haptic + VoiceOver announcement.

Test plan

  • 183 Swift Testing + 15 XCTest package tests pass (TripProgress phase machine, rail row states, stops-remaining estimation, same-stop transfers, segments, delay decoding, localization key parity across 13 locales)
  • SwiftLint clean
  • Live simulator walkthroughs (iPhone 17 Pro, iOS 27, San Diego MTS OTP server): planned real trips and verified — pre-trip overview opens at medium with Start Trip visible; Start Trip collapses to the tip; rail renders correctly at tip/medium/large with single-line gutter times; stepper preview + map reframing; row tap focus with dark outline + Back-to-now pill (including pre-trip); tapping the current row is a re-tether no-op; End Trip reachable at medium/large and pre-trip, with confirmation dialog returning to the planner; live countdown ticks ("Leave in 2m" → "1m") and header time-left updates
  • OTPKitDemo app builds and runs
  • OneBusAway iOS app (worktree off main, pointed at this branch's package) builds against the new OTPKit

Review notes

Non-blocking findings from the multi-agent review, left as-is deliberately — reviewer input welcome:

  • tripStarted lives as @State on the sheet rather than in TripPlannerViewModel; it resets if the sheet is ever re-created mid-presentation (not observed in practice).
  • TripProgress builds on Itinerary.relevantLegs, which drops ≤60s walk legs; a trip that ends with a sub-minute walk can announce arrival up to a minute early, and one that starts with one shows the pre-trip banner for its first minute.
  • The riding stop ladder shows a static last-3-stops preview (per-stop times aren't decoded from OTP yet), so it doesn't count down live.
  • Plurals use paired singular/plural keys rather than .stringsdict; ru/pl/ar translators worked around it with count-neutral phrasing.
  • CLAUDE.md says package tests use XCTest, but the suite (including pre-existing files) is majority Swift Testing — the doc looks stale.
  • Pre-existing: the End Trip confirmation renders as a popover anchored to the sheet rather than a bottom action sheet, a side effect of the panel's popover presentation.
  • Deferred design scope (tracked in the design doc): missed-transfer recovery options in the rail, service-alert decoding/footer, geofence-based leg advance (engine is clock-only), and the "Alert me one stop early" notification.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Summary by CodeRabbit

  • New Features

    • Added a live trip-progress rail showing walking, boarding, riding, transfers, stops, and arrival.
    • Added real-time status indicators for on-time, early, late, and scheduled departures and arrivals.
    • Added countdowns, progress bars, route badges, detailed walking instructions, and leg previews.
    • Directions now automatically follow trip progress, update map focus, and provide “Back to now” navigation.
    • Added localized messaging for the new trip-progress experience across supported languages.
  • Bug Fixes

    • Trip data now includes departure and arrival delays for more accurate status displays.

Implements the In-Trip Panel design (Claude Design 7bb92a3b): the horizontal
pager becomes one vertical timeline built from a repeated three-column
RailRowView (time gutter / rail marks / content), driven by a pure,
clock-based TripProgress engine with the two-cursor model: currentLeg
advances only with the clock, focusedLeg follows taps and the tip-detent
stepper, and a Back-to-now pill re-tethers.

- Destination header pinned at every detent; violet late-arrival treatment
- Now-card for the current step: boarding countdown, stop code reference,
  riding stop ladder; done rows collapse to one line at 50%
- 160pt tip detent with stepper, segmented progress bar, and status footer
- Leg now decodes departureDelay/arrivalDelay (REST + GraphQL) feeding the
  fixed status vocabulary: On time / n min late / n min early / Scheduled
- Rows are combined accessibility elements; the time gutter moves inline at
  accessibility type sizes; auto-advance posts a haptic + VO announcement
- 47 new strings localized across all 13 locales
- TripProgress unit tests
Before the first leg's start time the clock-derived phase alone kept the
footer stuck on Start Trip: tapping it only collapsed the sheet, and the
Hide / End Trip footer could never appear, leaving no way to end a trip.

Start Trip now flips a tripStarted flag (with a haptic, collapse to tip,
and map refocus on the first step), the in-trip footer keys off that flag
or the clock — whichever says the trip is underway — and the pre-trip
footer gains an End Trip button so a planned trip can be abandoned
without starting it.
Simplify: one TripProgress per tick passed down instead of rebuilt per
child; shared copy helpers (localizedActivityName, leaveInstruction,
boardingCountdown, Step.localizedInstruction reusing the existing
leg.step_turn/continue keys, Leg.riderFacingRouteName); focus flows
through the binding alone with the map driven from one onChange; dead
abstractions removed (showsTime, isBoard, phase early-return, unused
params); unused keys dropped from all locales.

Review fixes: route-name fallback goes through the localized
modeDisplayName instead of mode.capitalized (d012225 regression); the
arrive footer formats its key with the real argument so placeholder-first
locales keep word order; arrival no longer pans the map back to the trip
origin; tapping the current row re-tethers instead of focusing; the
pre-trip footer counts down instead of claiming "Walking"; the sheet
opens at medium pre-trip so Start Trip is visible, and Hide/End Trip
appear at medium as well as large; Back-to-now pill shows for any focused
row; orphaned directions.swipe_hint removed from all 13 locales.
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@aaronbrethorst, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 10 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d4b796cc-e368-4643-9bfb-564f5d6d2808

📥 Commits

Reviewing files that changed from the base of the PR and between 762f8ac and 7d5900c.

📒 Files selected for processing (7)
  • OTPKit/Sources/OTPKit/Core/TripProgress/TripProgress.swift
  • OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/DirectionsSheetView.swift
  • OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/InTripRailView.swift
  • OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/RailRowContentViews.swift
  • OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/RouteBadge.swift
  • OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/TipContentView.swift
  • OTPKit/Tests/TripProgressTests.swift
📝 Walkthrough

Walkthrough

Adds real-time delay modeling, clock-driven trip progress calculations, and a rail-based directions sheet with localized status, walking, transit, transfer, and arrival content.

Changes

Trip guidance experience

Layer / File(s) Summary
Real-time data contracts
OTPKit/Sources/OTPKit/Core/Models/OTP/Leg.swift, .../RealTimeStatus.swift, OTPKit/Sources/OTPKit/Network/*
Legs now carry departure and arrival delays from GraphQL, expose derived statuses and route names, and provide localized real-time status values.
Trip progress state and calculations
OTPKit/Sources/OTPKit/Core/TripProgress/*, OTPKit/Tests/TripProgressTests.swift
Adds phase, rail-row, rider-query, current-step, stop-count, and progress-segment calculations with comprehensive time-based tests.
Rail guidance presentation
OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/*, .../DirectionLegWalkView.swift, .../Formatters.swift, OTPKit/Sources/OTPKit/Resources/*/Localizable.strings
Adds rail rows, route badges, trip headers, progress bars, previews, localized instructions, countdown formatting, and real-time rail vocabulary across supported locales.
Directions sheet orchestration
OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/DirectionsSheetView.swift
Replaces paged directions with timeline-driven guidance, detent-aware controls, focused-leg navigation, map coordination, phase announcements, and trip start/end handling.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TimelineView
  participant DirectionsSheetView
  participant TripProgress
  participant InTripRailView
  TimelineView->>DirectionsSheetView: provide current time
  DirectionsSheetView->>TripProgress: calculate phase and rows
  DirectionsSheetView->>InTripRailView: render current progress
  InTripRailView-->>DirectionsSheetView: return focus or back-to-now action
  DirectionsSheetView->>DirectionsSheetView: update map and accessibility state
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.75% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: replacing the directions UI with a redesigned Progress Rail in-trip panel.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch new-design

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (2)
OTPKit/Sources/OTPKit/Network/GraphQLAPIService.swift (1)

187-189: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Avoid relying on OTP GraphQL deprecated delay fields.

deparutureDelay/arrivalDelay on Leg are deprecated in current OTP GraphQL GTFS versions in favor of end.estimated.delay / start.estimated.delay. This works for the current server target, but future OTP upgrades can drop deprecated fields, which would break parsing here since those values are also mapped through GraphQLLegLeg. Confirm the supported OTP server versions; if these are unsupported, migrate the query and response mapping together.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@OTPKit/Sources/OTPKit/Network/GraphQLAPIService.swift` around lines 187 -
189, Update the GraphQL query around realTime, departureDelay, and arrivalDelay
to use the supported end.estimated.delay and start.estimated.delay fields
instead of deprecated Leg delay fields. Update GraphQLLeg-to-Leg response
mapping consistently so delay values retain their current semantics, and verify
the fields against the supported OTP server versions.
OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/TipContentView.swift (1)

249-283: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider moving the "next boarding" lookup rule into TripProgress.

footerLeadingText computes which leg counts as the "next boarding" (with the riding/waiting exclusion rule) directly in the view. Since TripProgress already exposes similar rider-facing queries (waitAfterLeg, isSameStopTransfer, stopsRemaining), moving this lookup there would keep the business rule covered by TripProgressTests and keep this view limited to formatting, per the OTPKit convention of moving complex logic into the model layer.

As per coding guidelines, "Keep SwiftUI views small and focused ... and move complex logic into view models."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/TipContentView.swift`
around lines 249 - 283, Move the next-boarding selection rule from
TipContentView.footerLeadingText into TripProgress as a rider-facing query,
preserving the currentLegIndex and riding/waiting exclusion behavior. Update
footerLeadingText to call that TripProgress API and retain only localization and
formatting, adding or updating TripProgressTests to cover the lookup rule.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/DirectionsSheetView.swift`:
- Around line 38-41: Update DirectionsSheetView.tipDetent and the associated
TipContentView layout so the compact guidance sheet accommodates Dynamic Type
accessibility text without clipping. Replace the fixed-height-only behavior with
content-aware sizing or an overflow/scroll fallback, while preserving the
compact detent for text that fits.

In
`@OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/InTripRailView.swift`:
- Around line 143-147: The completed-row styling in the .board/.ride case only
treats .board as thin; update the condition to apply to both row kinds when
row.state is .done and isRiding(index) is false. Preserve the route-colored .bar
styling for active or still-riding segments.
- Around line 63-70: Update the rail view’s appearance handling to scroll to the
initial currentRowID when the sheet first displays. Add an onAppear path that
reuses the existing tethered guard and scrollProxy.scrollTo behavior from the
currentRowID onChange handler, preserving the centered anchor and avoiding
scrolling when focusedLegIndex is set.

In
`@OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/RailRowContentViews.swift`:
- Around line 240-266: Update rideSummaryLine and subtitleLine so missing
leg.headsign values still render the available stop count or duration, using a
headsign-less localized summary instead of returning no view. Preserve the
existing headsign-based formatting when a headsign exists and fall back between
progress.totalStops(onLegAt:) and leg.duration as currently done.

In `@OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/RouteBadge.swift`:
- Around line 37-52: The isPerceptuallyLight property should linearize each sRGB
red, green, and blue component before calculating relative luminance, then
choose the result based on which foreground—black or white—produces the higher
WCAG contrast ratio rather than using a fixed luminance threshold. Preserve the
existing UIColor extraction fallback behavior.

In `@OTPKit/Sources/OTPKit/Resources/ru.lproj/Localizable.strings`:
- Line 269: Update the rail.transfers_fmt localization to use count-aware
Russian pluralization, matching the existing rail.stops_fmt or
rail.stops_to_go_fmt pattern; ensure 2–4 transfers use “пересадки” while 0 and
5+ retain “пересадок”.

---

Nitpick comments:
In `@OTPKit/Sources/OTPKit/Network/GraphQLAPIService.swift`:
- Around line 187-189: Update the GraphQL query around realTime, departureDelay,
and arrivalDelay to use the supported end.estimated.delay and
start.estimated.delay fields instead of deprecated Leg delay fields. Update
GraphQLLeg-to-Leg response mapping consistently so delay values retain their
current semantics, and verify the fields against the supported OTP server
versions.

In
`@OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/TipContentView.swift`:
- Around line 249-283: Move the next-boarding selection rule from
TipContentView.footerLeadingText into TripProgress as a rider-facing query,
preserving the currentLegIndex and riding/waiting exclusion behavior. Update
footerLeadingText to call that TripProgress API and retain only localization and
formatting, adding or updating TripProgressTests to cover the lookup rule.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: eec09fcf-8d3d-4682-be63-122b51feeff3

📥 Commits

Reviewing files that changed from the base of the PR and between e5e9737 and 762f8ac.

📒 Files selected for processing (32)
  • OTPKit/Sources/OTPKit/Core/Models/OTP/Leg.swift
  • OTPKit/Sources/OTPKit/Core/Models/OTP/RealTimeStatus.swift
  • OTPKit/Sources/OTPKit/Core/Models/OTP/RelativeDirection.swift
  • OTPKit/Sources/OTPKit/Core/TripProgress/TripProgress.swift
  • OTPKit/Sources/OTPKit/Core/Utilities/Formatters.swift
  • OTPKit/Sources/OTPKit/Network/GraphQLAPIService.swift
  • OTPKit/Sources/OTPKit/Network/GraphQLPlanResponse.swift
  • OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Components/DirectionLegs/DirectionLegWalkView.swift
  • OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/DirectionsSheetView.swift
  • OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/PagedDirectionsView.swift
  • OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/InTripRailView.swift
  • OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/RailMarkColumn.swift
  • OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/RailRowContentViews.swift
  • OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/RailRowView.swift
  • OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/RouteBadge.swift
  • OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/TipContentView.swift
  • OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/TripHeaderView.swift
  • OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/TripProgressBarView.swift
  • OTPKit/Sources/OTPKit/Resources/ar.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/en.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/es.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/fil.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/fr.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/it.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/ko.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/pl.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/pt-BR.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/ru.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/vi.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/zh-Hans.lproj/Localizable.strings
  • OTPKit/Sources/OTPKit/Resources/zh-Hant.lproj/Localizable.strings
  • OTPKit/Tests/TripProgressTests.swift
💤 Files with no reviewable changes (1)
  • OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/PagedDirectionsView.swift

Comment on lines +143 to +147
case .board(let index), .ride(let index):
if row.state == .done, case .board = row.kind, !isRiding(index) {
return .thin
}
return .bar(progress.legs[index].routeUIColor ?? Color(.systemGray2))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Completed ride spans stay styled as active transit.

A completed .ride always returns .bar; only completed .board rows become thin. After alighting, the completed ride segment remains route-colored, contrary to the stated “ahead of (or under) the rider” rule. Apply the completed/non-riding check to both .board and .ride.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/InTripRailView.swift`
around lines 143 - 147, The completed-row styling in the .board/.ride case only
treats .board as thin; update the condition to apply to both row kinds when
row.state is .done and isRiding(index) is false. Preserve the route-colored .bar
styling for active or still-riding segments.

Comment on lines +240 to +266
@ViewBuilder
private var rideSummaryLine: some View {
if let headsign = leg.headsign, let total = progress.totalStops(onLegAt: legIndex) {
let ride = OTPLoc("rail.ride_stops_duration_fmt",
comment: "Stop count and duration of the ride",
RailText.stops(total),
Formatters.formatTimeDuration(leg.duration))
Text(OTPLoc("rail.toward_stops_fmt",
comment: "Vehicle headsign, then ride summary", headsign, ride))
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
subtitleLine
}
}

@ViewBuilder
private var subtitleLine: some View {
if let headsign = leg.headsign {
let stops = progress.totalStops(onLegAt: legIndex).map(RailText.stops) ??
Formatters.formatTimeDuration(leg.duration)
Text(OTPLoc("rail.toward_stops_fmt",
comment: "Vehicle headsign, then ride summary", headsign, stops))
.font(.subheadline)
.foregroundStyle(.secondary)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Boarding detail loses stop/duration info entirely when leg.headsign is nil.

Both rideSummaryLine and subtitleLine are gated on leg.headsign being present. If a transit leg has no headsign (plausible for some agencies), the entire secondary line in waitingDetail/focusedDetail/summary disappears — riders get no stop count or duration for that boarding, even though progress.totalStops or leg.duration may still be available.

Consider falling back to a headsign-less variant (e.g. just "12 stops · 8 min") instead of rendering nothing.

♻️ Possible fallback
     `@ViewBuilder`
     private var subtitleLine: some View {
         if let headsign = leg.headsign {
             let stops = progress.totalStops(onLegAt: legIndex).map(RailText.stops) ??
                 Formatters.formatTimeDuration(leg.duration)
             Text(OTPLoc("rail.toward_stops_fmt",
                         comment: "Vehicle headsign, then ride summary", headsign, stops))
                 .font(.subheadline)
                 .foregroundStyle(.secondary)
+        } else if let total = progress.totalStops(onLegAt: legIndex) {
+            Text(RailText.stops(total))
+                .font(.subheadline)
+                .foregroundStyle(.secondary)
         }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@OTPKit/Sources/OTPKit/Presentation/Sheets/Directions/Rail/RailRowContentViews.swift`
around lines 240 - 266, Update rideSummaryLine and subtitleLine so missing
leg.headsign values still render the available stop count or duration, using a
headsign-less localized summary instead of returning no view. Preserve the
existing headsign-based formatting when a headsign exists and fall back between
progress.totalStops(onLegAt:) and leg.duration as currently done.

"rail.arrive_at_fmt" = "Прибытие: %@";
"rail.time_left_fmt" = "Осталось %@";
"rail.transfer_one" = "1 пересадка";
"rail.transfers_fmt" = "Пересадок: %d";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the relevant localization keys and nearby count-based patterns.
fd -a 'Localizable.strings$' . | sed 's#^\./##'
echo '--- targeted matches ---'
rg -n 'rail\.(transfers|transfers_fmt|transfer_one|stops_fmt|stops_to_go_fmt)|rail_stops|stops_to_go' OTPKit/Sources/OTPKit/Resources -g '*.strings' || true
echo '--- relevant file snippet ---'
sed -n '168,276p' OTPKit/Sources/OTPKit/Resources/ru.lproj/Localizable.strings
EOF

Repository: OneBusAway/otpkit

Length of output: 10809


Use a count-aware plural string for rail.transfers_fmt.

"Пересадок: %d" is only correct for 0/5+ transfers; for 2, 3, 4 transfers the noun should be genitive singular (пересадки). Use .stringsdict plurals or the abbreviated stop pattern already used for rail.stops_fmt/rail.stops_to_go_fmt.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@OTPKit/Sources/OTPKit/Resources/ru.lproj/Localizable.strings` at line 269,
Update the rail.transfers_fmt localization to use count-aware Russian
pluralization, matching the existing rail.stops_fmt or rail.stops_to_go_fmt
pattern; ensure 2–4 transfers use “пересадки” while 0 and 5+ retain “пересадок”.

- Scroll the rail to the current row on first display, not just on
  auto-advance (onChange never fires for the initial value).
- Keep the boarding subtitle when a leg has no headsign: fall back to
  the stop count or duration instead of dropping the line.
- Pick RouteBadge text color by WCAG contrast ratio over linearized
  sRGB, instead of a gamma-encoded luminance threshold.
- Make the tip detent a CustomPresentationDetent that resolves to
  300pt at accessibility text sizes (scrolling past that), so Dynamic
  Type never clips against the fixed 160pt height.
- Move the footer's next-boarding rule into TripProgress as
  nextBoardingIndex, with a unit test.
- Give the rail enough bottom padding that the Back-to-now pill can't
  cover the Arrive row (simulator-found).
@aaronbrethorst

Copy link
Copy Markdown
Member Author

Addressed the review feedback in 7d5900c:

Fixed

  • Initial scroll to the current row (InTripRailView): added an onAppear jump with the same tethered guard — onChange never fires for the initial value.
  • Headsign-less boarding rows (RailRowContentViews): subtitleLine now falls back to the stop count or duration instead of rendering nothing.
  • RouteBadge foreground (RouteBadge): now linearizes sRGB before applying the WCAG luminance coefficients and picks black/white by which has the higher contrast ratio.
  • Tip detent vs Dynamic Type (DirectionsSheetView/TipContentView): the tip is now a CustomPresentationDetent that resolves to 300pt at accessibility text sizes (160pt otherwise), and the tip content scrolls at accessibility sizes so nothing can clip. Detent identity comparisons are unaffected.
  • Next-boarding rule (TipContentViewTripProgress.nextBoardingIndex): moved into the model with a unit test, per the keep-views-thin convention.

Not changed, with reasons

  • Completed ride spans staying route-colored (InTripRailView.segment(for:)): a .ride row is synthetic and exists only while TripPhase.riding that leg (see TripProgress.rows), so a .done ride row is unreachable. The .board row keeps the fat colored bar while its leg is still being ridden by design, and goes thin once done.
  • ru rail.transfers_fmt: the label form "Пересадок: %d" was chosen deliberately — the colon-label construction takes genitive plural regardless of count, which sidesteps 2–4 agreement without .stringsdict. Proper stringsdict pluralization across all locales is tracked as deferred work in the PR description.
  • Deprecated GraphQL departureDelay/arrivalDelay: intentional for now — these fields work across all OTP 2.x releases, while start/end.estimated.delay only exists on newer servers. Migrating (or querying both) is worth doing when we can verify against the range of servers we target.

Verified after the changes: 184 package tests pass, SwiftLint clean, and a full simulator walkthrough (plan → start trip → tip detent at 160pt → expand → focus/back-to-now → end trip) passed. The walkthrough also caught the Back-to-now pill overlapping the final Arrive row, fixed in the same commit.

@aaronbrethorst
aaronbrethorst merged commit 7be9420 into main Jul 29, 2026
5 checks passed
@aaronbrethorst
aaronbrethorst deleted the new-design branch July 29, 2026 05:22
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