diff --git a/client/src/components/Dawarich/dawarichSuggestionModel.ts b/client/src/components/Dawarich/dawarichSuggestionModel.ts index 7dbfe2179..0e7927e31 100644 --- a/client/src/components/Dawarich/dawarichSuggestionModel.ts +++ b/client/src/components/Dawarich/dawarichSuggestionModel.ts @@ -34,7 +34,9 @@ export function groupByDay(suggestions: DawarichSuggestion[]): SuggestionDay[] { else byDate.set(stay.localDate, [stay]) } - const oldestFirst = [...byDate.keys()].sort() + // ISO dates, so lexicographic order IS chronological order. Spelled out rather than + // left bare, and deliberately not locale-aware, which would vary by runtime. + const oldestFirst = [...byDate.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) return oldestFirst .map((date, index) => ({ date, diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx index c10a58491..513e1ddc1 100644 --- a/client/src/components/Map/MapViewGL.test.tsx +++ b/client/src/components/Map/MapViewGL.test.tsx @@ -2037,6 +2037,28 @@ describe('MapViewGL', () => { ) }) + it('FE-COMP-MAPVIEWGL-100: the imagery goes under the offered roads too, not just under the route', async () => { + // The roads offered for a leg are added before the route source so they sit beneath the + // current route, which also puts them before the first `trip-` layer. Anchoring the + // imagery on that first `trip-` layer therefore painted it straight over them, and an + // offered road on a satellite basemap could not be seen at all. + loadOnAttach() + useSettingsStore.setState({ + settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' }, + } as never) + glMap.getStyle.mockReturnValue({ + layers: [{ id: 'background' }, { id: 'road' }, { id: 'route-alt-hit' }, { id: 'route-alt-line' }, { id: 'trip-route' }], + }) + + render() + await flushFrames() + + expect(glMap.addLayer).toHaveBeenCalledWith( + expect.objectContaining({ id: 'trip-satellite-raster', type: 'raster' }), + 'route-alt-hit', + ) + }) + it('FE-COMP-MAPVIEWGL-075: with the default basemap no imagery is fetched at all', async () => { loadOnAttach() useSettingsStore.setState({ diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index e72b138ef..db8584d28 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -74,7 +74,11 @@ const GPX_HIT_LAYER_ID = 'trip-gpx-hit' const SATELLITE_SOURCE_ID = 'trip-satellite' const SATELLITE_LAYER_ID = 'trip-satellite-raster' /** Everything TREK draws is prefixed; the imagery is inserted before the first of them. */ -const OWN_LAYER_PREFIXES = ['trip-', 'trek-'] +// Everything TREK draws on a GL map, so the satellite raster can be slipped underneath all +// of it. `route-alt-` earns its place here: the roads offered for a leg are added before the +// route source so they sit under the current route, which also puts them before the first +// `trip-` layer, and anchoring the imagery there painted right over them. +const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-'] type PlaceWithCoords = Place & { lat: number; lng: number } diff --git a/client/src/components/Map/dawarichTrail.ts b/client/src/components/Map/dawarichTrail.ts index 97adec6af..5fc536d6a 100644 --- a/client/src/components/Map/dawarichTrail.ts +++ b/client/src/components/Map/dawarichTrail.ts @@ -130,7 +130,9 @@ export function collapsedDayDates( const foldedInPlan = !!expandedDayIds && !expandedDayIds.has(day.id) if (foldedInPlan || collapsedDayIds?.has(day.id)) dates.add(day.date) } - return [...dates].sort() + // Explicit rather than bare: these are ISO dates, where lexicographic IS chronological, + // and a locale-aware comparison would reorder them differently per runtime. + return [...dates].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) } /** The overlay as a GeoJSON FeatureCollection, for the GL renderer. */ diff --git a/client/src/components/Roadtrip/EndDayControl.tsx b/client/src/components/Roadtrip/EndDayControl.tsx index 86d11a7ba..750315f74 100644 --- a/client/src/components/Roadtrip/EndDayControl.tsx +++ b/client/src/components/Roadtrip/EndDayControl.tsx @@ -5,7 +5,12 @@ import ToggleSwitch from '../Settings/ToggleSwitch' export interface EndDayControlProps { active: boolean - onToggle: () => Promise + /** + * Whatever it resolves to is ignored here: this control reads its state back from the + * trip. The writer reports success to callers that flip a switch optimistically, and the + * type has to let that through. + */ + onToggle: () => Promise } export default function EndDayControl({ active, onToggle }: EndDayControlProps) { diff --git a/client/src/components/Roadtrip/useCorridorPois.ts b/client/src/components/Roadtrip/useCorridorPois.ts index 69c12ede8..16c863053 100644 --- a/client/src/components/Roadtrip/useCorridorPois.ts +++ b/client/src/components/Roadtrip/useCorridorPois.ts @@ -212,7 +212,17 @@ export function useCorridorPois( if (controller.signal.aborted || runId !== runIdRef.current) return // Out of time stops STARTING boxes; whatever answered stays on screen and the // rest reports itself as unsearched, the same way a failed box does. - if (outOfTime()) { failures += jobs.length - next; setFailedAreas(failures); return } + // + // The remainder is claimed before it is counted, because every worker still alive + // runs this line: each one used to add the whole rest, so a search that timed out + // with three workers up reported three times the boxes it had left, which can be + // more than there ever were. Taking `next` to the end also ends the others' loops. + if (outOfTime()) { + const unsearched = jobs.length - next + next = jobs.length + if (unsearched > 0) { failures += unsearched; setFailedAreas(failures) } + return + } const job = jobs[next++] if (!(await collect(job))) retryable.push(job) done++ diff --git a/client/src/hooks/useDawarichJournalTrail.ts b/client/src/hooks/useDawarichJournalTrail.ts index de9e7c380..a16068c24 100644 --- a/client/src/hooks/useDawarichJournalTrail.ts +++ b/client/src/hooks/useDawarichJournalTrail.ts @@ -37,7 +37,9 @@ export function useDawarichJournalTrail( // here rather than trusting insertion order. const window = useMemo(() => { if (dates.size === 0) return null - const sorted = [...dates].sort() + // ISO dates: lexicographic order is chronological, and staying locale-independent + // keeps the window identical on every runtime. + const sorted = [...dates].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) return { from: `${sorted[0]}T00:00:00Z`, to: `${sorted[sorted.length - 1]}T23:59:59Z` } }, [dates]) diff --git a/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx b/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx index dd1bc50cf..c784d5385 100644 --- a/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx +++ b/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx @@ -5,6 +5,7 @@ import MIconBtn from '../../../components/MIconBtn' import { formatDurationShort, serviceColor } from '../../../../components/Roadtrip/roadtripModel' import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds' import { formatDistance } from '../../../../utils/units' +import { formatClockTime } from '../../../../utils/formatters' import type { StopRow } from '../../../../components/Roadtrip/roadtripRowModel' import type { RefuelSearch } from '../../../../components/Roadtrip/useRefuelSearch' import { REFUEL_EMPTY_KEY, REFUEL_WORDS, refuelBandState, type RefuelCandidate } from '../../../../components/Roadtrip/refuelSuggestion' @@ -29,6 +30,15 @@ import type { TranslationFn } from '../../../../types' export interface RowChrome { t: TranslationFn unit: DistanceUnit + /** + * Whether this reader is on a twelve hour clock. + * + * The model hands every clock over as `HH:MM`, which is the shape the schedule computes + * in, and printing that straight to the screen ignored the setting the desktop rail, the + * day timeline and this tab's own sheets all honour. It rides in the chrome rather than + * being read per row: the chain draws dozens of rows and they must not disagree. + */ + is12h: boolean } /** @@ -151,7 +161,7 @@ export function RtStopRow({ row, chrome, onOpen, onPickKind }: { // dir=ltr so a clock reads the same way round in an RTL locale. {row.pinned && } - {row.time} + {formatClockTime(row.time, chrome.is12h)} )} @@ -470,7 +480,7 @@ export function RtAutoRow({ phase, time, chrome }: { {phase === 'end' ? t('roadtrip.window.stop') : t('roadtrip.window.resume')} - {time && {time}} + {time && {formatClockTime(time, chrome.is12h)}} ) } diff --git a/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx index c3314e1e7..6d4dca398 100644 --- a/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx +++ b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx @@ -13,6 +13,7 @@ import { formatDurationShort } from '../../../../components/Roadtrip/roadtripMod import { getNavigationTargets } from '../../../../components/Planner/placeNavigation' import { useSettingsStore } from '../../../../store/settingsStore' import { formatDistance } from '../../../../utils/units' +import { formatClockTime } from '../../../../utils/formatters' import { isRtlLanguage } from '../../../../i18n' import type { MTripTabPanelProps } from '../MTripShell' import { legReroutable, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel' @@ -40,7 +41,8 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) { // the map read one controller, so a leg shows pressed for exactly the picker on the map. const alts = useMRtAlternatives(planner, shell) const unit = useSettingsStore(s => s.settings.distance_unit) - const chrome: RowChrome = { t, unit } + const is12h = useSettingsStore(s => s.settings.time_format) === '12h' + const chrome: RowChrome = { t, unit, is12h } // The same gesture the day timeline uses, called rather than rebuilt: 370 lines of // worked-out conflict avoidance, down to the dead 24px gutter that stops iOS from @@ -176,7 +178,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) { {t('mobileTrip.rtStart')} - {rt.clocks.start ?? '-'} + {rt.clocks.start ? formatClockTime(rt.clocks.start, is12h) : '-'} @@ -184,7 +186,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) { {t('roadtrip.stay.arrive')} - {rt.clocks.arrive ?? '-'} + {rt.clocks.arrive ? formatClockTime(rt.clocks.arrive, is12h) : '-'} @@ -291,6 +293,7 @@ function UpNext({ planner, shell, rt, stageDayId, onOpen }: { onOpen: (row: StopRow) => void }) { const { t } = planner + const is12h = useSettingsStore(s => s.settings.time_format) === '12h' const next = rt.upNext if (!next) return null const late = next.minutesUntil < 0 @@ -320,7 +323,7 @@ function UpNext({ planner, shell, rt, stageDayId, onOpen }: { {next.row.stop.name} {next.row.time && ( - {next.row.time} + {formatClockTime(next.row.time, is12h)} )}
@@ -332,7 +335,7 @@ function UpNext({ planner, shell, rt, stageDayId, onOpen }: { className={`flex h-11 flex-1 items-center justify-center gap-[7px] rounded-full bg-m-act text-[0.8125rem] font-semibold text-m-actfg shadow-[0_10px_24px_-10px_rgba(0,0,0,.45)] ${targets.length ? '' : 'pointer-events-none opacity-40'}`} >