Compare commits

..

12 Commits

Author SHA1 Message Date
Maurice 91f6430762 test(collections): read a row's +N without waiting for one 2026-11-05 14:18:04 +01:00
Maurice 6ee540d5e4 test(collections): hover the +N of a row that has a category beside its labels 2026-11-05 13:53:57 +01:00
Maurice 99cdcfcd09 test(collections): cover fitting a row's labels and folding the map's label strip 2026-11-05 13:31:25 +01:00
Maurice fd11944ed6 test(collections): count the labels a row shows, and give the three-viewport spec room to clean up 2026-11-05 13:29:27 +01:00
Maurice 82c6a3cd5f fix(collections): name a hidden label's class without a leading space prettier strips 2026-11-05 13:10:15 +01:00
Maurice fbafa270c4 fix(collections): fit a row's labels without setting state in an effect, and bring them back when the row grows
The fitting ran in a layout effect and measured chips the row had already
squeezed, so a row that got wider kept its "+N". Every label now stays in
the row, the ones without room kept out of sight, and the observer that
sees the row change size fits them from each chip's full width. The name
takes the width it needs on a wide list and the labels fill the rest.
2026-11-05 13:01:49 +01:00
Maurice 1c54d7d6c1 fix(collections): give place rows two fixed lines and fit labels to the room
The first fix let a row wrap freely, which on a list beside the map put
the status on a line of its own and broke every row differently. A row is
now laid out the same way each time: on a list narrower than 760px the
name, address and status take the first line and the category and labels
one line below; a wider list keeps them on one line. The row shows as
many labels as it has room for, category first, and a "+N" chip names
the rest in its tooltip, so a wide screen shows every label. Chips that
cut a name short say it in full in their tooltip.

The label strip over the map hid the labels that did not fit behind a
scroll with no scrollbar. It now folds into a "Labels" button that opens
them as a list when the bar has no room for all of them, and unfolds
again when the room is back. The filter row stays on one line on a
desktop, a dropdown at its default keeping only its icon when narrow, and
the select bar's actions keep their icons with the words in a tooltip
when the bar is too narrow for them.
2026-11-05 12:46:16 +01:00
Maurice d2c7d83256 fix(collections): keep a place's name readable next to long labels
A place row was a three-column grid whose last column held the labels,
the category and the status, none of which could shrink. With two or
three long labels the name got what was left, often one letter or
nothing, and the status pill ran out of the card.

The row now wraps: the name keeps at least 11rem, and when the labels,
category and status do not fit beside it they go to a line of their own
under it and wrap there. A label or category too long for its chip ends
in an ellipsis instead of being cut mid-letter. Kept tours use the same
row and get the same layout.

Closes #2981
2026-11-05 12:33:05 +01:00
Maurice 91630dd811 fix(planner): let a timed booking stay where it is dropped when only other bookings stand against its time
A booking dropped against another booking's time went back to the nearest
slot its own time allowed. With a 06:30 flight listed below the day's
untimed stops, every slot among them was against the flight, so a 13:00
transit dropped between two stops went back behind the flight on every
drop, and the flight's legs cannot be dragged out of the way.

The dropped row now stays where it was dropped and the other timed
bookings it puts out of order move next to it, the earlier ones ahead of
it and the later ones behind. Timed stops and notes never move: against
one of those the row is still seated by its time as before (#2631). The
phone's move applies the same rule before it refuses a move.

Closes #2983
2026-11-05 12:15:53 +01:00
Maurice 14c767a675 fix(client): keep the open-at rule on the client's library target, and settle CI
Array.prototype.at is past the client's lib target, so the journey open-at
rule indexes the array. The packing row test expects the title that names
every bringer, a carousel test acts without a floating promise, and the
lint baseline drops to the counts this branch reaches.
2026-11-04 19:07:39 +01:00
Maurice da324121bd fix(packing): name everyone who brings a shared item
A common item brought by several members showed the first bringer's avatar
and a bare "+2". The tooltip only named the first person, and on the phone
neither the row nor the item sheet said who else brings it (discussion
#2782).

packingBringerNames lists the bringer and every contributor; the desktop
row, the phone row and the phone item sheet use it for "brought by".
2026-11-04 17:53:15 +01:00
Maurice b41f6e3542 fix(journey): a newest-first share link opens on the latest day on the phone
The public journey page on a phone shows the map with a card carousel, which
always opened on the first entry. The owner's "newest first" choice for the
link was ignored there, so readers following a running trip had to swipe
through every day to reach the latest one (discussion #2851).

The rule for where a journey opens moves out of the journal hook into
journeyOpenAtEntryId, which the public page now uses as well: a newest-first
link opens on the latest day, otherwise a running journey opens on today as
the journal already does.

The carousel also settles on the card at the end it is scrolled to. The last
card can never reach the centre, so opening on the latest day used to
activate the day before it, in the journal as well.
2026-11-04 17:53:15 +01:00
28 changed files with 1032 additions and 122 deletions
@@ -0,0 +1,104 @@
import { expect, test, type Locator } from '@playwright/test';
// #2981: labels, category and status took the whole row, so a place with two or
// three long labels showed one letter of its name or none, and the status pill
// ran out of the card. Measured in a real browser because the layout is the bug.
const PLACES = [
{ name: 'Oceanario de Lisboa', labels: ['Core shortlist', 'Heritage specialist', 'Pair with another stop'] },
{ name: 'Cove of Ursa', labels: ['Heritage specialist', 'Pair with another stop'] },
{ name: 'A Vida Portuguesa', labels: ['Flagship', 'Market', 'Core shortlist', 'Pair with another stop'] },
{ name: 'Torre de Belem', labels: [] as string[] },
];
async function expectInside(row: Locator) {
const escapes = await row.evaluate((el) => {
const box = el.getBoundingClientRect();
return Array.from(el.querySelectorAll('*')).filter((child) => {
// The strip's hidden measuring copy is meant to run past the bar.
if (child.closest('[aria-hidden="true"]')) return false;
const r = child.getBoundingClientRect();
return r.width > 0 && (r.left < box.left - 0.5 || r.right > box.right + 0.5);
}).length;
});
expect(escapes).toBe(0);
}
for (const viewport of [
{ width: 1920, height: 1000 },
{ width: 1400, height: 900 },
{ width: 1024, height: 800 },
]) {
test(`place names stay readable next to long labels at ${viewport.width}px`, async ({ page }) => {
test.setTimeout(90_000);
await page.setViewportSize(viewport);
const addonsResponse = await page.request.get('/api/admin/addons');
expect(addonsResponse.ok()).toBeTruthy();
const { addons } = await addonsResponse.json();
const original = addons.find((addon: { id: string }) => addon.id === 'collections');
expect(original).toBeDefined();
let collectionId: number | undefined;
try {
expect((await page.request.put('/api/admin/addons/collections', { data: { enabled: true } })).ok()).toBeTruthy();
const created = await page.request.post('/api/addons/collections', { data: { name: `Rows ${viewport.width}` } });
expect(created.ok()).toBeTruthy();
const body = await created.json();
collectionId = (body.collection ?? body).id as number;
const labelIds = new Map<string, number>();
for (const name of new Set(PLACES.flatMap((p) => p.labels))) {
const res = await page.request.post('/api/addons/collections/labels', {
data: { collection_id: collectionId, name, color: '#2563eb' },
});
expect(res.ok(), `label ${name}`).toBeTruthy();
const label = await res.json();
labelIds.set(name, (label.label ?? label).id);
}
for (const place of PLACES) {
const res = await page.request.post('/api/addons/collections/places', {
data: {
collection_id: collectionId,
name: place.name,
address: 'Rua Anchieta 11, 1200-023 Lisboa, Portugal',
lat: 38.71,
lng: -9.14,
},
});
expect(res.ok(), `place ${place.name}`).toBeTruthy();
const saved = await res.json();
if (!place.labels.length) continue;
const assign = await page.request.post('/api/addons/collections/labels/assign', {
data: { label_ids: place.labels.map((l) => labelIds.get(l)), place_ids: [(saved.place ?? saved).id] },
});
expect(assign.ok()).toBeTruthy();
}
await page.goto(`/collections/${collectionId}`);
const rows = page.locator('.col-lrow');
await expect(rows).toHaveCount(PLACES.length);
for (const place of PLACES) {
const row = rows.filter({ hasText: place.name });
const name = row.locator('.li .t');
await expect(name).toHaveText(place.name);
// The whole name fits: it is not cut to an ellipsis.
expect(await name.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
await expectInside(row);
// Every label is either on the row or counted by its "+N" chip.
const shown = await row.locator('.col-lrow-label:not(.is-over)').count();
// Read without waiting: a row whose labels all fit has no "+N".
const [more] = await row.locator('.col-lrow-more').allTextContents();
expect(shown + (more ? Number(more.replace('+', '')) : 0)).toBe(place.labels.length);
}
// The map's label strip stays inside the map: whole, or folded into its button.
const topbar = page.locator('.col-map-topbar');
if (await topbar.count()) await expectInside(topbar);
} finally {
if (collectionId !== undefined) {
expect((await page.request.delete(`/api/addons/collections/${collectionId}`)).ok()).toBeTruthy();
}
expect(
(await page.request.put('/api/admin/addons/collections', { data: { enabled: !!original.enabled } })).ok()
).toBeTruthy();
}
});
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 380,
"@typescript-eslint/no-non-null-assertion": 377,
"@typescript-eslint/no-misused-promises": 847,
"no-console": 88,
"complexity": 218,
@@ -8,7 +8,7 @@
"trek/no-swallowed-catch": 128,
"max-params": 32,
"trek/store-without-selector": 67,
"react-hooks/refs": 555,
"react-hooks/refs": 554,
"react-hooks/set-state-in-effect": 193,
"@typescript-eslint/no-floating-promises": 164,
"react-hooks/exhaustive-deps": 116,
@@ -35,7 +35,7 @@ function Dropdown({ current, options, onSelect, lead }: {
const cur = options.find(o => o.key === current) ?? options[0]
return (
<div className="col-filter" ref={ref}>
<button type="button" className={`col-filter-btn${open ? ' open' : ''}`} onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}>
<button type="button" className={`col-filter-btn${open ? ' open' : ''}${cur.key === options[0]?.key ? ' is-default' : ''}`} onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open} title={cur.label}>
{cur.icon ?? lead}
<span className="col-filter-lbl">{cur.label}</span>
<ChevronDown size={14} className="col-filter-chev" />
@@ -0,0 +1,121 @@
// FE-COMP-COLLABELS-001 to FE-COMP-COLLABELS-006
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import type { TranslationFn } from '../../types';
import CollectionLabelFilter, { type LabelOption } from './CollectionLabelFilter';
const t = ((key: string) => key) as unknown as TranslationFn;
const LABELS = [
{ id: 1, name: 'Core shortlist', color: '#2563eb', count: 2 },
{ id: 2, name: 'Heritage', color: null, count: 0 },
] as unknown as LabelOption[];
beforeAll(() => {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
});
afterEach(() => vi.restoreAllMocks());
/** The labels need `need` px against a slot of `room` px beside the map's controls. */
function room(need: number, room: number) {
vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockImplementation(function (this: Element) {
return this.classList.contains('col-labelfilter-measure') ? need : 0;
});
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockImplementation(function (this: Element) {
return this.classList.contains('col-labelfilter-slot') ? room : 0;
});
}
function setup(props: Partial<Parameters<typeof CollectionLabelFilter>[0]> = {}) {
const onLabelFilter = vi.fn();
const onManageLabels = vi.fn();
const view = render(
<CollectionLabelFilter
labelOptions={LABELS}
labelFilter={[]}
onLabelFilter={onLabelFilter}
canManageLabels
onManageLabels={onManageLabels}
t={t}
{...props}
/>
);
return { ...view, onLabelFilter, onManageLabels };
}
describe('CollectionLabelFilter', () => {
it('FE-COMP-COLLABELS-001: the filter row toggles a label on and off', () => {
const on = setup();
fireEvent.click(screen.getByRole('button', { name: /Core shortlist/ }));
expect(on.onLabelFilter).toHaveBeenCalledWith([1]);
on.unmount();
const off = setup({ labelFilter: [1, 2] });
expect(screen.getByRole('button', { name: /Core shortlist/ })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: /Core shortlist/ }));
expect(off.onLabelFilter).toHaveBeenCalledWith([2]);
});
it('FE-COMP-COLLABELS-002: offers to add a label when there is none, and to manage them otherwise', () => {
const empty = setup({ labelOptions: [] });
fireEvent.click(screen.getByRole('button', { name: 'collections.labels.add' }));
expect(empty.onManageLabels).toHaveBeenCalled();
empty.unmount();
setup();
expect(screen.getByRole('button', { name: 'collections.labels.manage' })).toBeInTheDocument();
});
it('FE-COMP-COLLABELS-003: on the map the strip shows its labels while they fit', () => {
room(300, 500);
setup({ variant: 'map' });
expect(screen.getByRole('button', { name: /Heritage/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /collections.labels.title/ })).toBeNull();
});
it('FE-COMP-COLLABELS-004: without room it folds into one button that lists the labels', () => {
room(600, 200);
const { onLabelFilter } = setup({ variant: 'map', labelFilter: [2] });
const fold = screen.getByRole('button', { name: /collections.labels.title/ });
expect(fold).toHaveTextContent('1');
expect(fold).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(fold);
const list = screen.getByRole('group', { name: 'collections.labels.title' });
expect(list).toHaveTextContent('Core shortlist');
expect(screen.getByRole('button', { name: /Heritage/ })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: /Core shortlist/ }));
expect(onLabelFilter).toHaveBeenCalledWith([2, 1]);
});
it('FE-COMP-COLLABELS-005: the folded list manages labels and closes on Escape or a click beside it', () => {
room(600, 200);
const { onManageLabels } = setup({ variant: 'map' });
const fold = screen.getByRole('button', { name: /collections.labels.title/ });
fireEvent.click(fold);
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('group', { name: 'collections.labels.title' })).toBeNull();
fireEvent.click(fold);
fireEvent.mouseDown(document.body);
expect(screen.queryByRole('group', { name: 'collections.labels.title' })).toBeNull();
fireEvent.click(fold);
fireEvent.click(screen.getByRole('button', { name: /collections.labels.manage/ }));
expect(onManageLabels).toHaveBeenCalled();
expect(screen.queryByRole('group', { name: 'collections.labels.title' })).toBeNull();
});
it('FE-COMP-COLLABELS-006: a click inside the folded list keeps it open', () => {
room(600, 200);
setup({ variant: 'map' });
fireEvent.click(screen.getByRole('button', { name: /collections.labels.title/ }));
fireEvent.mouseDown(screen.getByRole('group', { name: 'collections.labels.title' }));
expect(screen.getByRole('group', { name: 'collections.labels.title' })).toBeInTheDocument();
});
});
@@ -1,5 +1,5 @@
import React from 'react'
import { Plus, Settings2, Tags } from 'lucide-react'
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Check, ChevronDown, Plus, Settings2, Tags } from 'lucide-react'
import type { LabelOption } from '../../pages/collections/collectionsModel'
import type { TranslationFn } from '../../types'
@@ -19,54 +19,154 @@ interface CollectionLabelFilterProps {
t: TranslationFn
}
interface ChipsProps {
labelOptions: LabelOption[]
labelFilter: number[]
onToggle?: (id: number) => void
}
/** The label chips, as buttons, or as plain spans for the copy the map strip measures. */
function LabelChips({ labelOptions, labelFilter, onToggle }: ChipsProps): React.ReactElement {
return (
<>
{labelOptions.map(l => {
const on = labelFilter.includes(l.id)
const body = (
<>
<span className="col-labelchip-dot" />
<span className="col-filter-lbl">{l.name}</span>
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
</>
)
const style = { ['--label' as string]: l.color ?? 'var(--accent)' }
if (!onToggle) return <span key={l.id} className={`col-labelchip${on ? ' on' : ''}`} style={style}>{body}</span>
return (
<button key={l.id} type="button" className={`col-labelchip${on ? ' on' : ''}`} style={style} onClick={() => onToggle(l.id)} aria-pressed={on}>
{body}
</button>
)
})}
</>
)
}
/**
* Whether the map strip has to fold into its button: true once its chips need more room
* than the slot beside the map's other controls. Measured on a hidden copy, so the strip
* unfolds again as soon as the room comes back.
*/
function useFolds(enabled: boolean) {
const slotRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLDivElement>(null)
const [folded, setFolded] = useState(false)
useLayoutEffect(() => {
const slot = slotRef.current
const measure = measureRef.current
if (!enabled || !slot || !measure) return
const check = () => setFolded(measure.scrollWidth > slot.clientWidth + 1)
check()
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(check)
observer.observe(slot)
observer.observe(measure)
return () => observer.disconnect()
}, [enabled])
return { slotRef, measureRef, folded }
}
/** Open state of the folded strip's list: closed by a click beside it or Escape, like the filter row's dropdowns. */
function useFoldList(ref: React.RefObject<HTMLElement | null>) {
const [open, setOpen] = useState(false)
useEffect(() => {
if (!open) return
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', onDoc)
document.addEventListener('keydown', onKey)
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey) }
}, [open, ref])
return { open, setOpen }
}
/**
* The label filter, in one place because it has two homes.
*
* Labels are an axis of their own next to status, category and rating, so they
* are grouped into a tray instead of joining the row as more loose buttons. On
* a desktop it rides in the map's top bar, where there is room to spare; the
* filter row keeps it whenever no map is on screen, so it never becomes
* unreachable.
* a desktop it rides in the map's top bar; when the bar has no room for every
* label it folds into one button that opens them as a list, instead of hiding
* the last ones behind a scroll nobody sees. The filter row keeps it whenever
* no map is on screen, so it never becomes unreachable.
*/
export default function CollectionLabelFilter({
labelOptions, labelFilter, onLabelFilter, canManageLabels = false, onManageLabels,
variant = 'bar', t,
}: CollectionLabelFilterProps): React.ReactElement {
return (
<div
className={`col-labelfilter${variant === 'map' ? ' on-map' : ''}`}
role="group"
aria-label={t('collections.labels.manage')}
>
const onMap = variant === 'map'
const { slotRef, measureRef, folded } = useFolds(onMap)
const fold = useFoldList(slotRef)
const toggle = (id: number) => onLabelFilter(labelFilter.includes(id) ? labelFilter.filter(x => x !== id) : [...labelFilter, id])
const manageLabel = labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')
const manage = canManageLabels && onManageLabels && (
<button type="button" className="col-filter-addlabel" onClick={onManageLabels} title={manageLabel} aria-label={manageLabel}>
{labelOptions.length ? <Settings2 size={13} /> : <Plus size={13} />}
</button>
)
const tray = (
<div className={`col-labelfilter${onMap ? ' on-map' : ''}`} role="group" aria-label={t('collections.labels.manage')}>
<Tags size={13} className="col-labelfilter-lead" aria-hidden="true" />
{labelOptions.map(l => {
const on = labelFilter.includes(l.id)
return (
<LabelChips labelOptions={labelOptions} labelFilter={labelFilter} onToggle={toggle} />
{manage}
</div>
)
if (!onMap) return tray
const active = labelFilter.filter(id => labelOptions.some(l => l.id === id)).length
return (
<div className="col-labelfilter-slot" ref={slotRef}>
{!folded ? tray : (
<>
<button
key={l.id}
type="button"
className={`col-labelchip${on ? ' on' : ''}`}
style={{ ['--label' as string]: l.color ?? 'var(--accent)' }}
onClick={() => onLabelFilter(on ? labelFilter.filter(id => id !== l.id) : [...labelFilter, l.id])}
aria-pressed={on}
onClick={() => fold.setOpen(o => !o)}
aria-haspopup="true"
aria-expanded={fold.open}
className={`col-labelfilter on-map col-labelfilter-fold${fold.open ? ' open' : ''}`}
>
<span className="col-labelchip-dot" />
<span className="col-filter-lbl">{l.name}</span>
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
<Tags size={13} className="col-labelfilter-lead" aria-hidden="true" />
<span className="col-filter-lbl">{t('collections.labels.title')}</span>
{active > 0 && <span className="col-labelfilter-active">{active}</span>}
<ChevronDown size={14} className="col-filter-chev" aria-hidden="true" />
</button>
)
})}
{canManageLabels && onManageLabels && (
<button
type="button"
className="col-filter-addlabel"
onClick={onManageLabels}
title={labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}
aria-label={labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}
>
{labelOptions.length ? <Settings2 size={13} /> : <Plus size={13} />}
</button>
{fold.open && (
<div className="col-filter-pop col-labelfilter-pop" role="group" aria-label={t('collections.labels.title')}>
{labelOptions.map(l => {
const on = labelFilter.includes(l.id)
return (
<button key={l.id} type="button" aria-pressed={on} className={`col-filter-opt${on ? ' on' : ''}`} onClick={() => toggle(l.id)}>
<span className="col-filter-dot" style={{ background: l.color ?? 'var(--accent)' }} />
<span className="col-filter-lbl">{l.name}</span>
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
{on && <Check size={13} className="col-filter-check" />}
</button>
)
})}
{canManageLabels && onManageLabels && (
<button type="button" className="col-filter-opt col-labelfilter-manage" onClick={() => { fold.setOpen(false); onManageLabels() }}>
{labelOptions.length ? <Settings2 size={13} /> : <Plus size={13} />}
<span className="col-filter-lbl">{manageLabel}</span>
</button>
)}
</div>
)}
</>
)}
<div className="col-labelfilter on-map col-labelfilter-measure" ref={measureRef} aria-hidden="true">
<Tags size={13} className="col-labelfilter-lead" />
<LabelChips labelOptions={labelOptions} labelFilter={labelFilter} />
{canManageLabels && onManageLabels && <span className="col-filter-addlabel"><Settings2 size={13} /></span>}
</div>
</div>
)
}
@@ -3,8 +3,8 @@ import { Check, MapPin } from 'lucide-react'
import type { CollectionPlace, CollectionStatus, CollectionLabel } from '@trek/shared'
import type { TranslationFn } from '../../types'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
import StatusBadge from './StatusBadge'
import CollectionPlaceTags from './CollectionPlaceTags'
interface CollectionListProps {
places: CollectionPlace[]
@@ -51,7 +51,7 @@ export default function CollectionList({
tabIndex={0}
onClick={() => (selectMode ? onToggleSelect(place.id) : onOpenPlace(place.id))}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (selectMode) onToggleSelect(place.id); else onOpenPlace(place.id) } }}
className={`col-lrow${active || selected ? ' sel' : ''}`}
className={`col-lrow col-lrow-place${active || selected ? ' sel' : ''}`}
>
{selectMode ? (
<span className={`col-lcheck${selected ? ' on' : ''}`}>{selected && <Check size={14} strokeWidth={3} />}</span>
@@ -67,24 +67,8 @@ export default function CollectionList({
</div>
)}
</div>
<div className="col-lrow-end">
{placeLabels.slice(0, 2).map(l => (
<span key={l.id} className="col-lrow-label" style={{ ['--label' as string]: l.color || 'var(--accent)' }} title={l.name}>
<span className="col-labelchip-dot" /> {l.name}
</span>
))}
{placeLabels.length > 2 && <span className="col-lrow-label more" title={placeLabels.map(l => l.name).join(', ')}>+{placeLabels.length - 2}</span>}
{place.category?.name && (() => {
const CatIcon = getCategoryIcon(place.category.icon ?? undefined)
return (
<>
<span className="col-lrow-cat" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
<CatIcon size={11} /> {place.category.name}
</span>
<span className="col-lrow-div" aria-hidden />
</>
)
})()}
<CollectionPlaceTags place={place} labels={placeLabels} />
<div className="col-lrow-status">
<StatusBadge status={place.status} onChange={selectMode || !onStatusChange ? undefined : next => onStatusChange(place.id, next)} t={t} />
</div>
</div>
@@ -0,0 +1,88 @@
// FE-COMP-COLTAGS-001 to FE-COMP-COLTAGS-005
import type { CollectionLabel, CollectionPlace } from '@trek/shared';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import CollectionPlaceTags from './CollectionPlaceTags';
// jsdom lays nothing out: the observer reports each element once it is observed, and
// the widths below stand in for the browser's.
beforeAll(() => {
globalThis.ResizeObserver = class {
constructor(private readonly cb: ResizeObserverCallback) {}
observe(target: Element) {
this.cb([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver);
}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
});
afterEach(() => vi.restoreAllMocks());
/** Row and chip widths in px: the row holds the category, one label and the "+N" chip. */
function layout({ row = 250, cutName }: { row?: number; cutName?: string } = {}) {
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) {
if (this.classList.contains('col-lrow-cat')) return 50;
if (this.classList.contains('col-lrow-label')) return 100;
return 0;
});
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockImplementation(function (this: Element) {
if (this.classList.contains('col-lrow-tags')) return row;
if (this.classList.contains('col-lrow-txt')) return 60;
return 0;
});
vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockImplementation(function (this: Element) {
if (this.classList.contains('col-lrow-txt')) return this.textContent === cutName ? 90 : 60;
return 0;
});
}
const label = (id: number, name: string): CollectionLabel =>
({ id, collection_id: 1, name, color: '#2563eb' }) as unknown as CollectionLabel;
const place = (category: { name: string; color?: string; icon?: string } | null = { name: 'Attraction' }) =>
({ id: 7, name: 'Torre', category }) as unknown as CollectionPlace;
const LABELS = [label(1, 'Core shortlist'), label(2, 'Heritage'), label(3, 'Pair with another stop')];
describe('CollectionPlaceTags', () => {
it('FE-COMP-COLTAGS-001: draws nothing for a place without labels or a category', () => {
const { container } = render(<CollectionPlaceTags place={place(null)} labels={[]} />);
expect(container).toBeEmptyDOMElement();
});
it('FE-COMP-COLTAGS-002: shows the category first, then the labels that fit, then a "+N" for the rest', () => {
layout();
const { container } = render(<CollectionPlaceTags place={place()} labels={LABELS} />);
const chips = Array.from(container.querySelectorAll('.col-lrow-tags > span')).map((el) => el.className);
expect(chips[0]).toBe('col-lrow-cat');
expect(container.querySelectorAll('.col-lrow-label:not(.is-over)')).toHaveLength(1);
expect(container.querySelectorAll('.col-lrow-label.is-over')).toHaveLength(2);
expect(container.querySelector('.col-lrow-label.is-over')).toHaveAttribute('aria-hidden', 'true');
expect(screen.getByText('+2')).toBeInTheDocument();
});
it('FE-COMP-COLTAGS-003: a row wide enough shows every label and no "+N"', () => {
layout({ row: 800 });
const { container } = render(<CollectionPlaceTags place={place()} labels={LABELS} />);
expect(container.querySelectorAll('.col-lrow-label:not(.is-over)')).toHaveLength(3);
expect(container.querySelector('.col-lrow-more')).toBeNull();
});
it('FE-COMP-COLTAGS-004: the "+N" names the hidden labels in its tooltip', async () => {
layout();
render(<CollectionPlaceTags place={place()} labels={LABELS} />);
fireEvent.mouseEnter(screen.getByText('+2'));
expect(await screen.findByRole('tooltip')).toHaveTextContent('Heritage, Pair with another stop');
});
it('FE-COMP-COLTAGS-005: a chip says its name in a tooltip only while it cuts the name short', async () => {
layout({ row: 800, cutName: 'Heritage' });
render(<CollectionPlaceTags place={place()} labels={LABELS} />);
fireEvent.mouseEnter(screen.getByText('Core shortlist').closest('.col-lrow-label') as HTMLElement);
await new Promise((r) => setTimeout(r, 350));
expect(screen.queryByRole('tooltip')).toBeNull();
fireEvent.mouseLeave(screen.getByText('Core shortlist').closest('.col-lrow-label') as HTMLElement);
fireEvent.mouseEnter(screen.getByText('Heritage').closest('.col-lrow-label') as HTMLElement);
expect(await screen.findByRole('tooltip')).toHaveTextContent('Heritage');
});
});
@@ -0,0 +1,145 @@
import type { CollectionLabel, CollectionPlace } from '@trek/shared';
import React, { createElement, useEffect, useRef, useState } from 'react';
import { getCategoryIcon } from '../shared/categoryIcons';
import { Tooltip } from '../shared/Tooltip';
/**
* One observer for every row and chip of a list, so a list of hundreds of places does
* not hold one per element. An element starts with one notification once observed.
*/
const sizeWatch = new Map<Element, () => void>();
let sizeObserver: ResizeObserver | null = null;
function watchSize(el: Element, onResize: () => void): () => void {
if (typeof ResizeObserver === 'undefined') return () => {};
sizeObserver ??= new ResizeObserver((entries) => {
for (const entry of entries) sizeWatch.get(entry.target)?.();
});
sizeWatch.set(el, onResize);
sizeObserver.observe(el);
return () => {
sizeWatch.delete(el);
sizeObserver?.unobserve(el);
};
}
/**
* The tooltip of a label or category chip: only while the chip cuts its name short,
* since one repeating the words on screen says nothing.
*/
function ChipTooltip({
label,
children,
}: {
label: string;
children: React.ReactElement<React.HTMLAttributes<HTMLElement> & React.RefAttributes<HTMLElement>>;
}): React.ReactElement {
const [cut, setCut] = useState(false);
const ref = useRef<HTMLElement | null>(null);
useEffect(() => {
const text = ref.current?.querySelector('.col-lrow-txt');
if (!text) return undefined;
return watchSize(text, () => setCut(text.scrollWidth > text.clientWidth + 1));
}, []);
return (
<Tooltip label={label} placement="top" disabled={!cut}>
{React.cloneElement(children, { ref })}
</Tooltip>
);
}
/** The room a "+N" chip takes, held free while the labels are fitted. */
const MORE_CHIP = 34;
/**
* A chip's own width, also while the row squeezes it: the part its name is cut short by
* is the name's scroll width over its box, up to the widest the chip may grow.
*/
const naturalWidth = (el: HTMLElement): number => {
const text = el.querySelector<HTMLElement>('.col-lrow-txt');
const full = el.offsetWidth + (text ? text.scrollWidth - text.clientWidth : 0);
const cap = parseFloat(getComputedStyle(el).maxWidth);
return Number.isFinite(cap) ? Math.min(full, cap) : full;
};
/**
* How many labels fit beside the category in `row`: as many as it holds with room left
* for the "+N" chip, and at least one, which then shortens its name. Every label is in
* the row, the ones that do not fit kept out of sight, so each can be measured.
*/
function labelsThatFit(row: HTMLElement): number {
const gap = parseFloat(getComputedStyle(row).columnGap) || 0;
const labels = Array.from(row.querySelectorAll<HTMLElement>('.col-lrow-label'));
const fixed = Array.from(row.querySelectorAll<HTMLElement>('.col-lrow-cat, .col-lrow-div'))
.filter((el) => el.offsetWidth > 0)
.reduce((sum, el) => sum + naturalWidth(el) + gap, 0);
let used = fixed;
let fit = 0;
for (let i = 0; i < labels.length; i++) {
const left = labels.length - i - 1;
const need = used + naturalWidth(labels[i]) + (left > 0 ? gap + MORE_CHIP : 0);
if (need > row.clientWidth && i > 0) break;
used += naturalWidth(labels[i]) + gap;
fit = i + 1;
}
return fit;
}
/**
* A place row's category and labels, on one line that never wraps (#2981). The category
* comes first, then as many labels as the row has room for, then a "+N" chip naming the
* rest in its tooltip, so a wide list shows every label and a narrow one the first.
*/
export default function CollectionPlaceTags({
place,
labels,
}: {
place: CollectionPlace;
labels: CollectionLabel[];
}): React.ReactElement | null {
const ref = useRef<HTMLDivElement>(null);
const [fit, setFit] = useState(labels.length);
const labelKey = labels.map((l) => `${l.id}:${l.name}`).join('|');
const categoryName = place.category?.name ?? null;
// Fitted on every change of the row's width and once more whenever its labels change:
// observing anew hands the observer's first notification to the new labels.
useEffect(() => {
const row = ref.current;
if (!row) return undefined;
return watchSize(row, () => setFit(labelsThatFit(row)));
}, [labelKey, categoryName]);
if (!labels.length && !categoryName) return null;
const shown = Math.min(labels.length, Math.max(1, fit));
const hidden = labels.slice(shown);
return (
<div ref={ref} className="col-lrow-tags">
{categoryName && (
<ChipTooltip label={categoryName}>
<span className="col-lrow-cat" style={{ ['--cat' as string]: place.category?.color || '#6366f1' }}>
{createElement(getCategoryIcon(place.category?.icon ?? undefined), { size: 11 })}{' '}
<span className="col-lrow-txt">{categoryName}</span>
</span>
</ChipTooltip>
)}
{labels.map((l, i) => (
<ChipTooltip key={l.id} label={l.name}>
<span
className={i < shown ? 'col-lrow-label' : 'col-lrow-label is-over'}
style={{ ['--label' as string]: l.color || 'var(--accent)' }}
aria-hidden={i < shown ? undefined : true}
>
<span className="col-labelchip-dot" /> <span className="col-lrow-txt">{l.name}</span>
</span>
</ChipTooltip>
))}
{hidden.length > 0 && (
<Tooltip label={hidden.map((l) => l.name).join(', ')} placement="top">
<span className="col-lrow-more">+{hidden.length}</span>
</Tooltip>
)}
<span className="col-lrow-div" aria-hidden />
</div>
);
}
@@ -0,0 +1,34 @@
// FE-JRN-OPENAT-001 to FE-JRN-OPENAT-005
import { describe, expect, it } from 'vitest';
import { journeyOpenAtEntryId } from './journeyOpenAt';
const entries = [
{ id: 3, entry_date: '2026-05-03' },
{ id: 1, entry_date: '2026-05-01' },
{ id: 2, entry_date: '2026-05-01' },
{ id: 5, entry_date: '2026-05-05' },
];
describe('journeyOpenAtEntryId', () => {
it('FE-JRN-OPENAT-001: a running journey opens on the first entry of today', () => {
expect(journeyOpenAtEntryId(entries, { today: '2026-05-03' })).toBe('3');
});
it('FE-JRN-OPENAT-002: a rest day lands on the last day written before it', () => {
expect(journeyOpenAtEntryId(entries, { today: '2026-05-04' })).toBe('3');
});
it('FE-JRN-OPENAT-003: a journey that is over, or not begun, opens at its start', () => {
expect(journeyOpenAtEntryId(entries, { today: '2026-06-01' })).toBeNull();
expect(journeyOpenAtEntryId(entries, { today: '2026-04-01' })).toBeNull();
});
it('FE-JRN-OPENAT-004: newest-first opens on the latest day, also after the trip', () => {
expect(journeyOpenAtEntryId(entries, { today: '2026-06-01', newestFirst: true })).toBe('5');
expect(journeyOpenAtEntryId(entries, { today: '2026-05-03', newestFirst: true })).toBe('5');
});
it('FE-JRN-OPENAT-005: an empty journey has nothing to open on', () => {
expect(journeyOpenAtEntryId([], { today: '2026-05-03', newestFirst: true })).toBeNull();
});
});
@@ -0,0 +1,40 @@
interface DatedEntry {
id: number | string;
entry_date: string;
}
/**
* The entry a journey opens on, or null for its first one.
*
* Every load used to start at the first entry, so keeping a journal on a trip
* meant scrolling past everything already written before you could add to it
* (discussion #2299). "Today" means the last day at or before now: a rest day
* with nothing written still lands you at yesterday rather than at the start.
*
* Only while the journey is running, though. A journey that ended last spring
* is something you read, and reading starts at the beginning; opening it at the
* last page would be the worse of the two mistakes.
*
* A link the owner published newest-first reads like a blog (#1614), so it
* opens on the latest day whether or not the trip is still running (#2851).
*
* Always the first entry OF the chosen day, not the last one before now: a day
* is read from its beginning.
*/
export function journeyOpenAtEntryId(
entries: readonly DatedEntry[],
{ today, newestFirst = false }: { today: string; newestFirst?: boolean }
): string | null {
const dated = [...entries].sort((a, b) => a.entry_date.localeCompare(b.entry_date));
const first = dated[0];
const last = dated[dated.length - 1];
if (!first || !last) return null;
let day = last.entry_date;
if (!newestFirst) {
if (today < first.entry_date || today > last.entry_date) return null;
const reached = dated.filter((e) => e.entry_date <= today);
day = reached[reached.length - 1]?.entry_date ?? first.entry_date;
}
const dayStart = dated.find((e) => e.entry_date === day) ?? first;
return String(dayStart.id);
}
@@ -1,4 +1,4 @@
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-008: the card carousel over the journey map,
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-009: the card carousel over the journey map,
// behind both the phone journey screen and the narrow desktop and public timeline.
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -145,4 +145,43 @@ describe('useJourneyCarouselSync', () => {
act(() => result.current.openEntryById('99'));
expect(props.onOpenEntry).toHaveBeenCalledTimes(1);
});
it('FE-JRN-CAROUSEL-009: a carousel scrolled to either end settles on the card at that end (#2851)', () => {
const carousel = document.createElement('div');
carousel.getBoundingClientRect = () => ({ left: 0 }) as DOMRect;
Object.defineProperty(carousel, 'clientWidth', { value: 300 });
Object.defineProperty(carousel, 'scrollWidth', { value: 600 });
const hook = renderHook(
(p: Options) => {
const sync = useJourneyCarouselSync(p);
(sync.carouselRef as { current: HTMLDivElement | null }).current = carousel;
return sync;
},
{ initialProps: { entries: [], mapEntries: [], onOpenEntry: vi.fn() } }
);
hook.rerender({ entries: ENTRIES, mapEntries: [], onOpenEntry: vi.fn() });
// Card 1 sits at the centre (150) in both positions: only the ends decide.
[0, 100, 200].forEach((left, i) => {
const node = document.createElement('div');
node.getBoundingClientRect = () => ({ left, width: 100 }) as DOMRect;
hook.result.current.cardRefs.current.set(i, node);
});
const settleAt = (scrollLeft: number) => {
Object.defineProperty(carousel, 'scrollLeft', { value: scrollLeft, configurable: true });
carousel.dispatchEvent(new Event('scroll'));
act(() => {
vi.advanceTimersByTime(150);
});
};
settleAt(300);
expect(hook.result.current.activeIndex).toBe(2);
settleAt(150);
expect(hook.result.current.activeIndex).toBe(1);
// Right to left: the scroll position runs negative from the start.
settleAt(-300);
expect(hook.result.current.activeIndex).toBe(2);
settleAt(0);
expect(hook.result.current.activeIndex).toBe(0);
});
});
@@ -71,6 +71,14 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
bestIdx = idx;
}
});
// The first and the last card can never reach the centre, so a carousel scrolled
// as far as it goes means the card at that end: without this, opening on the
// latest day settled on the one before it (#2851). Measured from the start in
// either direction: in a right-to-left layout scrollLeft runs negative.
const travel = el.scrollWidth - el.clientWidth;
const scrolled = Math.abs(el.scrollLeft);
if (travel > 1 && scrolled >= travel - 1) bestIdx = cardRefs.current.size - 1;
else if (travel > 1 && scrolled <= 0) bestIdx = 0;
setActiveIndex((prev) => {
if (prev !== bestIdx) syncMapToCard(bestIdx);
return bestIdx;
@@ -419,8 +419,8 @@ describe('ArtikelZeile — sharing badges', () => {
currentUserId: 1,
})
// The bringer's initial on an avatar, the co-bringers as a count.
expect(screen.getByTitle('brought by Bob')).toHaveTextContent('B+1')
// The bringer's initial on an avatar, the co-bringers as a count; the title names everyone (#2782).
expect(screen.getByTitle('brought by Bob, cleo')).toHaveTextContent('B+1')
})
it('FE-W5ROW-022: a bringer without co-bringers shows no counter', () => {
@@ -18,6 +18,7 @@ import { PopoverItem } from './PackingPopover'
import { POPOVER, POPOVER_CAPTION, POPOVER_DIVIDER } from './packingPopoverStyles'
import type { TripMember } from './usePackingListPanel'
import { NumericInput } from '../shared/NumericInput'
import { packingBringerNames } from './packingBringers'
interface ArtikelZeileProps {
item: PackingItem
@@ -226,7 +227,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
</span>
)}
{!isPlaceholder && broughtBy && (
<span className="packing-row-badge" title={t('packing.broughtBy', { name: broughtBy })} aria-label={t('packing.broughtBy', { name: broughtBy })}
<span className="packing-row-badge" title={t('packing.broughtBy', { name: packingBringerNames(item) })} aria-label={t('packing.broughtBy', { name: packingBringerNames(item) })}
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0 }}>
{ownerAvatar
? <img src={ownerAvatar} alt="" style={{ width: 20, height: 20, borderRadius: '50%', objectFit: 'cover', display: 'block' }} />
@@ -0,0 +1,22 @@
// FE-PACK-BRINGERS-001 to FE-PACK-BRINGERS-003
import { describe, expect, it } from 'vitest';
import { packingBringerNames } from './packingBringers';
describe('packingBringerNames', () => {
it('FE-PACK-BRINGERS-001: names the bringer first, then everyone who also brings it', () => {
const item = {
owner_username: 'anna',
contributors: [{ username: 'ben' }, { username: 'carla' }],
};
expect(packingBringerNames(item)).toBe('anna, ben, carla');
});
it('FE-PACK-BRINGERS-002: names a person once, even when listed twice', () => {
expect(packingBringerNames({ owner_username: 'anna', contributors: [{ username: 'anna' }] })).toBe('anna');
});
it('FE-PACK-BRINGERS-003: copes with an item without a bringer or contributors', () => {
expect(packingBringerNames({ owner_username: null, contributors: [{ username: 'ben' }] })).toBe('ben');
expect(packingBringerNames({})).toBe('');
});
});
@@ -0,0 +1,14 @@
interface BroughtItem {
owner_username?: string | null;
contributors?: { username: string }[] | null;
}
/**
* Everyone bringing a common item, as one readable list: its bringer first, then
* whoever also brings it. The row only has room for one avatar and a "+n", so this
* is what its tooltip and the item sheet name (#2782).
*/
export function packingBringerNames(item: BroughtItem): string {
const names = [item.owner_username, ...(item.contributors ?? []).map((c) => c.username)];
return [...new Set(names.filter((n): n is string => !!n))].join(', ');
}
@@ -7,7 +7,7 @@ import { toDisplayTemp } from '../../../../components/Planner/dayDetailModel'
import { getDayBookendHotels } from '../../../../utils/dayOrder'
import { dayWeatherAnchor } from '../../../../utils/dayWeather'
import { metadataWithLegPositions, planMergedOrder, withTransportPositions } from '../../../../utils/mergedOrder'
import { getDisplayTimeForDay, getMergedItems, getTransportForDay, hasCarrierEndpointOnDay, isCarrierTransport, stopDayIds } from '../../../../utils/dayMerge'
import { getDisplayTimeForDay, getMergedItems, getTransportForDay, hasCarrierEndpointOnDay, isCarrierTransport, stopDayIds, yieldTimedBookings } from '../../../../utils/dayMerge'
import { dayCoMapsUrl, dayExportStops, dayGoogleMapsUrl, optimizeDayOrder, type DayCarrier } from '../../../../components/Planner/dayRoute'
import type { PlannedTransitLeg, TransitLeg } from '../../../../components/Planner/transitLeg'
import { useTransitLegPlanner } from '../../../../components/Planner/useTransitLegPlanner'
@@ -208,10 +208,17 @@ export function useMPlanTimeline(planner: TripPlanner) {
const newOrder = [...merged]
newOrder.splice(idx, 1)
newOrder.splice(toIndex, 0, item)
// Same guard as the desktop arrow buttons: a TIMED item may not jump past
// other timed items (untimed items move freely and keep their neighbours).
// A TIMED item may not jump past other timed items (untimed items move freely and
// keep their neighbours). Bookings it passes make room when they can, as on a desktop
// drop (#2983); a timed stop or note in the way, or a move that leaves the day as it
// was, is refused.
if (itemHasTime(item, day.id) && breaksChronology(newOrder, day.id, getDisplayTimeForDay)) {
toast.info(t('dayplan.cannotBreakChronology'))
const yielded = yieldTimedBookings(newOrder, toIndex, day.id, getDisplayTimeForDay)
if (!yielded || yielded.every((it, i) => it === merged[i])) {
toast.info(t('dayplan.cannotBreakChronology'))
return
}
void applyMergedOrder(day.id, yielded)
return
}
void applyMergedOrder(day.id, newOrder)
@@ -6,6 +6,7 @@ import { avatarSrc } from '../../../../utils/avatarSrc'
import type { PackingItem, TripMember } from '../../../../types'
import type { TripPlanner } from '../MTripShell'
import { isPackingPlaceholder } from '../../../../components/Packing/packingListModel'
import { packingBringerNames } from '../../../../components/Packing/packingBringers'
export interface MPackItemSheetProps {
planner: TripPlanner
@@ -160,7 +161,7 @@ export default function MPackItemSheet({
<div className="rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-3">
{item.owner_username && (
<div className="mb-[9px] font-geist text-[0.6875rem] text-m-muted">
{t('packing.broughtBy', { name: item.owner_username })}
{t('packing.broughtBy', { name: packingBringerNames(item) })}
</div>
)}
<div className="flex gap-2">
@@ -28,6 +28,7 @@ import MPackItemSheet from './MPackItemSheet'
import MPackingImportSheet from './MPackingImportSheet'
import PackingPrintPreview from '../../../../components/Packing/PackingPrintPreview'
import { usePackingExport } from '../../../../components/Packing/usePackingExport'
import { packingBringerNames } from '../../../../components/Packing/packingBringers'
type ActionView = 'menu' | 'apply' | 'save'
@@ -718,7 +719,7 @@ function PackingItemRow({ item, planner, currentUserId, editMode, canEdit, bagTr
</span>
)}
{!badgeSharedToMe && !badgeSharedByMe && badgeBroughtBy && (
<span className="flex flex-none items-center gap-[3px]" title={item.owner_username || undefined}>
<span className="flex flex-none items-center gap-[3px]" title={t('packing.broughtBy', { name: packingBringerNames(item) })} aria-label={t('packing.broughtBy', { name: packingBringerNames(item) })}>
{ownerAvatarUrl
? <img src={ownerAvatarUrl} alt={item.owner_username || ''} className="h-5 w-5 flex-none rounded-full object-cover" />
: <span className="flex h-5 w-5 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] font-geist text-[0.5625rem] font-bold text-m-muted">{(item.owner_username || '?')[0]?.toUpperCase()}</span>}
+10 -10
View File
@@ -294,24 +294,24 @@ function CollectionsPageDesktop(): React.ReactElement {
<span className="lbl">{t('collections.selectedCount', { count: c.selectedIds.length })}</span>
<div className="col-toolbar-spacer" />
{c.canEdit && isRealList && (
<button type="button" onClick={() => c.setLabelPickerOpen(true)} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<Tags size={14} /> {t('collections.labels.assign')}
<button type="button" onClick={() => c.setLabelPickerOpen(true)} disabled={c.selectedIds.length === 0} className="col-selbar-btn act" title={t('collections.labels.assign')}>
<Tags size={14} /> <span className="col-selbar-txt">{t('collections.labels.assign')}</span>
</button>
)}
{c.canDelete && (
<button type="button" onClick={() => c.setListPickerMode('move')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<FolderInput size={14} /> {t('collections.moveToList')}
<button type="button" onClick={() => c.setListPickerMode('move')} disabled={c.selectedIds.length === 0} className="col-selbar-btn act" title={t('collections.moveToList')}>
<FolderInput size={14} /> <span className="col-selbar-txt">{t('collections.moveToList')}</span>
</button>
)}
<button type="button" onClick={() => c.setListPickerMode('copy')} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<CopyPlus size={14} /> {t('collections.duplicateToList')}
<button type="button" onClick={() => c.setListPickerMode('copy')} disabled={c.selectedIds.length === 0} className="col-selbar-btn act" title={t('collections.duplicateToList')}>
<CopyPlus size={14} /> <span className="col-selbar-txt">{t('collections.duplicateToList')}</span>
</button>
<button type="button" onClick={c.openCopyForSelection} disabled={c.selectedIds.length === 0} className="col-selbar-btn">
<Copy size={14} /> {t('collections.copyToTrip')}
<button type="button" onClick={c.openCopyForSelection} disabled={c.selectedIds.length === 0} className="col-selbar-btn act" title={t('collections.copyToTrip')}>
<Copy size={14} /> <span className="col-selbar-txt">{t('collections.copyToTrip')}</span>
</button>
{c.canDelete && (
<button type="button" onClick={c.handleDeleteSelected} disabled={c.selectedIds.length === 0} className="col-selbar-btn danger">
<Trash2 size={14} /> {t('common.delete')}
<button type="button" onClick={c.handleDeleteSelected} disabled={c.selectedIds.length === 0} className="col-selbar-btn act danger" title={t('common.delete')}>
<Trash2 size={14} /> <span className="col-selbar-txt">{t('common.delete')}</span>
</button>
)}
<button type="button" onClick={() => c.setSelectMode(false)} className="col-selbar-btn" aria-label={t('common.cancel')}>
+2
View File
@@ -133,6 +133,7 @@ export default function JourneyPublicPage() {
sortedDates,
displayDates,
newestFirst,
openAtEntryId,
setNewestFirst,
sidebarMapItems,
allPhotos,
@@ -695,6 +696,7 @@ export default function JourneyPublicPage() {
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
<MobileMapTimeline
entries={timelineEntries}
initialEntryId={openAtEntryId}
mapEntries={sidebarMapItems as any}
dark={document.documentElement.classList.contains('dark')}
readOnly
@@ -10,6 +10,8 @@ import type { JourneyMapAutoHandle as JourneyMapHandle } from '../../components/
import { useToast } from '../../components/shared/Toast'
import { useIsMobile } from '../../hooks/useIsMobile'
import { lockBodyScroll } from '../../utils/bodyScrollLock'
import { localIsoDate } from '../../utils/localDate'
import { journeyOpenAtEntryId } from '../../components/Journey/journeyOpenAt'
import type { JourneyEntry } from '../../store/journeyStore'
import { createDraftJourneyEntry, photoUrl } from './JourneyDetailPage.helpers'
import { useDawarichSuggestions } from '../../hooks/useDawarichSuggestions'
@@ -475,34 +477,11 @@ export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailO
})
}, [current?.gallery])
/**
* Where a journey should open: on today, when today is part of it.
*
* Every load used to start at the first entry, so keeping a journal on a trip
* meant scrolling past everything already written before you could add to it
* (discussion #2299). "Today" means the last day at or before now — a rest day
* with nothing written still lands you at yesterday rather than at the start.
*
* Only while the journey is running, though. A journey that ended last spring
* is something you read, and reading starts at the beginning; opening it at the
* last page would be the worse of the two mistakes.
*/
/** Where a journey should open: a linked entry, else today while it runs (see journeyOpenAtEntryId). */
const openAtEntryId = useMemo(() => {
const entries = current?.entries ?? []
if (entries.length === 0) return null
if (linkedEntryId !== null && entries.some(e => String(e.id) === linkedEntryId)) return linkedEntryId
const dated = [...entries].sort((a, b) => a.entry_date.localeCompare(b.entry_date))
const today = new Date()
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
const first = dated[0]!.entry_date
const last = dated[dated.length - 1]!.entry_date
if (todayKey < first || todayKey > last) return null
const reached = dated.filter(e => e.entry_date <= todayKey)
const target = reached[reached.length - 1] ?? dated[0]!
// The first entry OF that day, not the last one before now: a day is read
// from its beginning.
const dayStart = dated.find(e => e.entry_date === target.entry_date) ?? target
return String(dayStart.id)
return journeyOpenAtEntryId(entries, { today: localIsoDate() })
}, [current?.entries, linkedEntryId])
// Desktop only: scroll it into the feed once per journey and once per link, after
@@ -1,4 +1,4 @@
// FE-JRN-PUBHOOK-001 to FE-JRN-PUBHOOK-002
// FE-JRN-PUBHOOK-001 to FE-JRN-PUBHOOK-003
import { http, HttpResponse } from 'msw';
import { describe, expect, it, vi } from 'vitest';
import { server } from '../../../tests/helpers/msw/server';
@@ -32,14 +32,14 @@ function buildEntry(over: Partial<PublicEntry> & { id: number }): PublicEntry {
};
}
function serveShare(entries: PublicEntry[]): void {
function serveShare(entries: PublicEntry[], permissions: Record<string, boolean> = {}): void {
server.use(
http.get('/api/public/journey/tok-1', () =>
HttpResponse.json({
journey: { title: 'Japan' },
entries,
gallery: [],
permissions: { share_timeline: true, share_gallery: true, share_map: false },
permissions: { share_timeline: true, share_gallery: true, share_map: false, ...permissions },
stats: {},
})
)
@@ -91,4 +91,21 @@ describe('useJourneyPublic', () => {
]);
expect(result.current.stopNumberById.has('7')).toBe(false);
});
it('FE-JRN-PUBHOOK-003: a link published newest-first opens on the first entry of the latest day', async () => {
serveShare(
[
buildEntry({ id: 1, entry_date: '2020-03-01' }),
buildEntry({ id: 2, entry_date: '2020-03-02' }),
buildEntry({ id: 3, entry_date: '2020-03-02', sort_order: 1 }),
],
{ newest_first: true }
);
const { result } = renderHook(() => useJourneyPublic());
await waitFor(() => expect(result.current.openAtEntryId).toBe('2'));
result.current.setNewestFirst(false);
// A journey that ended long ago, read oldest-first, starts at the beginning.
await waitFor(() => expect(result.current.openAtEntryId).toBeNull());
});
});
@@ -9,6 +9,8 @@ import { journeyMapStops } from '../../components/Journey/journeyMapStops'
import { posterlessVideo } from '../journeyDetail/JourneyDetailPage.helpers'
import { groupByDate, type PublicEntry, type PublicGalleryPhoto } from './journeyPublicModel'
import { useJourneyMapLayers } from '../../components/Journey/useJourneyMapLayers'
import { journeyOpenAtEntryId } from '../../components/Journey/journeyOpenAt'
import { localIsoDate } from '../../utils/localDate'
/**
* Public-journey (read-only share) data hook — owns the token fetch, the
@@ -144,6 +146,12 @@ export function useJourneyPublic() {
() => (effectiveNewestFirst ? [...sortedDates].reverse() : sortedDates),
[sortedDates, effectiveNewestFirst],
)
// The phone's map carousel keeps the trip in time order, so newest-first there means
// opening on the latest day instead of reversing the cards (#2851).
const openAtEntryId = useMemo(
() => journeyOpenAtEntryId(timelineEntries, { today: localIsoDate(), newestFirst: effectiveNewestFirst }),
[timelineEntries, effectiveNewestFirst],
)
// Two-column desktop layout: timeline feed left + sticky map right
const desktopTwoColumn = !isMobile && perms.share_timeline && perms.share_map
@@ -166,7 +174,7 @@ export function useJourneyPublic() {
perms, journey, stats,
timelineEntries, groupedEntries, sortedDates, displayDates, sidebarMapItems, allPhotos, stopNumberById, mapPhotos,
mapLayers, entryTitleOf,
newestFirst: effectiveNewestFirst, setNewestFirst,
newestFirst: effectiveNewestFirst, setNewestFirst, openAtEntryId,
desktopTwoColumn,
}
}
+71 -4
View File
@@ -215,6 +215,13 @@
display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 18px; padding: 10px 14px;
border-radius: var(--r-md); background: var(--glass-bg); border: 1px solid var(--glass-border); box-shadow: var(--sh-sm);
}
.trek-dash .col-selbar { container-type: inline-size; }
/* Too narrow for every action's words: the actions keep their icon and say what they do
in their tooltip, so the bar stays one line instead of stranding "Delete" below. */
@container (max-width: 1120px) {
.trek-dash .col-selbar-btn.act { padding-inline: 9px; }
.trek-dash .col-selbar-btn.act .col-selbar-txt { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
}
.trek-dash .col-selbar .lbl { font-size: 13px; font-weight: 600; color: var(--ink); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trek-dash .col-selbar-btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border-radius: 9px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); transition: background .12s, color .12s; }
.trek-dash .col-selbar-btn:hover { background: var(--surface-2); color: var(--ink); }
@@ -254,6 +261,8 @@
/* ----------------- list view ----------------- */
.trek-dash .col-listview { display: flex; flex-direction: column; gap: 7px; }
/* A row: tile, name and address, then whatever sits at its end. A kept tour keeps one
line: tile, name, its end group. */
.trek-dash .col-lrow {
display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; gap: 14px; align-items: center;
padding: 10px 14px; border-radius: var(--r-md);
@@ -272,8 +281,40 @@
/* The pin before the address must not be squeezed by a long address. */
.trek-dash .col-lrow .li .s svg { flex-shrink: 0; }
.trek-dash .col-lrow-end { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
/* A place row (#2981). Its labels and category used to sit in an end group that could
not shrink, so with two or three long labels the name got one letter or none. Now the
name keeps the room it needs and the labels give way: on a wide list they share the
line and end in an ellipsis; on a narrow one (below 760px) the row has two fixed lines,
name and status on top, labels and category below, still on one line. */
.trek-dash .col-listview { container-type: inline-size; }
.trek-dash .col-lrow-place {
grid-template-columns: 40px minmax(12rem, max-content) minmax(0, 1fr) auto;
grid-template-areas: "tile main tags status";
}
.trek-dash .col-lrow-place > :first-child { grid-area: tile; }
.trek-dash .col-lrow-place > .li { grid-area: main; }
.trek-dash .col-lrow-tags { grid-area: tags; position: relative; display: flex; justify-content: flex-end; align-items: center; gap: 8px; min-width: 0; overflow: hidden; }
/* A label the row has no room for stays in it, out of sight, so it can be measured. */
.trek-dash .col-lrow-tags > .is-over { position: absolute; inset-inline-start: 0; visibility: hidden; pointer-events: none; }
.trek-dash .col-lrow-tags > * { flex-shrink: 0; }
.trek-dash .col-lrow-tags > .col-lrow-label, .trek-dash .col-lrow-tags > .col-lrow-cat { flex-shrink: 1; min-width: 0; }
.trek-dash .col-lrow-more { display: inline-flex; align-items: center; flex-shrink: 0; padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; color: var(--ink-3); background: var(--surface-2); cursor: default; }
.trek-dash .col-lrow-status { grid-area: status; display: flex; }
@container (max-width: 760px) {
.trek-dash .col-lrow-place {
grid-template-columns: 40px minmax(0, 1fr) auto;
grid-template-areas: "tile main status" "tile tags tags";
row-gap: 0; align-items: start;
}
.trek-dash .col-lrow-place > :first-child { align-self: center; }
.trek-dash .col-lrow-tags { margin-top: 8px; justify-content: flex-start; }
.trek-dash .col-lrow-tags .col-lrow-div { display: none; }
.trek-dash .col-lrow-tags > .col-lrow-label, .trek-dash .col-lrow-tags > .col-lrow-cat { max-width: 170px; }
}
.trek-dash .col-lrow-cat { display: inline-flex; align-items: center; gap: 5px; max-width: 130px; padding: 3px 9px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; color: var(--cat, var(--accent)); background: color-mix(in oklch, var(--cat, var(--accent)) 14%, transparent); }
.trek-dash .col-lrow-cat svg { flex-shrink: 0; }
.trek-dash .col-lrow-txt { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.trek-dash .col-lrow-div { width: 1px; height: 18px; flex-shrink: 0; background: var(--line-2); }
/* A kept tour (LIB-03) is a row of the same list: its grade tile stands where
@@ -318,7 +359,7 @@
/* On the map the tray joins the floating controls, so it takes their glass,
their radius and their shadow instead of the row's flat surface. */
.trek-dash .col-labelfilter.on-map {
pointer-events: auto; max-width: min(52ch, 100%); flex-wrap: nowrap; overflow-x: auto;
pointer-events: auto; max-width: 100%; flex-wrap: nowrap; overflow: hidden;
border-radius: 11px; border-color: var(--glass-border); box-shadow: var(--sh-md);
background: color-mix(in oklch, var(--surface) 92%, transparent);
backdrop-filter: blur(8px) saturate(1.2); -webkit-backdrop-filter: blur(8px) saturate(1.2);
@@ -326,6 +367,17 @@
}
.trek-dash .col-labelfilter.on-map::-webkit-scrollbar { display: none; }
.trek-dash .col-labelfilter.on-map .col-labelchip { flex-shrink: 0; }
/* The strip takes the room the map's top bar leaves between its toggle and the search.
When its labels need more, it folds into one button with the labels as a list; the
hidden copy measures what the labels need, so the strip unfolds once the room is back. */
.trek-dash .col-map-group:not(.right) { flex: 1 1 auto; min-width: 0; }
.trek-dash .col-labelfilter-slot { position: relative; display: flex; flex: 1 1 0; min-width: 0; }
.trek-dash .col-labelfilter-measure { position: absolute; top: 0; inset-inline-start: 0; width: max-content; max-width: none; visibility: hidden; pointer-events: none; }
.trek-dash .col-labelfilter-fold { gap: 7px; padding-inline: 10px 9px; font: inherit; font-size: 12.5px; font-weight: 600; color: var(--ink-2); cursor: pointer; }
.trek-dash .col-labelfilter-fold:hover, .trek-dash .col-labelfilter-fold.open { color: var(--ink); }
.trek-dash .col-labelfilter-active { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; display: grid; place-items: center; font-size: 11px; font-weight: 700; color: var(--accent-text); background: var(--accent); }
.trek-dash .col-labelfilter-pop { pointer-events: auto; }
.trek-dash .col-labelfilter-manage { color: var(--ink-3); border-top: 1px solid var(--line); border-radius: 0 0 8px 8px; margin-top: 2px; }
.trek-dash .col-labelfilter.on-map .col-labelchip:hover { background: var(--surface-2); }
.trek-dash .col-labelchip { display: inline-flex; align-items: center; gap: 6px; max-width: 180px; padding: 5px 9px; border-radius: 8px; font-size: 12.5px; font-weight: 600; white-space: nowrap; color: var(--ink-2); background: transparent; border: 1px solid transparent; transition: background .12s, border-color .12s, color .12s; }
.trek-dash .col-labelchip:hover { background: var(--surface); color: var(--ink); }
@@ -358,7 +410,21 @@
over the map are read as a single band when the page is scrolled to the top,
so they share a height and a top offset instead of each having their own. */
.trek-dash { --col-ctl-h: 38px; }
.trek-dash .col-filterbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 12px; }
/* One line on desktop: the dropdowns shorten their labels instead of pushing the sort
control onto a second line. Phones wrap (see the 720px block). */
.trek-dash .col-filterbar { display: flex; flex-wrap: nowrap; align-items: center; gap: 8px; margin-bottom: 12px; min-width: 0; }
.trek-dash .col-filterbar > * { flex-shrink: 0; }
.trek-dash .col-filterbar > .col-filter { flex-shrink: 1; min-width: 0; }
.trek-dash .col-filterbar > .col-filter > .col-filter-btn { max-width: 100%; min-width: 0; }
.trek-dash .col-filterbar > .col-filter > .col-filter-btn > svg { flex-shrink: 0; }
.trek-dash .col-filterbar > .col-filter .col-filter-lbl { min-width: 0; }
/* Narrow: a dropdown left at its default ("All", "Default") keeps its icon and says its
value in its tooltip; one set to a value still shows it. */
.trek-dash .col-filterbar { container-type: inline-size; }
@container (max-width: 540px) {
.trek-dash .col-filter-btn.is-default .col-filter-lbl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.trek-dash .col-filter-btn.is-default { padding-inline: 9px; }
}
/* Beside the map, the row starts where the map's own top bar does. */
.trek-dash .col-split-list .col-filterbar { margin-top: 12px; }
.trek-dash .col-filter { position: relative; }
@@ -583,12 +649,13 @@
.trek-dash .col-drawer .col-rail-new { min-height: 44px; }
.trek-dash .col-drawer .col-rail-import { min-width: 44px; }
/* enlarge the interactive status badge's tap area without changing its look */
.trek-dash .col-lrow-end > [role="button"] { position: relative; }
.trek-dash .col-lrow-end > [role="button"]::after { content: ''; position: absolute; inset: -9px -6px; }
.trek-dash .col-lrow-status > [role="button"] { position: relative; }
.trek-dash .col-lrow-status > [role="button"]::after { content: ''; position: absolute; inset: -9px -6px; }
/* select action bar: break the bulk actions onto their own line instead of
stranding them behind a growing spacer */
.trek-dash .col-selbar .col-toolbar-spacer { flex-basis: 100%; height: 0; }
.trek-dash .col-filterbar { flex-wrap: wrap; }
}
/* ----------------- mobile rail drawer ----------------- */
+47 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getAssignmentReservations, pinnedAway, stopDayIds, getPlaceBookings, getMergedItems, chronoKeyOf, nightRowsOf, rideSeatKey, seatTimedDrop, stayStartingOn, storedRideSlot, timedSlot, type MergedItem } from './dayMerge'
import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getAssignmentReservations, pinnedAway, stopDayIds, getPlaceBookings, getMergedItems, chronoKeyOf, nightRowsOf, rideSeatKey, seatTimedDrop, yieldTimedBookings, stayStartingOn, storedRideSlot, timedSlot, type MergedItem } from './dayMerge'
import { planMergedOrder, withTransportPositions } from './mergedOrder'
describe('parseTimeToMinutes', () => {
@@ -646,6 +646,52 @@ describe('seatTimedDrop', () => {
const order = draw(setup())
expect(seatTimedDrop(order, 1, DAY)).toEqual(order)
})
// #2983: a flight listed below the day's untimed stops held every slot among them.
const untimedDay = (rides: ReturnType<typeof ride>[]): Day => ({
stops: [stop(1, 'Hotel'), stop(2, 'Shrine'), stop(3, 'Crossing'), stop(4, 'Tower')].map((a, i) => ({ ...a, order_index: i })),
rides,
notes: [],
})
it('a ride dropped among untimed stops stays there, and an earlier booking below them makes room ahead of it', () => {
const day = untimedDay([ride(101, '06:30', 3.5), ride(102, '13:00', 3.6)])
expect(draw(day).map(label)).toEqual(['Hotel', 'Shrine', 'Crossing', 'Tower', 'ride 06:30', 'ride 13:00'])
const { seated, drawn } = drop(day, isRide(102), isStop('Shrine'))
expect(drawn).toEqual(seated)
expect(drawn).toEqual(['Hotel', 'Shrine', 'ride 06:30', 'ride 13:00', 'Crossing', 'Tower'])
})
it('a later booking ahead of the drop moves behind it, and the stops keep their order', () => {
const day = untimedDay([ride(101, '09:00', -0.5), ride(102, '18:00', 1.5)])
expect(draw(day).map(label)).toEqual(['ride 09:00', 'Hotel', 'Shrine', 'ride 18:00', 'Crossing', 'Tower'])
const { seated, drawn } = drop(day, isRide(101), isStop('Tower'))
expect(drawn).toEqual(seated)
expect(drawn).toEqual(['Hotel', 'Shrine', 'Crossing', 'Tower', 'ride 09:00', 'ride 18:00'])
})
it('a timed stop in the way still sends the ride back to its time, the bookings stay put', () => {
const day = untimedDay([ride(101, '06:30', 3.5), ride(102, '13:00', 3.6)])
day.stops[2] = { ...day.stops[2], place: { name: 'Crossing', place_time: '12:00' } }
expect(draw(day).map(label)).toEqual(['Hotel', 'Shrine', 'ride 06:30', 'Crossing', 'Tower', 'ride 13:00'])
const { seated, drawn } = drop(day, isRide(102), isStop('Shrine'))
expect(drawn).toEqual(seated)
expect(drawn).toEqual(['Hotel', 'Shrine', 'ride 06:30', 'Crossing', 'ride 13:00', 'Tower'])
})
})
describe('yieldTimedBookings', () => {
const DAY = 1
const row = (type: MergedItem['type'], data: Record<string, unknown>): MergedItem => ({ type, sortKey: 0, data })
const place = (id: number) => row('place', { id, place: { place_time: null } })
const booking = (id: number, time: string | null) => row('transport', { id, day_id: DAY, reservation_time: time })
it('leaves an untimed row, a stop and an order without a booking out of place to the caller', () => {
const order = [place(1), booking(2, null), booking(3, '2026-11-20T08:00')]
expect(yieldTimedBookings(order, 1, DAY)).toBeNull()
expect(yieldTimedBookings(order, 0, DAY)).toBeNull()
expect(yieldTimedBookings(order, 2, DAY)).toBeNull()
})
})
+57 -10
View File
@@ -428,20 +428,65 @@ function zonedClockSlots(
return slots
}
/** Whether the list draws `order` as it stands. */
function drawnAsIs(order: readonly MergedItem[], dayId: number, getDisplayTime: DisplayTimeOf): boolean {
return applyChronoOrder([...order], dayId, getDisplayTime).every((it, i) => it === order[i])
}
/**
* A day order with the row at `index` kept where it is, and the other timed bookings it
* puts out of time order moved next to it: the ones due earlier right ahead of it, the
* later ones right behind it, each group in the order it had. Stops and notes never move.
* Null when the row has no time, when no booking is out of order, or when the result is
* still not the order the list draws, which is when a timed stop or note stands against it.
*
* A booking nobody can drag otherwise blocks every slot on its far side: with a 06:30
* flight listed below the day's untimed stops, a 13:00 transit dropped between two of
* them went back behind the flight on every drop (#2983).
*/
export function yieldTimedBookings(
order: readonly MergedItem[],
index: number,
dayId: number,
getDisplayTime: DisplayTimeOf = getDisplayTimeForDay
): MergedItem[] | null {
const moved = order[index]
if (!moved || moved.type === 'place') return null
const keyOf = chronoKeyOf(order, dayId, getDisplayTime)
const own = keyOf(moved)
if (own === null) return null
const ahead: MergedItem[] = []
const behind: MergedItem[] = []
order.forEach((it, i) => {
if (it === moved || it.type !== 'transport') return
const key = keyOf(it)
if (key === null) return
if (i > index && key < own) ahead.push(it)
else if (i < index && key > own) behind.push(it)
})
if (!ahead.length && !behind.length) return null
const yielding = new Set([...ahead, ...behind])
const kept = order.filter(it => !yielding.has(it))
const at = kept.indexOf(moved)
const result = [...kept.slice(0, at), ...ahead, moved, ...behind, ...kept.slice(at + 1)]
return drawnAsIs(result, dayId, getDisplayTime) ? result : null
}
/**
* A day order with a dragged booking or note at `index`, moved to the nearest slot the
* list draws it in. A row with a time is drawn by it, and the untimed rows behind it
* take that time too. Dropped against its time, a train went back to its time on the
* next render and took every untimed stop behind the drop with it, and the next drag
* stored that order (#2631). So it lands as close to the drop as its time allows and
* nothing else moves. A row without a time, or one the list draws where it was
* dropped, is returned as it came.
* stored that order (#2631). So it stays where it was dropped when only other bookings
* stand against its time, and they make room (`yieldTimedBookings`); against a timed
* stop or note it lands as close to the drop as its time allows and nothing else moves.
* A row without a time, or one the list draws where it was dropped, is returned as it came.
*/
export function seatTimedDrop(
order: readonly MergedItem[],
index: number,
dayId: number,
getDisplayTime: (r: MergedItem['data'], dayId: number) => string | null = getDisplayTimeForDay
getDisplayTime: DisplayTimeOf = getDisplayTimeForDay
): MergedItem[] {
const moved = order[index]
if (!moved || moved.type === 'place') return [...order]
@@ -449,16 +494,18 @@ export function seatTimedDrop(
? parseTimeToMinutes(moved.data?.time)
: parseTimeToMinutes(getDisplayTime(moved.data, dayId))
if (minutes === null) return [...order]
if (drawnAsIs(order, dayId, getDisplayTime)) return [...order]
const yielded = yieldTimedBookings(order, index, dayId, getDisplayTime)
if (yielded) return yielded
const rest = order.filter((_, i) => i !== index)
const drawnAsIs = (at: number): MergedItem[] | null => {
const seatAt = (at: number): MergedItem[] | null => {
const candidate = [...rest.slice(0, at), moved, ...rest.slice(at)]
const drawn = applyChronoOrder(candidate, dayId, getDisplayTime)
return drawn.every((it, i) => it === candidate[i]) ? candidate : null
return drawnAsIs(candidate, dayId, getDisplayTime) ? candidate : null
}
for (let dist = 0; dist <= rest.length; dist++) {
for (const at of dist === 0 ? [index] : [index - dist, index + dist]) {
for (let dist = 1; dist <= rest.length; dist++) {
for (const at of [index - dist, index + dist]) {
if (at < 0 || at > rest.length) continue
const seated = drawnAsIs(at)
const seated = seatAt(at)
if (seated) return seated
}
}
@@ -12,7 +12,7 @@ import { buildPlanner, buildTripActions } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { act, renderHook, waitFor } from '../../../helpers/render'
// FE-MOB-PLTL-001 to FE-MOB-PLTL-052
// FE-MOB-PLTL-001 to FE-MOB-PLTL-054
// The connector calculation is its own hook with real OSRM calls — stubbed here so
// the timeline sees exactly the legs a test wants to match against.
@@ -337,6 +337,41 @@ describe('useMPlanTimeline', () => {
expect(planner.toast.info).not.toHaveBeenCalled()
})
// #2983: a 06:30 flight listed below the day's untimed stops blocked every slot of a
// 13:00 transit among them. The flight makes room ahead of the transit instead.
const FLIGHT_EARLY = buildReservation({
id: 61, type: 'flight', title: 'Flight', day_id: 2, reservation_time: '2026-05-02T06:30', day_positions: { 2: 1.5 },
})
const TRANSIT_NOON = buildReservation({
id: 62, type: 'transit', title: 'Transit', day_id: 2, reservation_time: '2026-05-02T13:00', day_positions: { 2: 1.6 },
})
it('FE-MOB-PLTL-053: keeps a timed booking where it was moved and lets an earlier booking make room', async () => {
seedStore(useTripStore, { reservations: [FLIGHT_EARLY, TRANSIT_NOON] })
const planner = makePlanner({ assignments: { '2': [A_MUSEUM, A_PARK] }, reservations: [FLIGHT_EARLY, TRANSIT_NOON] })
const { result } = await renderTimeline(planner)
expect(result.current.merged.map(m => m.data.id)).toEqual([11, 12, 61, 62])
await act(async () => { result.current.moveRowTo(result.current.merged[3], 1) })
await waitFor(() => expect(planner.tripActions.updateReservationPositions).toHaveBeenCalledWith(
1, [{ id: 61, day_plan_position: 1 / 3 }, { id: 62, day_plan_position: 2 / 3 }], 2,
))
expect(planner.tripActions.reorderAssignments).toHaveBeenCalledWith(1, 2, [11, 12])
expect(planner.toast.info).not.toHaveBeenCalled()
})
it('FE-MOB-PLTL-054: still refuses a move the earlier booking would only undo', async () => {
seedStore(useTripStore, { reservations: [FLIGHT_EARLY, TRANSIT_NOON] })
const planner = makePlanner({ assignments: { '2': [A_MUSEUM, A_PARK] }, reservations: [FLIGHT_EARLY, TRANSIT_NOON] })
const { result } = await renderTimeline(planner)
await act(async () => { result.current.moveRow(result.current.merged[3], 'up') })
expect(planner.toast.info).toHaveBeenCalledWith('dayplan.cannotBreakChronology')
expect(planner.tripActions.updateReservationPositions).not.toHaveBeenCalled()
})
it('FE-MOB-PLTL-021: persists note and transport positions alongside the reorder', async () => {
const bus = buildReservation({
id: 51, type: 'bus', title: 'Bus 13A', day_id: 2, day_positions: { 2: 1.5 },