mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
Merge remote-tracking branch 'origin/dev' into feat/orm
This commit is contained in:
@@ -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' } });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })),
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': 'غدًا',
|
||||
|
||||
@@ -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ã',
|
||||
|
||||
@@ -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à',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Αύριο',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '明日',
|
||||
|
||||
@@ -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': '내일',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Завтра',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Завтра',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '明天',
|
||||
|
||||
@@ -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': '明天',
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user