diff --git a/client/src/components/Collab/CollabLinks.test.tsx b/client/src/components/Collab/CollabLinks.test.tsx index bb7db9940..4b2d7e7c3 100644 --- a/client/src/components/Collab/CollabLinks.test.tsx +++ b/client/src/components/Collab/CollabLinks.test.tsx @@ -63,7 +63,8 @@ describe('CollabLinks', () => { ); render(); expect(await screen.findByText('Ferry timetable')).toBeInTheDocument(); - expect(screen.getByText('https://ferries.example/timetable')).toBeInTheDocument(); + // The chip shows the host, not the whole address, and links to the address itself. + expect(screen.getByText('ferries.example')).toBeInTheDocument(); expect(screen.getByRole('link', { name: /ferry timetable/i })).toHaveAttribute('href', 'https://ferries.example/timetable'); }); @@ -118,6 +119,47 @@ describe('CollabLinks', () => { expect(screen.getByRole('button', { name: /delete link|collab\.links\.delete/i })).toBeInTheDocument(); }); + it('FE-COMP-LINKS-009: a link can be edited in place, title and address alike', async () => { + // #2414: a link used to be delete-and-add once it needed a correction. + const user = userEvent.setup(); + let put: Record | null = null; + server.use( + http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })), + http.put('/api/trips/1/collab/links/1', async ({ request }) => { + put = (await request.json()) as Record; + return HttpResponse.json({ link: buildLink({ title: 'Ferry timetable 2026', url: 'https://ferries.example/2026' }) }); + }), + ); + render(); + await user.click(await screen.findByRole('button', { name: /edit link|collab\.links\.edit/i })); + const title = await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i); + expect(title).toHaveValue('Ferry timetable'); + await user.clear(title); + await user.type(title, 'Ferry timetable 2026'); + const url = screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i); + expect(url).toHaveValue('https://ferries.example/timetable'); + await user.clear(url); + await user.type(url, 'https://ferries.example/2026'); + await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i })); + + await waitFor(() => expect(put).toEqual({ title: 'Ferry timetable 2026', url: 'https://ferries.example/2026' })); + await waitFor(() => expect(screen.queryByLabelText(/link title|collab\.links\.titlePlaceholder/i)).not.toBeInTheDocument()); + expect(await screen.findByText('Ferry timetable 2026')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /ferry timetable 2026/i })).toHaveAttribute('href', 'https://ferries.example/2026'); + }); + + it('FE-COMP-LINKS-010: the chip is the link and opens the address in a new tab', async () => { + server.use( + http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })), + ); + render(); + const open = await screen.findByRole('link', { name: /ferry timetable/i }); + expect(open).toHaveAttribute('href', 'https://ferries.example/timetable'); + expect(open).toHaveAttribute('target', '_blank'); + // The host stands beside the title; the whole address would not fit a chip. + expect(screen.getByText('ferries.example')).toBeInTheDocument(); + }); + it('FE-COMP-LINKS-008: a viewer without edit rights gets no add button', async () => { // collab_edit reserved for the owner, on somebody else's trip. seedStore(usePermissionsStore, { permissions: { collab_edit: 'trip_owner' } }); diff --git a/client/src/components/Collab/CollabLinks.tsx b/client/src/components/Collab/CollabLinks.tsx index 564eacdde..a9fb3f217 100644 --- a/client/src/components/Collab/CollabLinks.tsx +++ b/client/src/components/Collab/CollabLinks.tsx @@ -1,6 +1,6 @@ import { CSSProperties, FormEvent, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' -import { ExternalLink, Link2, Pin, Plus, Trash2, X } from 'lucide-react' +import { ExternalLink, Link2, Pencil, Pin, Plus, Trash2, X } from 'lucide-react' import { collabApi } from '../../api/client' import { addListener, removeListener } from '../../api/websocket' import { useTranslation } from '../../i18n' @@ -18,23 +18,34 @@ interface CollabLink { pinned: boolean | number } -function LinkIcon({ url, title }: { url: string; title: string }) { +function LinkIcon({ url }: { url: string }) { const [failed, setFailed] = useState(false) let favicon = '' try { favicon = new URL('/favicon.ico', url).href } catch { /* not a parseable url, fall through to the glyph */ } - if (!favicon || failed) return - return setFailed(true)} style={{ width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, objectFit: 'contain', background: 'var(--bg-secondary)' }} /> + return ( + + ) +} + +/** The part of the address worth a chip's width: the host, without a leading www. */ +function hostOf(url: string): string { + try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url } } /** - * The add form as a modal, like the poll and note panels next to it. Inline it - * pushed the list down and gave the panel a second header, which is what made - * this tab read as a different app from its four siblings. + * The add and edit form as a modal, like the poll and note panels next to it. + * Inline it pushed the list down and gave the panel a second header, which is + * what made this tab read as a different app from its four siblings. With a + * link handed in it edits that link (#2414): a link used to be delete-and-add + * once its title or address needed a correction. */ -function AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (link: { title: string; url: string }) => Promise }) { +function LinkModal({ link, onClose, onSave }: { link?: CollabLink; onClose: () => void; onSave: (link: { title: string; url: string }) => Promise }) { const { t } = useTranslation() - const [title, setTitle] = useState('') - const [url, setUrl] = useState('') + const [title, setTitle] = useState(link?.title ?? '') + const [url, setUrl] = useState(link?.url ?? '') const [busy, setBusy] = useState(false) const valid = title.trim().length > 0 && url.trim().length > 0 @@ -43,10 +54,10 @@ function AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (l if (!valid || busy) return setBusy(true) try { - await onCreate({ title: title.trim(), url: url.trim() }) + await onSave({ title: title.trim(), url: url.trim() }) onClose() } catch { - // add() already said what went wrong; the form stays open with the input in it. + // The caller already said what went wrong; the form stays open with the input in it. } finally { setBusy(false) } @@ -66,7 +77,7 @@ function AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (l
{ if (e.target === e.currentTarget) onClose() }}>
-

{t('collab.links.add')}

+

{t(link ? 'collab.links.edit' : 'collab.links.add')}

@@ -99,6 +110,7 @@ export default function CollabLinks({ tripId }: { tripId: number }) { const toast = useToast() const [links, setLinks] = useState([]) const [showForm, setShowForm] = useState(false) + const [editing, setEditing] = useState(null) // Both are new objects on every render, so depending on them would reload the // list after each keystroke and drop a link that was just added back out of it. @@ -168,6 +180,17 @@ export default function CollabLinks({ tripId }: { tripId: number }) { } } + const edit = async (link: CollabLink, data: { title: string; url: string }) => { + try { + const d = await collabApi.updateLink(tripId, link.id, data) + setLinks(v => v.map(x => x.id === link.id ? d.link : x)) + } catch (err) { + console.error('Failed to edit collab link:', err) + toast.error(t('common.error')) + throw err + } + } + const remove = async (id: number) => { try { await collabApi.deleteLink(tripId, id) @@ -197,36 +220,44 @@ export default function CollabLinks({ tripId }: { tripId: number }) { )}
- {/* Content */} -
+ {/* Content: one chip per link, two to a row (see .collab-link-chip). The chip is + the link, with the host beside the title; the actions sit in its tail. */} +
{links.length === 0 ? ( ) : ( -
+
{links.map(link => ( -
- - -
{link.title}
-
{link.url}
+
+ + + + {link.title} + {hostOf(link.url)} + -
))}
)}
- {/* Create Modal */} - {showForm && setShowForm(false)} onCreate={add} />} + {/* Create and edit modals */} + {showForm && setShowForm(false)} onSave={add} />} + {editing && setEditing(null)} onSave={data => edit(editing, data)} />}
) } diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index c2cd1d9c9..7a6b3f884 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -223,6 +223,17 @@ describe('DayPlanSidebar', () => { expect(document.body).toBeInTheDocument() }) + it('FE-PLANNER-DAYPLAN-001b: the panel yields to whatever the desktop shell puts above it', () => { + // With the Days / Road trip switch above it, a panel sized by height alone ran the + // switch's height past the clipped edge and the last day could never be scrolled + // into view. jsdom lays nothing out, so the sizing itself is the assertion. + const { container } = render() + const panel = container.firstElementChild as HTMLElement + expect(panel.style.flex).toBe('1 1 0%') + expect(panel.style.minHeight).toBe('0px') + expect(panel.style.height).toBe('100%') + }) + it('FE-PLANNER-DAYPLAN-002: renders day titles', () => { const day = buildDay({ title: 'Amsterdam Day', date: '2025-06-01' }) render() diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index ff565277b..f0296f8d3 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -1525,7 +1525,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP } return ( -
+ // Sized as a flex child as well as by height: the desktop panel puts the Days / Road + // trip switch above this, and at height 100% alone the list ran the switch's height + // past the panel's clipped edge, so the last day could never be scrolled into view. + // Where nothing sits above it (the mobile shell), the height still fills the panel. +
{/* Toolbar */} { + it('FE-PLANNER-HOURS-001: a range that stays in one half of the day carries the meridiem back to its opener', () => { + // Google writes "1:00 – 3:00 PM" for one to three in the afternoon (#2412). The + // opener used to stay "1:00" next to a converted "15:00". + expect(convertHoursLine('Wednesday: 1:00 – 3:00 PM, 5:00 – 8:00 PM', '24h')) + .toBe('Wednesday: 13:00 – 15:00, 17:00 – 20:00') + }) + + it('FE-PLANNER-HOURS-002: a range spelled on both sides is converted as written', () => { + expect(convertHoursLine('Monday: 11:00 AM – 1:00 PM', '24h')).toBe('Monday: 11:00 – 13:00') + expect(convertHoursLine('Friday: 5:00 PM – 2:00 AM', '24h')).toBe('Friday: 17:00 – 02:00') + }) + + it('FE-PLANNER-HOURS-003: twelve stays twelve in the afternoon and turns to zero after midnight', () => { + expect(convertHoursLine('Sunday: 12:00 – 3:00 PM', '24h')).toBe('Sunday: 12:00 – 15:00') + expect(convertHoursLine('Sunday: 12:00 – 1:00 AM', '24h')).toBe('Sunday: 00:00 – 01:00') + }) + + it('FE-PLANNER-HOURS-004: a morning range keeps its morning', () => { + expect(convertHoursLine('Tuesday: 8:00 – 11:30 AM', '24h')).toBe('Tuesday: 08:00 – 11:30') + }) + + it('FE-PLANNER-HOURS-005: a plain hyphen separates the same way the en dash does', () => { + expect(convertHoursLine('Thursday: 1:00-3:00 PM', '24h')).toBe('Thursday: 13:00-15:00') + }) + + it('FE-PLANNER-HOURS-006: the 12h display and lines without a meridiem are untouched', () => { + expect(convertHoursLine('Wednesday: 1:00 – 3:00 PM', '12h')).toBe('Wednesday: 1:00 – 3:00 PM') + expect(convertHoursLine('Montag: 11:30–23:00', '24h')).toBe('Montag: 11:30–23:00') + expect(convertHoursLine('Saturday: Open 24 hours', '24h')).toBe('Saturday: Open 24 hours') + expect(convertHoursLine('', '24h')).toBe('') + }) + + it('FE-PLANNER-HOURS-007: 24h lines become 12h, with the German "Uhr" dropped', () => { + expect(convertHoursLine('Montag: 09:00 – 21:00 Uhr', '12h')).toBe('Montag: 9:00 AM – 9:00 PM') + expect(convertHoursLine('Monday: 00:30 – 12:00', '12h')).toBe('Monday: 12:30 AM – 12:00 PM') + }) +}) + +describe('splitHoursLine', () => { + it('FE-PLANNER-HOURS-008: splits on the first colon only, the fullwidth one included', () => { + expect(splitHoursLine('Monday: 09:00-18:00')).toEqual(['Monday', '09:00-18:00']) + expect(splitHoursLine('月曜日:9:00~18:00')).toEqual(['月曜日', '9:00~18:00']) + expect(splitHoursLine('Open 24 hours')).toEqual(['', 'Open 24 hours']) + }) +}) + +describe('isUnknownHoursLine', () => { + it('FE-PLANNER-HOURS-009: a bare question mark is the tag saying nothing', () => { + expect(isUnknownHoursLine(' ? ')).toBe(true) + expect(isUnknownHoursLine('09:00-18:00')).toBe(false) + }) +}) diff --git a/client/src/components/Planner/placeHoursFormat.ts b/client/src/components/Planner/placeHoursFormat.ts index 02f08b8a6..656584bb5 100644 --- a/client/src/components/Planner/placeHoursFormat.ts +++ b/client/src/components/Planner/placeHoursFormat.ts @@ -31,8 +31,18 @@ export function convertHoursLine(line: string, timeFormat: string): string { }) } if (timeFormat !== '12h' && hasAmPm) { + // Google spells a range that stays in one half of the day with the meridiem on + // the closing time only: "1:00 – 3:00 PM" is one to three in the afternoon. The + // conversion below only sees times that carry their own AM/PM, so the opener + // stayed "1:00" next to a "15:00" (#2412). Hand the closing meridiem back to an + // opener that has none. It fires only where nothing but the dash sits between + // the two times, so "11:00 AM – 1:00 PM" keeps both halves as written. + const paired = line.replace( + /(\d{1,2}:\d{2})(\s*[–—-]\s*)(\d{1,2}:\d{2}\s*)(AM|PM)\b/gi, + (_, open, sep, close, p) => `${open} ${p}${sep}${close}${p}`, + ) // 12h → 24h: "10:00 AM" → "10:00", "9:00 PM" → "21:00" - return line.replace(/(\d{1,2}):(\d{2})\s*(AM|PM)/gi, (_, h, m, p) => { + return paired.replace(/(\d{1,2}):(\d{2})\s*(AM|PM)/gi, (_, h, m, p) => { let hour = Number.parseInt(h) if (p.toUpperCase() === 'PM' && hour !== 12) hour += 12 if (p.toUpperCase() === 'AM' && hour === 12) hour = 0 diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index 222f2e7bc..f80292918 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -1553,12 +1553,16 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on {formatDate(day.date, language)} ) : null} - - {t('roadtrip.leg.driveText', { - distance: formatDistance(day.distance / 1000, distanceUnit), - time: formatDurationShort(day.duration), - })} - + {/* A day that is only the night booked for it has no drive to sum up and no + stop to count; the hotel row below says everything it has to say. */} + {day.legs.length > 0 ? ( + + {t('roadtrip.leg.driveText', { + distance: formatDistance(day.distance / 1000, distanceUnit), + time: formatDurationShort(day.duration), + })} + + ) : null} {day.dayWarning ? ( @@ -1569,9 +1573,11 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on ) : null} - - {t('roadtrip.day.stopCount', { count: day.stops.filter(s => !s.automaticNight && !isServiceStopType(s.stopType)).length })} - + {day.legs.length > 0 ? ( + + {t('roadtrip.day.stopCount', { count: day.stops.filter(s => !s.automaticNight && !isServiceStopType(s.stopType)).length })} + + ) : null} {/* The other half of "where possible". The setting is a weighting, so a day with no untolled crossing comes back on the toll road — and the only thing worse than not avoiding it is not avoiding it silently, which reads as the diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index f185063c0..4ca36efb4 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -1,5 +1,5 @@ import { useRoadtripSettings } from '../../hooks/useRoadtripSettings' -import { assembleRoadtrip, foldRouteRun, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip' +import { assembleRoadtrip, foldRouteRun, standsAsDay, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip' import { useEffect, useMemo, useRef, useState } from 'react' import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator' import { resolveLegMode } from '../Planner/legMode' @@ -91,6 +91,7 @@ const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommoda // traveller's own statement about this stop, and the stay then runs until it. leaveAt: a.assignment_end_time ?? p.end_time ?? null, checkInTime: stay?.check_in ?? null, + night: stay !== undefined, dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null, endDay: a.end_day === true, legMode: a.leg_transport_mode ?? null, @@ -222,7 +223,7 @@ export function useRoadtripRoutes( stops, } }) - .filter(d => d.stops.length > 1) + .filter(d => standsAsDay(d.stops)) }, [days, assignments, accommodations]) const quietDays = useMemo(() => { @@ -240,7 +241,7 @@ export function useRoadtripRoutes( .filter((s): s is RoadtripStop => s !== null) .map((s, i) => ({ ...s, ownerIndex: i })), })) - .filter(d => d.stops.length < 2) + .filter(d => !standsAsDay(d.stops)) }, [days, assignments, accommodations]) // Only the geometry decides whether legs have to be re-fetched: renaming a place or diff --git a/client/src/index.css b/client/src/index.css index 56b6f5440..ace630308 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1375,6 +1375,136 @@ img[alt="TREK"] { justify-content: center; } +/* Shared links in the Collab tab: one chip per link, two to a row across the full + width, one to a row where the panel is narrow. The chip is the link; edit, pin and + delete sit quietly in its tail and come forward on hover or keyboard focus. They + keep their width throughout: unfolding them reflowed the row under the pointer, + and a chip that grows as you reach for it is a chip you cannot catch. */ +.collab-link-grid { + container-type: inline-size; +} +.collab-link-grid__cells { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} +@container (max-width: 420px) { + .collab-link-grid__cells { grid-template-columns: minmax(0, 1fr); } +} +.collab-link-chip { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + padding: 5px 8px 5px 5px; + border-radius: 999px; + background: var(--bg-secondary); + border: 1px solid var(--border-faint); + box-shadow: var(--shadow-sm); + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease, background-color 0.15s ease; +} +.collab-link-chip:hover, .collab-link-chip:focus-within { + transform: translateY(-1px); + box-shadow: var(--shadow-md); + border-color: var(--border-primary); +} +.collab-link-chip--pinned { + background: var(--accent-subtle); + border-color: color-mix(in srgb, var(--accent) 35%, transparent); +} +.collab-link-chip__main { + display: flex; + align-items: center; + gap: 7px; + flex: 1 1 auto; + min-width: 0; + color: var(--text-primary); + text-decoration: none; + outline: none; +} +.collab-link-chip__icon { + position: relative; + display: grid; + place-items: center; + width: 24px; + height: 24px; + flex: 0 0 24px; + border-radius: 50%; + overflow: hidden; + background: var(--bg-card); + color: var(--text-faint); +} +.collab-link-chip__icon img { width: 16px; height: 16px; object-fit: contain; } +.collab-link-chip__icon > * { transition: opacity 0.15s ease; } +/* Reaching for the chip turns its favicon into the open-in-new-tab glyph: the + affordance without a glyph of its own taking room from the title. */ +.collab-link-chip__go { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: var(--text-primary); + opacity: 0; +} +.collab-link-chip__main:hover .collab-link-chip__go, .collab-link-chip__main:focus-visible .collab-link-chip__go { opacity: 1; } +.collab-link-chip__main:hover .collab-link-chip__icon > :not(.collab-link-chip__go), +.collab-link-chip__main:focus-visible .collab-link-chip__icon > :not(.collab-link-chip__go) { opacity: 0; } +/* Title first, host with what is left: a max-content track takes its full width + before the fr track sees any, and shrinks only once the title alone no longer + fits. Two flex items shrank together and both ended in an ellipsis. */ +.collab-link-chip__text { + display: grid; + grid-template-columns: minmax(0, max-content) minmax(0, 1fr); + align-items: baseline; + column-gap: 6px; + min-width: 0; + flex: 1 1 auto; +} +.collab-link-chip__title { + font-size: calc(12.5px * var(--fs-scale-body, 1)); + font-weight: 600; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.collab-link-chip__host { + font-size: calc(10.5px * var(--fs-scale-caption, 1)); + color: var(--text-faint); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.collab-link-chip__actions { + display: flex; + align-items: center; + flex: 0 0 auto; + gap: 1px; + margin-left: auto; + opacity: 0.45; + transition: opacity 0.15s ease; +} +.collab-link-chip:hover .collab-link-chip__actions, .collab-link-chip:focus-within .collab-link-chip__actions { opacity: 1; } +.collab-link-chip__action { + display: grid; + place-items: center; + width: 22px; + height: 22px; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--text-faint); + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; +} +.collab-link-chip__action:hover, .collab-link-chip__action:focus-visible { background: var(--bg-hover); color: var(--text-primary); } +.collab-link-chip__action--on { color: var(--accent); } +@media (hover: none) { + .collab-link-chip__actions { opacity: 1; } +} + /* Markdown in Collab Notes */ .collab-note-md strong, .collab-note-md-full strong { font-weight: 700 !important; } .collab-note-md em, .collab-note-md-full em { font-style: italic !important; } diff --git a/client/src/store/slices/remoteEventHandler.ts b/client/src/store/slices/remoteEventHandler.ts index d7877ee61..e08b21e58 100644 --- a/client/src/store/slices/remoteEventHandler.ts +++ b/client/src/store/slices/remoteEventHandler.ts @@ -252,11 +252,17 @@ export const STATE_APPLIERS: Partial> } // Genuinely new — including a legitimate second assignment of a place - // already on this day (no temp version to reconcile). Append. + // already on this day (no temp version to reconcile). Seated where the server + // put it: a booked night lands at the front of its day, and the rows behind it + // were moved up one there. Appending it with that index would leave it tied + // with the old first row and drawn second until the next reload. + const ordered = existing.slice().sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) + const at = Math.max(0, Math.min(incoming.order_index ?? ordered.length, ordered.length)) + ordered.splice(at, 0, incoming) return { assignments: { ...state.assignments, - [dayKey]: [...existing, incoming], + [dayKey]: ordered.map((a, i) => (a.order_index === i ? a : { ...a, order_index: i })), } } }, diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index ceaf909d2..596ea745f 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -2,6 +2,7 @@ import { ROADTRIP_PREFERENCE_KEYS } from '@trek/shared'; import { readEnv } from '../app-config'; import { encrypt_api_key } from '../nest/common/crypto/apiKeyCrypto'; import { seedDocumentProviders } from './document-provider-seed'; +import { reseatBookedNights } from './reseat-booked-nights'; import Database from 'better-sqlite3'; import fs from 'fs'; @@ -5212,6 +5213,19 @@ function runMigrations(db: Database.Database): void { ALTER TABLE roadtrip_day_boundaries_new RENAME TO roadtrip_day_boundaries; `); }, + + /* + * Seat every booked night where its check-in says, the way a night booked + * today is seated. The night leads its day now; the stops a booking put on + * their check-in day before this release sit last, and the trips people + * already have would keep that order until somebody edits the check-in. + * The rules, and the drawn roads that follow the stops, are in + * reseat-booked-nights.ts. Re-runnable. + */ + () => { + const seated = reseatBookedNights(db); + if (seated > 0) console.log(`[DB] Seated ${seated} booked night(s) at the head of their day`); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/reseat-booked-nights.ts b/server/src/db/reseat-booked-nights.ts new file mode 100644 index 000000000..cfe904449 --- /dev/null +++ b/server/src/db/reseat-booked-nights.ts @@ -0,0 +1,118 @@ +import Database from 'better-sqlite3'; + +type DayRow = { id: number; at: string | null; located: number }; +type Via = { id: number; after_order_index: number; sequence: number }; + +/** + * Seat every booked night where its check-in says, the way a night booked today is + * seated (AccommodationsService.positionForCheckIn). + * + * The stop a booking puts on its check-in day used to go last unless a stop pinned to + * a later hour pulled it forward, and the backfill that gave older bookings their stop + * appended it too. The night leads its day now: first, behind only a stop whose own + * clock is at or before the check-in, with the stops that carry no hour following it. + * Without this step every trip planned before the change keeps the old order until + * somebody edits the check-in. + * + * Only the stops a booking owns move (accommodation_id). A stop the traveller placed + * themselves and then booked a night on is theirs, wherever it sits. The drawn roads + * (roadtrip_vias) are pinned to positions among the day's located stops, so they are + * carried along by the rules the service applies (AccommodationsService.reanchorVias): + * a via follows the stop it was drawn after, a via behind a stop that is last stays + * with it, and one left without a leg goes. + * + * Runs inside the migration's transaction. Re-runnable: a night already in its seat is + * passed over. Returns how many nights moved. + */ +export function reseatBookedNights(db: Database.Database): number { + const nights = db.prepare(` + SELECT da.id, da.day_id, a.check_in + FROM day_assignments da + JOIN day_accommodations a ON a.id = da.accommodation_id + ORDER BY da.day_id, da.order_index, da.id + `).all() as Array<{ id: number; day_id: number; check_in: string | null }>; + // Another night on the same day counts by its check-in, so two bookings on one + // day settle by the clock instead of by whichever was seated last. + const dayRows = db.prepare(` + SELECT da.id, COALESCE(da.assignment_time, p.place_time, other.check_in) AS at, + (p.lat IS NOT NULL AND p.lng IS NOT NULL) AS located + FROM day_assignments da JOIN places p ON p.id = da.place_id + LEFT JOIN day_accommodations other ON other.id = da.accommodation_id + WHERE da.day_id = ? + ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC + `); + const setIndex = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?'); + const viasOf = db.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?'); + const dropVia = db.prepare('DELETE FROM roadtrip_vias WHERE id = ?'); + const moveVia = db.prepare('UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ?'); + const setSequence = db.prepare('UPDATE roadtrip_vias SET sequence = ? WHERE id = ?'); + + let seated = 0; + for (const night of nights) { + const rows = dayRows.all(night.day_id) as DayRow[]; + const own = rows.findIndex((row) => row.id === night.id); + if (own < 0) continue; + const others = rows.filter((row) => row.id !== night.id); + let seat = 0; + if (night.check_in) { + others.forEach((row, i) => { + if (row.at !== null && row.at <= night.check_in!) seat = i + 1; + }); + } + const next = [...others.slice(0, seat), rows[own]!, ...others.slice(seat)]; + if (next.every((row, i) => row.id === rows[i]!.id)) continue; + + const previousLocated = rows.filter((row) => row.located).map((row) => row.id); + next.forEach((row, i) => setIndex.run(i, row.id)); + seated += 1; + + const nextLocated = next.filter((row) => row.located).map((row) => row.id); + const vias = viasOf.all(night.day_id) as Via[]; + if (!vias.length) continue; + const previousLast = previousLocated.length - 1; + const nextLast = nextLocated.length - 1; + const lastStayed = previousLast >= 0 && previousLocated[previousLast] === nextLocated[nextLast]; + const landed = new Set(); + const kept: Array = []; + for (const via of vias) { + const leg = lastStayed && via.after_order_index === previousLast + ? nextLast + : legAfter(via.after_order_index, previousLocated, nextLocated); + if (leg === null) { + dropVia.run(via.id); + continue; + } + if (leg !== via.after_order_index) { + moveVia.run(leg, via.id); + landed.add(via.id); + } + kept.push({ ...via, was: via.after_order_index, after_order_index: leg }); + } + // Two legs that merged carry two sequence series side by side. Renumbered the way + // the service does it: the earlier leg's points first, then by their old sequence, + // then by id. Only a leg that received a via is touched. + const byLeg = new Map>(); + for (const via of kept) byLeg.set(via.after_order_index, [...(byLeg.get(via.after_order_index) ?? []), via]); + for (const onLeg of byLeg.values()) { + if (!onLeg.some((via) => landed.has(via.id))) continue; + onLeg.sort((a, b) => a.was - b.was || a.sequence - b.sequence || a.id - b.id); + onLeg.forEach((via, i) => { + if (via.sequence !== i) setSequence.run(i, via.id); + }); + } + } + return seated; +} + +/** + * The leg a via pinned behind the n-th stop of the old order is on in the new one + * (AccommodationsService.legAfter). Null when no leg is left for it. + */ +function legAfter(index: number, previousIds: number[], nextIds: number[]): number | null { + if (index > previousIds.length - 1) return null; + let at = index; + while (at >= 0 && !nextIds.includes(previousIds[at]!)) at -= 1; + if (at < 0) return null; + const next = nextIds.indexOf(previousIds[at]!); + return next >= nextIds.length - 1 ? null : next; +} diff --git a/server/src/nest/accommodations/accommodations.service.ts b/server/src/nest/accommodations/accommodations.service.ts index b140feb9f..a7403fe7d 100644 --- a/server/src/nest/accommodations/accommodations.service.ts +++ b/server/src/nest/accommodations/accommodations.service.ts @@ -164,9 +164,16 @@ export class AccommodationsService { return this.mirrorStay(accommodationId, placeId, dayId, checkIn); } - /** Carry a stay's own stop over to where the stay now is. */ - moveStayStop(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): AccommodationMirror { - return this.remirrorStay(accommodationId, placeId, dayId, checkIn); + /** Carry a stay's own stop over to where the stay now is. `checkInChanged` says the + * booking was given a new hour, which seats the night afresh (see remirrorStay). */ + moveStayStop( + accommodationId: number, + placeId: number | null, + dayId: number, + checkIn?: string | null, + opts: { checkInChanged?: boolean } = {}, + ): AccommodationMirror { + return this.remirrorStay(accommodationId, placeId, dayId, checkIn, opts); } /** Take back the stops of a stay that is being deleted elsewhere. */ @@ -277,42 +284,47 @@ export class AccommodationsService { /** * Where in the day the booked night belongs. * - * Last, unless the booking names an hour. A night usually ends the day, which is why - * that is the default — but a check-in at eleven says the traveller is at the desk - * before whatever they pinned to the afternoon, and dropping the stop behind it built - * a drive that visits the hotel after the sixteen-hundred it was booked around. - * - * Only stops that carry a clock of their own are compared. One without a time takes - * its position from the chain, not from the hour, so pushing past it would move a - * stop the traveller placed deliberately. + * First. The hotel is where the day is based: the traveller checks in and the stops + * they placed without an hour follow from there. Only a stop with a clock of its + * own can stand ahead of it, and only when that clock is at or before the check-in: + * a stop pinned to eight with a check-in at ten puts the night second, one pinned + * to the afternoon does not. It used to go last unless something pinned later + * pulled it forward, and a night booked for ten in the morning sat behind a whole + * day of unpinned stops, reached at a quarter past twelve. */ - private positionForCheckIn(dayId: number, checkIn: string | null | undefined, excludeId?: number): number | undefined { - if (!checkIn) return undefined; + private positionForCheckIn(dayId: number, checkIn: string | null | undefined, excludeId?: number): number { + if (!checkIn) return 0; // excludeId leaves the row being re-seated out of the chain it is measured // against. Without it a night parked at the end of the day can find itself. + // Another booked night on the day counts by its check-in, so two bookings on + // one day settle by the clock. const rows = this.db.all<{ order_index: number; at: string | null }>(` - SELECT da.order_index, COALESCE(da.assignment_time, p.place_time) AS at + SELECT da.order_index, COALESCE(da.assignment_time, p.place_time, other.check_in) AS at FROM day_assignments da JOIN places p ON p.id = da.place_id + LEFT JOIN day_accommodations other ON other.id = da.accommodation_id WHERE da.day_id = ? AND da.id != ? ORDER BY da.order_index `, dayId, excludeId ?? -1); - const later = rows.find(row => row.at !== null && row.at > checkIn); - return later?.order_index; + let seat = 0; + for (const row of rows) if (row.at !== null && row.at <= checkIn) seat = row.order_index + 1; + return seat; } /** - * Whether the night already sits where its check-in says it should. + * Whether the night sits somewhere its check-in allows, on an edit that did not + * touch the check-in. * * Read off the chain rather than recomputed, because the index a fresh insert would * get is not the index this row already occupies. Two ways to be wrong: something * with a later hour ahead of it, or something with an earlier one behind it. Stops - * without an hour are passed over in both directions — their place in the chain is - * the traveller's doing, not a clock's. + * without an hour are passed over in both directions: the night may have been + * dragged past them on purpose, and a change of notes is no reason to undo that. */ private seatedByCheckIn(dayId: number, ownId: number, checkIn: string | null | undefined): boolean { if (!checkIn) return true; const rows = this.db.all<{ id: number; at: string | null }>(` - SELECT da.id, COALESCE(da.assignment_time, p.place_time) AS at + SELECT da.id, COALESCE(da.assignment_time, p.place_time, other.check_in) AS at FROM day_assignments da JOIN places p ON p.id = da.place_id + LEFT JOIN day_accommodations other ON other.id = da.accommodation_id WHERE da.day_id = ? ORDER BY da.order_index `, dayId); const own = rows.findIndex(row => row.id === ownId); @@ -493,7 +505,7 @@ export class AccommodationsService { this.db.run('UPDATE day_assignments SET day_id = ?, place_id = ?, order_index = ? WHERE id = ?', dayId, placeId, end, stop.id); const seat = this.positionForCheckIn(dayId, checkIn, stop.id); - if (seat !== undefined && seat < end) { + if (seat < end) { this.db.run( 'UPDATE day_assignments SET order_index = order_index + 1 WHERE day_id = ? AND order_index >= ? AND id != ?', dayId, seat, stop.id, @@ -519,9 +531,8 @@ export class AccommodationsService { if (this.db.get('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?', dayId, placeId)) return mirror; const before = this.stopOrders([dayId]); - // Through AssignmentsService, so the stop lands at the end of the day with the - // order_index every other new assignment gets. Evening is where you arrive at a - // hotel, and the day planner has no drive to position it against anyway. + // Through AssignmentsService, seated where the check-in says (see + // positionForCheckIn), with everything behind it moved up one. // // The booking id goes in with the INSERT, not as an UPDATE afterwards: what this // returns is the row the answer hands the client, and stamping the id on later @@ -590,14 +601,24 @@ export class AccommodationsService { * * Runs inside the caller's transaction. */ - private remirrorStay(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): AccommodationMirror { + private remirrorStay( + accommodationId: number, + placeId: number | null, + dayId: number, + checkIn?: string | null, + opts: { checkInChanged?: boolean } = {}, + ): AccommodationMirror { const own = this.ownStops(accommodationId); if (own.length === 0) return noMirror(); if (own.length === 1 && own[0].day_id === dayId && own[0].place_id === placeId) { - // Same place, same day — but a check-in moved to a different hour can still put - // the night somewhere else in the chain, and a stop sitting after the afternoon - // it was booked around is the plan reading back wrong. - if (this.seatedByCheckIn(dayId, own[0].id, checkIn)) return noMirror(); + // Same place, same day. A check-in given a new hour seats the night afresh, the + // way booking it with that hour would have; any other edit leaves a stop where + // it is unless the clocks around it say it is in the wrong place, so a night the + // traveller dragged somewhere stays there through a change of notes. + const settled = opts.checkInChanged + ? this.positionForCheckIn(dayId, checkIn, own[0].id) === own[0].order_index + : this.seatedByCheckIn(dayId, own[0].id, checkIn); + if (settled) return noMirror(); } const mirror = noMirror(); @@ -689,7 +710,9 @@ export class AccommodationsService { 'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_in_end = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?', newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckInEnd, newCheckOut, newConfirmation, newNotes, id ); - return this.remirrorStay(Number(id), newPlaceId, newStartDayId, newCheckIn); + return this.remirrorStay(Number(id), newPlaceId, newStartDayId, newCheckIn, { + checkInChanged: fields.check_in !== undefined && (fields.check_in || null) !== (existing.check_in || null), + }); }); // Sync check-in/out/confirmation to every linked reservation. The booking form diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts index 5d190e8ba..eb4ae18a1 100644 --- a/server/src/nest/reservations/reservations.service.ts +++ b/server/src/nest/reservations/reservations.service.ts @@ -846,6 +846,7 @@ export class ReservationsService { if (start_day_id && end_day_id) { this.requireResolvableStay(create_accommodation); if (resolvedAccId) { + const prior = this.db.get<{ check_in: string | null }>('SELECT check_in FROM day_accommodations WHERE id = ?', resolvedAccId); this.db.run( 'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?', accPlaceId || null, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId @@ -853,7 +854,9 @@ export class ReservationsService { // The stay just moved. Its stop moves with it, or it is left sitting on a // day nobody sleeps there any more, hidden from the day list because it // still carries this booking's id and stranded in the middle of the drive. - stayMirror = this.accommodations.moveStayStop(resolvedAccId, accPlaceId || null, start_day_id, check_in); + stayMirror = this.accommodations.moveStayStop(resolvedAccId, accPlaceId || null, start_day_id, check_in, { + checkInChanged: (check_in || null) !== (prior?.check_in ?? null), + }); } else if (accPlaceId) { const accResult = this.db.run( 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)', diff --git a/server/src/nest/roadtrip/roadtrip-plan.service.ts b/server/src/nest/roadtrip/roadtrip-plan.service.ts index 91a8a5f9b..7e99ceb05 100644 --- a/server/src/nest/roadtrip/roadtrip-plan.service.ts +++ b/server/src/nest/roadtrip/roadtrip-plan.service.ts @@ -22,6 +22,7 @@ import { type RoutedLeg, type SnappedWaypoint, type RouteAvoidClass, + standsAsDay, } from '@trek/shared/roadtrip'; interface StoredDay { @@ -32,6 +33,8 @@ interface StoredDay { default_transport_mode: string | null; } interface VisitRow { + /** The booking this stop stands for on its check-in day, when there is one. */ + stay_id: number | null; check_in: string | null; /** The drive does not read these two (#2357): they are the booking as get_roadtrip_context reports it. */ check_out: string | null; @@ -75,7 +78,7 @@ export class RoadtripPlanService { `SELECT a.id, a.day_id, a.place_id, p.name, p.lat, p.lng, COALESCE(a.assignment_time, p.place_time) AS time, COALESCE(a.assignment_end_time, p.end_time) AS end_time, p.duration_minutes, a.end_day, - a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent, stay.check_in, stay.check_out, checkout.day_number AS checkout_day + a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent, stay.id AS stay_id, stay.check_in, stay.check_out, checkout.day_number AS checkout_day FROM day_assignments a JOIN days d ON d.id = a.day_id JOIN places p ON p.id = a.place_id LEFT JOIN day_accommodations stay ON stay.id = (SELECT id FROM day_accommodations WHERE place_id = p.id AND start_day_id = d.id ORDER BY id LIMIT 1) LEFT JOIN days checkout ON checkout.id = stay.end_day_id @@ -123,6 +126,7 @@ export class RoadtripPlanService { // planner makes in the browser (useRoadtripRoutes). leaveAt: v.end_time, checkInTime: v.check_in, + night: v.stay_id !== null, dwellMinutes: v.duration_minutes, endDay: v.end_day === 1, legMode: v.leg_transport_mode, @@ -245,8 +249,8 @@ export class RoadtripPlanService { preferences.roadtrip_range_km, ) || null; const calculated = assembleRoadtrip({ - plan: plan.filter((d) => d.stops.length > 1), - quietDays: plan.filter((d) => d.stops.length < 2), + plan: plan.filter((d) => standsAsDay(d.stops)), + quietDays: plan.filter((d) => !standsAsDay(d.stops)), window, allLegs, snapByDay, diff --git a/server/tests/unit/db/booked-night-reseat.test.ts b/server/tests/unit/db/booked-night-reseat.test.ts new file mode 100644 index 000000000..58797af18 --- /dev/null +++ b/server/tests/unit/db/booked-night-reseat.test.ts @@ -0,0 +1,223 @@ +/** + * The step that seats every booked night where its check-in says. + * + * A night's stop used to go last on its check-in day unless a stop pinned to a later + * hour pulled it forward, and the backfill for older bookings appended it as well. The + * night leads its day now (AccommodationsService.positionForCheckIn), and this step + * brings the trips planned under the old rule along, drawn roads included. + * + * The rules live in reseat-booked-nights.ts and are tested there directly on a fully + * migrated database: the columns the step reads (assignment_time, accommodation_id) + * and the vias table come from migrations, so seeding before runMigrations is not an + * option, and rewinding schema_version would tie this file to the tail of the + * append-only array. + */ +import { describe, it, expect } from 'vitest'; +import Database from 'better-sqlite3'; +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { reseatBookedNights } from '../../../src/db/reseat-booked-nights'; + +function freshDb() { + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + createTables(db); + runMigrations(db); + db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'u', 'u@test.local', 'x', 'user')").run(); + db.prepare("INSERT INTO trips (id, user_id, title) VALUES (1, 1, 'T')").run(); + db.prepare("INSERT INTO days (id, trip_id, day_number, date) VALUES (1, 1, 1, '2026-10-02')").run(); + return db; +} + +let nextPlace = 1; +function place(db: Database.Database, name: string, time: string | null = null, located = true) { + const id = nextPlace++; + db.prepare('INSERT INTO places (id, trip_id, name, lat, lng, place_time) VALUES (?, 1, ?, ?, ?, ?)') + .run(id, name, located ? 50 + id / 100 : null, located ? 10 : null, time); + return id; +} + +let nextStop = 1; +function stop(db: Database.Database, placeId: number, orderIndex: number, time: string | null = null, accommodationId: number | null = null) { + const id = nextStop++; + db.prepare('INSERT INTO day_assignments (id, day_id, place_id, order_index, assignment_time, accommodation_id) VALUES (?, 1, ?, ?, ?, ?)') + .run(id, placeId, orderIndex, time, accommodationId); + return id; +} + +function night(db: Database.Database, placeId: number, checkIn: string | null) { + return Number(db.prepare('INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in) VALUES (1, ?, 1, 1, ?)') + .run(placeId, checkIn).lastInsertRowid); +} + +const order = (db: Database.Database) => + (db.prepare('SELECT place_id FROM day_assignments WHERE day_id = 1 ORDER BY order_index').all() as { place_id: number }[]).map((r) => r.place_id); +const indexes = (db: Database.Database) => + (db.prepare('SELECT order_index FROM day_assignments WHERE day_id = 1 ORDER BY order_index').all() as { order_index: number }[]).map((r) => r.order_index); +const vias = (db: Database.Database) => + db.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = 1 ORDER BY after_order_index, sequence, id').all() as + { id: number; after_order_index: number; sequence: number }[]; +const via = (db: Database.Database, afterOrderIndex: number, sequence = 0) => + Number(db.prepare('INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (1, ?, ?, 50, 10)').run(afterOrderIndex, sequence).lastInsertRowid); + +describe('booked night reseat', () => { + it('RESEAT-001: a night appended behind stops without an hour leads the day', () => { + const db = freshDb(); + const [fuel, farm, hotel] = [place(db, 'Aral'), place(db, 'Karls'), place(db, 'Rostock')]; + stop(db, fuel, 0); + stop(db, farm, 1); + stop(db, hotel, 2, null, night(db, hotel, '10:00')); + + expect(reseatBookedNights(db)).toBe(1); + + expect(order(db)).toEqual([hotel, fuel, farm]); + expect(indexes(db)).toEqual([0, 1, 2]); + }); + + it('RESEAT-002: a night without a check-in leads the day too', () => { + const db = freshDb(); + const [museum, hotel] = [place(db, 'Museum'), place(db, 'Adlon')]; + stop(db, museum, 0); + stop(db, hotel, 1, null, night(db, hotel, null)); + + reseatBookedNights(db); + + expect(order(db)).toEqual([hotel, museum]); + }); + + it('RESEAT-003: a stop with its own hour at or before the check-in stays ahead', () => { + const db = freshDb(); + const [early, loose, late, hotel] = [place(db, 'Aral'), place(db, 'Hafen'), place(db, 'Museum'), place(db, 'Rostock')]; + stop(db, early, 0, '08:00'); + stop(db, loose, 1); + // The place's own hour counts as much as the visit's. + stop(db, late, 2, null); + db.prepare("UPDATE places SET place_time = '14:00' WHERE id = ?").run(late); + stop(db, hotel, 3, null, night(db, hotel, '10:00')); + + reseatBookedNights(db); + + expect(order(db)).toEqual([early, hotel, loose, late]); + }); + + it('RESEAT-004: a night already in its seat, and a stop the traveller placed, are left alone', () => { + const db = freshDb(); + const [hotel, museum, own] = [place(db, 'Adlon'), place(db, 'Museum'), place(db, 'Campsite')]; + stop(db, hotel, 0, null, night(db, hotel, '15:00')); + stop(db, museum, 1); + // Booked in road trip mode: the stop is the traveller's, the booking owns no row. + night(db, own, '18:00'); + stop(db, own, 2); + + expect(reseatBookedNights(db)).toBe(0); + + expect(order(db)).toEqual([hotel, museum, own]); + }); + + it('RESEAT-005: the drawn roads follow the stops they were drawn after', () => { + const db = freshDb(); + const [a, b, hotel] = [place(db, 'Harbour'), place(db, 'Mercure'), place(db, 'Rostock')]; + stop(db, a, 0); + stop(db, b, 1); + stop(db, hotel, 2, null, night(db, hotel, '10:00')); + const afterA = via(db, 0); + const afterB = via(db, 1); + const afterHotel = via(db, 2); + + reseatBookedNights(db); + + // The rules the service applies when it moves a night: a via follows the stop + // it was drawn after, so A's road is on leg one now and the hotel's on leg + // zero; B is last now and has no leg to keep a via on, so its road goes. + expect(order(db)).toEqual([hotel, a, b]); + expect(vias(db)).toEqual([ + { id: afterHotel, after_order_index: 0, sequence: 0 }, + { id: afterA, after_order_index: 1, sequence: 0 }, + ]); + expect(vias(db).map((v) => v.id)).not.toContain(afterB); + }); + + it('RESEAT-006: a road behind the last stop stays with it, and the roads ahead follow their stops', () => { + // The hotel moves from the middle to the front. C is last before and after, so the + // drive into the next day stays behind it; the hotel's road follows the hotel to + // leg zero and A's follows A to leg one. Nothing merges in a pure reorder. + const db = freshDb(); + const [a, hotel, c] = [place(db, 'Harbour'), place(db, 'Rostock'), place(db, 'Museum')]; + stop(db, a, 0); + stop(db, hotel, 1, null, night(db, hotel, '10:00')); + stop(db, c, 2); + const afterHotel = via(db, 1); + const afterA = via(db, 0); + const intoTomorrow = via(db, 2); + + reseatBookedNights(db); + + expect(order(db)).toEqual([hotel, a, c]); + expect(vias(db)).toEqual([ + { id: afterHotel, after_order_index: 0, sequence: 0 }, + { id: afterA, after_order_index: 1, sequence: 0 }, + { id: intoTomorrow, after_order_index: 2, sequence: 0 }, + ]); + }); + + it('RESEAT-007: a stop without coordinates is not counted among the positions the roads are pinned to', () => { + const db = freshDb(); + const [a, ghost, hotel] = [place(db, 'Harbour'), place(db, 'No map', null, false), place(db, 'Rostock')]; + stop(db, a, 0); + stop(db, ghost, 1); + stop(db, hotel, 2, null, night(db, hotel, '10:00')); + const afterA = via(db, 0); + + reseatBookedNights(db); + + expect(order(db)).toEqual([hotel, a, ghost]); + // Located stops went [A, hotel] to [hotel, A]: A is last now, so its road has + // no leg left. + expect(vias(db).map((v) => v.id)).not.toContain(afterA); + }); + + it('RESEAT-010: two nights on one day settle by their check-ins', () => { + const db = freshDb(); + const [later, earlier] = [place(db, 'Rue de Paris'), place(db, 'Brandenburger Tor')]; + stop(db, later, 0, null, night(db, later, '12:00')); + stop(db, earlier, 1, null, night(db, earlier, '10:00')); + + reseatBookedNights(db); + + expect(order(db)).toEqual([earlier, later]); + expect(reseatBookedNights(db)).toBe(0); + }); + + it('RESEAT-008: runs again without moving anything', () => { + const db = freshDb(); + const [fuel, hotel] = [place(db, 'Aral'), place(db, 'Rostock')]; + stop(db, fuel, 0); + stop(db, hotel, 1, null, night(db, hotel, '10:00')); + reseatBookedNights(db); + const once = order(db); + + expect(reseatBookedNights(db)).toBe(0); + expect(order(db)).toEqual(once); + }); + + it('RESEAT-009: the migration runs the step on a database being upgraded', () => { + // Seeded through the tables the base schema has, so the step fires on its normal + // pass: the backfill for older bookings appends the night last first, then the + // reseat brings it to the front. + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + createTables(db); + db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'u', 'u@test.local', 'x', 'user')").run(); + db.prepare("INSERT INTO trips (id, user_id, title) VALUES (1, 1, 'T')").run(); + db.prepare("INSERT INTO days (id, trip_id, day_number, date) VALUES (1, 1, 1, '2026-10-02')").run(); + db.prepare("INSERT INTO places (id, trip_id, name, lat, lng) VALUES (901, 1, 'Aral', 50.1, 10), (902, 1, 'Rostock', 50.2, 10)").run(); + db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (1, 901, 0)').run(); + db.prepare("INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in) VALUES (1, 902, 1, 1, '10:00')").run(); + + runMigrations(db); + + expect(order(db)).toEqual([902, 901]); + }); +}); diff --git a/server/tests/unit/nest/accommodations.service.test.ts b/server/tests/unit/nest/accommodations.service.test.ts index 0fdd0e26c..d017e2332 100644 --- a/server/tests/unit/nest/accommodations.service.test.ts +++ b/server/tests/unit/nest/accommodations.service.test.ts @@ -615,7 +615,10 @@ describe('the day stop a booking implies', () => { expect(mirror.created.accommodation_id).toBe(accommodation.id); }); - it('ACC-022 the stop lands at the end of the day, where you arrive at a hotel', () => { + it('ACC-022 the stop lands first: the hotel is where the day is based', () => { + // It used to go last, and a night booked for the morning sat behind a whole day + // of unpinned stops. The stops the traveller placed without an hour follow the + // hotel; only a clock of their own can put one ahead of it (ACC-022c). const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); @@ -625,13 +628,12 @@ describe('the day stop a booking implies', () => { book(trip.id, hotel.id, day.id, day.id); - expect(stopsOn(day.id).map(a => a.place_id)).toEqual([museum.id, hotel.id]); + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, museum.id]); + expect(stopsOn(day.id).map(a => a.order_index)).toEqual([0, 1]); }); it('ACC-022b a check-in puts the night before whatever is pinned to a later hour', () => { - // The default is last, because a night usually ends the day. A check-in at eleven - // says otherwise, and dropping the stop behind a stop pinned to the afternoon built - // a drive that reaches the hotel after the hour it was booked around. + // A stop pinned to the afternoon cannot stand ahead of a check-in at eleven. const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); @@ -645,7 +647,7 @@ describe('the day stop a booking implies', () => { expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, afternoon.id]); }); - it('ACC-022c a check-in after everything pinned stays last, like a night without one', () => { + it('ACC-022c a stop pinned to an earlier hour stays ahead of the check-in', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); @@ -659,18 +661,67 @@ describe('the day stop a booking implies', () => { expect(stopsOn(day.id).map(a => a.place_id)).toEqual([morning.id, hotel.id]); }); - it('ACC-022d a stop without an hour of its own is never pushed past', () => { - // Its position came from the chain, not from a clock, so the traveller put it there. + it('ACC-022d a stop without an hour of its own follows the night, whatever the check-in', () => { + // A stop with no clock is planned from the hotel, not the other way round. This + // is the day from Discord: two unpinned stops and a night booked for ten in the + // morning, which the drive used to reach at a quarter past twelve. const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); - const untimed = createPlace(testDb, trip.id, { name: 'Hafen' }); - const hotel = createPlace(testDb, trip.id, { name: 'Billstedt' }); - createDayAssignment(testDb, day.id, untimed.id); + const fuel = createPlace(testDb, trip.id, { name: 'Aral' }); + const farm = createPlace(testDb, trip.id, { name: 'Karls Erdbeerhof' }); + const hotel = createPlace(testDb, trip.id, { name: 'Rostock' }); + createDayAssignment(testDb, day.id, fuel.id); + createDayAssignment(testDb, day.id, farm.id); - svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' }); + svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }); - expect(stopsOn(day.id).map(a => a.place_id)).toEqual([untimed.id, hotel.id]); + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, fuel.id, farm.id]); + }); + + it('ACC-022f the night goes behind the last stop whose own hour is at or before the check-in', () => { + // Pinned eight, unpinned, pinned two: a check-in at ten sits behind the eight and + // ahead of everything else, the unpinned stop included. + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const [early, loose, late, hotel] = ['Aral', 'Hafen', 'Museum', 'Rostock'].map(name => createPlace(testDb, trip.id, { name })); + const first = createDayAssignment(testDb, day.id, early.id); + createDayAssignment(testDb, day.id, loose.id); + const third = createDayAssignment(testDb, day.id, late.id); + testDb.prepare("UPDATE day_assignments SET assignment_time = '08:00' WHERE id = ?").run(first.id); + testDb.prepare("UPDATE day_assignments SET assignment_time = '14:00' WHERE id = ?").run(third.id); + + svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }); + + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([early.id, hotel.id, loose.id, late.id]); + }); + + it('ACC-022g a new check-in seats the night afresh, a change of notes leaves a dragged night alone', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const [harbour, market, hotel] = ['Hafen', 'Markt', 'Rostock'].map(name => createPlace(testDb, trip.id, { name })); + createDayAssignment(testDb, day.id, harbour.id); + createDayAssignment(testDb, day.id, market.id); + const { accommodation } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id }) as any; + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, market.id]); + + // Dragged to the end of the day by hand. + const own = stopsOn(day.id).find(a => a.place_id === hotel.id)!; + testDb.prepare('UPDATE day_assignments SET order_index = order_index - 1 WHERE day_id = ? AND order_index > ?').run(day.id, own.order_index); + testDb.prepare('UPDATE day_assignments SET order_index = 2 WHERE id = ?').run(own.id); + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([harbour.id, market.id, hotel.id]); + + let existing = svc.getAccommodation(accommodation.id, trip.id)!; + const quiet = svc.updateAccommodation(accommodation.id, existing, { notes: 'late arrival' }) as any; + expect(quiet.mirror.moved).toBeNull(); + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([harbour.id, market.id, hotel.id]); + + existing = svc.getAccommodation(accommodation.id, trip.id)!; + const reseated = svc.updateAccommodation(accommodation.id, existing, { check_in: '10:00' }) as any; + expect(reseated.mirror.moved).toMatchObject({ oldDayId: day.id, assignment: { id: own.id } }); + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, market.id]); }); it('ACC-022e moving the check-in later re-seats the night', () => { @@ -692,6 +743,52 @@ describe('the day stop a booking implies', () => { expect(stopsOn(day.id).map(a => a.place_id)).toEqual([afternoon.id, hotel.id]); }); + it('ACC-022i a second night on the day counts by its check-in', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const [noon, morning] = ['Rue de Paris', 'Brandenburger Tor'].map(name => createPlace(testDb, trip.id, { name })); + svc.createAccommodation(trip.id, { place_id: noon.id, start_day_id: day.id, end_day_id: day.id, check_in: '12:00' }); + svc.createAccommodation(trip.id, { place_id: morning.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }); + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([morning.id, noon.id]); + + const evening = createPlace(testDb, trip.id, { name: 'Late' }); + svc.createAccommodation(trip.id, { place_id: evening.id, start_day_id: day.id, end_day_id: day.id, check_in: '18:00' }); + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([morning.id, noon.id, evening.id]); + }); + + it('ACC-022h an edit that leaves the check-in alone still corrects a night the clocks contradict', () => { + // Dragged behind a stop pinned to the afternoon, with a check-in at ten: a change of + // notes puts it back ahead of that stop, because a night sitting after an afternoon it + // was booked before is the plan reading back wrong. Dragged behind a stop without an + // hour, the same edit leaves it where the traveller put it. + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const [harbour, museum, hotel] = ['Hafen', 'Museum', 'Rostock'].map(name => createPlace(testDb, trip.id, { name })); + createDayAssignment(testDb, day.id, harbour.id); + const pinned = createDayAssignment(testDb, day.id, museum.id); + testDb.prepare("UPDATE day_assignments SET assignment_time = '14:00' WHERE id = ?").run(pinned.id); + const { accommodation } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }) as any; + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, museum.id]); + const own = stopsOn(day.id).find(a => a.place_id === hotel.id)!; + + // Behind the harbour only: allowed, and left alone. + testDb.prepare('UPDATE day_assignments SET order_index = 0 WHERE day_id = ? AND place_id = ?').run(day.id, harbour.id); + testDb.prepare('UPDATE day_assignments SET order_index = 1 WHERE id = ?').run(own.id); + let existing = svc.getAccommodation(accommodation.id, trip.id)!; + expect((svc.updateAccommodation(accommodation.id, existing, { notes: 'late' }) as any).mirror.moved).toBeNull(); + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([harbour.id, hotel.id, museum.id]); + + // Behind the afternoon: contradicted by the clocks, and seated afresh, which is + // the front, ahead of the harbour that has no hour of its own. + testDb.prepare('UPDATE day_assignments SET order_index = 1 WHERE day_id = ? AND place_id = ?').run(day.id, museum.id); + testDb.prepare('UPDATE day_assignments SET order_index = 2 WHERE id = ?').run(own.id); + existing = svc.getAccommodation(accommodation.id, trip.id)!; + expect((svc.updateAccommodation(accommodation.id, existing, { notes: 'later' }) as any).mirror.moved).not.toBeNull(); + expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, museum.id]); + }); + it('ACC-023 the place is typed as lodging, so the rail draws it as a service stop', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); @@ -887,7 +984,10 @@ describe('the day stop a booking implies', () => { createDayAssignment(testDb, day.id, dinner.id); const { accommodation } = book(trip.id, hotel.id, day.id, day.id); const stop = stopsOn(day.id).find((row: { place_id: number }) => row.place_id === hotel.id)!; - expect(stop.order_index).toBe(1); + // The backfill's placement, written by hand: the night behind the dinner. + testDb.prepare('UPDATE day_assignments SET order_index = 0 WHERE day_id = ? AND place_id = ?').run(day.id, dinner.id); + testDb.prepare('UPDATE day_assignments SET order_index = 1 WHERE id = ?').run(stop.id); + expect(stopsOn(day.id).map((row: { place_id: number }) => row.place_id)).toEqual([dinner.id, hotel.id]); const existing = svc.getAccommodation(accommodation.id, trip.id)!; const { mirror } = svc.updateAccommodation(accommodation.id, existing, { check_in: '15:00' }) as any; @@ -1051,13 +1151,13 @@ describe('the drawn roads a booking moves', () => { const pin = (assignmentId: number, time: string) => testDb.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run(time, assignmentId); - /** A(0), B(1) pinned to noon, C(2): the day a check-in at eleven lands in the middle of. */ + /** A(0) pinned to nine, B(1) pinned to noon, C(2): the day a check-in at eleven lands in the middle of. */ function afternoonDay() { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const [a, b, c, hotel] = ['Harbour', 'Mercure', 'Museum', 'Billstedt'].map(name => createPlace(testDb, trip.id, { name })); - createDayAssignment(testDb, day.id, a.id); + pin(createDayAssignment(testDb, day.id, a.id).id, '09:00'); pin(createDayAssignment(testDb, day.id, b.id).id, '12:00'); createDayAssignment(testDb, day.id, c.id); return { trip, day, a, b, c, hotel }; @@ -1091,20 +1191,22 @@ describe('the drawn roads a booking moves', () => { }]); }); - it('ACC-034b a night appended at the end of the day moves no road and reports none', () => { + it('ACC-034b a night seated behind every drawn road moves none and reports none', () => { const { trip, day, a, b, c, hotel } = afternoonDay(); const afterA = addVia(day.id, 0); const afterB = addVia(day.id, 1); - const { mirror } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id }) as any; + const { mirror } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '18:00' }) as any; - expect(stopsOn(day.id)).toEqual([a.id, b.id, c.id, hotel.id]); + expect(stopsOn(day.id)).toEqual([a.id, b.id, hotel.id, c.id]); expect(viaAnchors(day.id)).toEqual([{ id: afterA, after_order_index: 0 }, { id: afterB, after_order_index: 1 }]); expect(mirror.vias).toBeUndefined(); }); it('ACC-034c re-seating the night by a later check-in re-pins the roads around it', () => { const { trip, day, a, b, c, hotel } = afternoonDay(); + // C pinned to the afternoon as well, so an evening check-in puts the night last. + testDb.prepare("UPDATE day_assignments SET assignment_time = '15:00' WHERE day_id = ? AND place_id = ?").run(day.id, c.id); const { accommodation } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' }) as any; expect(stopsOn(day.id)).toEqual([a.id, hotel.id, b.id, c.id]); const afterA = addVia(day.id, 0); @@ -1134,7 +1236,7 @@ describe('the drawn roads a booking moves', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const [a, b, hotel] = ['Harbour', 'Mercure', 'Billstedt'].map(name => createPlace(testDb, trip.id, { name })); - createDayAssignment(testDb, day.id, a.id); + pin(createDayAssignment(testDb, day.id, a.id).id, '09:00'); pin(createDayAssignment(testDb, day.id, b.id).id, '12:00'); const { accommodation } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' }) as any; expect(stopsOn(day.id)).toEqual([a.id, hotel.id, b.id]); @@ -1168,7 +1270,7 @@ describe('the drawn roads a booking moves', () => { const day1 = createDay(testDb, trip.id); const day2 = createDay(testDb, trip.id); const [a, b, c, hotel] = ['Harbour', 'Mercure', 'Museum', 'Billstedt'].map(name => createPlace(testDb, trip.id, { name })); - createDayAssignment(testDb, day1.id, a.id); + pin(createDayAssignment(testDb, day1.id, a.id).id, '09:00'); pin(createDayAssignment(testDb, day1.id, b.id).id, '12:00'); pin(createDayAssignment(testDb, day2.id, c.id).id, '14:00'); const { accommodation } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day1.id, end_day_id: day1.id, check_in: '11:00' }) as any; diff --git a/shared/src/i18n/ar/collab.ts b/shared/src/i18n/ar/collab.ts index 1cb6e4ae3..4c41584a1 100644 --- a/shared/src/i18n/ar/collab.ts +++ b/shared/src/i18n/ar/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'تثبيت الرابط', 'collab.links.unpin': 'إلغاء تثبيت الرابط', 'collab.links.delete': 'حذف الرابط', + 'collab.links.edit': 'تعديل الرابط', + 'collab.links.open': 'فتح الرابط', 'collab.whatsNext.title': 'ما التالي', 'collab.whatsNext.today': 'اليوم', 'collab.whatsNext.tomorrow': 'غدًا', diff --git a/shared/src/i18n/br/collab.ts b/shared/src/i18n/br/collab.ts index 1c3163f5b..630e72674 100644 --- a/shared/src/i18n/br/collab.ts +++ b/shared/src/i18n/br/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Fixar link', 'collab.links.unpin': 'Desafixar link', 'collab.links.delete': 'Excluir link', + 'collab.links.edit': 'Editar link', + 'collab.links.open': 'Abrir link', 'collab.whatsNext.title': 'Próximos passos', 'collab.whatsNext.today': 'Hoje', 'collab.whatsNext.tomorrow': 'Amanhã', diff --git a/shared/src/i18n/ca/collab.ts b/shared/src/i18n/ca/collab.ts index 8a4a117cc..acddf0dd3 100644 --- a/shared/src/i18n/ca/collab.ts +++ b/shared/src/i18n/ca/collab.ts @@ -15,6 +15,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Fixa l\'enllaç', 'collab.links.unpin': 'Desfixa l\'enllaç', 'collab.links.delete': 'Suprimeix l\'enllaç', + 'collab.links.edit': 'Edita l\'enllaç', + 'collab.links.open': 'Obre l\'enllaç', 'collab.whatsNext.title': 'Què ve ara', 'collab.whatsNext.today': 'Avui', 'collab.whatsNext.tomorrow': 'Demà', diff --git a/shared/src/i18n/cs/collab.ts b/shared/src/i18n/cs/collab.ts index a77dd7ecc..55f71fa69 100644 --- a/shared/src/i18n/cs/collab.ts +++ b/shared/src/i18n/cs/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Připnout odkaz', 'collab.links.unpin': 'Odepnout odkaz', 'collab.links.delete': 'Smazat odkaz', + 'collab.links.edit': 'Upravit odkaz', + 'collab.links.open': 'Otevřít odkaz', 'collab.whatsNext.title': 'Co následuje', 'collab.whatsNext.today': 'Dnes', 'collab.whatsNext.tomorrow': 'Zítra', diff --git a/shared/src/i18n/de/collab.ts b/shared/src/i18n/de/collab.ts index c86fb542d..453b2396c 100644 --- a/shared/src/i18n/de/collab.ts +++ b/shared/src/i18n/de/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Link anheften', 'collab.links.unpin': 'Link lösen', 'collab.links.delete': 'Link löschen', + 'collab.links.edit': 'Link bearbeiten', + 'collab.links.open': 'Link öffnen', 'collab.whatsNext.title': 'Nächste', 'collab.whatsNext.today': 'Heute', 'collab.whatsNext.tomorrow': 'Morgen', diff --git a/shared/src/i18n/en/collab.ts b/shared/src/i18n/en/collab.ts index 7e52cd538..b7a9fc9f9 100644 --- a/shared/src/i18n/en/collab.ts +++ b/shared/src/i18n/en/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Pin link', 'collab.links.unpin': 'Unpin link', 'collab.links.delete': 'Delete link', + 'collab.links.edit': 'Edit link', + 'collab.links.open': 'Open link', 'collab.whatsNext.title': "What's Next", 'collab.whatsNext.today': 'Today', 'collab.whatsNext.tomorrow': 'Tomorrow', diff --git a/shared/src/i18n/es/collab.ts b/shared/src/i18n/es/collab.ts index 13d0343ab..c2d2ce1b5 100644 --- a/shared/src/i18n/es/collab.ts +++ b/shared/src/i18n/es/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Fijar enlace', 'collab.links.unpin': 'Desfijar enlace', 'collab.links.delete': 'Eliminar enlace', + 'collab.links.edit': 'Editar enlace', + 'collab.links.open': 'Abrir enlace', 'collab.whatsNext.title': 'Qué viene ahora', 'collab.whatsNext.today': 'Hoy', 'collab.whatsNext.tomorrow': 'Mañana', diff --git a/shared/src/i18n/fr/collab.ts b/shared/src/i18n/fr/collab.ts index 04312f926..c37f1063e 100644 --- a/shared/src/i18n/fr/collab.ts +++ b/shared/src/i18n/fr/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Épingler le lien', 'collab.links.unpin': 'Désépingler le lien', 'collab.links.delete': 'Supprimer le lien', + 'collab.links.edit': 'Modifier le lien', + 'collab.links.open': 'Ouvrir le lien', 'collab.whatsNext.title': 'À venir', 'collab.whatsNext.today': "Aujourd'hui", 'collab.whatsNext.tomorrow': 'Demain', diff --git a/shared/src/i18n/gr/collab.ts b/shared/src/i18n/gr/collab.ts index 2fb4ba5a1..7d5688fd8 100644 --- a/shared/src/i18n/gr/collab.ts +++ b/shared/src/i18n/gr/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Καρφίτσωμα συνδέσμου', 'collab.links.unpin': 'Ξεκαρφίτσωμα συνδέσμου', 'collab.links.delete': 'Διαγραφή συνδέσμου', + 'collab.links.edit': 'Επεξεργασία συνδέσμου', + 'collab.links.open': 'Άνοιγμα συνδέσμου', 'collab.whatsNext.title': 'Τι Επόμενο', 'collab.whatsNext.today': 'Σήμερα', 'collab.whatsNext.tomorrow': 'Αύριο', diff --git a/shared/src/i18n/hu/collab.ts b/shared/src/i18n/hu/collab.ts index 07cb30a96..13b86ea13 100644 --- a/shared/src/i18n/hu/collab.ts +++ b/shared/src/i18n/hu/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Link rögzítése', 'collab.links.unpin': 'Link feloldása', 'collab.links.delete': 'Link törlése', + 'collab.links.edit': 'Link szerkesztése', + 'collab.links.open': 'Link megnyitása', 'collab.whatsNext.title': 'Mi következik', 'collab.whatsNext.today': 'Ma', 'collab.whatsNext.tomorrow': 'Holnap', diff --git a/shared/src/i18n/id/collab.ts b/shared/src/i18n/id/collab.ts index 1f15afc33..4c2b426ef 100644 --- a/shared/src/i18n/id/collab.ts +++ b/shared/src/i18n/id/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Sematkan tautan', 'collab.links.unpin': 'Lepas sematan tautan', 'collab.links.delete': 'Hapus tautan', + 'collab.links.edit': 'Edit tautan', + 'collab.links.open': 'Buka tautan', 'collab.whatsNext.title': 'Berikutnya', 'collab.whatsNext.today': 'Hari ini', 'collab.whatsNext.tomorrow': 'Besok', diff --git a/shared/src/i18n/it/collab.ts b/shared/src/i18n/it/collab.ts index 0bf2c0d92..a3f588b82 100644 --- a/shared/src/i18n/it/collab.ts +++ b/shared/src/i18n/it/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Fissa link', 'collab.links.unpin': 'Sblocca link', 'collab.links.delete': 'Elimina link', + 'collab.links.edit': 'Modifica link', + 'collab.links.open': 'Apri link', 'collab.whatsNext.title': "Cosa c'è dopo", 'collab.whatsNext.today': 'Oggi', 'collab.whatsNext.tomorrow': 'Domani', diff --git a/shared/src/i18n/ja/collab.ts b/shared/src/i18n/ja/collab.ts index fadbec1f9..bc54cb6f4 100644 --- a/shared/src/i18n/ja/collab.ts +++ b/shared/src/i18n/ja/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'リンクを固定', 'collab.links.unpin': 'リンクの固定を解除', 'collab.links.delete': 'リンクを削除', + 'collab.links.edit': 'リンクを編集', + 'collab.links.open': 'リンクを開く', 'collab.whatsNext.title': '次にすること', 'collab.whatsNext.today': '今日', 'collab.whatsNext.tomorrow': '明日', diff --git a/shared/src/i18n/ko/collab.ts b/shared/src/i18n/ko/collab.ts index 96839286d..bdc367907 100644 --- a/shared/src/i18n/ko/collab.ts +++ b/shared/src/i18n/ko/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': '링크 고정', 'collab.links.unpin': '링크 고정 해제', 'collab.links.delete': '링크 삭제', + 'collab.links.edit': '링크 편집', + 'collab.links.open': '링크 열기', 'collab.whatsNext.title': '다음 할 일', 'collab.whatsNext.today': '오늘', 'collab.whatsNext.tomorrow': '내일', diff --git a/shared/src/i18n/nl/collab.ts b/shared/src/i18n/nl/collab.ts index 3567ced61..1abb00e60 100644 --- a/shared/src/i18n/nl/collab.ts +++ b/shared/src/i18n/nl/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Link vastmaken', 'collab.links.unpin': 'Link losmaken', 'collab.links.delete': 'Link verwijderen', + 'collab.links.edit': 'Link bewerken', + 'collab.links.open': 'Link openen', 'collab.whatsNext.title': 'Wat komt er', 'collab.whatsNext.today': 'Vandaag', 'collab.whatsNext.tomorrow': 'Morgen', diff --git a/shared/src/i18n/pl/collab.ts b/shared/src/i18n/pl/collab.ts index f605554bd..de4fb3598 100644 --- a/shared/src/i18n/pl/collab.ts +++ b/shared/src/i18n/pl/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Przypnij link', 'collab.links.unpin': 'Odepnij link', 'collab.links.delete': 'Usuń link', + 'collab.links.edit': 'Edytuj link', + 'collab.links.open': 'Otwórz link', 'collab.whatsNext.title': 'Co dalej', 'collab.whatsNext.today': 'Dzisiaj', 'collab.whatsNext.tomorrow': 'Jutro', diff --git a/shared/src/i18n/ru/collab.ts b/shared/src/i18n/ru/collab.ts index 41611e33d..f7c7c2881 100644 --- a/shared/src/i18n/ru/collab.ts +++ b/shared/src/i18n/ru/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Закрепить ссылку', 'collab.links.unpin': 'Открепить ссылку', 'collab.links.delete': 'Удалить ссылку', + 'collab.links.edit': 'Изменить ссылку', + 'collab.links.open': 'Открыть ссылку', 'collab.whatsNext.title': 'Что дальше', 'collab.whatsNext.today': 'Сегодня', 'collab.whatsNext.tomorrow': 'Завтра', diff --git a/shared/src/i18n/sv/collab.ts b/shared/src/i18n/sv/collab.ts index 676ca815a..bf5badd2c 100644 --- a/shared/src/i18n/sv/collab.ts +++ b/shared/src/i18n/sv/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Fäst länk', 'collab.links.unpin': 'Lossa länk', 'collab.links.delete': 'Ta bort länk', + 'collab.links.edit': 'Redigera länk', + 'collab.links.open': 'Öppna länk', 'collab.whatsNext.title': 'Vad händer härnäst?', 'collab.whatsNext.today': 'Idag', 'collab.whatsNext.tomorrow': 'I morgon', diff --git a/shared/src/i18n/tr/collab.ts b/shared/src/i18n/tr/collab.ts index 569047ec9..95ad138a2 100644 --- a/shared/src/i18n/tr/collab.ts +++ b/shared/src/i18n/tr/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Bağlantıyı sabitle', 'collab.links.unpin': 'Sabitlemeyi kaldır', 'collab.links.delete': 'Bağlantıyı sil', + 'collab.links.edit': 'Bağlantıyı düzenle', + 'collab.links.open': 'Bağlantıyı aç', 'collab.whatsNext.title': "What's Next", 'collab.whatsNext.today': 'Bugün', 'collab.whatsNext.tomorrow': 'Yarın', diff --git a/shared/src/i18n/uk/collab.ts b/shared/src/i18n/uk/collab.ts index f343ad14b..df9ed1bb0 100644 --- a/shared/src/i18n/uk/collab.ts +++ b/shared/src/i18n/uk/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Закріпити посилання', 'collab.links.unpin': 'Відкріпити посилання', 'collab.links.delete': 'Видалити посилання', + 'collab.links.edit': 'Редагувати посилання', + 'collab.links.open': 'Відкрити посилання', 'collab.whatsNext.title': 'Що далі', 'collab.whatsNext.today': 'Сьогодні', 'collab.whatsNext.tomorrow': 'Завтра', diff --git a/shared/src/i18n/vi/collab.ts b/shared/src/i18n/vi/collab.ts index 603da8a4c..c1a26a10e 100644 --- a/shared/src/i18n/vi/collab.ts +++ b/shared/src/i18n/vi/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': 'Ghim liên kết', 'collab.links.unpin': 'Bỏ ghim liên kết', 'collab.links.delete': 'Xóa liên kết', + 'collab.links.edit': 'Chỉnh sửa liên kết', + 'collab.links.open': 'Mở liên kết', 'collab.whatsNext.title': 'Tiếp theo là gì', 'collab.whatsNext.today': 'Hôm nay', 'collab.whatsNext.tomorrow': 'Ngày mai', diff --git a/shared/src/i18n/zh-TW/collab.ts b/shared/src/i18n/zh-TW/collab.ts index c26f74bff..e146932ff 100644 --- a/shared/src/i18n/zh-TW/collab.ts +++ b/shared/src/i18n/zh-TW/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': '釘選連結', 'collab.links.unpin': '取消釘選連結', 'collab.links.delete': '刪除連結', + 'collab.links.edit': '編輯連結', + 'collab.links.open': '開啟連結', 'collab.whatsNext.title': '接下來', 'collab.whatsNext.today': '今天', 'collab.whatsNext.tomorrow': '明天', diff --git a/shared/src/i18n/zh/collab.ts b/shared/src/i18n/zh/collab.ts index f9ad4a001..adbf3de50 100644 --- a/shared/src/i18n/zh/collab.ts +++ b/shared/src/i18n/zh/collab.ts @@ -23,6 +23,8 @@ const collab: TranslationStrings = { 'collab.links.pin': '固定链接', 'collab.links.unpin': '取消固定链接', 'collab.links.delete': '删除链接', + 'collab.links.edit': '编辑链接', + 'collab.links.open': '打开链接', 'collab.whatsNext.title': '接下来', 'collab.whatsNext.today': '今天', 'collab.whatsNext.tomorrow': '明天', diff --git a/shared/src/roadtrip/assemble.spec.ts b/shared/src/roadtrip/assemble.spec.ts index cd0bb5de2..58b12b81e 100644 --- a/shared/src/roadtrip/assemble.spec.ts +++ b/shared/src/roadtrip/assemble.spec.ts @@ -294,3 +294,47 @@ describe('assembleRoadtrip leave times', () => { expect(schedule.warnings).toContainEqual({ index: 2, code: 'late', minutes: 30 }); }); }); + +describe('assembleRoadtrip booked nights', () => { + // 90 km at the 40 seconds a kilometre `leg` drives is an hour. + const hour = 90; + + it('ROADTRIP-ASSEMBLE-012: a day that is only its booked night stands among the days', () => { + // The arrival day of a trip: nothing but the hotel, entered under Days with its + // check-in. It used to be filed under the quiet days at the foot of the rail, which + // reads as the hotel missing from the road trip altogether. + const routes = assemble([stop({ ownerIndex: 0, night: true, checkInTime: '15:00', stopType: 'hotel' })], []); + + expect(routes.quietDays).toEqual([]); + expect(routes.days).toHaveLength(1); + const day = routes.days[0]!; + expect(day.legs).toEqual([]); + expect(day.distance).toBe(0); + expect(day.schedule.entries[0]!).toMatchObject({ arrival: '15:00', anchored: true }); + }); + + it('ROADTRIP-ASSEMBLE-013: a lone stop that is no night stays a quiet day', () => { + const routes = assemble([stop({ ownerIndex: 0 })], []); + + expect(routes.days).toEqual([]); + expect(routes.quietDays).toHaveLength(1); + }); + + it('ROADTRIP-ASSEMBLE-014: the check-in holds the night and the day lines up behind it', () => { + // The day from Discord: two stops without an hour and the night booked for ten, + // which the drive used to reach at a quarter past twelve. Seated first by the + // server now, and held at ten by the schedule; the stops follow from there. + const stops = [ + stop({ ownerIndex: 0, night: true, checkInTime: '10:00', dwellMinutes: 60, stopType: 'hotel' }), + stop({ ownerIndex: 1, dwellMinutes: 30 }), + stop({ ownerIndex: 2, dwellMinutes: 60 }), + ]; + + const routes = assemble(stops, [hour, hour]); + + const { schedule } = routes.days[0]!; + expect(schedule.entries.map((e) => e.arrival)).toEqual(['10:00', '12:00', '13:30']); + expect(schedule.entries[0]!.anchored).toBe(true); + expect(schedule.warnings).toEqual([]); + }); +}); diff --git a/shared/src/roadtrip/assemble.ts b/shared/src/roadtrip/assemble.ts index 58bab1de2..1f41abca1 100644 --- a/shared/src/roadtrip/assemble.ts +++ b/shared/src/roadtrip/assemble.ts @@ -21,6 +21,7 @@ import { refuelsRange, isServiceStopType, scheduleStopOf, + standsAsDay, type DryPoint, type DriveLimits, type VehicleKind, @@ -278,7 +279,7 @@ export function assembleRoadtrip({ dayWarning: drive.day, }); } - const drives = out.filter((d) => d.stops.length > 1 || !!d.spills?.length || d.stops.some((s) => s.automaticNight)); + const drives = out.filter((d) => standsAsDay(d.stops) || !!d.spills?.length || d.stops.some((s) => s.automaticNight)); const originalStops = [...plan, ...quietDays].sort((a, b) => a.dayNumber - b.dayNumber).flatMap((day) => day.stops); const boundaryPath = originalStops.slice(0, -1).flatMap((from, position) => { const to = originalStops[position + 1]!; @@ -317,7 +318,7 @@ export function assembleRoadtrip({ ), quietDays: out - .filter((d) => d.stops.length < 2 && !d.spills?.length && !d.stops.some((s) => s.automaticNight)) + .filter((d) => !standsAsDay(d.stops) && !d.spills?.length && !d.stops.some((s) => s.automaticNight)) .map((d) => ({ dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops })), loading, }; diff --git a/shared/src/roadtrip/dayWindow.spec.ts b/shared/src/roadtrip/dayWindow.spec.ts index 2bc82739d..365b01823 100644 --- a/shared/src/roadtrip/dayWindow.spec.ts +++ b/shared/src/roadtrip/dayWindow.spec.ts @@ -394,32 +394,51 @@ describe('daily travel window', () => { }); }); -it('waits for a check-in the drive overshoots rather than rushing it', () => { +it('holds a check-in the drive reaches within the minute a rounded leg can add', () => { const stops = [ stop(1, { time: '07:00', dwellMinutes: 60 }), stop(2, { checkInTime: '09:11' }), stop(3), ]; - const plan = calculate(stops, [72.5, 29]); + const plan = calculate(stops, [71.5, 29]); expect(plan.issue).toBeNull(); - // Arrives two minutes past the hour it may check in from, and is left there rather - // than pulled back to the round number. - expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('09:13'); + // Half a minute over is the rounding of the drive, not being late: the night keeps + // its check-in, the same grace a pinned stop gets. + expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('09:11'); const stops0 = plan.chains[0]!.stops; expect(stops0.at(-1)!.placeId).toBe(3); - // And carries straight on from there: nothing holds the traveller at the stop. - expect(plan.chains[0]!.schedule.entries.at(-1)!.arrival).toBe('09:42'); + expect(plan.chains[0]!.schedule.entries.at(-1)!.arrival).toBe('09:40'); }); -it('waits for check-in without treating it as a fixed appointment', () => { +it('does not rush a check-in the drive overshoots, and does not quietly move it either', () => { + // Two minutes past the check-in is past it. A pinned time out of reach is a + // conflict here, and a check-in is one of those now. + const stops = [ + stop(1, { time: '07:00', dwellMinutes: 60 }), + stop(2, { checkInTime: '09:11' }), + stop(3), + ]; + expect(calculate(stops, [72.5, 29]).issue).toBe('conflict'); +}); + +it('waits for a check-in the drive reaches early', () => { const plan = calculate([stop(1), stop(2, { checkInTime: '15:00' }), stop(3)], [60, 29]); expect(plan.issue).toBeNull(); - // The check-in is a door opening, not an appointment: the drive waits for it and then - // carries straight on, rather than the day being rebuilt around it. + // The night is held to its check-in the way a pinned stop is held to its time: the + // drive waits for it and carries on from there. expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('15:00'); + expect(plan.chains[0]!.schedule.entries[1]!.anchored).toBe(true); expect(plan.chains[0]!.schedule.entries.at(-1)!.arrival).toBe('15:29'); }); +it('treats a check-in the drive cannot make as the conflict a missed pin is', () => { + // A night booked for ten with two hours of driving pinned ahead of it. The window + // planner does not invent a plan around a time it cannot keep; it hands back to the + // plain schedule, which keeps the ten and says how late the drive is. + const plan = calculate([stop(1, { time: '09:00' }), stop(2, { checkInTime: '10:00' })], [120]); + expect(plan.issue).toBe('conflict'); +}); + describe('a time set to leave a stop, with daily travel times', () => { it('spends the hours until then rather than the stay', () => { const plan = calculate( diff --git a/shared/src/roadtrip/dayWindow.ts b/shared/src/roadtrip/dayWindow.ts index 4cc22be37..27d8d1125 100644 --- a/shared/src/roadtrip/dayWindow.ts +++ b/shared/src/roadtrip/dayWindow.ts @@ -207,7 +207,10 @@ export function planDayWindow( let previous: RoadtripStop | undefined; for (let i = 0; i < stops.length; i++) { const { stop, day } = stops[i]!; - const pin = parseClock(stop.time); + // A booked night's check-in pins the stop the way its own time would: the day is + // built to be there by then, and a drive that cannot make it is the same conflict + // a pinned stop out of reach is. + const pin = parseClock(stop.time) ?? parseClock(stop.checkInTime); const leave = parseClock(stop.leaveAt); // When the drive into this stop set out, on this day's clock: for one a night broke // up, the morning it went on. @@ -290,7 +293,6 @@ export function planDayWindow( const target = targets.get(number); if (target !== undefined && target < position) return failed('conflict'); if (pin !== null) clock = pin; - if (pin === null && number === day.dayNumber) clock = Math.max(clock, parseClock(stop.checkInTime) ?? 0); append(stop, clock); previous = stop; diff --git a/shared/src/roadtrip/planning-types.ts b/shared/src/roadtrip/planning-types.ts index dcf41bec9..18d143ab9 100644 --- a/shared/src/roadtrip/planning-types.ts +++ b/shared/src/roadtrip/planning-types.ts @@ -25,6 +25,8 @@ export interface RoadtripStop { dwellMinutes: number | null; checkInTime?: string | null; + /** A booked night's stop on its check-in day, whether or not the booking names an hour. */ + night?: boolean; endDay?: boolean; legMode: string | null; diff --git a/shared/src/roadtrip/roadtripModel.spec.ts b/shared/src/roadtrip/roadtripModel.spec.ts index d806b5eb5..14d5931e5 100644 --- a/shared/src/roadtrip/roadtripModel.spec.ts +++ b/shared/src/roadtrip/roadtripModel.spec.ts @@ -1064,7 +1064,6 @@ describe('scheduleStopOf', () => { it('turns a leave time into a departure', () => { expect(scheduleStopOf({ time: '10:00', checkInTime: null, dwellMinutes: 60, leaveAt: '14:00' })).toEqual({ anchor: '10:00', - earliest: null, dwellMinutes: 60, departureAt: 840, }); @@ -1073,11 +1072,21 @@ describe('scheduleStopOf', () => { it('leaves the departure out when there is no usable leave time', () => { expect(scheduleStopOf({ time: null, dwellMinutes: 30 })).toEqual({ anchor: null, - earliest: null, dwellMinutes: 30, }); expect(scheduleStopOf({ time: null, dwellMinutes: 30, leaveAt: 'soon' })).not.toHaveProperty('departureAt'); }); + + it('anchors a booked night on its check-in', () => { + expect(scheduleStopOf({ time: null, checkInTime: '10:00', dwellMinutes: 60 })).toEqual({ + anchor: '10:00', + dwellMinutes: 60, + }); + }); + + it('lets a time pinned on the stop itself win over the check-in', () => { + expect(scheduleStopOf({ time: '16:00', checkInTime: '15:00', dwellMinutes: 60 }).anchor).toBe('16:00'); + }); }); @@ -1104,19 +1113,19 @@ describe('hasChosenArrival', () => { }); }); -describe('a check-in is a door opening, not an appointment', () => { - // The hour a room becomes available. Reaching it later is reaching it; only a time - // somebody pinned to a stop can be missed. Read as an anchor it did the opposite: - // arriving at 16:14 was reported as 5 h 14 late against an 11:00 check-in, and the - // hotel was pushed onto the next day because 11:00 reads as earlier than the 15:00 - // before it. +describe('a check-in holds a booked night the way a pinned time does', () => { + // The hour the traveller said they are at the hotel. It used to be a floor, waited + // for when the drive got there first and silent otherwise, and once anything before + // it set the clock it held nothing: a night booked for ten in the morning was + // reported reached at a quarter past twelve, with the rest of the day lined up + // behind that. The check-in goes in as the anchor now (see scheduleStopOf), so + // these run on the anchor the way the rail does. it('waits for it when the drive gets there first', () => { const schedule = computeSchedule( - [{ anchor: '09:00', dwellMinutes: 0 }, { anchor: null, earliest: '15:00', dwellMinutes: 0 }], + [{ anchor: '09:00', dwellMinutes: 0 }, { anchor: '15:00', dwellMinutes: 0 }], [3600], ); expect(schedule.entries[1]!.arrival).toBe('15:00'); - // Waiting for a door is waiting for a time somebody named, so it is printed as one. expect(schedule.entries[1]!.anchored).toBe(true); expect(schedule.warnings).toEqual([]); }); @@ -1126,7 +1135,7 @@ describe('a check-in is a door opening, not an appointment', () => { // Read only as a floor, the chain worked the hotel backwards out of the afternoon // and put it at 13:46: true enough as arithmetic, and nothing anybody asked for. const schedule = computeSchedule( - [{ anchor: null, earliest: '10:00', dwellMinutes: 60 }, { anchor: '15:00', dwellMinutes: 60 }], + [{ anchor: '10:00', dwellMinutes: 60 }, { anchor: '15:00', dwellMinutes: 60 }], [14 * 60], ); expect(schedule.entries[0]!.arrival).toBe('10:00'); @@ -1135,18 +1144,21 @@ describe('a check-in is a door opening, not an appointment', () => { expect(schedule.warnings).toEqual([]); }); - it('is simply arrived at when the drive gets there later', () => { + it('keeps its hour and says how late the drive is when it gets there later', () => { + // Two stops, then the hotel booked for ten: the drive reaches it at 12:17. The + // night stays at ten and the day is lined up behind that, and the finding says + // by how much the drive misses it, the way it would for any pinned stop. const schedule = computeSchedule( - [{ anchor: '15:00', dwellMinutes: 60 }, { anchor: null, earliest: '11:00', dwellMinutes: 60 }], - [14 * 60], + [{ anchor: '08:00', dwellMinutes: 30 }, { anchor: null, dwellMinutes: 60 }, { anchor: '10:00', dwellMinutes: 60 }], + [89 * 60, 78 * 60], ); - expect(schedule.entries[1]!.arrival).toBe('16:14'); - // Nothing was decided here, so it reads as computed. - expect(schedule.entries[1]!.anchored).toBe(false); - // And nothing was missed. - expect(schedule.warnings).toEqual([]); + expect(schedule.entries[1]!.arrival).toBe('09:59'); + expect(schedule.entries[2]!.arrival).toBe('10:00'); + expect(schedule.entries[2]!.anchored).toBe(true); + expect(schedule.entries[2]!.departure).toBe('11:00'); + expect(schedule.warnings).toEqual([{ index: 2, code: 'late', minutes: 137 }]); // Still the same day, which is what the day split reads. - expect(schedule.entries[1]!.dayOffset).toBe(0); + expect(schedule.entries[2]!.dayOffset).toBe(0); }); it('still reports a pinned time that cannot be made', () => { diff --git a/shared/src/roadtrip/roadtripModel.ts b/shared/src/roadtrip/roadtripModel.ts index 8e7b65422..a70d360ab 100644 --- a/shared/src/roadtrip/roadtripModel.ts +++ b/shared/src/roadtrip/roadtripModel.ts @@ -42,12 +42,12 @@ export function formatDurationShort(seconds: number): string { * Whether this stop's arrival is a time somebody chose, rather than one the chain * worked out from the stop before it. * - * A check-in counts. Every schedule already anchors on `time ?? checkInTime`, so a - * booked night starts its day exactly like a pinned stop does; asking only about - * `time` here made the rail print that chosen hour in the grey it reserves for - * computed ones. One function because the answer was spelled out separately in each - * of the three schedulers, and two of them spelled it differently from the anchor - * they had just used. + * A check-in counts. Every schedule anchors on `time ?? checkInTime`, so a booked + * night holds its hour exactly like a pinned stop does; asking only about `time` + * here made the rail print that chosen hour in the grey it reserves for computed + * ones. One function because the answer was spelled out separately in each of the + * three schedulers, and two of them spelled it differently from the anchor they had + * just used. */ export function hasChosenArrival(stop: { time?: string | null; checkInTime?: string | null; automaticNight?: unknown }): boolean { if (stop.automaticNight) return false; @@ -83,16 +83,9 @@ export interface ScheduleStop { */ departureAt?: number; /** A time somebody fixed this stop to. The chain restarts from it, and arriving - * after it is being late. */ + * after it is being late. A booked night's check-in is one of these. */ anchor: string | null; - /** - * The earliest this stop can be entered, when something says so — a check-in is - * the hour a room becomes available, not an appointment. Arriving before it means - * waiting for it; arriving after it means arriving, with nothing to report. - */ - earliest?: string | null; - dwellMinutes: number | null; } @@ -222,6 +215,13 @@ export function leaveAfter( /** * The part of a road trip stop the schedule reads. * + * A booked night's check-in is the hour the traveller said they are at the hotel, so + * it holds the stop the way a pinned time does: the day is built around it, and a + * drive that gets there later is late. Read as a mere floor it held nothing once + * anything before it set the clock, and a night booked for ten in the morning was + * reported reached at a quarter past twelve, with the rest of the day lined up + * behind that. A time pinned on the stop itself still wins. + * * A visit's end time is when the drive leaves it. It is the traveller's own statement * about this visit, unlike the check-out that used to feed `departureAt` (the LATEST a * room has to be handed back, which is why #2357 took it out of the drive). @@ -231,8 +231,7 @@ export function scheduleStopOf( ): ScheduleStop { const leave = parseClock(stop.leaveAt); return { - anchor: stop.time ?? null, - earliest: stop.checkInTime ?? null, + anchor: stop.time ?? stop.checkInTime ?? null, dwellMinutes: stop.dwellMinutes, ...(leave === null ? {} : { departureAt: leave }), }; @@ -267,22 +266,7 @@ export function computeSchedule( for (let i = 0; i < stops.length; i++) { const stop = stops[i]!; const anchor = parseClock(stop.anchor); - const resolved = resolveArrival(anchor, cursor, dayOffset); - // A door that opens at eleven is not an appointment at eleven. Reaching the stop - // later than that is simply reaching it; only a time somebody pinned can be missed. - const opens = parseClock(stop.earliest ?? null); - // With nothing before it deciding the hour, the door IS the hour: a night booked - // to check in at ten starts the day at ten rather than being worked backwards out - // of whatever comes after it. Reached later, it is only a floor, and a floor below - // the arrival changes nothing. - const waited = anchor === null && opens !== null - ? resolved.arrival === null - ? opens + dayOffset * DAY_MINUTES - : opens + Math.round((resolved.arrival - opens) / DAY_MINUTES) * DAY_MINUTES - : null; - const held = waited !== null && (resolved.arrival === null || waited > resolved.arrival); - const arrival = held ? waited : resolved.arrival; - const lateBy = resolved.lateBy; + const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset); if (lateBy !== null) warnings.push({ index: i, code: 'late', minutes: lateBy }); if (arrival === null) { @@ -302,9 +286,7 @@ export function computeSchedule( if (offset > dayOffset) dayOffset = offset; arrivals[i]! = arrival; - // Ink for a time somebody decided: a pinned one always, a check-in only where the - // drive actually had to wait for it. - anchored[i]! = anchor !== null || held; + anchored[i]! = anchor !== null; let departure = arrival + (stop.dwellMinutes ?? 0); if (stop.departureAt !== undefined) { @@ -385,6 +367,20 @@ export function computeSchedule( return { entries, warnings, endsAt }; } +/** + * Whether the rail lists this day among the days, rather than as a quiet placeholder + * asking for stops. + * + * Two stops make a drive. A booked night makes a day on its own: the day you arrive + * somewhere and go no further, or the one you check in at ten and set out from. It + * used to need company, and a hotel entered under Days for the arrival day was filed + * at the bottom of the rail under "only X so far", which reads as the hotel missing + * from the road trip altogether. + */ +export function standsAsDay(stops: readonly { night?: boolean }[]): boolean { + return stops.length > 1 || stops.some((stop) => stop.night === true); +} + export function splitIntoRuns(stops: T[], modeOfLeg: (from: T, to: T) => string): { stops: T[]; mode: string }[] { const runs: { stops: T[]; mode: string }[] = []; let current: T[] = [];