Merge pull request #2387 from liketrek/fix/pr2273-audit-0917

fix: eleven from a full audit of the 4.3.0 release branch
This commit is contained in:
Maurice
2026-09-17 16:55:23 +02:00
committed by GitHub
48 changed files with 369 additions and 77 deletions
@@ -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,
@@ -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(<MapViewGL places={[]} fitKey={1} />)
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({
+5 -1
View File
@@ -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 }
+3 -1
View File
@@ -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. */
@@ -5,7 +5,12 @@ import ToggleSwitch from '../Settings/ToggleSwitch'
export interface EndDayControlProps {
active: boolean
onToggle: () => Promise<void>
/**
* 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<unknown>
}
export default function EndDayControl({ active, onToggle }: EndDayControlProps) {
@@ -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++
+3 -1
View File
@@ -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])
@@ -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.
<span dir="ltr" className={`flex items-center gap-[3px] whitespace-nowrap text-[0.8125rem] tabular-nums ${row.pinned ? 'font-semibold text-m-ink' : 'font-medium text-m-faint'}`}>
{row.pinned && <Pin size={9} strokeWidth={2.4} className="flex-none text-m-faint" aria-label={t('roadtrip.stop.pinned')} />}
{row.time}
{formatClockTime(row.time, chrome.is12h)}
</span>
)}
</div>
@@ -470,7 +480,7 @@ export function RtAutoRow({ phase, time, chrome }: {
<span className="text-[0.75rem] font-semibold text-m-muted">
{phase === 'end' ? t('roadtrip.window.stop') : t('roadtrip.window.resume')}
</span>
{time && <span className="ms-auto text-[0.8125rem] font-semibold tabular-nums text-m-ink">{time}</span>}
{time && <span className="ms-auto text-[0.8125rem] font-semibold tabular-nums text-m-ink">{formatClockTime(time, chrome.is12h)}</span>}
</div>
)
}
@@ -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')}
</span>
<span dir="ltr" className="mt-0.5 block text-[1.5rem] font-extrabold leading-none tabular-nums text-m-ink">
{rt.clocks.start ?? '-'}
{rt.clocks.start ? formatClockTime(rt.clocks.start, is12h) : '-'}
</span>
</span>
<span className="min-w-0 text-right">
@@ -184,7 +186,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
{t('roadtrip.stay.arrive')}
</span>
<span dir="ltr" className="mt-0.5 block text-[1.5rem] font-extrabold leading-none tabular-nums text-m-ink">
{rt.clocks.arrive ?? '-'}
{rt.clocks.arrive ? formatClockTime(rt.clocks.arrive, is12h) : '-'}
</span>
</span>
</div>
@@ -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 }: {
</span>
<span className="mt-1.5 block truncate text-[1.125rem] font-bold text-m-ink">{next.row.stop.name}</span>
{next.row.time && (
<span className="mt-[2px] block font-geist text-[0.75rem] tabular-nums text-m-muted">{next.row.time}</span>
<span className="mt-[2px] block font-geist text-[0.75rem] tabular-nums text-m-muted">{formatClockTime(next.row.time, is12h)}</span>
)}
</button>
<div className="mt-3 flex gap-2">
@@ -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'}`}
>
<Navigation size={15} strokeWidth={2.2} aria-hidden="true" />
{t('places.navigate')}
{targets.length === 1 ? targets[0].label : t('inspector.navigation')}
</a>
<button
type="button"
@@ -187,7 +187,15 @@ function StatusBand({ corridor, t }: { corridor: MRtCorridorController; t: Trans
// The phone's own wording, because the desk's ends in "narrow the corridor" and the
// corridor width is a control this screen deliberately does not have. What it does
// have is the category chips, and fewer of them really is what makes a box fit.
if (corridor.truncatedAreas > 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 <Note tone="warn" icon={<WifiOff size={13} strokeWidth={2} />}>{t('mobileTrip.rtSearchOffline')}</Note>
if (corridor.error) return <Note tone="warn" icon={<AlertTriangle size={13} strokeWidth={2} />}>{t('roadtrip.poi.failed')}</Note>
@@ -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'))
@@ -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'))
@@ -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 {
@@ -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)
+21 -9
View File
@@ -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'
+31 -4
View File
@@ -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<boolean> => {
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,
+3
View File
@@ -169,6 +169,9 @@ export function buildPlanner(overrides: Partial<TripPlanner> = {}): 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(),
@@ -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> = {}): 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(<RtStopRow row={stopRow({ time: '14:05' })} chrome={{ ...chrome, is12h: true }} onOpen={vi.fn()} />)
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(<RtStopRow row={stopRow({ time: '14:05' })} chrome={chrome} onOpen={vi.fn()} />)
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(<RtStopRow row={stopRow({ warning })} chrome={chrome} onOpen={vi.fn()} />)
@@ -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()] })
@@ -50,7 +50,7 @@ function makePlanner(overrides: Record<string, unknown> = {}) {
function makeShell(overrides: Record<string, unknown> = {}) {
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<MTripShellApi>)
}
@@ -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', () => {
@@ -98,7 +98,7 @@ function makePlanner(overrides: Record<string, unknown> = {}) {
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<TripPlanner>)
@@ -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()
@@ -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++) {
@@ -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');
+2 -1
View File
@@ -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;
+3 -1
View File
@@ -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;
+2 -1
View File
@@ -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.',
};
+4 -2
View File
@@ -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;
+3 -1
View File
@@ -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;
+2 -1
View File
@@ -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;
+3 -1
View File
@@ -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;
+3 -1
View File
@@ -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;
+3 -1
View File
@@ -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;
+3 -1
View File
@@ -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;
+3 -1
View File
@@ -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;
+3 -1
View File
@@ -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;
+2 -1
View File
@@ -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;
+3 -1
View File
@@ -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;
+2 -1
View File
@@ -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;
+4 -2
View File
@@ -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;
+3 -1
View File
@@ -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;
+3 -1
View File
@@ -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;
+3 -1
View File
@@ -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;
+3 -1
View File
@@ -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;
+3 -1
View File
@@ -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;
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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;
+25
View File
@@ -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
+12 -6
View File
@@ -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);
}