)
}
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[] = [];