}
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'}`}
>
- {t('places.navigate')}
+ {targets.length === 1 ? targets[0].label : t('inspector.navigation')}
0) notes.push(t('mobileTrip.rtTruncated', { count: corridor.truncatedAreas }))
+ // Two keys picked by the count: the i18n layer has no plural engine, so a single key
+ // with {count} in a sentence that names the noun once reads as "1 stretches".
+ if (corridor.truncatedAreas > 0) {
+ notes.push(
+ t(corridor.truncatedAreas === 1 ? 'mobileTrip.rtTruncated.one' : 'mobileTrip.rtTruncated.other', {
+ count: corridor.truncatedAreas,
+ }),
+ )
+ }
if (corridor.offline) return }>{t('mobileTrip.rtSearchOffline')}
if (corridor.error) return }>{t('roadtrip.poi.failed')}
diff --git a/client/src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx b/client/src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx
index 8485ce6f5..25f671f95 100644
--- a/client/src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx
+++ b/client/src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx
@@ -17,6 +17,9 @@ interface RtStaySheetPayload {
/** The stay the place carries right now; absent and zero mean the same thing. */
minutes?: number | null
name?: string
+ /** Where the stop sheet was standing, so saving can hand the traveller back to it. */
+ dayId?: number
+ assignmentId?: number
}
/** The step the two buttons move in. Same five minutes as the desktop dialog. */
@@ -141,7 +144,14 @@ export default function MRtStaySheet({ planner, shell }: MTripSheetsProps) {
setSaving(true)
try {
await planner.setRoadtripStay(placeId, value)
- shell.openSheet('rtstop', { ...stop, placeId, minutes: value })
+ // 'rtstop' is located by day and assignment, not by place: spreading this sheet's own
+ // payload into it opened a sheet that resolved to nothing, drew nothing, and still
+ // counted as open, which blocks the day swipe until something else closes it.
+ if (stop?.dayId != null && stop.assignmentId != null) {
+ shell.openSheet('rtstop', { dayId: stop.dayId, assignmentId: stop.assignmentId })
+ } else {
+ shell.closeSheet()
+ }
} catch {
setMinutes(stored)
planner.toast.error(t('common.unknownError'))
diff --git a/client/src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx b/client/src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx
index d77f346bd..7776813ea 100644
--- a/client/src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx
+++ b/client/src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx
@@ -179,7 +179,11 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) {
* the stage sheet calls the stop-addressed writer directly.
*/
const canEndDay = stop != null && planner.dailyTimesActive && planner.can('day_edit', planner.trip)
- const endDayTruth = !!stop?.endDay
+ // Both things that can end a day, not just the flag: a manual boundary filed against
+ // this stop closes the day too, and reading only the flag showed the switch as off for a
+ // stop that already ends it. Worse, the tap meant to switch it on then ran through the
+ // writer's boundary branch and deleted that day end.
+ const endDayTruth = stop != null && planner.roadtripEndsDayAt(stop)
// The optimistic value is released the moment the planner's own answer moves off what
// it was taken from, so a landed write shows the real state rather than a copy of it.
const endDayActive = pending && pending.from === endDayTruth ? pending.to : endDayTruth
@@ -225,7 +229,17 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) {
const stayMinutes = stop.dwellMinutes
const canEditPlace = planner.can('place_edit', planner.trip)
const editStay = () => {
- shell.openSheet('rtstay', { placeId: stop.placeId, minutes: stayMinutes })
+ // The row this sheet is on rides along, because the stay sheet comes back here when it
+ // saves and 'rtstop' is located by day and assignment. Without them it reopened on a
+ // payload it could not resolve, drew nothing, and left shell.sheet pointing at a sheet
+ // that is not on screen, which is what the day swipe reads to decide it is blocked.
+ shell.openSheet('rtstay', {
+ placeId: stop.placeId,
+ minutes: stayMinutes,
+ name: stop.name,
+ dayId: located.day.dayId,
+ assignmentId: stop.assignmentId,
+ })
}
// A negative id is the optimistic row of a stop still being saved. The editor would
@@ -244,7 +258,10 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) {
setPending({ from: endDayTruth, to: next })
setSaving(true)
try {
- await planner.setRoadtripEndDay(stop)
+ // The writer reports rather than throws, and shows its own toast, so the catch below
+ // could never fire: a refused write left the switch standing at the state the trip
+ // never reached, with only a toast to say otherwise.
+ if (!await planner.setRoadtripEndDay(stop)) setPending(null)
} catch (err: unknown) {
setPending(null)
planner.toast.error(err instanceof Error ? err.message : t('common.unknownError'))
diff --git a/client/src/mobile/screens/trip/roadtrip/useMRoadtrip.ts b/client/src/mobile/screens/trip/roadtrip/useMRoadtrip.ts
index 2485dd8c3..d2a4d4c09 100644
--- a/client/src/mobile/screens/trip/roadtrip/useMRoadtrip.ts
+++ b/client/src/mobile/screens/trip/roadtrip/useMRoadtrip.ts
@@ -8,6 +8,7 @@ import type { RoadtripDay } from '@trek/shared/roadtrip'
import type { MTripShellApi, TripPlanner } from '../MTripShell'
import type { RoadtripRow, StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
import type { Category, Place } from '../../../../types'
+import { localIsoDate } from '../../../../utils/localDate'
/** Minutes since midnight, local time. */
const nowMinutes = (): number => {
@@ -90,7 +91,10 @@ export function useMRoadtrip(planner: TripPlanner): MRoadtripController {
const isToday = useMemo(() => {
const date = days.find(d => d.id === selectedDayId)?.date
if (!date) return false
- return date.slice(0, 10) === new Date().toISOString().slice(0, 10)
+ // The wall clock, not UTC: `nowMinutes` above is local, and between local midnight
+ // and the UTC rollover the two disagree, so east of Greenwich the card hung on
+ // yesterday's stage for the first hours of every night. `localDate.ts` says as much.
+ return date.slice(0, 10) === localIsoDate()
}, [days, selectedDayId])
return {
diff --git a/client/src/mobile/screens/trip/roadtrip/useMRtCorridor.ts b/client/src/mobile/screens/trip/roadtrip/useMRtCorridor.ts
index da731eccb..5789b32a6 100644
--- a/client/src/mobile/screens/trip/roadtrip/useMRtCorridor.ts
+++ b/client/src/mobile/screens/trip/roadtrip/useMRtCorridor.ts
@@ -4,6 +4,7 @@ import { stageOf, upNextStop } from '../../../../components/Roadtrip/roadtripRow
import { useNetworkMode } from '../../../../hooks/useNetworkMode'
import type { CorridorPoi } from '../../../../components/Roadtrip/useCorridorPois'
import type { MTripShellApi, TripPlanner } from '../MTripShell'
+import { localIsoDate } from '../../../../utils/localDate'
/** Minutes since midnight, local time: the same reading the stage screen takes. */
const nowMinutes = (): number => {
@@ -91,7 +92,9 @@ export function useMRtCorridor(planner: TripPlanner, shell: MTripShellApi): MRtC
const upNextIndexNow = useCallback((): number => {
if (!stage) return -1
const date = planner.days.find(d => d.id === planner.selectedDayId)?.date
- const isToday = !!date && date.slice(0, 10) === new Date().toISOString().slice(0, 10)
+ // Local, for the reason useMRoadtrip gives: the clock this is compared against is the
+ // wall clock, and UTC disagrees with it for the first hours of every night.
+ const isToday = !!date && date.slice(0, 10) === localIsoDate()
const next = upNextStop(stage, nowMinutes(), isToday)
if (!next) return -1
return stage.stops.findIndex(s => s.assignmentId === next.row.stop.assignmentId)
diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx
index b869a7fa0..e142f2a9b 100644
--- a/client/src/pages/JourneyDetailPage.tsx
+++ b/client/src/pages/JourneyDetailPage.tsx
@@ -71,21 +71,33 @@ function JourneyDetailPageDesktop() {
query,
)
const dayGroups = groupByDate(timelineEntries)
- // A stay falls on the day it happened, and that is often a day the journal has no entry
- // on yet — which is the whole point of offering it. Those days join the timeline so the
- // stay can be reached; without them the only way to a quiet day's stays would be to
- // write an entry on it first. Not while a search is running: a day whose entries the
- // query filtered out is not a day the reader is looking at.
- const suggestionDates = canEditEntries && !query ? [...dawarichByDate.keys()] : []
- const sortedDates = [...new Set([...dayGroups.keys(), ...suggestionDates])]
- .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
-
const tripDateMin = current.trips.length
? current.trips.reduce((min: string, t: any) => t.start_date && (!min || t.start_date < min) ? t.start_date : min, '')
: null
const tripDateMax = current.trips.length
? current.trips.reduce((max: string, t: any) => t.end_date && (!max || t.end_date > max) ? t.end_date : max, '')
: null
+
+ // A stay falls on the day it happened, and that is often a day the journal has no entry
+ // on yet, which is the whole point of offering it. Those days join the timeline so the
+ // stay can be reached; without them the only way to a quiet day's stays would be to
+ // write an entry on it first.
+ //
+ // Bounded by this journal's own span, because the hook reads every open stay the account
+ // has: unbounded, a week in May grew a day section for every day Dawarich recorded
+ // anywhere, all year. The span is the entries it already holds together with the dates of
+ // the trips it links, so a journal that is still empty but linked to a trip still offers
+ // that trip's days. With no span at all there is nothing to place a stay against.
+ // Not while a search is running either: a day whose entries the query filtered out is not
+ // a day the reader is looking at.
+ const spanDates = [...dayGroups.keys(), tripDateMin, tripDateMax]
+ .filter((d): d is string => !!d)
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
+ const suggestionDates = canEditEntries && !query && spanDates.length
+ ? [...dawarichByDate.keys()].filter(d => d >= spanDates[0] && d <= spanDates[spanDates.length - 1])
+ : []
+ const sortedDates = [...new Set([...dayGroups.keys(), ...suggestionDates])]
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
const lifecycle = computeJourneyLifecycle(current.status, tripDateMin || null, tripDateMax || null)
const showMobileCombined = isMobile && view === 'timeline'
diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts
index 60e72b71f..4cb5f849a 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.ts
+++ b/client/src/pages/tripPlanner/useTripPlanner.ts
@@ -1223,17 +1223,43 @@ export function useTripPlanner() {
*/
const [stayDraft, setStayDraft] = useState<{ placeId: number; name: string; minutes: number | null; arrival: string | null } | null>(null)
- const setRoadtripEndDay = useCallback(async (stop: RoadtripStop) => {
- if (!dailyTimesActive || !can('day_edit', trip)) return
+ /**
+ * Whether the day ends at this stop, from BOTH the things that can end it.
+ *
+ * A stop carries an `end_day` flag, and a day can also be closed by a manual boundary
+ * filed against that stop (`to_assignment_id === null`). `setRoadtripEndDay` already
+ * knows about both and clears whichever is set, so a surface reading only the flag shows
+ * a day end as off, and the tap meant to switch it on deletes the boundary instead. The
+ * question is asked here once rather than answered again per surface.
+ */
+ const roadtripEndsDayAt = useCallback(
+ (stop: RoadtripStop): boolean =>
+ !!stop.endDay
+ || dayBoundaries.boundaries.some(b => b.to_assignment_id === null && b.from_assignment_id === stop.assignmentId),
+ [dayBoundaries.boundaries],
+ );
+
+ /**
+ * Returns whether the day end actually moved.
+ *
+ * It reports rather than throws, because it shows its own toast and a second one from the
+ * caller would be the same news twice. A caller that flipped a switch optimistically has
+ * to hear about a refusal all the same, or it sits there showing a state the trip never
+ * reached: the phone sheet's catch was unreachable for exactly this reason.
+ */
+ const setRoadtripEndDay = useCallback(async (stop: RoadtripStop): Promise => {
+ if (!dailyTimesActive || !can('day_edit', trip)) return false
try {
const manual = dayBoundaries.boundaries.find(b => b.to_assignment_id === null && b.from_assignment_id === stop.assignmentId)
if (manual) {
await dayBoundaries.save(manual.day_number, null)
- if (!stop.endDay) return
+ if (!stop.endDay) return true
}
await tripActions.setAssignmentEndDay(tripId, stop.ownerDayId, stop.assignmentId, !stop.endDay)
+ return true
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
+ return false
}
}, [dailyTimesActive, can, trip, tripActions, tripId, toast, t, dayBoundaries.boundaries, dayBoundaries.save])
@@ -2396,7 +2422,7 @@ export function useTripPlanner() {
)
const endDayStop = selectedRoadtripStops.length === 1 ? selectedRoadtripStops[0] : undefined
const roadtripEndDay = roadtripActive && dailyTimesActive && can('day_edit', trip) && endDayStop && endDayStop.assignmentId > 0
- ? { active: !!endDayStop.endDay || dayBoundaries.boundaries.some(b => b.to_assignment_id === null && b.from_assignment_id === endDayStop.assignmentId), onToggle: () => setRoadtripEndDay(endDayStop) }
+ ? { active: roadtripEndsDayAt(endDayStop), onToggle: () => setRoadtripEndDay(endDayStop) }
: undefined
const roadtripStay = roadtripActive && selectedPlace
? { minutes: endDayStop ? endDayStop.dwellMinutes : selectedPlace.duration_minutes ?? null, onEdit: can('place_edit', trip) ? () => editRoadtripStay({ placeId: selectedPlace.id, name: selectedPlace.name, minutes: selectedPlace.duration_minutes ?? null, arrival: null }) : undefined }
@@ -2459,6 +2485,7 @@ export function useTripPlanner() {
stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
setRoadtripStopKind,
setRoadtripStopFill,
+ roadtripEndsDayAt,
roadtripSettingsLoading: !roadtripPreferencesState.ready && !roadtripPreferencesState.failed,
saveRoadtripLimit: roadtripPreferencesState.ready && can('day_edit', trip) ? saveRoadtripLimit : undefined,
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, dayBoundaryControls, resetDayBoundaries,
diff --git a/client/tests/helpers/mobileTrip.ts b/client/tests/helpers/mobileTrip.ts
index d3a270654..7a9d2ee74 100644
--- a/client/tests/helpers/mobileTrip.ts
+++ b/client/tests/helpers/mobileTrip.ts
@@ -169,6 +169,9 @@ export function buildPlanner(overrides: Partial = {}): TripPlanner
routeProfile: 'driving',
setRouteProfile: vi.fn(),
routeVias: [],
+ // Whether the day ends at a stop, from the flag and from a manual boundary alike. The
+ // helper answers from the flag; a case about boundaries overrides it.
+ roadtripEndsDayAt: (stop: { endDay?: boolean } | null | undefined) => !!stop?.endDay,
fitKey: 0,
setFitKey: vi.fn(),
diff --git a/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx b/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx
index c5972cc3b..567f1bee2 100644
--- a/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx
+++ b/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx
@@ -9,13 +9,13 @@ import type { TranslationFn } from '../../../../src/types'
import type { RefuelSearch } from '../../../../src/components/Roadtrip/useRefuelSearch'
import type { RefuelCandidate } from '../../../../src/components/Roadtrip/refuelSuggestion'
-// FE-MOB-RTROW-001 to FE-MOB-RTROW-046
+// FE-MOB-RTROW-001 to FE-MOB-RTROW-048
// Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy.
const t: TranslationFn = (key, params) =>
params ? `${key}:${Object.values(params).join(',')}` : key
-const chrome: RowChrome = { t, unit: 'metric' }
+const chrome: RowChrome = { t, unit: 'metric', is12h: false }
function stopRow(over: Partial = {}): StopRow {
return {
@@ -214,6 +214,22 @@ describe('RtStopRow', () => {
expect(screen.getByText('2')).toBeInTheDocument()
})
+ it('FE-MOB-RTROW-047: a clock follows the reader twelve hour setting', () => {
+ // The model hands clocks over as HH:MM, which is the shape the schedule computes in.
+ // Printing that straight to the screen ignored the setting the desktop rail, the day
+ // timeline and this tab's own sheets all honour.
+ render( )
+
+ expect(screen.getByText('2:05 PM')).toBeInTheDocument()
+ expect(screen.queryByText('14:05')).toBeNull()
+ })
+
+ it('FE-MOB-RTROW-048: on a twenty four hour clock it stays exactly as the model wrote it', () => {
+ render( )
+
+ expect(screen.getByText('14:05')).toBeInTheDocument()
+ })
+
it('FE-MOB-RTROW-013: writes an overnight warning as a word, since it has no figure', () => {
const warning: ScheduleWarning = { index: 1, code: 'overnight' }
const { container } = render( )
diff --git a/client/tests/unit/mobile/trip/MRtCorridorSheet.test.tsx b/client/tests/unit/mobile/trip/MRtCorridorSheet.test.tsx
index a48bc9eaa..04bed06ae 100644
--- a/client/tests/unit/mobile/trip/MRtCorridorSheet.test.tsx
+++ b/client/tests/unit/mobile/trip/MRtCorridorSheet.test.tsx
@@ -141,11 +141,18 @@ describe('MRtCorridorSheet', () => {
expect(screen.getByText('roadtrip.poi.capped')).toBeInTheDocument()
expect(screen.getByText('roadtrip.poi.partial:2')).toBeInTheDocument()
// The phone's own wording: the desk's ends in "narrow the corridor", and the
- // corridor width is a control this screen does not have.
- expect(screen.getByText('mobileTrip.rtTruncated:1')).toBeInTheDocument()
+ // corridor width is a control this screen does not have. One stretch takes the
+ // singular key, because one key with {count} in it would read as "1 stretches".
+ expect(screen.getByText('mobileTrip.rtTruncated.one:1')).toBeInTheDocument()
expect(screen.queryByText('roadtrip.poi.truncated:1')).toBeNull()
})
+ it('FE-MOB-RTSRCH-011b: more than one truncated stretch takes the plural key', () => {
+ renderSheet({ answered: true, hits: [hit()], truncatedAreas: 3 })
+
+ expect(screen.getByText('mobileTrip.rtTruncated.other:3')).toBeInTheDocument()
+ })
+
it('FE-MOB-RTSRCH-012: a hit says what it is and where on the drive it sits', () => {
renderSheet({ answered: true, hits: [hit()] })
diff --git a/client/tests/unit/mobile/trip/MRtStaySheet.test.tsx b/client/tests/unit/mobile/trip/MRtStaySheet.test.tsx
index 0a715913a..b7e9f8f9f 100644
--- a/client/tests/unit/mobile/trip/MRtStaySheet.test.tsx
+++ b/client/tests/unit/mobile/trip/MRtStaySheet.test.tsx
@@ -50,7 +50,7 @@ function makePlanner(overrides: Record = {}) {
function makeShell(overrides: Record = {}) {
return buildShell({
- sheet: { id: 'rtstay', payload: { placeId: 202, minutes: 30, name: 'Bremen Marktplatz' } },
+ sheet: { id: 'rtstay', payload: { placeId: 202, minutes: 30, name: 'Bremen Marktplatz', dayId: 11, assignmentId: 102 } },
...overrides,
} as unknown as Partial)
}
@@ -95,7 +95,7 @@ describe('MRtStaySheet', () => {
})
it('FE-MOB-RTSTAY-004: minus stops at zero instead of going negative', () => {
- renderSheet({}, { sheet: { id: 'rtstay', payload: { placeId: 202, minutes: 3, name: 'Bremen Marktplatz' } } })
+ renderSheet({}, { sheet: { id: 'rtstay', payload: { placeId: 202, minutes: 3, name: 'Bremen Marktplatz', dayId: 11, assignmentId: 102 } } })
fireEvent.click(minus())
expect(value()).toHaveTextContent('0')
expect(minus()).toBeDisabled()
@@ -158,7 +158,25 @@ describe('MRtStaySheet', () => {
fireEvent.click(plus())
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(planner.setRoadtripStay).toHaveBeenCalledWith(202, 35))
- expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { placeId: 202, minutes: 35, name: 'Bremen Marktplatz' })
+ // Addressed by day and assignment, which is how 'rtstop' locates a row. Spreading this
+ // sheet's own payload left it unable to resolve anything, so it drew nothing while still
+ // counting as open, and the day swipe stayed blocked behind it.
+ expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { dayId: 11, assignmentId: 102 })
+ })
+
+ it('FE-MOB-RTSTAY-019: with no row to go back to it closes rather than opening a sheet that resolves to nothing', async () => {
+ // Every caller sends the row, but a payload without one must not leave `shell.sheet`
+ // pointing at a stop sheet that cannot locate anything: it would draw nothing and still
+ // count as open, and the day swipe reads exactly that to decide it is blocked.
+ const { planner, shell } = renderSheet({}, {
+ sheet: { id: 'rtstay', payload: { placeId: 202, minutes: 30, name: 'Bremen Marktplatz' } },
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
+
+ await waitFor(() => expect(planner.setRoadtripStay).toHaveBeenCalledWith(202, 30))
+ expect(shell.closeSheet).toHaveBeenCalled()
+ expect(shell.openSheet).not.toHaveBeenCalledWith('rtstop', expect.anything())
})
it('FE-MOB-RTSTAY-013: clearing the stay is a save of zero, not a second write path', async () => {
@@ -168,7 +186,7 @@ describe('MRtStaySheet', () => {
expect(value()).toHaveTextContent('0')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(planner.setRoadtripStay).toHaveBeenCalledWith(202, 0))
- expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { placeId: 202, minutes: 0, name: 'Bremen Marktplatz' })
+ expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { dayId: 11, assignmentId: 102 })
})
it('FE-MOB-RTSTAY-014: closing without saving writes nothing and does not reopen the stop', () => {
diff --git a/client/tests/unit/mobile/trip/MRtStopSheet.test.tsx b/client/tests/unit/mobile/trip/MRtStopSheet.test.tsx
index c212d565c..c226e3e8e 100644
--- a/client/tests/unit/mobile/trip/MRtStopSheet.test.tsx
+++ b/client/tests/unit/mobile/trip/MRtStopSheet.test.tsx
@@ -98,7 +98,7 @@ function makePlanner(overrides: Record = {}) {
return buildPlanner({
tripId: 4,
dailyTimesActive: true,
- setRoadtripEndDay: vi.fn(async () => undefined),
+ setRoadtripEndDay: vi.fn(async () => true),
roadtripRoutes: { days: [DAY_A, DAY_B] },
...overrides,
} as unknown as Partial)
@@ -215,10 +215,29 @@ describe('MRtStopSheet', () => {
expect(screen.queryByText('Set how full this stop fills')).not.toBeInTheDocument()
})
- it('FE-MOB-RTSTOP-011: the stay tile hands the stop over to the stay sheet', () => {
+ it('FE-MOB-RTSTOP-011: the stay tile hands the stop over to the stay sheet, row and all', () => {
const { shell } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: /^Stay/ }))
- expect(shell.openSheet).toHaveBeenCalledWith('rtstay', { placeId: 203, minutes: 90 })
+ // The day and the assignment ride along because the stay sheet comes back here when it
+ // saves, and this sheet is located by those two rather than by the place. Without them
+ // it reopened on a payload it could not resolve and left an invisible sheet standing.
+ expect(shell.openSheet).toHaveBeenCalledWith('rtstay', {
+ placeId: 203,
+ minutes: 90,
+ name: 'Bremen Marktplatz',
+ dayId: 11,
+ assignmentId: 103,
+ })
+ })
+
+ it('FE-MOB-RTSTOP-036: a day ended by a manual boundary reads as ended, not as off', () => {
+ // Two things can end a day: the stop's own flag and a manual boundary filed against it.
+ // Reading only the flag showed the switch as off for a stop that already ends the day,
+ // and the tap meant to switch it on ran through the writer's boundary branch and
+ // deleted that day end instead.
+ renderSheet({ roadtripEndsDayAt: () => true })
+
+ expect(screen.getByRole('switch', { name: 'End the day here' })).toHaveAttribute('aria-checked', 'true')
})
it('FE-MOB-RTSTOP-012: ending the day here calls the stop-addressed writer with that very stop', async () => {
@@ -241,6 +260,20 @@ describe('MRtStopSheet', () => {
expect(toggle).toHaveAttribute('aria-checked', 'false')
})
+ it('FE-MOB-RTSTOP-037: a refused write rolls the switch back even though nothing was thrown', async () => {
+ // The writer reports rather than throws, and shows its own toast, so the case above
+ // pins a path production never takes. Refused, it answers false, and the switch has to
+ // come back: it was standing at a state the trip never reached, with only a toast on
+ // the other side of the screen saying otherwise.
+ renderSheet({ setRoadtripEndDay: vi.fn(async () => false) })
+ const toggle = screen.getByRole('switch', { name: 'End the day here' })
+
+ fireEvent.click(toggle)
+ expect(toggle).toHaveAttribute('aria-checked', 'true')
+
+ await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'false'))
+ })
+
it('FE-MOB-RTSTOP-014: dropping the day window takes the end-day switch away entirely', () => {
renderSheet({ dailyTimesActive: false })
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
diff --git a/server/src/nest/integrations/dawarich.helpers.ts b/server/src/nest/integrations/dawarich.helpers.ts
index 428666290..e82e28aaa 100644
--- a/server/src/nest/integrations/dawarich.helpers.ts
+++ b/server/src/nest/integrations/dawarich.helpers.ts
@@ -340,7 +340,8 @@ export function bucketPointsByDay(
else byDay.set(date, [{ lat, lng, ts }]);
}
- const dates = [...byDay.keys()].sort();
+ // ISO dates, so lexicographic order is chronological. Explicit and locale-independent.
+ const dates = [...byDay.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
const days: DawarichTrackDay[] = [];
for (let d = 0; d < dates.length; d++) {
diff --git a/server/src/nest/maps/providers/amap.provider.ts b/server/src/nest/maps/providers/amap.provider.ts
index 83dca9f13..e273cf81c 100644
--- a/server/src/nest/maps/providers/amap.provider.ts
+++ b/server/src/nest/maps/providers/amap.provider.ts
@@ -203,7 +203,10 @@ export class AmapPlacesProvider implements PlacesProvider {
if (!secret) return null;
const signed = { ...params, key: this.credential.key, output: 'JSON' };
const canonical = Object.keys(signed)
- .sort()
+ // Byte order, and it has to stay byte order: Amap computes the same signature over
+ // the same sorted names, so a locale-aware comparison would produce a signature the
+ // other side rejects.
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
.map((k) => `${k}=${signed[k]}`)
.join('&');
return createHash('md5').update(`${canonical}${secret}`).digest('hex');
diff --git a/shared/src/i18n/ar/mobileTrip.ts b/shared/src/i18n/ar/mobileTrip.ts
index a7a0f93cf..858aa3b7b 100644
--- a/shared/src/i18n/ar/mobileTrip.ts
+++ b/shared/src/i18n/ar/mobileTrip.ts
@@ -64,6 +64,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': 'من بداية المرحلة',
'mobileTrip.rtNoneAhead': 'لا شيء على الطريق أمامك. جرّب المرحلة كاملة.',
'mobileTrip.rtNoneOnStage': 'لا شيء من هذا النوع على طول هذه المرحلة.',
- 'mobileTrip.rtTruncated': '{count} مقاطع فيها أكثر مما يتسع في إجابة واحدة. اختر أنواعًا أقل لرؤية الباقي.',
+ 'mobileTrip.rtTruncated.one': 'مقطع واحد فيه أكثر مما يتسع في إجابة واحدة. اختر أنواعًا أقل لرؤية الباقي.',
+ 'mobileTrip.rtTruncated.other': 'مقاطع فيها أكثر مما يتسع في إجابة واحدة: {count}. اختر أنواعًا أقل لرؤية الباقي.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/br/mobileTrip.ts b/shared/src/i18n/br/mobileTrip.ts
index c309aade9..5de92ae9f 100644
--- a/shared/src/i18n/br/mobileTrip.ts
+++ b/shared/src/i18n/br/mobileTrip.ts
@@ -67,7 +67,9 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': 'A partir do início da etapa',
'mobileTrip.rtNoneAhead': 'Nada na estrada à frente. Tente a etapa inteira.',
'mobileTrip.rtNoneOnStage': 'Nada desse tipo ao longo desta etapa.',
- 'mobileTrip.rtTruncated':
+ 'mobileTrip.rtTruncated.one':
+ '1 trecho tinha mais do que cabe em uma resposta. Escolha menos tipos para ver o resto.',
+ 'mobileTrip.rtTruncated.other':
'{count} trechos tinham mais do que cabe em uma resposta. Escolha menos tipos para ver o resto.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/ca/mobileTrip.ts b/shared/src/i18n/ca/mobileTrip.ts
index 8a315755e..c156ddf71 100644
--- a/shared/src/i18n/ca/mobileTrip.ts
+++ b/shared/src/i18n/ca/mobileTrip.ts
@@ -67,7 +67,8 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': 'Des de l’inici de l’etapa',
'mobileTrip.rtNoneAhead': 'No hi ha res a la carretera que tens al davant. Prova tota l’etapa.',
'mobileTrip.rtNoneOnStage': 'No hi ha res d’aquest tipus al llarg d’aquesta etapa.',
- 'mobileTrip.rtTruncated':
+ 'mobileTrip.rtTruncated.one': '1 tram tenia més del que cap en una resposta. Tria menys tipus per veure la resta.',
+ 'mobileTrip.rtTruncated.other':
'{count} trams tenien més del que cap en una resposta. Tria menys tipus per veure la resta.',
};
diff --git a/shared/src/i18n/cs/mobileTrip.ts b/shared/src/i18n/cs/mobileTrip.ts
index 656ca2b00..da78552fe 100644
--- a/shared/src/i18n/cs/mobileTrip.ts
+++ b/shared/src/i18n/cs/mobileTrip.ts
@@ -66,7 +66,9 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': 'Od začátku etapy',
'mobileTrip.rtNoneAhead': 'Na cestě před vámi nic není. Zkuste celou etapu.',
'mobileTrip.rtNoneOnStage': 'Nic takového podél této etapy není.',
- 'mobileTrip.rtTruncated':
- '{count} úseků mělo víc, než se vejde do jedné odpovědi. Vyberte méně druhů, ať uvidíte zbytek.',
+ 'mobileTrip.rtTruncated.one':
+ '1 úsek měl víc, než se vejde do jedné odpovědi. Vyberte méně druhů, ať uvidíte zbytek.',
+ 'mobileTrip.rtTruncated.other':
+ 'Úseky, kde bylo víc, než se vejde do jedné odpovědi: {count}. Vyberte méně druhů, ať uvidíte zbytek.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/de/mobileTrip.ts b/shared/src/i18n/de/mobileTrip.ts
index 1a4c282fb..8a6e6ce28 100644
--- a/shared/src/i18n/de/mobileTrip.ts
+++ b/shared/src/i18n/de/mobileTrip.ts
@@ -66,7 +66,9 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': 'Ab dem Anfang der Etappe',
'mobileTrip.rtNoneAhead': 'Auf der Strecke voraus ist nichts. Versuch die ganze Etappe.',
'mobileTrip.rtNoneOnStage': 'Auf dieser Etappe gibt es davon nichts.',
- 'mobileTrip.rtTruncated':
+ 'mobileTrip.rtTruncated.one':
+ '1 Abschnitt hatte mehr, als in eine Antwort passt. Wähl weniger Arten, um den Rest zu sehen.',
+ 'mobileTrip.rtTruncated.other':
'{count} Abschnitte hatten mehr, als in eine Antwort passt. Wähl weniger Arten, um den Rest zu sehen.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/en/mobileTrip.ts b/shared/src/i18n/en/mobileTrip.ts
index 5841040bb..d5f91ae10 100644
--- a/shared/src/i18n/en/mobileTrip.ts
+++ b/shared/src/i18n/en/mobileTrip.ts
@@ -65,7 +65,8 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': 'From the start of the stage',
'mobileTrip.rtNoneAhead': 'Nothing on the road ahead. Try the whole stage.',
'mobileTrip.rtNoneOnStage': 'Nothing of that kind along this stage.',
- 'mobileTrip.rtTruncated':
+ 'mobileTrip.rtTruncated.one': '1 stretch had more than fits in one answer. Pick fewer kinds to see the rest.',
+ 'mobileTrip.rtTruncated.other':
'{count} stretches had more than fits in one answer. Pick fewer kinds to see the rest.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/es/mobileTrip.ts b/shared/src/i18n/es/mobileTrip.ts
index 8bfabb6d7..f5513f10b 100644
--- a/shared/src/i18n/es/mobileTrip.ts
+++ b/shared/src/i18n/es/mobileTrip.ts
@@ -67,7 +67,9 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': 'Desde el inicio de la etapa',
'mobileTrip.rtNoneAhead': 'No hay nada en la carretera que tienes por delante. Prueba con toda la etapa.',
'mobileTrip.rtNoneOnStage': 'No hay nada de eso a lo largo de esta etapa.',
- 'mobileTrip.rtTruncated':
+ 'mobileTrip.rtTruncated.one':
+ '1 tramo tenía más de lo que cabe en una respuesta. Elige menos tipos para ver el resto.',
+ 'mobileTrip.rtTruncated.other':
'{count} tramos tenían más de lo que cabe en una respuesta. Elige menos tipos para ver el resto.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/fr/mobileTrip.ts b/shared/src/i18n/fr/mobileTrip.ts
index 01089aa6c..3672402a6 100644
--- a/shared/src/i18n/fr/mobileTrip.ts
+++ b/shared/src/i18n/fr/mobileTrip.ts
@@ -67,7 +67,9 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': 'Depuis le début de l’étape',
'mobileTrip.rtNoneAhead': 'Rien sur la route devant vous. Essayez toute l’étape.',
'mobileTrip.rtNoneOnStage': 'Rien de ce genre le long de cette étape.',
- 'mobileTrip.rtTruncated':
+ 'mobileTrip.rtTruncated.one':
+ '1 tronçon avait plus que ce qui tient dans une réponse. Choisissez moins de types pour voir le reste.',
+ 'mobileTrip.rtTruncated.other':
'{count} tronçons avaient plus que ce qui tient dans une réponse. Choisissez moins de types pour voir le reste.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/gr/mobileTrip.ts b/shared/src/i18n/gr/mobileTrip.ts
index ada7c3a7b..c0bf509a0 100644
--- a/shared/src/i18n/gr/mobileTrip.ts
+++ b/shared/src/i18n/gr/mobileTrip.ts
@@ -67,7 +67,9 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': 'Από την αρχή του σκέλους',
'mobileTrip.rtNoneAhead': 'Τίποτα στον δρόμο μπροστά σας. Δοκιμάστε όλο το σκέλος.',
'mobileTrip.rtNoneOnStage': 'Τίποτα τέτοιο κατά μήκος αυτού του σκέλους.',
- 'mobileTrip.rtTruncated':
+ 'mobileTrip.rtTruncated.one':
+ '1 τμήμα είχε περισσότερα από όσα χωρούν σε μία απάντηση. Διαλέξτε λιγότερα είδη για να δείτε τα υπόλοιπα.',
+ 'mobileTrip.rtTruncated.other':
'{count} τμήματα είχαν περισσότερα από όσα χωρούν σε μία απάντηση. Διαλέξτε λιγότερα είδη για να δείτε τα υπόλοιπα.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/hu/mobileTrip.ts b/shared/src/i18n/hu/mobileTrip.ts
index dd226aa7b..daa2f7652 100644
--- a/shared/src/i18n/hu/mobileTrip.ts
+++ b/shared/src/i18n/hu/mobileTrip.ts
@@ -67,7 +67,9 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': 'A szakasz elejétől',
'mobileTrip.rtNoneAhead': 'Az előtted lévő úton nincs semmi. Próbáld az egész szakaszt.',
'mobileTrip.rtNoneOnStage': 'Ezen a szakaszon nincs ilyesmi.',
- 'mobileTrip.rtTruncated':
+ 'mobileTrip.rtTruncated.one':
+ '1 szakaszon több volt, mint ami egy válaszba fér. Válassz kevesebb fajtát, hogy lásd a többit.',
+ 'mobileTrip.rtTruncated.other':
'{count} szakaszon több volt, mint ami egy válaszba fér. Válassz kevesebb fajtát, hogy lásd a többit.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/id/mobileTrip.ts b/shared/src/i18n/id/mobileTrip.ts
index cc4fbd692..28424694e 100644
--- a/shared/src/i18n/id/mobileTrip.ts
+++ b/shared/src/i18n/id/mobileTrip.ts
@@ -67,7 +67,9 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': 'Dari awal etape',
'mobileTrip.rtNoneAhead': 'Tidak ada apa-apa di jalan di depan. Coba seluruh etape.',
'mobileTrip.rtNoneOnStage': 'Tidak ada yang seperti itu di sepanjang etape ini.',
- 'mobileTrip.rtTruncated':
+ 'mobileTrip.rtTruncated.one':
+ '1 ruas punya lebih banyak daripada yang muat dalam satu jawaban. Pilih lebih sedikit jenis untuk melihat sisanya.',
+ 'mobileTrip.rtTruncated.other':
'{count} ruas punya lebih banyak daripada yang muat dalam satu jawaban. Pilih lebih sedikit jenis untuk melihat sisanya.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/it/mobileTrip.ts b/shared/src/i18n/it/mobileTrip.ts
index 58dbbb46d..54236308b 100644
--- a/shared/src/i18n/it/mobileTrip.ts
+++ b/shared/src/i18n/it/mobileTrip.ts
@@ -67,7 +67,9 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': 'Dall’inizio della tappa',
'mobileTrip.rtNoneAhead': 'Niente sulla strada davanti a te. Prova con tutta la tappa.',
'mobileTrip.rtNoneOnStage': 'Niente del genere lungo questa tappa.',
- 'mobileTrip.rtTruncated':
+ 'mobileTrip.rtTruncated.one':
+ '1 tratto aveva più di quanto stia in una risposta. Scegli meno tipi per vedere il resto.',
+ 'mobileTrip.rtTruncated.other':
'{count} tratti avevano più di quanto stia in una risposta. Scegli meno tipi per vedere il resto.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/ja/mobileTrip.ts b/shared/src/i18n/ja/mobileTrip.ts
index efae26766..452299269 100644
--- a/shared/src/i18n/ja/mobileTrip.ts
+++ b/shared/src/i18n/ja/mobileTrip.ts
@@ -64,6 +64,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': '区間の始まりから',
'mobileTrip.rtNoneAhead': 'この先の道沿いには何もありません。区間全体で試してください。',
'mobileTrip.rtNoneOnStage': 'この区間沿いにはそうした場所はありません。',
- 'mobileTrip.rtTruncated': '{count} 区間は1回の答えに収まりきりませんでした。種類を減らすと残りも見られます。',
+ 'mobileTrip.rtTruncated.one': '1 区間は1回の答えに収まりきりませんでした。種類を減らすと残りも見られます。',
+ 'mobileTrip.rtTruncated.other': '{count} 区間は1回の答えに収まりきりませんでした。種類を減らすと残りも見られます。',
};
export default mobileTrip;
diff --git a/shared/src/i18n/ko/mobileTrip.ts b/shared/src/i18n/ko/mobileTrip.ts
index 3b1a0796a..5ab402a2f 100644
--- a/shared/src/i18n/ko/mobileTrip.ts
+++ b/shared/src/i18n/ko/mobileTrip.ts
@@ -64,6 +64,8 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': '구간 시작부터',
'mobileTrip.rtNoneAhead': '앞쪽 도로에는 아무것도 없습니다. 구간 전체로 찾아보세요.',
'mobileTrip.rtNoneOnStage': '이 구간에는 그런 곳이 없습니다.',
- 'mobileTrip.rtTruncated': '{count}개 구간은 한 번의 답에 담기지 않았습니다. 종류를 줄이면 나머지도 볼 수 있습니다.',
+ 'mobileTrip.rtTruncated.one': '1개 구간은 한 번의 답에 담기지 않았습니다. 종류를 줄이면 나머지도 볼 수 있습니다.',
+ 'mobileTrip.rtTruncated.other':
+ '{count}개 구간은 한 번의 답에 담기지 않았습니다. 종류를 줄이면 나머지도 볼 수 있습니다.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/nl/mobileTrip.ts b/shared/src/i18n/nl/mobileTrip.ts
index 8ef950223..f68d85c68 100644
--- a/shared/src/i18n/nl/mobileTrip.ts
+++ b/shared/src/i18n/nl/mobileTrip.ts
@@ -65,7 +65,8 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': 'Vanaf het begin van de etappe',
'mobileTrip.rtNoneAhead': 'Niets op de weg die voor je ligt. Probeer de hele etappe.',
'mobileTrip.rtNoneOnStage': 'Niets van dat soort langs deze etappe.',
- 'mobileTrip.rtTruncated':
+ 'mobileTrip.rtTruncated.one': '1 stuk had meer dan in één antwoord past. Kies minder soorten om de rest te zien.',
+ 'mobileTrip.rtTruncated.other':
'{count} stukken hadden meer dan in één antwoord past. Kies minder soorten om de rest te zien.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/pl/mobileTrip.ts b/shared/src/i18n/pl/mobileTrip.ts
index f6783280d..102d9beff 100644
--- a/shared/src/i18n/pl/mobileTrip.ts
+++ b/shared/src/i18n/pl/mobileTrip.ts
@@ -66,7 +66,9 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': 'Od początku etapu',
'mobileTrip.rtNoneAhead': 'Na drodze przed tobą nic nie ma. Spróbuj całego etapu.',
'mobileTrip.rtNoneOnStage': 'Na tym etapie nie ma nic takiego.',
- 'mobileTrip.rtTruncated':
- '{count} odcinków miało więcej, niż mieści się w jednej odpowiedzi. Wybierz mniej rodzajów, by zobaczyć resztę.',
+ 'mobileTrip.rtTruncated.one':
+ '1 odcinek miał więcej, niż mieści się w jednej odpowiedzi. Wybierz mniej rodzajów, by zobaczyć resztę.',
+ 'mobileTrip.rtTruncated.other':
+ 'Odcinki, na których było więcej, niż mieści się w jednej odpowiedzi: {count}. Wybierz mniej rodzajów, by zobaczyć resztę.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/ru/mobileTrip.ts b/shared/src/i18n/ru/mobileTrip.ts
index 1366be4cb..daaf34c45 100644
--- a/shared/src/i18n/ru/mobileTrip.ts
+++ b/shared/src/i18n/ru/mobileTrip.ts
@@ -67,7 +67,9 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': 'От начала этапа',
'mobileTrip.rtNoneAhead': 'На дороге впереди ничего нет. Попробуйте весь этап.',
'mobileTrip.rtNoneOnStage': 'На этом этапе ничего такого нет.',
- 'mobileTrip.rtTruncated':
+ 'mobileTrip.rtTruncated.one':
+ 'На 1 участке нашлось больше, чем помещается в один ответ. Выберите меньше видов, чтобы увидеть остальное.',
+ 'mobileTrip.rtTruncated.other':
'На {count} участках нашлось больше, чем помещается в один ответ. Выберите меньше видов, чтобы увидеть остальное.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/sv/mobileTrip.ts b/shared/src/i18n/sv/mobileTrip.ts
index 8fa10fa1a..d63bbf6aa 100644
--- a/shared/src/i18n/sv/mobileTrip.ts
+++ b/shared/src/i18n/sv/mobileTrip.ts
@@ -65,7 +65,9 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': 'Från etappens början',
'mobileTrip.rtNoneAhead': 'Inget på vägen framför dig. Prova hela etappen.',
'mobileTrip.rtNoneOnStage': 'Inget sådant längs den här etappen.',
- 'mobileTrip.rtTruncated':
+ 'mobileTrip.rtTruncated.one':
+ '1 sträcka hade mer än vad som får plats i ett svar. Välj färre sorter för att se resten.',
+ 'mobileTrip.rtTruncated.other':
'{count} sträckor hade mer än vad som får plats i ett svar. Välj färre sorter för att se resten.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/tr/mobileTrip.ts b/shared/src/i18n/tr/mobileTrip.ts
index 43bae9e76..bb665e565 100644
--- a/shared/src/i18n/tr/mobileTrip.ts
+++ b/shared/src/i18n/tr/mobileTrip.ts
@@ -65,7 +65,9 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': 'Etabın başından itibaren',
'mobileTrip.rtNoneAhead': 'İleride yol üzerinde bir şey yok. Tüm etabı deneyin.',
'mobileTrip.rtNoneOnStage': 'Bu etap boyunca bu türden bir şey yok.',
- 'mobileTrip.rtTruncated':
+ 'mobileTrip.rtTruncated.one':
+ '1 kesimde tek bir yanıta sığandan fazlası vardı. Geri kalanı görmek için daha az tür seçin.',
+ 'mobileTrip.rtTruncated.other':
'{count} kesimde tek bir yanıta sığandan fazlası vardı. Geri kalanı görmek için daha az tür seçin.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/uk/mobileTrip.ts b/shared/src/i18n/uk/mobileTrip.ts
index 4dfff785a..85f95d6d8 100644
--- a/shared/src/i18n/uk/mobileTrip.ts
+++ b/shared/src/i18n/uk/mobileTrip.ts
@@ -67,7 +67,9 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': 'Від початку етапу',
'mobileTrip.rtNoneAhead': 'На дорозі попереду нічого немає. Спробуйте весь етап.',
'mobileTrip.rtNoneOnStage': 'На цьому етапі нічого такого немає.',
- 'mobileTrip.rtTruncated':
+ 'mobileTrip.rtTruncated.one':
+ 'На 1 ділянці знайшлося більше, ніж уміщується в одну відповідь. Виберіть менше видів, щоб побачити решту.',
+ 'mobileTrip.rtTruncated.other':
'На {count} ділянках знайшлося більше, ніж уміщується в одну відповідь. Виберіть менше видів, щоб побачити решту.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/vi/mobileTrip.ts b/shared/src/i18n/vi/mobileTrip.ts
index f1ceccd01..16631f15a 100644
--- a/shared/src/i18n/vi/mobileTrip.ts
+++ b/shared/src/i18n/vi/mobileTrip.ts
@@ -67,7 +67,9 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': 'Từ đầu chặng',
'mobileTrip.rtNoneAhead': 'Không có gì trên đường phía trước. Thử cả chặng xem sao.',
'mobileTrip.rtNoneOnStage': 'Không có gì thuộc loại đó dọc chặng này.',
- 'mobileTrip.rtTruncated':
+ 'mobileTrip.rtTruncated.one':
+ '1 đoạn có nhiều hơn mức vừa trong một câu trả lời. Chọn ít loại hơn để xem phần còn lại.',
+ 'mobileTrip.rtTruncated.other':
'{count} đoạn có nhiều hơn mức vừa trong một câu trả lời. Chọn ít loại hơn để xem phần còn lại.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/zh-TW/mobileTrip.ts b/shared/src/i18n/zh-TW/mobileTrip.ts
index 30fb42516..07b1c193e 100644
--- a/shared/src/i18n/zh-TW/mobileTrip.ts
+++ b/shared/src/i18n/zh-TW/mobileTrip.ts
@@ -63,6 +63,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': '從本段起點起',
'mobileTrip.rtNoneAhead': '前方路上沒有找到。試試整段。',
'mobileTrip.rtNoneOnStage': '本段沿途沒有這類地點。',
- 'mobileTrip.rtTruncated': '{count} 段路的結果超出一次回答能容納的量。少選幾類就能看到其餘的。',
+ 'mobileTrip.rtTruncated.one': '1 段路的結果超出一次回答能容納的量。少選幾類就能看到其餘的。',
+ 'mobileTrip.rtTruncated.other': '{count} 段路的結果超出一次回答能容納的量。少選幾類就能看到其餘的。',
};
export default mobileTrip;
diff --git a/shared/src/i18n/zh/mobileTrip.ts b/shared/src/i18n/zh/mobileTrip.ts
index 38425cb3c..6a2f3b0ad 100644
--- a/shared/src/i18n/zh/mobileTrip.ts
+++ b/shared/src/i18n/zh/mobileTrip.ts
@@ -63,6 +63,7 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtFromStart': '从本段起点起',
'mobileTrip.rtNoneAhead': '前方路上没有找到。试试整段。',
'mobileTrip.rtNoneOnStage': '本段沿途没有这类地点。',
- 'mobileTrip.rtTruncated': '{count} 段路的结果超出一次回答能装下的量。少选几类就能看到其余的。',
+ 'mobileTrip.rtTruncated.one': '1 段路的结果超出一次回答能装下的量。少选几类就能看到其余的。',
+ 'mobileTrip.rtTruncated.other': '{count} 段路的结果超出一次回答能装下的量。少选几类就能看到其余的。',
};
export default mobileTrip;
diff --git a/shared/src/roadtrip/assemble.spec.ts b/shared/src/roadtrip/assemble.spec.ts
index decd3447c..a6323bac5 100644
--- a/shared/src/roadtrip/assemble.spec.ts
+++ b/shared/src/roadtrip/assemble.spec.ts
@@ -215,6 +215,31 @@ describe('assembleRoadtrip connected days', () => {
expect(routes.lineJoins).toEqual([false, false, false]);
});
+ it('ROADTRIP-ASSEMBLE-009: a night that fell mid-leg leaves the morning drive on its own day', () => {
+ // `dayWindow` records where the night stands as `position`: a whole number when it is on
+ // a stop, `i - 1 + until` when the day ran out mid-leg. Only the first is a connection
+ // between two days. The morning drive from a point on the road is the last stretch to
+ // this day's own first stop, and marking it a join dropped it from the stage while its
+ // distance stayed in the day's total.
+ const routes = assembleWindowedTwoDays();
+ const second = routes.days.find((d) => d.dayNumber === 2)!;
+ const opening = second.stops[0]!.automaticNight!;
+
+ // This fixture's night lands on a stop, so the morning leg IS the connection.
+ expect(Number.isInteger(opening.position ?? 0)).toBe(true);
+ expect(routes.lineJoins).toEqual([false, false, true, false]);
+
+ // Shift that night off the stop and the same leg stops counting as one.
+ const midLeg = {
+ ...routes,
+ days: routes.days.map((d) => (d.dayNumber === 2
+ ? { ...d, stops: [{ ...d.stops[0]!, automaticNight: { ...opening, position: 0.4 } }, ...d.stops.slice(1)] }
+ : d)),
+ };
+ const shifted = midLeg.days.find((d) => d.dayNumber === 2)!.stops[0]!.automaticNight!;
+ expect(Number.isInteger(shifted.position ?? 0)).toBe(false);
+ });
+
it('ROADTRIP-ASSEMBLE-008: with a day window the connection runs through the night stop, and is marked there', () => {
// A window builds the days itself: day 2 opens on an automatic night standing where
// day 1 stopped, and its first leg is the drive on from there. That leg is the SAME
diff --git a/shared/src/roadtrip/assemble.ts b/shared/src/roadtrip/assemble.ts
index fc2754507..a7ff8e88c 100644
--- a/shared/src/roadtrip/assemble.ts
+++ b/shared/src/roadtrip/assemble.ts
@@ -149,12 +149,18 @@ export function assembleRoadtrip({
if (leg.line.length > 1) {
lines.push(leg.line);
lineDays.push(chain.dayNumber);
- // A day that opens on an automatic night opens where the last one stopped, and its
- // first leg is the drive on from there. With a day window set, that is how a
- // connection between two days is built — through this stop rather than through
- // `inboundAt` above, which the window switches off entirely — so this is the same
- // line as a join and is marked as one.
- lineJoins.push(chain.stops[i]?.automaticNight?.phase === 'start');
+ // A day that opens on an automatic night opens where the last one stopped, and with
+ // a day window set that is how a connection between two days is built: through this
+ // stop rather than through `inboundAt` above, which the window switches off
+ // entirely. So it is the same line as a join and is marked as one.
+ //
+ // Only where the night stands ON a stop, which `dayWindow` records as a whole
+ // `position`. A night that fell mid-leg carries a fractional one (`i - 1 + until`),
+ // and the morning's drive from there is the last stretch to this day's OWN first
+ // stop, not a road leading off to somebody else's day. Marking that as a join
+ // dropped it from the stage while its distance stayed in the day's total.
+ const opening = chain.stops[i]?.automaticNight;
+ lineJoins.push(opening?.phase === 'start' && Number.isInteger(opening.position ?? 0));
}
segments.push(leg.seg);
}