Merge remote-tracking branch 'origin/dev' into feat/orm

This commit is contained in:
jubnl
2026-09-20 21:45:48 +02:00
47 changed files with 1079 additions and 173 deletions
@@ -63,7 +63,8 @@ describe('CollabLinks', () => {
);
render(<CollabLinks tripId={1} />);
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<string, unknown> | 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<string, unknown>;
return HttpResponse.json({ link: buildLink({ title: 'Ferry timetable 2026', url: 'https://ferries.example/2026' }) });
}),
);
render(<CollabLinks tripId={1} />);
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(<CollabLinks tripId={1} />);
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' } });
+63 -32
View File
@@ -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 <span aria-hidden="true" style={{ display: 'grid', placeItems: 'center', width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, background: 'var(--bg-secondary)', color: 'var(--text-faint)' }}><Link2 size={15} /></span>
return <img src={favicon} alt="" aria-hidden="true" title={title} onError={() => setFailed(true)} style={{ width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, objectFit: 'contain', background: 'var(--bg-secondary)' }} />
return (
<span className="collab-link-chip__icon" aria-hidden="true">
{!favicon || failed ? <Link2 size={13} /> : <img src={favicon} alt="" onError={() => setFailed(true)} />}
<span className="collab-link-chip__go"><ExternalLink size={13} /></span>
</span>
)
}
/** 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<void> }) {
function LinkModal({ link, onClose, onSave }: { link?: CollabLink; onClose: () => void; onSave: (link: { title: string; url: string }) => Promise<void> }) {
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
<div role="presentation" style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onSubmit={submit}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.links.add')}</h3>
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t(link ? 'collab.links.edit' : 'collab.links.add')}</h3>
<button type="button" onClick={onClose} aria-label={t('collab.links.cancel')} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button>
</div>
<div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
@@ -99,6 +110,7 @@ export default function CollabLinks({ tripId }: { tripId: number }) {
const toast = useToast()
const [links, setLinks] = useState<CollabLink[]>([])
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<CollabLink | null>(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 }) {
)}
</div>
{/* Content */}
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 12px 12px' }}>
{/* 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. */}
<div className="chat-scroll collab-link-grid" style={{ flex: 1, overflowY: 'auto', padding: '2px 12px 12px' }}>
{links.length === 0 ? (
<EmptyState scene="links" title={t('collab.links.empty')} />
) : (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div className="collab-link-grid__cells">
{links.map(link => (
<div key={link.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 6px', borderBottom: '1px solid var(--border-faint)' }}>
<LinkIcon url={link.url} title={link.title} />
<a href={link.url} target="_blank" rel="noreferrer" style={{ flex: 1, minWidth: 0, color: 'var(--text-primary)', textDecoration: 'none' }}>
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.title}</div>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.url}</div>
<div key={link.id} className={link.pinned ? 'collab-link-chip collab-link-chip--pinned' : 'collab-link-chip'}>
<a className="collab-link-chip__main" href={link.url} target="_blank" rel="noreferrer" title={t('collab.links.open')}>
<LinkIcon url={link.url} />
<span className="collab-link-chip__text">
<span className="collab-link-chip__title">{link.title}</span>
<span className="collab-link-chip__host">{hostOf(link.url)}</span>
</span>
</a>
<ExternalLink size={15} color="var(--text-faint)" aria-hidden="true" />
{canEdit && <>
<button type="button" onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} style={{ border: 0, background: 'transparent', color: link.pinned ? 'var(--accent)' : 'var(--text-faint)', cursor: 'pointer' }}>
<Pin size={15} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" />
</button>
<button type="button" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} style={{ border: 0, background: 'transparent', color: 'var(--text-faint)', cursor: 'pointer' }}>
<Trash2 size={15} aria-hidden="true" />
</button>
</>}
{canEdit && (
<span className="collab-link-chip__actions">
<button type="button" className="collab-link-chip__action" onClick={() => setEditing(link)} aria-label={t('collab.links.edit')} title={t('collab.links.edit')}>
<Pencil size={13} aria-hidden="true" />
</button>
<button type="button" className={link.pinned ? 'collab-link-chip__action collab-link-chip__action--on' : 'collab-link-chip__action'} onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} title={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')}>
<Pin size={13} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" />
</button>
<button type="button" className="collab-link-chip__action" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} title={t('collab.links.delete')}>
<Trash2 size={13} aria-hidden="true" />
</button>
</span>
)}
</div>
))}
</div>
)}
</div>
{/* Create Modal */}
{showForm && <AddLinkModal onClose={() => setShowForm(false)} onCreate={add} />}
{/* Create and edit modals */}
{showForm && <LinkModal onClose={() => setShowForm(false)} onSave={add} />}
{editing && <LinkModal key={editing.id} link={editing} onClose={() => setEditing(null)} onSave={data => edit(editing, data)} />}
</div>
)
}
@@ -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(<DayPlanSidebar {...makeDefaultProps()} />)
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(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
@@ -1525,7 +1525,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
}
return (
<div ref={setPanel} data-touch-drag={dragDisabled ? undefined : ''} style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "var(--font-system)" }}>
// 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.
<div ref={setPanel} data-touch-drag={dragDisabled ? undefined : ''} style={{ display: 'flex', flexDirection: 'column', flex: '1 1 0%', minHeight: 0, height: '100%', position: 'relative', fontFamily: "var(--font-system)" }}>
{/* Toolbar */}
<DayPlanSidebarToolbar
tripId={tripId}
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest'
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
describe('convertHoursLine', () => {
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)
})
})
@@ -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
@@ -1553,12 +1553,16 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
{formatDate(day.date, language)}
</time>
) : null}
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
{t('roadtrip.leg.driveText', {
distance: formatDistance(day.distance / 1000, distanceUnit),
time: formatDurationShort(day.duration),
})}
</span>
{/* 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 ? (
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
{t('roadtrip.leg.driveText', {
distance: formatDistance(day.distance / 1000, distanceUnit),
time: formatDurationShort(day.duration),
})}
</span>
) : null}
{day.dayWarning ? (
<Tooltip label={t('roadtrip.limit.hint')}>
<span className={`${DAY_BADGE} gap-1 bg-warning-soft text-warning`} style={{ fontSize: FS.label }}>
@@ -1569,9 +1573,11 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
</span>
</Tooltip>
) : null}
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
{t('roadtrip.day.stopCount', { count: day.stops.filter(s => !s.automaticNight && !isServiceStopType(s.stopType)).length })}
</span>
{day.legs.length > 0 ? (
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
{t('roadtrip.day.stopCount', { count: day.stops.filter(s => !s.automaticNight && !isServiceStopType(s.stopType)).length })}
</span>
) : 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
@@ -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<QuietDay[]>(() => {
@@ -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
+130
View File
@@ -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; }
@@ -252,11 +252,17 @@ export const STATE_APPLIERS: Partial<Record<TrekWsTripEventName, StateApplier>>
}
// 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 })),
}
}
},
+14
View File
@@ -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) {
+118
View File
@@ -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<number>();
const kept: Array<Via & { was: number }> = [];
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<number, Array<Via & { was: number }>>();
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;
}
@@ -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
@@ -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 (?, ?, ?, ?, ?, ?, ?)',
@@ -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,
@@ -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]);
});
});
@@ -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;
+2
View File
@@ -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': 'غدًا',
+2
View File
@@ -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ã',
+2
View File
@@ -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à',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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': 'Αύριο',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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': '明日',
+2
View File
@@ -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': '내일',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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': 'Завтра',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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': 'Завтра',
+2
View File
@@ -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',
+2
View File
@@ -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': '明天',
+2
View File
@@ -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': '明天',
+44
View File
@@ -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([]);
});
});
+3 -2
View File
@@ -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,
};
+29 -10
View File
@@ -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(
+4 -2
View File
@@ -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;
+2
View File
@@ -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;
+32 -20
View File
@@ -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', () => {
+31 -35
View File
@@ -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<T>(stops: T[], modeOfLeg: (from: T, to: T) => string): { stops: T[]; mode: string }[] {
const runs: { stops: T[]; mode: string }[] = [];
let current: T[] = [];