Conversation
- Create favoritesStore.js with CRUD operations (add, remove, toggle) - Implement FavoriteButton.svelte component with reactive store subscription - Integrate FavoriteButton into StopItem and RouteItem components - Add FavoriteButton to modal headers via ModalPane component - Update StopModal to pass favoriteId and favoriteType props - Fix test selectors for nested button scenarios - All tests passing (1163 tests) - Features: star icon toggles instantly, favorites persist across reloads
- Detect short-line trips via stopHeadsign comparison - Show amber background + † marker for short-lines - Add instant hover tooltip showing destination info - Only display legend when route has short-lines - Add unit tests for detection logic - Add i18n translations (en.json)
There was a problem hiding this comment.
Pull request overview
This PR primarily adds short-line detection and presentation to the stop schedule view (issue #251) by tagging trips whose stopHeadsign differs from the route’s primary headsign and surfacing that difference in the schedule UI. It also introduces a favorites feature (store + UI buttons) and some repo/dev artifacts.
Changes:
- Add short-line detection in stop schedule grouping and expose
isShortLine/stopHeadsignto the UI. - Update the schedule table UI to highlight short-line times with a marker and tooltip/legend behavior.
- Add a new favorites store and UI buttons (Stop/Route items and modal header), plus related test updates.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| src/routes/stops/[stopID]/schedule/+page.svelte | Adds main headsign plumbing and sets isShortLine/stopHeadsign when grouping stop times. |
| src/components/schedule-for-stop/RouteScheduleTable.svelte | Highlights short-line times and shows tooltip/legend for short-line trips. |
| src/locales/en.json | Adds i18n strings intended for short-line legend/tooltip text. |
| src/routes/stops/tests/schedule-shortline.test.js | Adds unit tests for short-line detection logic (currently via a test-local helper). |
| src/stores/favoritesStore.js | Introduces a persisted favorites store backed by localStorage. |
| src/components/favorites/FavoriteButton.svelte | Adds a reusable favorites toggle button component. |
| src/components/StopItem.svelte | Adds favorites button to stop list items. |
| src/components/RouteItem.svelte | Adds favorites button to route list items. |
| src/components/navigation/ModalPane.svelte | Adds optional favorites button in the modal header. |
| src/components/stops/StopModal.svelte | Wires stop modal to show a favorites control via ModalPane. |
| src/components/stops/tests/StopItem.test.js | Updates tests to account for additional button(s) in StopItem. |
| src/components/tests/RouteItem.test.js | Updates tests to account for additional button(s) in RouteItem. |
| PHASE_1_AUDIT.md | Adds an architecture/audit document. |
| .vscode/tasks.json | Adds a VS Code build task config. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| </div> | ||
| </div> | ||
| <FavoriteButton id={stop.id} type="stop" ariaLabel={`Add ${stop.name} to favorites`} /> | ||
| </button> |
There was a problem hiding this comment.
StopItem renders a <button> that contains FavoriteButton, which itself renders another <button>. Nested buttons are invalid HTML and can break click/keyboard behavior and accessibility. Restructure so the favorite control is a sibling of the primary button (e.g., wrap both in a container, or change the outer element to a non-button with appropriate role/handlers).
| <div class="text-normal flex-1 self-center font-semibold">{title}</div> | ||
| {#if favoriteId} | ||
| <FavoriteButton id={favoriteId} type={favoriteType} /> | ||
| {/if} |
There was a problem hiding this comment.
ModalPane renders FavoriteButton without an ariaLabel, so many panes will expose a generic "Add to favorites" label. This is ambiguous for assistive tech; consider passing a specific label (e.g., derived from title) or adding a favoriteAriaLabel prop that callers can set.
| function getTooltip(stopTime) { | ||
| if (!stopTime.isShortLine || !stopTime.stopHeadsign) return null; | ||
| return `Ends at ${stopTime.stopHeadsign} — does not continue to ${schedule.mainHeadsign}`; | ||
| } |
There was a problem hiding this comment.
The short-line tooltip/legend strings are currently hardcoded (getTooltip() and the legend text), but this PR adds i18n keys (schedule_for_stop.ends_at_short_line, schedule_for_stop.short_line_legend). To avoid untranslated UI and dead strings, use $t(...) with interpolation for both tooltip and legend (or remove the unused keys).
| <span | ||
| class="relative cursor-help rounded bg-amber-100 px-2 text-amber-900 dark:bg-amber-900 dark:text-amber-100" | ||
| on:mouseenter={() => (hoveredTime = `${hour}_${index}`)} | ||
| on:mouseleave={() => (hoveredTime = null)} | ||
| > | ||
| {extractMinutes(stopTime.arrivalTime)}<sup class="text-amber-600 dark:text-amber-300">†</sup> | ||
| {#if hoveredTime === `${hour}_${index}`} | ||
| <div | ||
| class="absolute bottom-full left-0 z-50 mb-2 whitespace-nowrap rounded bg-gray-900 px-3 py-1 text-xs text-white shadow-lg dark:bg-gray-700" | ||
| > | ||
| {getTooltip(stopTime)} | ||
| <div class="absolute top-full left-2 h-0 w-0 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700" | ||
| ></div> | ||
| </div> |
There was a problem hiding this comment.
The legend text says "tap/hover for details", but the UI only shows details on mouseenter/mouseleave. On touch devices there is no hover, so the tooltip will never appear. Add a click/tap toggle (and ideally keyboard focus handling) for short-line markers to match the stated interaction.
|
|
||
| /** | ||
| * Test helper to simulate the groupStopTimesByHour function | ||
| * This tests the short-line detection logic | ||
| */ | ||
| function groupStopTimesByHour(stopTimes, mainHeadsign) { | ||
| const grouped = {}; | ||
| for (let stopTime of stopTimes) { | ||
| const date = new Date(stopTime.arrivalTime); | ||
| const hour = date.getHours(); | ||
| if (!grouped[hour]) grouped[hour] = []; | ||
|
|
||
| // Detect short-line: if stopHeadsign differs from main headsign, it's a short-line | ||
| const isShortLine = !!(stopTime.stopHeadsign && stopTime.stopHeadsign !== mainHeadsign); | ||
|
|
||
| grouped[hour].push({ | ||
| arrivalTime: stopTime.arrivalTime, | ||
| stopHeadsign: stopTime.stopHeadsign, | ||
| isShortLine: isShortLine | ||
| }); | ||
| } | ||
| return grouped; | ||
| } | ||
|
|
There was a problem hiding this comment.
These tests re-implement groupStopTimesByHour() inside the test file instead of exercising the production logic from +page.svelte. As written, the tests can pass even if the real short-line detection breaks. Consider extracting the grouping/short-line detection into a shared module (e.g., $lib/scheduleUtils) and importing it in both the page and the tests.
| /** | |
| * Test helper to simulate the groupStopTimesByHour function | |
| * This tests the short-line detection logic | |
| */ | |
| function groupStopTimesByHour(stopTimes, mainHeadsign) { | |
| const grouped = {}; | |
| for (let stopTime of stopTimes) { | |
| const date = new Date(stopTime.arrivalTime); | |
| const hour = date.getHours(); | |
| if (!grouped[hour]) grouped[hour] = []; | |
| // Detect short-line: if stopHeadsign differs from main headsign, it's a short-line | |
| const isShortLine = !!(stopTime.stopHeadsign && stopTime.stopHeadsign !== mainHeadsign); | |
| grouped[hour].push({ | |
| arrivalTime: stopTime.arrivalTime, | |
| stopHeadsign: stopTime.stopHeadsign, | |
| isShortLine: isShortLine | |
| }); | |
| } | |
| return grouped; | |
| } | |
| import { groupStopTimesByHour } from '../+page.svelte'; |
| {getDisplayRouteName(route)} | ||
| </div> | ||
| <FavoriteButton id={route.id} type="route" ariaLabel={`Add ${getDisplayRouteName(route)} to favorites`} /> | ||
| </button> |
There was a problem hiding this comment.
RouteItem is a <button> and now contains FavoriteButton (another <button>), creating nested interactive elements. This is invalid HTML and can cause inconsistent click/keyboard behavior. Refactor so the favorite button is not nested inside the main button (make them siblings in a wrapper, or change the outer element).
| <button | ||
| type="button" | ||
| onclick={handleToggle} | ||
| aria-label={ariaLabel} | ||
| class="favorite-btn inline-flex items-center justify-center rounded p-2 transition-colors hover:bg-gray-200 dark:hover:bg-gray-700" | ||
| title={isFavorited ? 'Remove from favorites' : 'Add to favorites'} | ||
| > |
There was a problem hiding this comment.
FavoriteButton keeps the same aria-label regardless of whether the item is already favorited. For screen readers, the label should reflect the current action/state (e.g., "Remove … from favorites" when isFavorited is true). Consider deriving the label from isFavorited or accepting separate add/remove labels.
| 12<sup class="text-amber-600 dark:text-amber-300">†</sup> | ||
| </span> | ||
| Ends before final destination — tap/hover for details |
There was a problem hiding this comment.
The legend text at the bottom is hardcoded and duplicates the new i18n string (schedule_for_stop.short_line_legend). Also the example "12†" is a magic value that may confuse users. Consider rendering just the marker (†) with localized legend text via $t(...).
| 12<sup class="text-amber-600 dark:text-amber-300">†</sup> | |
| </span> | |
| Ends before final destination — tap/hover for details | |
| <sup class="text-amber-600 dark:text-amber-300">†</sup> | |
| </span> | |
| {$isLoading ? '' : $t('schedule_for_stop.short_line_legend')} |
| import { writable } from 'svelte/store'; | ||
| import { browser } from '$app/environment'; | ||
|
|
||
| const STORAGE_KEY = 'wayfinder_favorites'; | ||
|
|
||
| function createFavoritesStore() { |
There was a problem hiding this comment.
favoritesStore adds a full favorites/bookmarks feature (store + UI hooks elsewhere), but the PR title/description focus on short-line schedule highlighting (#251). If favorites is intended here, please update the PR description/scope; otherwise consider splitting favorites-related changes into a separate PR to keep review and rollback safer.
| isFavorite: (id, type = 'stop') => { | ||
| let result = false; | ||
| subscribe((favorites) => { | ||
| result = favorites.some((fav) => fav.id === id && fav.type === type); | ||
| })(); | ||
| return result; | ||
| }, |
There was a problem hiding this comment.
isFavorite() subscribes to the store just to compute a synchronous boolean, which creates a transient subscription on every call. This can be inefficient and is easy to misuse in render paths. Consider exposing a derived store per id/type, or (if you need sync reads) using get() from svelte/store inside this module.
|
Resolved all comments, PTAL @aaronbrethorst |
Overview
This PR implements short-line detection for route schedules to address issue #251.
Problem
When viewing a route schedule, if some trips end at an intermediate stop (short-line) instead of the final destination, this wasn't indicated in any way. For example, Route 120 at stop 12434 has trips ending at both Fashion Valley and Kearny Mesa, but there was no visual distinction.
Solution
stopHeadsignwith the main route headsignChanges
src/routes/stops/[stopID]/schedule/+page.svelte: Added short-line detection logic ingroupStopTimesByHour()src/components/schedule-for-stop/RouteScheduleTable.svelte: Updated UI with amber styling and hover tooltipssrc/locales/en.json: Added i18n translationssrc/routes/stops/__tests__/schedule-shortline.test.js: Added unit tests (4 tests, all passing)Testing
i used mock data to test the feature :

AFTER