mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91f6430762 | |||
| 6ee540d5e4 | |||
| 99cdcfcd09 | |||
| fd11944ed6 | |||
| 82c6a3cd5f | |||
| fbafa270c4 | |||
| 1c54d7d6c1 | |||
| d2c7d83256 | |||
| 91630dd811 | |||
| 14c767a675 | |||
| da324121bd | |||
| b41f6e3542 |
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ----------------- */
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user