mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 07:10:56 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91f6430762 | |||
| 6ee540d5e4 | |||
| 99cdcfcd09 | |||
| fd11944ed6 | |||
| 82c6a3cd5f | |||
| fbafa270c4 | |||
| 1c54d7d6c1 | |||
| d2c7d83256 | |||
| 91630dd811 | |||
| 14c767a675 | |||
| da324121bd | |||
| b41f6e3542 | |||
| 9f5d01ee09 | |||
| 67ca58f2a5 | |||
| 9eab19ea6c | |||
| 50ea9a74c9 | |||
| 82295a1d3f | |||
| 3c78452c14 |
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hotel, Utensils, ShoppingCart, Bus, Plane, Ticket, Camera, ShoppingBag, FileText, HeartPulse, Coins, MoreHorizontal, Fuel, ParkingCircle } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { COST_CATEGORIES, type CostCategory } from '@trek/shared'
|
||||
import { COST_CATEGORIES, costCategoryOf, type CostCategory } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The fixed Costs categories. Users can't add their own — every expense maps to
|
||||
@@ -34,34 +34,7 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
|
||||
|
||||
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
|
||||
|
||||
/**
|
||||
* Legacy / English free-text categories (and reservation type labels) mapped to
|
||||
* the fixed keys. Bookings used to store labels like "Flight"/"Train"/"Other",
|
||||
* which never matched the lowercase keys and fell through to `other`.
|
||||
*/
|
||||
const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
|
||||
flight: 'flights', flights: 'flights', plane: 'flights', flug: 'flights',
|
||||
train: 'transport', bus: 'transport', car: 'transport', 'car rental': 'transport',
|
||||
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport', cable_car: 'transport',
|
||||
transport: 'transport', transportation: 'transport',
|
||||
hotel: 'accommodation', accommodation: 'accommodation', lodging: 'accommodation', hostel: 'accommodation',
|
||||
restaurant: 'food', food: 'food', dining: 'food', meal: 'food', meals: 'food',
|
||||
grocery: 'groceries', groceries: 'groceries',
|
||||
activity: 'activities', activities: 'activities',
|
||||
sightseeing: 'sightseeing', sights: 'sightseeing',
|
||||
shop: 'shopping', shopping: 'shopping',
|
||||
fee: 'fees', fees: 'fees',
|
||||
health: 'health', medical: 'health',
|
||||
tip: 'tips', tips: 'tips',
|
||||
gas: 'fuel', fuel: 'fuel', petrol: 'fuel',
|
||||
parking: 'parking', parkings: 'parking', 'car park': 'parking',
|
||||
other: 'other', misc: 'other',
|
||||
}
|
||||
|
||||
/** Map any stored category (incl. legacy/localized free-text values) to a known meta. */
|
||||
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
|
||||
if (!cat) return COST_CAT_META.other
|
||||
if (cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
||||
const mapped = LEGACY_CATEGORY_MAP[cat.trim().toLowerCase()]
|
||||
return mapped ? COST_CAT_META[mapped] : COST_CAT_META.other
|
||||
return COST_CAT_META[costCategoryOf(cat) ?? 'other']
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -169,6 +169,45 @@ describe('buildDayRouteRuns', () => {
|
||||
expect(hotelBookendOf(run[0], run[1])).toBe('morning')
|
||||
})
|
||||
|
||||
it("FE-MAP-DRP-017: the morning after a night away starts at the side trip's hotel, not the room kept at home", () => {
|
||||
// The first hotel stays booked over the whole trip and comes first in the list; the
|
||||
// side trip's hotel holds the night before. The day used to start with a 716 km drive
|
||||
// from the kept room to the side trip's first stop.
|
||||
const days = [1, 2, 3, 4, 5].map(n => buildDay({ id: n, day_number: n }))
|
||||
const kept = { id: 1, trip_id: 1, place_lat: 13.7466, place_lng: 100.5393, start_day_id: 1, end_day_id: 5 } as unknown as Accommodation
|
||||
const away = { id: 2, trip_id: 1, place_lat: 18.7883, place_lng: 98.9853, start_day_id: 2, end_day_id: 3 } as unknown as Accommodation
|
||||
const runs = buildDayRouteRuns(3, inputs({
|
||||
days,
|
||||
assignments: { '3': [buildAssignment({ day_id: 3, order_index: 0, place: buildPlace({ lat: 18.8048, lng: 98.9216, place_time: '10:00' }) })] },
|
||||
accommodations: [kept, away],
|
||||
optimizeFromAccommodation: true,
|
||||
}))
|
||||
expect(runs[0][0]).toMatchObject({ lat: 18.7883, lng: 98.9853, hotel: 'morning' })
|
||||
expect(runs.flat().some(p => p.hotel === 'morning' && p.lat === 13.7466)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-018: a taxi that leaves from the hotel adds no road from the hotel to itself', () => {
|
||||
const days = [1, 2, 3].map(n => buildDay({ id: n, day_number: n }))
|
||||
const hotel = { id: 1, trip_id: 1, place_lat: 13.7466, place_lng: 100.5393, start_day_id: 1, end_day_id: 3 } as unknown as Accommodation
|
||||
const taxi = {
|
||||
id: 9, trip_id: 1, type: 'taxi', title: 'Taxi', status: 'confirmed', day_id: 2, end_day_id: 2,
|
||||
reservation_time: '2026-12-02T09:00', reservation_end_time: '2026-12-02T09:40',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'Hotel', lat: 13.7466, lng: 100.5393 },
|
||||
{ role: 'to', sequence: 1, name: 'Museum', lat: 13.7516, lng: 100.4927 },
|
||||
],
|
||||
} as unknown as Reservation
|
||||
const runs = buildDayRouteRuns(2, inputs({
|
||||
days,
|
||||
assignments: { '2': [buildAssignment({ day_id: 2, order_index: 0, place: buildPlace({ lat: 13.7563, lng: 100.5018, place_time: '12:00' }) })] },
|
||||
reservations: [taxi],
|
||||
accommodations: [hotel],
|
||||
optimizeFromAccommodation: true,
|
||||
}))
|
||||
const zeroLength = runs.filter(run => run.length === 2 && run[0].lat === run[1].lat && run[0].lng === run[1].lng)
|
||||
expect(zeroLength).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-008: a day that is not in the trip has no route', () => {
|
||||
expect(buildDayRouteRuns(99, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
|
||||
})
|
||||
|
||||
@@ -196,7 +196,10 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
const role: CarrierEdge = side === 'first'
|
||||
? (e.from ? 'departure' : 'arrival')
|
||||
: (e.to ? 'arrival' : 'departure')
|
||||
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null }
|
||||
// The booking's own point at this edge of the day, as the sidebar passes it: a taxi
|
||||
// that starts or ends at the hotel then adds no zero-length leg to it.
|
||||
const point = side === 'first' ? (e.from ?? e.to) : (e.to ?? e.from)
|
||||
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null, lat: point?.lat, lng: point?.lng }
|
||||
}
|
||||
// A carrier with a located endpoint today means the day changes geography by
|
||||
// air/rail/sea — the check-in hotel is then the day's destination and the
|
||||
|
||||
@@ -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(', ');
|
||||
}
|
||||
@@ -133,7 +133,7 @@ describe('ReservationModal', () => {
|
||||
expect(screen.getByRole('button', { name: /^Accommodation$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Restaurant$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Event$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Tour$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Guided tour$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Parking$/i })).toBeInTheDocument();
|
||||
// The pill reads "Booking Type: Other", so only its option is named plain "Other".
|
||||
expect(screen.getAllByRole('button', { name: /^Other$/i })).toHaveLength(1);
|
||||
@@ -708,7 +708,7 @@ describe('ReservationModal', () => {
|
||||
|
||||
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await pickType(/^Tour$/i);
|
||||
await pickType(/^Guided tour$/i);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('BookingsTimeline: trip view', () => {
|
||||
expect(screen.getByText('No date')).toBeInTheDocument();
|
||||
const metro = screen.getByRole('button', { name: /Metro ride/ });
|
||||
expect(metro).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: /Bonus tour/ })).toHaveTextContent('Tour, Pending');
|
||||
expect(screen.getByRole('button', { name: /Bonus tour/ })).toHaveTextContent('Guided tour, Pending');
|
||||
expect(screen.getByRole('button', { name: /Farewell dinner/ })).toHaveTextContent('Restaurant, Confirmed');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Farewell dinner/ }));
|
||||
expect(onSelect).toHaveBeenCalledWith(early);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"src/db/repositories/Users.repository.ts": 1445,
|
||||
"src/nest/atlas/atlas.service.ts": 1177,
|
||||
"src/nest/auth/auth.service.ts": 1143,
|
||||
"src/nest/budget/budget.service.ts": 1613,
|
||||
"src/nest/budget/budget.service.ts": 1612,
|
||||
"src/nest/collections/collections.service.ts": 1426,
|
||||
"src/nest/doc-sync/doc-sync.service.ts": 1060,
|
||||
"src/nest/doc-sync/providers/synology-drive.client.ts": 1160,
|
||||
|
||||
@@ -26,7 +26,7 @@ import { BudgetWritesService } from './budget-writes.service';
|
||||
import { BudgetService } from './budget.service';
|
||||
import { ExchangeRatesService } from './exchange-rates.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { idSchema, sumMinor, toMinor } from '@trek/shared';
|
||||
import { COST_CATEGORY_HINT, idSchema, sumMinor, toMinor } from '@trek/shared';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -200,7 +200,7 @@ export class BudgetMcp {
|
||||
inputSchema: {
|
||||
tripId: idSchema,
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
||||
category: z.string().max(100).optional().describe(COST_CATEGORY_HINT),
|
||||
total_price: z.number().describe('Signed: a negative total records a refund/partial reimbursement (#2176)'),
|
||||
currency: z
|
||||
.string()
|
||||
@@ -341,7 +341,7 @@ export class BudgetMcp {
|
||||
tripId: idSchema,
|
||||
itemId: idSchema,
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
category: z.string().max(100).optional(),
|
||||
category: z.string().max(100).optional().describe(COST_CATEGORY_HINT),
|
||||
total_price: z.number().optional(),
|
||||
currency: z
|
||||
.string()
|
||||
@@ -482,7 +482,7 @@ export class BudgetMcp {
|
||||
inputSchema: {
|
||||
tripId: idSchema,
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
||||
category: z.string().max(100).optional().describe(COST_CATEGORY_HINT),
|
||||
total_price: z.number().describe('Signed: a negative total records a refund/partial reimbursement (#2176)'),
|
||||
note: z.string().max(500).optional(),
|
||||
userIds: z
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import type { BudgetFallbackFx, BudgetParticipantFinal, BudgetUnconverted, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
|
||||
import { splitEqualShares, sumMinor, toMinor } from '@trek/shared';
|
||||
import { costCategoryOf, splitEqualShares, storedCostCategory, sumMinor, toMinor } from '@trek/shared';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { avatarUrl } from '../common/avatarUrl';
|
||||
@@ -91,8 +91,7 @@ export function splitLegacyTicketNote(
|
||||
* queries instead of a full listSettlements() scan, settlement usernames use
|
||||
* COALESCE(display_name, username) like every item query, and updateMembers no
|
||||
* longer double-applies avatarUrl.
|
||||
*/
|
||||
@Injectable()
|
||||
*/@Injectable()
|
||||
export class BudgetService {
|
||||
constructor(
|
||||
private readonly permissions: PermissionsService,
|
||||
@@ -520,7 +519,7 @@ export class BudgetService {
|
||||
const maxOrder = await this.budgetItemsRepo.maxSortOrder(tripId);
|
||||
const sortOrder = (maxOrder !== null ? maxOrder : -1) + 1;
|
||||
|
||||
const cat = data.category || 'other';
|
||||
const cat = storedCostCategory(data.category);
|
||||
|
||||
// Ensure category has a sort_order entry
|
||||
const catExists = await this.budgetCategoryOrderRepo.exists(tripId, cat);
|
||||
@@ -660,7 +659,7 @@ export class BudgetService {
|
||||
// (the legacy `COALESCE(?, col)` truthy-wins shape), everything else
|
||||
// passes `present = <field> !== undefined` (a true presence sentinel).
|
||||
await this.budgetItemsRepo.update(id, {
|
||||
category: [!!data.category, data.category || ''],
|
||||
category: [!!data.category, data.category ? storedCostCategory(data.category) : ''],
|
||||
name: [!!data.name, data.name || ''],
|
||||
total_price: [data.total_price !== undefined, data.total_price !== undefined ? data.total_price : 0],
|
||||
currency: [data.currency !== undefined, data.currency !== undefined ? (data.currency || null) : null],
|
||||
@@ -705,11 +704,11 @@ export class BudgetService {
|
||||
|
||||
// If category changed, update category order table
|
||||
if (data.category) {
|
||||
const catExists = await this.budgetCategoryOrderRepo.exists(tripId, data.category);
|
||||
const catExists = await this.budgetCategoryOrderRepo.exists(tripId, storedCostCategory(data.category));
|
||||
if (!catExists) {
|
||||
const maxCatOrder = await this.budgetCategoryOrderRepo.maxSortOrder(tripId);
|
||||
const catOrder = (maxCatOrder !== null && maxCatOrder !== undefined ? maxCatOrder : -1) + 1;
|
||||
await this.budgetCategoryOrderRepo.insertIgnore(tripId, data.category, catOrder);
|
||||
await this.budgetCategoryOrderRepo.insertIgnore(tripId, storedCostCategory(data.category), catOrder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1037,7 +1036,7 @@ export class BudgetService {
|
||||
}
|
||||
const cents = toMinor(convert(r.total_price || 0));
|
||||
total += cents;
|
||||
const cat = r.category || '';
|
||||
const cat = costCategoryOf(r.category) ?? (r.category || '');
|
||||
byCategory[cat] = (byCategory[cat] || 0) + cents;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -383,7 +383,7 @@ export class JourneyMcp {
|
||||
tags: z.array(z.string()).optional(),
|
||||
pros_cons: PROS_CONS.optional().describe('The verdict on the place: what was worth it and what was not'),
|
||||
visibility: ENTRY_VISIBILITY.optional().describe(
|
||||
'Defaults to private; "shared" and "public" expose the entry through the journey share link',
|
||||
'Stored with the entry, defaults to private. It does not decide the share link: every entry that is not a draft shows on the journey share link. Use is_draft to keep an entry to the contributors',
|
||||
),
|
||||
type: ENTRY_TYPE.optional().describe(
|
||||
'Defaults to "entry"; "skeleton" is the stub TREK derives from a trip place and hides behind the hide-skeletons preference',
|
||||
|
||||
@@ -155,7 +155,8 @@ describe('Tool: create_budget_item', () => {
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.name).toBe('Hotel Paris');
|
||||
expect(data.item.category).toBe('Accommodation');
|
||||
// Stored as its fixed key, as the Costs tab shows it (#2835).
|
||||
expect(data.item.category).toBe('accommodation');
|
||||
expect(data.item.total_price).toBe(500);
|
||||
expect(data.item.note).toBe('Prepaid');
|
||||
});
|
||||
|
||||
@@ -565,10 +565,10 @@ describe('Prompt: budget-overview', () => {
|
||||
const client = await buildServer(user.id);
|
||||
const text = await invokePromptText(client, 'budget-overview', { tripId: trip.id });
|
||||
expect(text).toContain('Budget Trip');
|
||||
expect(text).toContain('Transport');
|
||||
expect(text).toContain('Accommodation');
|
||||
expect(text).toContain('550'); // Transport total
|
||||
expect(text).toContain('300'); // Accommodation total
|
||||
// Totals group by the fixed Costs key, whatever case the expense was stored in (#2835).
|
||||
expect(text).toContain('- transport: 250');
|
||||
expect(text).toContain('- accommodation: 300');
|
||||
expect(text).toContain('550');
|
||||
});
|
||||
|
||||
it('prints a foreign-currency bill at the rate it was booked at, not as that many euros (#2525)', async () => {
|
||||
@@ -588,8 +588,8 @@ describe('Prompt: budget-overview', () => {
|
||||
const text = await invokePromptText(client, 'budget-overview', { tripId: trip.id });
|
||||
// 801.76 USD at 1.17 is 685.26 EUR. Summed as stored it read "Total: 901.76 EUR".
|
||||
expect(text).toContain('Total: 785.26 EUR');
|
||||
expect(text).toContain('- Accommodation: 685.26 EUR');
|
||||
expect(text).toContain('- Food: 100 EUR');
|
||||
expect(text).toContain('- accommodation: 685.26 EUR');
|
||||
expect(text).toContain('- food: 100 EUR');
|
||||
expect(text).not.toContain('801.76');
|
||||
});
|
||||
|
||||
|
||||
@@ -291,7 +291,7 @@ describe('MCP transit tools', () => {
|
||||
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary, price: 230, budget_category: 'Transport' },
|
||||
}),
|
||||
) as { reservation: { id: number; metadata: string } };
|
||||
expect((await findRow(orm, BudgetItems, { reservation: paid.reservation.id }))?.category).toBe('Transport');
|
||||
expect((await findRow(orm, BudgetItems, { reservation: paid.reservation.id }))?.category).toBe('transport');
|
||||
|
||||
const free = parseToolResult(
|
||||
await harness.client.callTool({
|
||||
|
||||
@@ -349,7 +349,8 @@ describe('Transport tools: the price link', () => {
|
||||
const data = parseToolResult(result) as { reservation: { id: number } };
|
||||
|
||||
const item = await findRow(orm, BudgetItems, { reservation: data.reservation.id });
|
||||
expect(item).toMatchObject({ name: 'ZRH → CDG', category: 'Flights', total_price: 240 });
|
||||
// Stored as its fixed Costs key (#2835).
|
||||
expect(item).toMatchObject({ name: 'ZRH → CDG', category: 'flights', total_price: 240 });
|
||||
// The price also rides along in the reservation metadata, as the REST path does.
|
||||
expect(broadcastMock.mock.calls.some((c) => c[1] === 'budget:created')).toBe(true);
|
||||
});
|
||||
@@ -365,7 +366,8 @@ describe('Transport tools: the price link', () => {
|
||||
});
|
||||
const data = parseToolResult(result) as { reservation: { id: number } };
|
||||
const item = await findRow(orm, BudgetItems, { reservation: data.reservation.id });
|
||||
expect(item?.category).toBe('train');
|
||||
// The type is stored as its Costs key: a train is a transport (#2835).
|
||||
expect(item?.category).toBe('transport');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// COST-CATEGORY-001 to COST-CATEGORY-005: a typed category read as one of the fixed keys.
|
||||
import { COST_CATEGORY_HINT, costCategoryOf, storedCostCategory } from './cost-category';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('costCategoryOf', () => {
|
||||
it('COST-CATEGORY-001: a key stays itself', () => {
|
||||
expect(costCategoryOf('food')).toBe('food');
|
||||
expect(costCategoryOf('accommodation')).toBe('accommodation');
|
||||
});
|
||||
|
||||
it('COST-CATEGORY-002: another case or a common name reads as its key', () => {
|
||||
expect(costCategoryOf('Food')).toBe('food');
|
||||
expect(costCategoryOf(' FOOD ')).toBe('food');
|
||||
expect(costCategoryOf('Flight')).toBe('flights');
|
||||
expect(costCategoryOf('Hotel')).toBe('accommodation');
|
||||
expect(costCategoryOf('Car park')).toBe('parking');
|
||||
});
|
||||
|
||||
it('COST-CATEGORY-003: anything else is no key', () => {
|
||||
expect(costCategoryOf('Comida')).toBeNull();
|
||||
expect(costCategoryOf('')).toBeNull();
|
||||
expect(costCategoryOf(null)).toBeNull();
|
||||
expect(costCategoryOf(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('COST-CATEGORY-004: an expense is stored under the key, other text as given, nothing as other', () => {
|
||||
expect(storedCostCategory('Food')).toBe('food');
|
||||
expect(storedCostCategory('Train')).toBe('transport');
|
||||
expect(storedCostCategory('Comida')).toBe('Comida');
|
||||
expect(storedCostCategory('')).toBe('other');
|
||||
expect(storedCostCategory(null)).toBe('other');
|
||||
expect(storedCostCategory(undefined)).toBe('other');
|
||||
});
|
||||
|
||||
it('COST-CATEGORY-005: the hint an assistant reads names every key', () => {
|
||||
for (const key of ['accommodation', 'food', 'transport', 'flights', 'other'])
|
||||
expect(COST_CATEGORY_HINT).toContain(key);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { COST_CATEGORIES, type CostCategory } from './budget.schema';
|
||||
|
||||
/**
|
||||
* Free-text and legacy categories (and booking type labels) mapped to the fixed
|
||||
* keys. Bookings used to store labels like "Flight"/"Train"/"Other", and an
|
||||
* assistant writing through the MCP says "Food" where the key is "food".
|
||||
*/
|
||||
const CATEGORY_ALIASES: Record<string, CostCategory> = {
|
||||
flight: 'flights',
|
||||
flights: 'flights',
|
||||
plane: 'flights',
|
||||
flug: 'flights',
|
||||
train: 'transport',
|
||||
bus: 'transport',
|
||||
car: 'transport',
|
||||
'car rental': 'transport',
|
||||
ferry: 'transport',
|
||||
boat: 'transport',
|
||||
taxi: 'transport',
|
||||
transfer: 'transport',
|
||||
cable_car: 'transport',
|
||||
transport: 'transport',
|
||||
transportation: 'transport',
|
||||
hotel: 'accommodation',
|
||||
accommodation: 'accommodation',
|
||||
lodging: 'accommodation',
|
||||
hostel: 'accommodation',
|
||||
restaurant: 'food',
|
||||
food: 'food',
|
||||
dining: 'food',
|
||||
meal: 'food',
|
||||
meals: 'food',
|
||||
grocery: 'groceries',
|
||||
groceries: 'groceries',
|
||||
activity: 'activities',
|
||||
activities: 'activities',
|
||||
sightseeing: 'sightseeing',
|
||||
sights: 'sightseeing',
|
||||
shop: 'shopping',
|
||||
shopping: 'shopping',
|
||||
fee: 'fees',
|
||||
fees: 'fees',
|
||||
health: 'health',
|
||||
medical: 'health',
|
||||
tip: 'tips',
|
||||
tips: 'tips',
|
||||
gas: 'fuel',
|
||||
fuel: 'fuel',
|
||||
petrol: 'fuel',
|
||||
parking: 'parking',
|
||||
parkings: 'parking',
|
||||
'car park': 'parking',
|
||||
other: 'other',
|
||||
misc: 'other',
|
||||
};
|
||||
|
||||
const KEYS: ReadonlySet<string> = new Set(COST_CATEGORIES);
|
||||
|
||||
/**
|
||||
* The fixed key a stored or typed category stands for, whatever its case, or
|
||||
* null for a category that is none of them.
|
||||
*/
|
||||
export function costCategoryOf(category: string | null | undefined): CostCategory | null {
|
||||
if (!category) return null;
|
||||
if (KEYS.has(category)) return category as CostCategory;
|
||||
return CATEGORY_ALIASES[category.trim().toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The category an expense is stored under: the fixed key when the text names one,
|
||||
* so "Food" and "food" are one category, and the text itself otherwise.
|
||||
*/
|
||||
export function storedCostCategory(category: string | null | undefined): string {
|
||||
return costCategoryOf(category) ?? (category || 'other');
|
||||
}
|
||||
|
||||
/** What the MCP tells an assistant about the category field. */
|
||||
export const COST_CATEGORY_HINT = `One of the Costs categories: ${COST_CATEGORIES.join(', ')}. Another case or a common name (Hotel, Flight, Food) is stored as its key; any other text is kept as given and shows as other`;
|
||||
@@ -120,6 +120,37 @@ describe('the hotels at both ends of a day', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('STAY-BOOKENDS-007: a night away on a side trip wakes up in the side trip and comes back to the room it kept', () => {
|
||||
// The first hotel stays booked over the whole trip and comes first in the list; the
|
||||
// side trip's hotel holds one night in between.
|
||||
const kept = stay(1, 10, 40, [13.7, 100.5]);
|
||||
const away = stay(2, 20, 30, [18.8, 99]);
|
||||
const list = [kept, away];
|
||||
// Day 2 leaves the kept room and sleeps away.
|
||||
expect(getDayBookendHotels(d2, days, list)).toEqual({
|
||||
morning: kept,
|
||||
evening: away,
|
||||
morningIsSleptHere: true,
|
||||
eveningIsOvernight: true,
|
||||
});
|
||||
// Day 3 wakes up away and goes back to the kept room for the night.
|
||||
expect(getDayBookendHotels(d3, days, list)).toEqual({
|
||||
morning: away,
|
||||
evening: kept,
|
||||
morningIsSleptHere: true,
|
||||
eveningIsOvernight: true,
|
||||
});
|
||||
// The order of the list no longer decides it.
|
||||
expect(getDayBookendHotels(d3, days, [away, kept])).toEqual(getDayBookendHotels(d3, days, list));
|
||||
// Day 4, the kept room's check-out day, wakes up there.
|
||||
expect(getDayBookendHotels(d4, days, list)).toEqual({
|
||||
morning: kept,
|
||||
evening: kept,
|
||||
morningIsSleptHere: true,
|
||||
eveningIsOvernight: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('STAY-BOOKENDS-005: a stay without coordinates is left out, and the other stay answers on its own', () => {
|
||||
const unplaced = stay(1, 10, 20, null);
|
||||
const next = stay(2, 20, 30, [9, 9]);
|
||||
|
||||
@@ -102,17 +102,27 @@ export const getDayBookendHotels = <A extends StayRange>(
|
||||
return d ? getDayOrder(d, days) : dayOrd;
|
||||
};
|
||||
const checkIn = inRange.find((a) => a.start_day_id === day.id); // the hotel you arrive at tonight
|
||||
const sleptHere = inRange.find((a) => orderOf(a.start_day_id) < dayOrd); // the hotel you woke up in
|
||||
// Of the stays checked into on an earlier day, the latest is the one you woke up in: a
|
||||
// night away on a side trip while the first room stays booked sleeps in the side trip's
|
||||
// hotel, not in the one booked first. The earliest of them used to win, so the morning
|
||||
// after drove from the first hotel to the side trip's airport. Ties keep the list order.
|
||||
const before = inRange
|
||||
.filter((a) => orderOf(a.start_day_id) < dayOrd)
|
||||
.sort((a, b) => orderOf(b.start_day_id) - orderOf(a.start_day_id));
|
||||
const sleptHere = before[0];
|
||||
// Tonight's hotel without a check-in today: a stay that runs on past today, the latest
|
||||
// first, which brings the side trip back to the room that was kept.
|
||||
const continuing = before.find((a) => orderOf(a.end_day_id) > dayOrd);
|
||||
|
||||
return {
|
||||
morning: sleptHere ?? checkIn ?? inRange[0],
|
||||
evening: checkIn ?? sleptHere ?? inRange[0],
|
||||
evening: checkIn ?? continuing ?? sleptHere ?? inRange[0],
|
||||
// Provenance for the drawing consumers (map + sidebar). A hotel↔transport bookend
|
||||
// is only real when you actually used the hotel: morningIsSleptHere is true only
|
||||
// when you woke up there (not a check-in fallback on an arrival day), and
|
||||
// eveningIsOvernight is true only when you sleep there tonight (you check in today,
|
||||
// or an earlier stay continues past today). The optimizer keeps using the values.
|
||||
morningIsSleptHere: sleptHere != null,
|
||||
eveningIsOvernight: checkIn != null || (sleptHere != null && orderOf(sleptHere.end_day_id) > dayOrd),
|
||||
eveningIsOvernight: checkIn != null || continuing != null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3182,7 +3182,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.trip-bookings.summary':
|
||||
'يضم هذا التبويب كل ما حُجز للرحلة عدا المواصلات: أماكن الإقامة، وحجوزات الطاولات، والتذاكر، والجولات، ومواقف السيارات. كل حجز يظهر كبطاقة أو صف في القائمة أو شريط على المخطط الزمني، مع رمزه ومستنداته ومسافريه وتكلفته. انقر على حجز لفتح تفاصيله.',
|
||||
'help.ctx.trip-bookings.bullet.1':
|
||||
'يفتح «حجز يدوي» في الطرف الأيمن من الشريط «حجز جديد». يمكنك إنشاء ستة أنواع من الحجوزات: «الإقامة» و«مطعم» و«فعالية» و«جولة» و«موقف سيارات» و«أخرى». أما الرحلات الجوية والقطارات ووسائل النقل الأخرى فتجدها في تبويب «المواصلات»، ولا تظهر هنا أبدًا.',
|
||||
'يفتح «حجز يدوي» في الطرف الأيمن من الشريط «حجز جديد». يمكنك إنشاء ستة أنواع من الحجوزات: «الإقامة» و«مطعم» و«فعالية» و«جولة مع مرشد» و«موقف سيارات» و«أخرى». أما الرحلات الجوية والقطارات ووسائل النقل الأخرى فتجدها في تبويب «المواصلات»، ولا تظهر هنا أبدًا.',
|
||||
'help.ctx.trip-bookings.bullet.2':
|
||||
'أيقونة التنزيل قبل «حجز يدوي»، «استيراد تأكيدات الحجز»، ترفع التأكيدات ليقرأها TREK تلقائيًا: EML أو PDF أو PKPass أو HTML أو TXT، حتى خمسة ملفات بحد أقصى 10 ميغابايت لكل منها، والصور إذا كان نموذج الذكاء الاصطناعي يقرأ الصور. ولا تظهر الأيقونة إلا إذا كان الخادم قادرًا على قراءتها.',
|
||||
'help.ctx.trip-bookings.bullet.3':
|
||||
@@ -3221,7 +3221,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.create-booking.goal': 'أضف إلى الرحلة يدويًا مطعمًا أو فعالية أو جولة أو موقف سيارات أو أي شيء آخر.',
|
||||
'help.guide.create-booking.step.1': 'انقر «حجز يدوي» في طرف الشريط. تفتح نافذة «حجز جديد» وفي رأسها العنوان وشارتان.',
|
||||
'help.guide.create-booking.step.2':
|
||||
'انقر على تسمية النوع في الشريط العلوي، وتظهر فيها «أخرى» في الحجز الجديد، ثم اختر «نوع الحجز». ينشئ هذا التبويب ستة أنواع: «الإقامة» و«مطعم» و«فعالية» و«جولة» و«موقف سيارات» و«أخرى». ويتغير النموذج حسب اختيارك: «الإقامة» وحدها تستخدم نطاقًا من الأيام بدل التواريخ.',
|
||||
'انقر على تسمية النوع في الشريط العلوي، وتظهر فيها «أخرى» في الحجز الجديد، ثم اختر «نوع الحجز». ينشئ هذا التبويب ستة أنواع: «الإقامة» و«مطعم» و«فعالية» و«جولة مع مرشد» و«موقف سيارات» و«أخرى». ويتغير النموذج حسب اختيارك: «الإقامة» وحدها تستخدم نطاقًا من الأيام بدل التواريخ.',
|
||||
'help.guide.create-booking.step.3':
|
||||
'اكتب العنوان في الشريط العلوي، في الحقل فوق الشارات. إنه الحقل الإلزامي الوحيد، ويبقى «إضافة» رماديًا إلى أن تكتب فيه شيئًا.',
|
||||
'help.guide.create-booking.step.4':
|
||||
|
||||
@@ -60,7 +60,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.type.car': 'سيارة',
|
||||
'reservations.type.cruise': 'رحلة بحرية',
|
||||
'reservations.type.event': 'فعالية',
|
||||
'reservations.type.tour': 'جولة',
|
||||
'reservations.type.tour': 'جولة مع مرشد',
|
||||
'reservations.type.parking': 'موقف سيارات',
|
||||
'reservations.type.other': 'أخرى',
|
||||
'reservations.type.bus': 'حافلة',
|
||||
|
||||
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'عرض الإضافة',
|
||||
'tours.title': 'الجولات',
|
||||
'tours.empty.title': 'لا توجد جولات بعد',
|
||||
'tours.empty.body': 'خطّط لجولة أو استورد ملف GPX أو KML للبدء.',
|
||||
'tours.empty.body': 'خطّط لجولة أو استورد ملف GPX أو KML للبدء. حجزت جولة مع مرشد؟ مكانها في الحجوزات.',
|
||||
'tours.addToDay': 'إضافة إلى يوم',
|
||||
'tours.badge.wanderer': 'wanderer',
|
||||
'tours.badge.gpx': 'استيراد GPX',
|
||||
|
||||
@@ -3145,7 +3145,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.trip-bookings.summary':
|
||||
'Bu tabda nəqliyyatdan başqa səyahət üçün rezerv olunan hər şey saxlanılır: qalacağınız yerlər, masalar, biletlər, turlar və parkinq. Hər rezervasiya kodu, sənədləri, səyahətçiləri və xərci ilə birlikdə kart, siyahı sətri və ya zaman xəttində zolaq kimi görünür. Təfərrüatları açmaq üçün rezervasiyaya klikləyin.',
|
||||
'help.ctx.trip-bookings.bullet.1':
|
||||
'Panelin sağ ucundakı «Əl ilə rezervasiya et» «Yeni rezervasiya» pəncərəsini açır. Burada altı növ rezervasiya yaradılır: Yaşayış yeri, Restoran, Tədbir, Tur, Parkinq və Digər. Uçuşlar, qatarlar və digər nəqliyyat «Nəqliyyat» bölməsindədir və burada heç vaxt görünmür.',
|
||||
'Panelin sağ ucundakı «Əl ilə rezervasiya et» «Yeni rezervasiya» pəncərəsini açır. Burada altı növ rezervasiya yaradılır: Yaşayış yeri, Restoran, Tədbir, Bələdçili tur, Parkinq və Digər. Uçuşlar, qatarlar və digər nəqliyyat «Nəqliyyat» bölməsindədir və burada heç vaxt görünmür.',
|
||||
'help.ctx.trip-bookings.bullet.2':
|
||||
'«Əl ilə rezervasiya et» qarşısındakı endirmə işarəsi, «Rezervasiya təsdiqlərini idxal et», təsdiqləri yükləyir ki, TREK onları avtomatik oxusun: EML, PDF, PKPass, HTML və ya TXT, ən çoxu 10 MB-lıq beş fayl, AI modeli şəkilləri oxuya bilirsə, fotolar da. Bu işarə yalnız server onları oxuya bildikdə görünür.',
|
||||
'help.ctx.trip-bookings.bullet.3':
|
||||
@@ -3187,7 +3187,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.create-booking.step.1':
|
||||
'Panelin sağ ucundakı «Əl ilə rezervasiya et» düyməsinə klikləyin. «Yeni rezervasiya» açılır, yuxarıdakı başlıqda ad və iki nişan olur.',
|
||||
'help.guide.create-booking.step.2':
|
||||
'Yuxarı zolaqdakı növ yazısına klikləyin (yeni rezervasiyada orada «Digər» yazılır) və «Rezervasiya növü»nü seçin. Bu bölmədə altı növ yaradılır: «Yaşayış yeri», «Restoran», «Tədbir», «Tur», «Parkinq» və «Digər». Forma seçiminizə görə dəyişir: yalnız «Yaşayış yeri» tarixlər əvəzinə günlər aralığından istifadə edir.',
|
||||
'Yuxarı zolaqdakı növ yazısına klikləyin (yeni rezervasiyada orada «Digər» yazılır) və «Rezervasiya növü»nü seçin. Bu bölmədə altı növ yaradılır: «Yaşayış yeri», «Restoran», «Tədbir», «Bələdçili tur», «Parkinq» və «Digər». Forma seçiminizə görə dəyişir: yalnız «Yaşayış yeri» tarixlər əvəzinə günlər aralığından istifadə edir.',
|
||||
'help.guide.create-booking.step.3':
|
||||
'Başlığı yuxarıdakı zolağa, nişanların üstündəki xanaya yazın. Bu, yeganə məcburi xanadır və nəsə yazmayana qədər «Əlavə et» boz qalır.',
|
||||
'help.guide.create-booking.step.4':
|
||||
|
||||
@@ -72,7 +72,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.type.car': 'Avtomobil',
|
||||
'reservations.type.cruise': 'Kruiz',
|
||||
'reservations.type.event': 'Tədbir',
|
||||
'reservations.type.tour': 'Tur',
|
||||
'reservations.type.tour': 'Bələdçili tur',
|
||||
'reservations.type.parking': 'Parkinq',
|
||||
'reservations.type.other': 'Digər',
|
||||
'reservations.type.bus': 'Avtobus',
|
||||
|
||||
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Əlavə etmə görünüşü',
|
||||
'tours.title': 'Turlar',
|
||||
'tours.empty.title': 'Hələ tur yoxdur',
|
||||
'tours.empty.body': 'Başlamaq üçün tur planlaşdırın və ya GPX ya KML faylı idxal edin.',
|
||||
'tours.empty.body':
|
||||
'Başlamaq üçün tur planlaşdırın və ya GPX ya KML faylı idxal edin. Bələdçili tur sifariş etmisiniz? Onun yeri Rezervasiyalar bölməsidir.',
|
||||
'tours.addToDay': 'Günə əlavə et',
|
||||
'tours.badge.wanderer': 'wanderer',
|
||||
'tours.badge.gpx': 'GPX idxalı',
|
||||
|
||||
@@ -3419,7 +3419,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.trip-bookings.summary':
|
||||
'Esta aba reúne tudo o que foi reservado para a viagem, menos transporte: hospedagens, mesas, ingressos, passeios e estacionamentos. Cada reserva é um cartão, uma linha de lista ou uma barra na linha do tempo, com código, documentos, viajantes e custo. Clique em uma reserva para abrir os detalhes.',
|
||||
'help.ctx.trip-bookings.bullet.1':
|
||||
'“Reserva manual”, na ponta direita da barra, abre “Nova reserva”. Ela cria seis tipos de reserva: Hospedagem, Restaurante, Evento, Passeio, Estacionamento e Outro. Voos, trens e outros transportes ficam na aba Transportes e nunca aparecem aqui.',
|
||||
'“Reserva manual”, na ponta direita da barra, abre “Nova reserva”. Ela cria seis tipos de reserva: Hospedagem, Restaurante, Evento, Passeio guiado, Estacionamento e Outro. Voos, trens e outros transportes ficam na aba Transportes e nunca aparecem aqui.',
|
||||
'help.ctx.trip-bookings.bullet.2':
|
||||
'O ícone de download antes de “Reserva manual”, “Importar confirmações de reserva”, envia confirmações para o TREK ler automaticamente: EML, PDF, PKPass, HTML ou TXT, no máximo cinco arquivos de 10 MB, e fotos quando o modelo de IA lê imagens. O ícone só aparece quando o servidor consegue ler esses arquivos.',
|
||||
'help.ctx.trip-bookings.bullet.3':
|
||||
@@ -3461,7 +3461,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.create-booking.step.1':
|
||||
'Clique em “Reserva manual”, na ponta direita da barra. Abre “Nova reserva”, com o título e dois selos num cabeçalho no topo.',
|
||||
'help.guide.create-booking.step.2':
|
||||
'Clique no rótulo do tipo na faixa do topo, que mostra “Outro” numa reserva nova, e escolha o “Tipo de reserva”. Esta aba cria seis tipos: Hospedagem, Restaurante, Evento, Passeio, Estacionamento e Outro. O formulário muda conforme a escolha: só Hospedagem usa um intervalo de dias em vez de datas.',
|
||||
'Clique no rótulo do tipo na faixa do topo, que mostra “Outro” numa reserva nova, e escolha o “Tipo de reserva”. Esta aba cria seis tipos: Hospedagem, Restaurante, Evento, Passeio guiado, Estacionamento e Outro. O formulário muda conforme a escolha: só Hospedagem usa um intervalo de dias em vez de datas.',
|
||||
'help.guide.create-booking.step.3':
|
||||
'Digite o título na faixa do topo, o campo acima das etiquetas. É o único campo obrigatório, e “Adicionar” fica cinza até você preencher algo.',
|
||||
'help.guide.create-booking.step.4':
|
||||
|
||||
@@ -60,7 +60,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.type.car': 'Carro',
|
||||
'reservations.type.cruise': 'Cruzeiro',
|
||||
'reservations.type.event': 'Evento',
|
||||
'reservations.type.tour': 'Passeio',
|
||||
'reservations.type.tour': 'Passeio guiado',
|
||||
'reservations.type.parking': 'Estacionamento',
|
||||
'reservations.type.other': 'Outro',
|
||||
'reservations.type.bus': 'Ônibus',
|
||||
|
||||
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Adicionar visualização',
|
||||
'tours.title': 'Passeios',
|
||||
'tours.empty.title': 'Nenhum passeio ainda',
|
||||
'tours.empty.body': 'Planeje um passeio ou importe um arquivo GPX ou KML para começar.',
|
||||
'tours.empty.body':
|
||||
'Planeje um passeio ou importe um arquivo GPX ou KML para começar. Reservou um passeio guiado? Ele fica em Reservas.',
|
||||
'tours.addToDay': 'Adicionar a um dia',
|
||||
'tours.badge.wanderer': 'wanderer',
|
||||
'tours.badge.gpx': 'Importação GPX',
|
||||
|
||||
@@ -3467,7 +3467,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.trip-bookings.summary':
|
||||
"Aquesta pestanya conté tot el que s'ha reservat per al viatge excepte el transport: allotjaments, taules, entrades, excursions i aparcaments. Cada reserva és una targeta, una fila de la llista o una barra de la cronologia, amb el seu codi, els documents, els viatgers i el cost. Fes clic a una reserva per obrir-ne els detalls.",
|
||||
'help.ctx.trip-bookings.bullet.1':
|
||||
'«Reserva manual», a l’extrem dret de la barra, obre «Reserva nova». Pots crear sis tipus de reserves: «Allotjament», «Restaurant», «Esdeveniment», «Excursió», «Aparcament» i «Altres». Els vols, els trens i la resta de transports són a la pestanya «Transports» i aquí no apareixen mai.',
|
||||
'«Reserva manual», a l’extrem dret de la barra, obre «Reserva nova». Pots crear sis tipus de reserves: «Allotjament», «Restaurant», «Esdeveniment», «Visita guiada», «Aparcament» i «Altres». Els vols, els trens i la resta de transports són a la pestanya «Transports» i aquí no apareixen mai.',
|
||||
'help.ctx.trip-bookings.bullet.2':
|
||||
"La icona de baixada de davant de «Reserva manual», «Importa confirmacions de reserva», puja confirmacions perquè TREK les llegeixi automàticament: EML, PDF, PKPass, HTML o TXT, fins a cinc fitxers de 10 MB com a màxim, i fotos si el model d'IA llegeix imatges. La icona només apareix si el servidor les pot llegir.",
|
||||
'help.ctx.trip-bookings.bullet.3':
|
||||
@@ -3509,7 +3509,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.create-booking.step.1':
|
||||
"Fes clic a «Reserva manual», a l'extrem dret de la barra. S'obre «Reserva nova», amb el títol i dues píndoles a la capçalera.",
|
||||
'help.guide.create-booking.step.2':
|
||||
'Fes clic a l’etiqueta del tipus a la franja superior, que en una reserva nova diu «Altres», i tria el «Tipus de reserva». Des d’aquesta pestanya pots crear sis tipus: «Allotjament», «Restaurant», «Esdeveniment», «Excursió», «Aparcament» i «Altres». El formulari canvia segons el que triïs: només «Allotjament» fa servir un interval de dies en lloc de dates.',
|
||||
'Fes clic a l’etiqueta del tipus a la franja superior, que en una reserva nova diu «Altres», i tria el «Tipus de reserva». Des d’aquesta pestanya pots crear sis tipus: «Allotjament», «Restaurant», «Esdeveniment», «Visita guiada», «Aparcament» i «Altres». El formulari canvia segons el que triïs: només «Allotjament» fa servir un interval de dies en lloc de dates.',
|
||||
'help.guide.create-booking.step.3':
|
||||
'Escriu el títol a la franja superior, al camp que hi ha sobre les etiquetes. És l’únic camp obligatori, i «Afegir» està desactivat fins que hi escriguis alguna cosa.',
|
||||
'help.guide.create-booking.step.4':
|
||||
|
||||
@@ -29,7 +29,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.type.car': 'Cotxe',
|
||||
'reservations.type.cruise': 'Creuer',
|
||||
'reservations.type.event': 'Esdeveniment',
|
||||
'reservations.type.tour': 'Excursió',
|
||||
'reservations.type.tour': 'Visita guiada',
|
||||
'reservations.type.parking': 'Aparcament',
|
||||
'reservations.type.other': 'Altres',
|
||||
'reservations.type.bus': 'Autobús',
|
||||
|
||||
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Afegeix una vista',
|
||||
'tours.title': 'Excursions',
|
||||
'tours.empty.title': 'Encara no hi ha excursions',
|
||||
'tours.empty.body': 'Planifica una excursió o importa un fitxer GPX o KML per començar.',
|
||||
'tours.empty.body':
|
||||
'Planifica una excursió o importa un fitxer GPX o KML per començar. Has reservat una visita guiada? Va a Reserves.',
|
||||
'tours.addToDay': 'Afegeix a un dia',
|
||||
'tours.badge.wanderer': 'wanderer',
|
||||
'tours.badge.gpx': 'Importació GPX',
|
||||
|
||||
@@ -3345,7 +3345,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.trip-bookings.summary':
|
||||
'Tato karta obsahuje vše, co je pro cestu zarezervované, kromě dopravy: ubytování, stoly, vstupenky, prohlídky a parkování. Každá rezervace je kartou, řádkem seznamu nebo pruhem na časové ose s kódem, dokumenty, cestujícími a cenou. Kliknutím na rezervaci otevřete její podrobnosti.',
|
||||
'help.ctx.trip-bookings.bullet.1':
|
||||
'„Ruční rezervace“ na pravém konci lišty otevře „Nová rezervace“. Vytvoříte tak šest druhů rezervací: Ubytování, Restaurace, Událost, Prohlídka, Parkování a Ostatní. Lety, vlaky a další doprava jsou na kartě „Doprava“ a tady se nikdy nezobrazí.',
|
||||
'„Ruční rezervace“ na pravém konci lišty otevře „Nová rezervace“. Vytvoříte tak šest druhů rezervací: Ubytování, Restaurace, Událost, Prohlídka s průvodcem, Parkování a Ostatní. Lety, vlaky a další doprava jsou na kartě „Doprava“ a tady se nikdy nezobrazí.',
|
||||
'help.ctx.trip-bookings.bullet.2':
|
||||
'Ikona stažení před „Ruční rezervace“, „Importovat potvrzení rezervace“, nahraje potvrzení, aby je TREK mohl automaticky přečíst: EML, PDF, PKPass, HTML nebo TXT, nejvýše pět souborů po 10 MB, a fotky, pokud model AI umí číst obrázky. Ikona se zobrazuje, jen když je server umí číst.',
|
||||
'help.ctx.trip-bookings.bullet.3':
|
||||
@@ -3387,7 +3387,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.create-booking.step.1':
|
||||
'Klikněte na „Ruční rezervace“ na pravém konci lišty. Otevře se „Nová rezervace“ se záhlavím nahoře, ve kterém je název a dva štítky.',
|
||||
'help.guide.create-booking.step.2':
|
||||
'Klikněte na štítek typu v horním pruhu, u nové rezervace je na něm „Ostatní“, a vyberte „Typ rezervace“. Na této kartě vytvoříte šest typů: Ubytování, Restaurace, Událost, Prohlídka, Parkování a Ostatní. Formulář se podle volby změní: jen Ubytování používá místo dat rozsah dnů.',
|
||||
'Klikněte na štítek typu v horním pruhu, u nové rezervace je na něm „Ostatní“, a vyberte „Typ rezervace“. Na této kartě vytvoříte šest typů: Ubytování, Restaurace, Událost, Prohlídka s průvodcem, Parkování a Ostatní. Formulář se podle volby změní: jen Ubytování používá místo dat rozsah dnů.',
|
||||
'help.guide.create-booking.step.3':
|
||||
'Napište název do pruhu nahoře, do pole nad štítky. Je to jediné povinné pole a tlačítko „Přidat“ zůstane šedé, dokud něco nevyplníte.',
|
||||
'help.guide.create-booking.step.4':
|
||||
|
||||
@@ -60,7 +60,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.type.car': 'Auto',
|
||||
'reservations.type.cruise': 'Plavba',
|
||||
'reservations.type.event': 'Událost',
|
||||
'reservations.type.tour': 'Prohlídka',
|
||||
'reservations.type.tour': 'Prohlídka s průvodcem',
|
||||
'reservations.type.parking': 'Parkování',
|
||||
'reservations.type.other': 'Jiné',
|
||||
'reservations.type.bus': 'Autobus',
|
||||
|
||||
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Zobrazení pro přidávání',
|
||||
'tours.title': 'Výlety',
|
||||
'tours.empty.title': 'Zatím žádné výlety',
|
||||
'tours.empty.body': 'Začněte naplánováním výletu nebo importem souboru GPX či KML.',
|
||||
'tours.empty.body':
|
||||
'Začněte naplánováním výletu nebo importem souboru GPX či KML. Zarezervovali jste prohlídku s průvodcem? Ta patří do Rezervací.',
|
||||
'tours.addToDay': 'Přidat ke dni',
|
||||
'tours.badge.wanderer': 'wanderer',
|
||||
'tours.badge.gpx': 'Import GPX',
|
||||
|
||||
@@ -3492,7 +3492,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.trip-bookings.summary':
|
||||
'Hier steht alles, was für die Reise gebucht ist, außer Transporten: Unterkünfte, Tischreservierungen, Tickets, Touren und Parkplätze. Jede Buchung ist eine Karte, eine Listenzeile oder ein Balken im Zeitstrahl, mit Code, Dokumenten, Reisenden und Kosten. Klick auf eine Buchung, um ihre Details zu öffnen.',
|
||||
'help.ctx.trip-bookings.bullet.1':
|
||||
'„Manuelle Buchung“ rechts in der Leiste öffnet „Neue Buchung“. Damit legst du sechs Arten von Buchungen an: „Unterkunft“, „Restaurant“, „Veranstaltung“, „Tour“, „Parken“ und „Andere“. Flüge, Züge und andere Verkehrsmittel gehören in den Tab „Transporte“ und erscheinen hier nie.',
|
||||
'„Manuelle Buchung“ rechts in der Leiste öffnet „Neue Buchung“. Damit legst du sechs Arten von Buchungen an: „Unterkunft“, „Restaurant“, „Veranstaltung“, „Geführte Tour“, „Parken“ und „Andere“. Flüge, Züge und andere Verkehrsmittel gehören in den Tab „Transporte“ und erscheinen hier nie.',
|
||||
'help.ctx.trip-bookings.bullet.2':
|
||||
'Das Download-Symbol vor „Manuelle Buchung“, „Buchungsbestätigungen importieren“, gibt Bestätigungen an den Parser weiter: EML, PDF, PKPass, HTML oder TXT, höchstens fünf Dateien mit je bis zu 10 MB, und Fotos, wenn das KI-Modell Bilder lesen kann. Das Symbol erscheint nur, wenn der Server die Dateien auslesen kann.',
|
||||
'help.ctx.trip-bookings.bullet.3':
|
||||
@@ -3534,7 +3534,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.create-booking.step.1':
|
||||
'Klick rechts in der Leiste auf „Manuelle Buchung“. „Neue Buchung“ öffnet sich, oben mit dem Titel und zwei Labels in einer Kopfzeile.',
|
||||
'help.guide.create-booking.step.2':
|
||||
'Klick oben in der Kopfzeile auf das Typ-Label, das bei einer neuen Buchung „Andere“ zeigt, und wähle die „Art der Buchung“. In diesem Tab legst du sechs Typen an: Unterkunft, Restaurant, Veranstaltung, Tour, Parken und Andere. Das Formular passt sich deiner Wahl an: Nur „Unterkunft“ nutzt einen Zeitraum aus Tagen statt einzelner Daten.',
|
||||
'Klick oben in der Kopfzeile auf das Typ-Label, das bei einer neuen Buchung „Andere“ zeigt, und wähle die „Art der Buchung“. In diesem Tab legst du sechs Typen an: Unterkunft, Restaurant, Veranstaltung, Geführte Tour, Parken und Andere. Das Formular passt sich deiner Wahl an: Nur „Unterkunft“ nutzt einen Zeitraum aus Tagen statt einzelner Daten.',
|
||||
'help.guide.create-booking.step.3':
|
||||
'Gib den Titel oben in der Kopfzeile ein, im Feld über den Labels. Er ist das einzige Pflichtfeld, und „Hinzufügen“ bleibt ausgegraut, bis du etwas eingibst.',
|
||||
'help.guide.create-booking.step.4':
|
||||
|
||||
@@ -63,7 +63,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.type.car': 'Auto',
|
||||
'reservations.type.cruise': 'Kreuzfahrt',
|
||||
'reservations.type.event': 'Veranstaltung',
|
||||
'reservations.type.tour': 'Tour', // same-as-en
|
||||
'reservations.type.tour': 'Geführte Tour',
|
||||
'reservations.type.parking': 'Parkplatz',
|
||||
'reservations.type.other': 'Sonstiges',
|
||||
'reservations.type.bus': 'Bus', // same-as-en
|
||||
|
||||
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Ansicht zum Hinzufügen',
|
||||
'tours.title': 'Touren',
|
||||
'tours.empty.title': 'Noch keine Touren',
|
||||
'tours.empty.body': 'Plane eine Tour oder importiere eine GPX- oder KML-Datei.',
|
||||
'tours.empty.body':
|
||||
'Plane eine Tour oder importiere eine GPX- oder KML-Datei. Eine geführte Tour gebucht? Die gehört unter Buchungen.',
|
||||
'tours.addToDay': 'Zum Tag hinzufügen',
|
||||
'tours.badge.wanderer': 'wanderer',
|
||||
'tours.badge.gpx': 'GPX-Import',
|
||||
|
||||
@@ -3627,7 +3627,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.trip-bookings.summary':
|
||||
'This tab holds everything booked for the trip except transport: places to stay, tables, tickets, tours and parking. Every booking is a card, a list row or a bar on the timeline, with its code, its documents, its travellers and its cost. Click a booking to open its details.',
|
||||
'help.ctx.trip-bookings.bullet.1':
|
||||
'Manual Booking at the right end of the bar opens New Reservation. It creates six kinds of bookings: Accommodation, Restaurant, Event, Tour, Parking and Other. Flights, trains and other transport are on the Transports tab and never appear here.',
|
||||
'Manual Booking at the right end of the bar opens New Reservation. It creates six kinds of bookings: Accommodation, Restaurant, Event, Guided tour, Parking and Other. Flights, trains and other transport are on the Transports tab and never appear here.',
|
||||
'help.ctx.trip-bookings.bullet.2':
|
||||
'The download icon before Manual Booking, Import booking confirmations, uploads confirmations so TREK can read them automatically: EML, PDF, PKPass, HTML or TXT, five files of 10 MB at most, and photos when the AI model reads pictures. The icon is only there when the server can read them.',
|
||||
'help.ctx.trip-bookings.bullet.3':
|
||||
@@ -3684,7 +3684,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.create-booking.step.1':
|
||||
'Click Manual Booking at the right end of the bar. New Reservation opens, with the title and two pills in a header at the top.',
|
||||
'help.guide.create-booking.step.2':
|
||||
'Click the type label in the top band, which reads Other on a new booking, and pick the Booking Type. This tab creates six types: Accommodation, Restaurant, Event, Tour, Parking and Other. The form changes with your choice: only Accommodation uses a range of days instead of dates.',
|
||||
'Click the type label in the top band, which reads Other on a new booking, and pick the Booking Type. This tab creates six types: Accommodation, Restaurant, Event, Guided tour, Parking and Other. The form changes with your choice: only Accommodation uses a range of days instead of dates.',
|
||||
'help.guide.create-booking.step.3':
|
||||
'Type the title into the band at the top, the field above the pills. It is the only required field, and Add stays greyed out until you enter something.',
|
||||
'help.guide.create-booking.step.4':
|
||||
|
||||
@@ -62,7 +62,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.type.car': 'Car',
|
||||
'reservations.type.cruise': 'Cruise',
|
||||
'reservations.type.event': 'Event',
|
||||
'reservations.type.tour': 'Tour',
|
||||
'reservations.type.tour': 'Guided tour',
|
||||
'reservations.type.parking': 'Parking',
|
||||
'reservations.type.other': 'Other',
|
||||
'reservations.type.bus': 'Bus',
|
||||
|
||||
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Add view',
|
||||
'tours.title': 'Tours',
|
||||
'tours.empty.title': 'No tours yet',
|
||||
'tours.empty.body': 'Plan a tour or import a GPX or KML file to get started.',
|
||||
'tours.empty.body':
|
||||
'Plan a tour or import a GPX or KML file to get started. Booked a guided tour? That goes under Bookings.',
|
||||
'tours.addToDay': 'Add to day',
|
||||
'tours.badge.wanderer': 'wanderer',
|
||||
'tours.badge.gpx': 'GPX import',
|
||||
|
||||
@@ -3477,7 +3477,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.trip-bookings.summary':
|
||||
'Esta pestaña recoge todo lo reservado para el viaje salvo el transporte: alojamientos, mesas, entradas, visitas guiadas y aparcamientos. Cada reserva es una tarjeta, una fila de lista o una barra en la cronología, con su código, sus documentos, sus viajeros y su coste. Haz clic en una reserva para ver sus detalles.',
|
||||
'help.ctx.trip-bookings.bullet.1':
|
||||
'«Reserva manual», en el extremo derecho de la barra, abre «Nueva reserva». Permite crear seis tipos de reservas: «Alojamiento», «Restaurante», «Evento», «Excursión», «Aparcamiento» y «Otros». Los vuelos, trenes y demás transportes están en la pestaña «Transportes» y nunca aparecen aquí.',
|
||||
'«Reserva manual», en el extremo derecho de la barra, abre «Nueva reserva». Permite crear seis tipos de reservas: «Alojamiento», «Restaurante», «Evento», «Visita guiada», «Aparcamiento» y «Otros». Los vuelos, trenes y demás transportes están en la pestaña «Transportes» y nunca aparecen aquí.',
|
||||
'help.ctx.trip-bookings.bullet.2':
|
||||
'El icono de descarga delante de «Reserva manual», «Importar confirmaciones de reserva», sube confirmaciones para que TREK las lea automáticamente: EML, PDF, PKPass, HTML o TXT, hasta cinco archivos de 10 MB como máximo, y fotos si el modelo de IA puede leer imágenes. El icono solo aparece si el servidor puede leerlas.',
|
||||
'help.ctx.trip-bookings.bullet.3':
|
||||
@@ -3519,7 +3519,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.create-booking.step.1':
|
||||
'Haz clic en «Reserva manual», en el extremo derecho de la barra. Se abre «Nueva reserva», con el título y dos etiquetas en una cabecera arriba del todo.',
|
||||
'help.guide.create-booking.step.2':
|
||||
'Haz clic en la etiqueta del tipo, en la franja superior, que en una reserva nueva pone «Otros», y elige el «Tipo de reserva». Desde esta pestaña se crean seis tipos: «Alojamiento», «Restaurante», «Evento», «Excursión», «Aparcamiento» y «Otros». El formulario cambia según lo que elijas: solo «Alojamiento» usa un rango de días en lugar de fechas.',
|
||||
'Haz clic en la etiqueta del tipo, en la franja superior, que en una reserva nueva pone «Otros», y elige el «Tipo de reserva». Desde esta pestaña se crean seis tipos: «Alojamiento», «Restaurante», «Evento», «Visita guiada», «Aparcamiento» y «Otros». El formulario cambia según lo que elijas: solo «Alojamiento» usa un rango de días en lugar de fechas.',
|
||||
'help.guide.create-booking.step.3':
|
||||
'Escribe el título en la franja superior, el campo que hay encima de las etiquetas. Es el único campo obligatorio, y Añadir sigue en gris hasta que escribas algo.',
|
||||
'help.guide.create-booking.step.4':
|
||||
|
||||
@@ -29,7 +29,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.type.car': 'Coche',
|
||||
'reservations.type.cruise': 'Crucero',
|
||||
'reservations.type.event': 'Evento',
|
||||
'reservations.type.tour': 'Excursión',
|
||||
'reservations.type.tour': 'Visita guiada',
|
||||
'reservations.type.parking': 'Aparcamiento',
|
||||
'reservations.type.other': 'Otro',
|
||||
'reservations.type.bus': 'Autobús',
|
||||
|
||||
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Añadir vista',
|
||||
'tours.title': 'Excursiones',
|
||||
'tours.empty.title': 'Aún no hay excursiones',
|
||||
'tours.empty.body': 'Planifica una excursión o importa un archivo GPX o KML para empezar.',
|
||||
'tours.empty.body':
|
||||
'Planifica una excursión o importa un archivo GPX o KML para empezar. ¿Has reservado una visita guiada? Va en Reservas.',
|
||||
'tours.addToDay': 'Añadir a un día',
|
||||
'tours.badge.wanderer': 'wanderer',
|
||||
'tours.badge.gpx': 'Importación GPX',
|
||||
|
||||
@@ -3066,7 +3066,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.trip-bookings.summary':
|
||||
'Sellel vahekaardil on kõik reisi jaoks broneeritu peale transpordi: majutus, lauad, piletid, ekskursioonid ja parkimine. Iga broneering on kaart, loendi rida või riba ajajoonel koos koodi, dokumentide, reisijate ja maksumusega. Üksikasjade avamiseks klõpsa broneeringul.',
|
||||
'help.ctx.trip-bookings.bullet.1':
|
||||
'Riba paremas otsas olev „Käsitsi broneering“ avab akna „Uus broneering“. Sellega saab luua kuut tüüpi broneeringuid: „Majutus“, „Restoran“, „Üritus“, „Ekskursioon“, „Parkimine“ ja „Muu“. Lennud, rongid ja muu transport on vahekaardil „Transport“ ega ilmu kunagi siia.',
|
||||
'Riba paremas otsas olev „Käsitsi broneering“ avab akna „Uus broneering“. Sellega saab luua kuut tüüpi broneeringuid: „Majutus“, „Restoran“, „Üritus“, „Giidiga ekskursioon“, „Parkimine“ ja „Muu“. Lennud, rongid ja muu transport on vahekaardil „Transport“ ega ilmu kunagi siia.',
|
||||
'help.ctx.trip-bookings.bullet.2':
|
||||
'Nupu „Käsitsi broneering“ ees olev allalaadimisikoon „Impordi broneeringukinnitused“ laadib kinnitused üles, et TREK saaks need automaatselt läbi lugeda: EML, PDF, PKPass, HTML või TXT, kuni viis faili, igaüks kuni 10 MB, ning fotod, kui tehisintellekti mudel oskab pilte lugeda. Ikoon on olemas ainult siis, kui server oskab neid lugeda.',
|
||||
'help.ctx.trip-bookings.bullet.3':
|
||||
@@ -3107,7 +3107,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.create-booking.step.1':
|
||||
'Klõpsa riba paremas otsas „Käsitsi broneering“. Avaneb „Uus broneering“, mille ülaosas on päis pealkirja ja kahe märgisega.',
|
||||
'help.guide.create-booking.step.2':
|
||||
'Klõpsa ülemisel ribal tüübisildil, millel on uue broneeringu puhul kirjas „Muu“, ja vali „Broneeringu tüüp“. Sellel vahekaardil saab luua kuut tüüpi: „Majutus“, „Restoran“, „Üritus“, „Ekskursioon“, „Parkimine“ ja „Muu“. Vorm muutub vastavalt valikule: ainult „Majutus“ kasutab kuupäevade asemel päevade vahemikku.',
|
||||
'Klõpsa ülemisel ribal tüübisildil, millel on uue broneeringu puhul kirjas „Muu“, ja vali „Broneeringu tüüp“. Sellel vahekaardil saab luua kuut tüüpi: „Majutus“, „Restoran“, „Üritus“, „Giidiga ekskursioon“, „Parkimine“ ja „Muu“. Vorm muutub vastavalt valikule: ainult „Majutus“ kasutab kuupäevade asemel päevade vahemikku.',
|
||||
'help.guide.create-booking.step.3':
|
||||
'Kirjuta pealkiri ülemisele ribale, kiipide kohal olevale väljale. See on ainus kohustuslik väli ja „Lisa“ jääb halliks, kuni oled midagi sisestanud.',
|
||||
'help.guide.create-booking.step.4':
|
||||
|
||||
@@ -62,7 +62,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.type.car': 'Auto',
|
||||
'reservations.type.cruise': 'Kruiis',
|
||||
'reservations.type.event': 'Üritus',
|
||||
'reservations.type.tour': 'Ekskursioon',
|
||||
'reservations.type.tour': 'Giidiga ekskursioon',
|
||||
'reservations.type.parking': 'Parkimine',
|
||||
'reservations.type.other': 'Muu',
|
||||
'reservations.type.bus': 'Buss',
|
||||
|
||||
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Lisamise vaade',
|
||||
'tours.title': 'Retked',
|
||||
'tours.empty.title': 'Retki veel pole',
|
||||
'tours.empty.body': 'Alustamiseks planeeri retk või impordi GPX- või KML-fail.',
|
||||
'tours.empty.body':
|
||||
'Alustamiseks planeeri retk või impordi GPX- või KML-fail. Broneerisid giidiga ekskursiooni? See käib Broneeringute alla.',
|
||||
'tours.addToDay': 'Lisa päevale',
|
||||
'tours.badge.wanderer': 'wanderer',
|
||||
'tours.badge.gpx': 'GPX-import',
|
||||
|
||||
@@ -3524,7 +3524,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.trip-bookings.summary':
|
||||
'Cet onglet regroupe tout ce qui est réservé pour le voyage, hors transports : hébergements, tables, billets, visites et parkings. Chaque réservation est une carte, une ligne de liste ou une barre sur la chronologie, avec son code, ses documents, ses voyageurs et son coût. Cliquez sur une réservation pour ouvrir ses détails.',
|
||||
'help.ctx.trip-bookings.bullet.1':
|
||||
'« Réservation manuelle », tout à droite de la barre, ouvre « Nouvelle réservation ». Elle permet de créer six types de réservation : « Hébergement », Restaurant, « Événement », « Visite », Parking et « Autre ». Les vols, trains et autres transports se trouvent dans l’onglet « Transports » et n’apparaissent jamais ici.',
|
||||
'« Réservation manuelle », tout à droite de la barre, ouvre « Nouvelle réservation ». Elle permet de créer six types de réservation : « Hébergement », Restaurant, « Événement », « Visite guidée », Parking et « Autre ». Les vols, trains et autres transports se trouvent dans l’onglet « Transports » et n’apparaissent jamais ici.',
|
||||
'help.ctx.trip-bookings.bullet.2':
|
||||
'L’icône de téléchargement devant « Réservation manuelle », « Importer des confirmations de réservation », importe des confirmations que TREK lit automatiquement : EML, PDF, PKPass, HTML ou TXT, cinq fichiers de 10 Mo maximum, ainsi que des photos si le modèle d’IA sait lire les images. L’icône n’apparaît que si le serveur peut les lire.',
|
||||
'help.ctx.trip-bookings.bullet.3':
|
||||
@@ -3566,7 +3566,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.create-booking.step.1':
|
||||
"Cliquez sur « Réservation manuelle » à droite de la barre. « Nouvelle réservation » s'ouvre, avec le titre et deux pastilles dans un en-tête en haut.",
|
||||
'help.guide.create-booking.step.2':
|
||||
"Cliquez sur l'étiquette de type dans le bandeau du haut, qui indique « Autre » sur une nouvelle réservation, et choisissez le « Type de réservation ». Cet onglet crée six types : « Hébergement », « Restaurant », « Événement », « Visite », « Parking » et « Autre ». Le formulaire s'adapte à votre choix : seul « Hébergement » utilise une plage de jours au lieu de dates.",
|
||||
"Cliquez sur l'étiquette de type dans le bandeau du haut, qui indique « Autre » sur une nouvelle réservation, et choisissez le « Type de réservation ». Cet onglet crée six types : « Hébergement », « Restaurant », « Événement », « Visite guidée », « Parking » et « Autre ». Le formulaire s'adapte à votre choix : seul « Hébergement » utilise une plage de jours au lieu de dates.",
|
||||
'help.guide.create-booking.step.3':
|
||||
"Saisissez le titre dans le bandeau du haut, le champ situé au-dessus des pastilles. C'est le seul champ obligatoire, et « Ajouter » reste grisé tant qu'il est vide.",
|
||||
'help.guide.create-booking.step.4':
|
||||
|
||||
@@ -62,7 +62,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.type.car': 'Voiture',
|
||||
'reservations.type.cruise': 'Croisière',
|
||||
'reservations.type.event': 'Événement',
|
||||
'reservations.type.tour': 'Visite',
|
||||
'reservations.type.tour': 'Visite guidée',
|
||||
'reservations.type.parking': 'Parking', // same-as-en
|
||||
'reservations.type.other': 'Autre',
|
||||
'reservations.type.bus': 'Bus', // same-as-en
|
||||
|
||||
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Ajouter une vue',
|
||||
'tours.title': 'Sorties',
|
||||
'tours.empty.title': 'Aucune sortie pour le moment',
|
||||
'tours.empty.body': 'Planifiez une sortie ou importez un fichier GPX ou KML pour commencer.',
|
||||
'tours.empty.body':
|
||||
'Planifiez une sortie ou importez un fichier GPX ou KML pour commencer. Vous avez réservé une visite guidée ? Elle va dans Réservations.',
|
||||
'tours.addToDay': 'Ajouter à une journée',
|
||||
'tours.badge.wanderer': 'wanderer',
|
||||
'tours.badge.gpx': 'Import GPX',
|
||||
|
||||
@@ -3513,7 +3513,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.trip-bookings.summary':
|
||||
'Αυτή η καρτέλα περιέχει όλες τις κρατήσεις του ταξιδιού εκτός από τις μεταφορές: καταλύματα, τραπέζια, εισιτήρια, περιηγήσεις και στάθμευση. Κάθε κράτηση είναι μια κάρτα, μια γραμμή σε λίστα ή μια μπάρα στο χρονολόγιο, με τον κωδικό της, τα έγγραφά της, τους ταξιδιώτες της και το κόστος της. Πατήστε μια κράτηση για να ανοίξουν οι λεπτομέρειές της.',
|
||||
'help.ctx.trip-bookings.bullet.1':
|
||||
'Το «Χειροκίνητη Κράτηση» στο δεξί άκρο της γραμμής ανοίγει τη «Νέα Κράτηση». Δημιουργεί έξι είδη κρατήσεων: «Διαμονή», «Εστιατόριο», «Εκδήλωση», «Περιήγηση», «Στάθμευση» και «Άλλο». Πτήσεις, τρένα και άλλες μεταφορές βρίσκονται στην καρτέλα «Μεταφορές» και δεν εμφανίζονται ποτέ εδώ.',
|
||||
'Το «Χειροκίνητη Κράτηση» στο δεξί άκρο της γραμμής ανοίγει τη «Νέα Κράτηση». Δημιουργεί έξι είδη κρατήσεων: «Διαμονή», «Εστιατόριο», «Εκδήλωση», «Ξενάγηση», «Στάθμευση» και «Άλλο». Πτήσεις, τρένα και άλλες μεταφορές βρίσκονται στην καρτέλα «Μεταφορές» και δεν εμφανίζονται ποτέ εδώ.',
|
||||
'help.ctx.trip-bookings.bullet.2':
|
||||
'Το εικονίδιο λήψης πριν από το «Χειροκίνητη Κράτηση», «Εισαγωγή επιβεβαιώσεων κράτησης», ανεβάζει επιβεβαιώσεις για να τις διαβάσει αυτόματα το TREK: EML, PDF, PKPass, HTML ή TXT, έως πέντε αρχεία των 10 MB, καθώς και φωτογραφίες όταν το μοντέλο AI διαβάζει εικόνες. Το εικονίδιο εμφανίζεται μόνο αν ο διακομιστής μπορεί να τις διαβάσει.',
|
||||
'help.ctx.trip-bookings.bullet.3':
|
||||
@@ -3555,7 +3555,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.create-booking.step.1':
|
||||
'Κάντε κλικ στη «Χειροκίνητη Κράτηση» στη δεξιά άκρη της γραμμής. Ανοίγει η «Νέα Κράτηση», με τον τίτλο και δύο ενδείξεις σε μια κεφαλίδα στην κορυφή.',
|
||||
'help.guide.create-booking.step.2':
|
||||
'Κάντε κλικ στην ετικέτα τύπου στην επάνω λωρίδα, που σε νέα κράτηση γράφει «Άλλο», και διαλέξτε τον «Τύπο Κράτησης». Αυτή η καρτέλα δημιουργεί έξι τύπους: «Διαμονή», «Εστιατόριο», «Εκδήλωση», «Περιήγηση», «Στάθμευση» και «Άλλο». Η φόρμα αλλάζει ανάλογα με την επιλογή: μόνο η «Διαμονή» χρησιμοποιεί εύρος ημερών αντί για ημερομηνίες.',
|
||||
'Κάντε κλικ στην ετικέτα τύπου στην επάνω λωρίδα, που σε νέα κράτηση γράφει «Άλλο», και διαλέξτε τον «Τύπο Κράτησης». Αυτή η καρτέλα δημιουργεί έξι τύπους: «Διαμονή», «Εστιατόριο», «Εκδήλωση», «Ξενάγηση», «Στάθμευση» και «Άλλο». Η φόρμα αλλάζει ανάλογα με την επιλογή: μόνο η «Διαμονή» χρησιμοποιεί εύρος ημερών αντί για ημερομηνίες.',
|
||||
'help.guide.create-booking.step.3':
|
||||
'Γράψτε τον τίτλο στην επάνω λωρίδα, στο πεδίο πάνω από τις ενδείξεις. Είναι το μόνο υποχρεωτικό πεδίο, και η «Προσθήκη» μένει γκριζαρισμένη μέχρι να γράψετε κάτι.',
|
||||
'help.guide.create-booking.step.4':
|
||||
|
||||
@@ -62,7 +62,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.type.car': 'Αυτοκίνητο',
|
||||
'reservations.type.cruise': 'Κρουαζιέρα',
|
||||
'reservations.type.event': 'Εκδήλωση',
|
||||
'reservations.type.tour': 'Περιήγηση',
|
||||
'reservations.type.tour': 'Ξενάγηση',
|
||||
'reservations.type.parking': 'Στάθμευση',
|
||||
'reservations.type.other': 'Άλλο',
|
||||
'reservations.type.bus': 'Λεωφορείο',
|
||||
|
||||
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Προβολή προσθήκης',
|
||||
'tours.title': 'Εκδρομές',
|
||||
'tours.empty.title': 'Δεν υπάρχουν εκδρομές ακόμη',
|
||||
'tours.empty.body': 'Σχεδιάστε μια εκδρομή ή εισαγάγετε ένα αρχείο GPX ή KML για να ξεκινήσετε.',
|
||||
'tours.empty.body':
|
||||
'Σχεδιάστε μια εκδρομή ή εισαγάγετε ένα αρχείο GPX ή KML για να ξεκινήσετε. Κλείσατε ξενάγηση; Αυτή μπαίνει στις Κρατήσεις.',
|
||||
'tours.addToDay': 'Προσθήκη σε ημέρα',
|
||||
'tours.badge.wanderer': 'wanderer',
|
||||
'tours.badge.gpx': 'Εισαγωγή GPX',
|
||||
|
||||
@@ -3362,7 +3362,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.trip-bookings.summary':
|
||||
'הלשונית הזו מכילה את כל מה שהוזמן לטיול מלבד תחבורה: מקומות לינה, שולחנות במסעדות, כרטיסים, סיורים וחניה. כל הזמנה מופיעה ככרטיס, כשורה ברשימה או כפס בציר הזמן, עם הקוד, המסמכים, הנוסעים והעלות שלה. לחצו על הזמנה כדי לפתוח את הפרטים שלה.',
|
||||
'help.ctx.trip-bookings.bullet.1':
|
||||
'״הזמנה ידנית״ בקצה הימני של הסרגל פותחת את ״הזמנה חדשה״. אפשר ליצור בה שישה סוגי הזמנות: לינה, מסעדה, אירוע, סיור, חניה ואחר. טיסות, רכבות ושאר אמצעי התחבורה נמצאים בלשונית ״תחבורה״ ואינם מופיעים כאן.',
|
||||
'״הזמנה ידנית״ בקצה הימני של הסרגל פותחת את ״הזמנה חדשה״. אפשר ליצור בה שישה סוגי הזמנות: לינה, מסעדה, אירוע, סיור מודרך, חניה ואחר. טיסות, רכבות ושאר אמצעי התחבורה נמצאים בלשונית ״תחבורה״ ואינם מופיעים כאן.',
|
||||
'help.ctx.trip-bookings.bullet.2':
|
||||
'סמל ההורדה שלפני ״הזמנה ידנית״, ״ייבוא אישורי הזמנה״, מעלה אישורים כדי ש-TREK יוכל לקרוא אותם אוטומטית: EML, PDF, PKPass, HTML או TXT, עד חמישה קבצים בגודל של עד 10 MB כל אחד, וגם תמונות כשמודל ה-AI יכול לקרוא אותן. הסמל מופיע רק כשהשרת יכול לקרוא את האישורים.',
|
||||
'help.ctx.trip-bookings.bullet.3':
|
||||
@@ -3417,7 +3417,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.create-booking.step.1':
|
||||
'לחצו על ״הזמנה ידנית״ בקצה הימני של הסרגל. ״הזמנה חדשה״ נפתחת עם כותרת ושתי תגיות בחלק העליון.',
|
||||
'help.guide.create-booking.step.2':
|
||||
'לחצו על תגית הסוג בפס העליון, שמציגה ״אחר״ בהזמנה חדשה, ובחרו את סוג ההזמנה. בלשונית הזו אפשר ליצור שישה סוגים: לינה, מסעדה, אירוע, סיור, חניה ואחר. הטופס משתנה לפי הבחירה: רק ״לינה״ משתמשת בטווח ימים במקום בתאריכים.',
|
||||
'לחצו על תגית הסוג בפס העליון, שמציגה ״אחר״ בהזמנה חדשה, ובחרו את סוג ההזמנה. בלשונית הזו אפשר ליצור שישה סוגים: לינה, מסעדה, אירוע, סיור מודרך, חניה ואחר. הטופס משתנה לפי הבחירה: רק ״לינה״ משתמשת בטווח ימים במקום בתאריכים.',
|
||||
'help.guide.create-booking.step.3':
|
||||
'הקלידו את הכותרת בפס העליון, בשדה שמעל התגיות. זהו שדה החובה היחיד, והכפתור ״הוספה״ נשאר אפור עד שתזינו משהו.',
|
||||
'help.guide.create-booking.step.4':
|
||||
|
||||
@@ -62,7 +62,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.type.car': 'רכב',
|
||||
'reservations.type.cruise': 'שייט',
|
||||
'reservations.type.event': 'אירוע',
|
||||
'reservations.type.tour': 'סיור',
|
||||
'reservations.type.tour': 'סיור מודרך',
|
||||
'reservations.type.parking': 'חניה',
|
||||
'reservations.type.other': 'אחר',
|
||||
'reservations.type.bus': 'אוטובוס',
|
||||
|
||||
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'הוספת תצוגה',
|
||||
'tours.title': 'מסלולים',
|
||||
'tours.empty.title': 'עדיין אין מסלולים',
|
||||
'tours.empty.body': 'תכננו מסלול או ייבאו קובץ GPX או KML כדי להתחיל.',
|
||||
'tours.empty.body': 'תכננו מסלול או ייבאו קובץ GPX או KML כדי להתחיל. הזמנתם סיור מודרך? מקומו בהזמנות.',
|
||||
'tours.addToDay': 'הוספה ליום',
|
||||
'tours.badge.wanderer': 'wanderer',
|
||||
'tours.badge.gpx': 'ייבוא GPX',
|
||||
|
||||
@@ -3484,7 +3484,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.trip-bookings.summary':
|
||||
'Ezen a fülön minden olyan foglalás megtalálható, amely nem közlekedés: szállások, asztalfoglalások, jegyek, túrák és parkolás. Minden foglalás egy kártya, egy listasor vagy egy sáv az idővonalon, a kódjával, dokumentumaival, utazóival és költségével. A részletek megnyitásához kattints egy foglalásra.',
|
||||
'help.ctx.trip-bookings.bullet.1':
|
||||
'A sáv jobb végén lévő „Kézi foglalás” megnyitja az „Új foglalás” ablakot. Hatféle foglalást hozhatsz létre: „Szállás”, „Étterem”, „Esemény”, „Túra”, „Parkolás” és „Egyéb”. A repülőjáratok, vonatok és egyéb közlekedési eszközök a „Közlekedés” fülön vannak, és itt sosem jelennek meg.',
|
||||
'A sáv jobb végén lévő „Kézi foglalás” megnyitja az „Új foglalás” ablakot. Hatféle foglalást hozhatsz létre: „Szállás”, „Étterem”, „Esemény”, „Vezetett túra”, „Parkolás” és „Egyéb”. A repülőjáratok, vonatok és egyéb közlekedési eszközök a „Közlekedés” fülön vannak, és itt sosem jelennek meg.',
|
||||
'help.ctx.trip-bookings.bullet.2':
|
||||
'A „Kézi foglalás” előtti letöltés ikonnal („Foglalási visszaigazolások importálása”) visszaigazolásokat tölthetsz fel, amelyeket a TREK automatikusan feldolgoz: EML, PDF, PKPass, HTML vagy TXT, legfeljebb öt fájl, egyenként 10 MB-ig, valamint fotók, ha az AI-modell képeket is tud olvasni. Az ikon csak akkor jelenik meg, ha a szerver képes feldolgozni őket.',
|
||||
'help.ctx.trip-bookings.bullet.3':
|
||||
@@ -3526,7 +3526,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.create-booking.step.1':
|
||||
'Kattints a sáv jobb végén a „Kézi foglalás” gombra. Megnyílik az „Új foglalás” ablak, felül egy fejlécben a címmel és két címkével.',
|
||||
'help.guide.create-booking.step.2':
|
||||
'Kattints a felső sávban a típuscímkére (új foglalásnál „Egyéb” áll rajta), és válaszd ki a „Foglalás típusa” értékét. Ezen a fülön hat típust hozhatsz létre: „Szállás”, „Étterem”, „Esemény”, „Túra”, „Parkolás” és „Egyéb”. Az űrlap a választásodtól függően változik: csak a „Szállás” használ dátumok helyett naptartományt.',
|
||||
'Kattints a felső sávban a típuscímkére (új foglalásnál „Egyéb” áll rajta), és válaszd ki a „Foglalás típusa” értékét. Ezen a fülön hat típust hozhatsz létre: „Szállás”, „Étterem”, „Esemény”, „Vezetett túra”, „Parkolás” és „Egyéb”. Az űrlap a választásodtól függően változik: csak a „Szállás” használ dátumok helyett naptartományt.',
|
||||
'help.guide.create-booking.step.3':
|
||||
'Írd be a címet a felső sávba, a címkék fölötti mezőbe. Ez az egyetlen kötelező mező, és amíg nem írsz bele semmit, a „Hozzáadás” gomb szürke marad.',
|
||||
'help.guide.create-booking.step.4':
|
||||
|
||||
@@ -62,7 +62,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.type.car': 'Autó',
|
||||
'reservations.type.cruise': 'Hajóút',
|
||||
'reservations.type.event': 'Esemény',
|
||||
'reservations.type.tour': 'Túra',
|
||||
'reservations.type.tour': 'Vezetett túra',
|
||||
'reservations.type.parking': 'Parkolás',
|
||||
'reservations.type.other': 'Egyéb',
|
||||
'reservations.type.bus': 'Busz',
|
||||
|
||||
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Hozzáadási nézet',
|
||||
'tours.title': 'Túrák',
|
||||
'tours.empty.title': 'Még nincsenek túrák',
|
||||
'tours.empty.body': 'A kezdéshez tervezz egy túrát, vagy importálj egy GPX- vagy KML-fájlt.',
|
||||
'tours.empty.body':
|
||||
'A kezdéshez tervezz egy túrát, vagy importálj egy GPX- vagy KML-fájlt. Vezetett túrát foglaltál? Annak a Foglalások között a helye.',
|
||||
'tours.addToDay': 'Hozzáadás egy naphoz',
|
||||
'tours.badge.wanderer': 'wanderer',
|
||||
'tours.badge.gpx': 'GPX-import',
|
||||
|
||||
@@ -3432,7 +3432,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.trip-bookings.summary':
|
||||
'Tab ini berisi semua yang dipesan untuk perjalanan selain transportasi: penginapan, meja restoran, tiket, tur, dan parkir. Setiap pemesanan tampil sebagai kartu, baris daftar, atau bar di linimasa, lengkap dengan kode, dokumen, peserta, dan biayanya. Klik pemesanan untuk membuka detailnya.',
|
||||
'help.ctx.trip-bookings.bullet.1':
|
||||
'“Pemesanan Manual” di ujung kanan bar membuka “Reservasi Baru”. Di sana bisa dibuat enam jenis pemesanan: “Akomodasi”, “Restoran”, “Acara”, “Tur”, “Parkir”, dan “Lainnya”. Penerbangan, kereta, dan transportasi lainnya ada di tab “Transportasi” dan tidak pernah muncul di sini.',
|
||||
'“Pemesanan Manual” di ujung kanan bar membuka “Reservasi Baru”. Di sana bisa dibuat enam jenis pemesanan: “Akomodasi”, “Restoran”, “Acara”, “Tur berpemandu”, “Parkir”, dan “Lainnya”. Penerbangan, kereta, dan transportasi lainnya ada di tab “Transportasi” dan tidak pernah muncul di sini.',
|
||||
'help.ctx.trip-bookings.bullet.2':
|
||||
'Ikon unduh sebelum “Pemesanan Manual”, “Impor konfirmasi pemesanan”, mengunggah konfirmasi agar TREK bisa membacanya secara otomatis: EML, PDF, PKPass, HTML, atau TXT, maksimal lima file masing-masing 10 MB, serta foto jika model AI bisa membaca gambar. Ikon ini hanya ada jika server bisa membacanya.',
|
||||
'help.ctx.trip-bookings.bullet.3':
|
||||
@@ -3474,7 +3474,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.create-booking.step.1':
|
||||
'Klik “Pemesanan Manual” di ujung kanan bilah. “Reservasi Baru” terbuka, dengan judul dan dua label di header bagian atas.',
|
||||
'help.guide.create-booking.step.2':
|
||||
'Klik label jenis di pita atas, yang bertuliskan “Lainnya” pada pemesanan baru, lalu pilih “Jenis Pemesanan”. Tab ini bisa membuat enam jenis: “Akomodasi”, “Restoran”, “Acara”, “Tur”, “Parkir”, dan “Lainnya”. Formulir berubah sesuai pilihanmu: hanya “Akomodasi” yang memakai rentang hari, bukan tanggal.',
|
||||
'Klik label jenis di pita atas, yang bertuliskan “Lainnya” pada pemesanan baru, lalu pilih “Jenis Pemesanan”. Tab ini bisa membuat enam jenis: “Akomodasi”, “Restoran”, “Acara”, “Tur berpemandu”, “Parkir”, dan “Lainnya”. Formulir berubah sesuai pilihanmu: hanya “Akomodasi” yang memakai rentang hari, bukan tanggal.',
|
||||
'help.guide.create-booking.step.3':
|
||||
'Ketik judul di pita atas, yaitu kolom di atas label. Hanya kolom ini yang wajib diisi, dan “Tambah” tetap abu-abu sampai kamu mengisinya.',
|
||||
'help.guide.create-booking.step.4':
|
||||
|
||||
@@ -61,7 +61,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.type.car': 'Mobil',
|
||||
'reservations.type.cruise': 'Kapal Pesiar',
|
||||
'reservations.type.event': 'Acara',
|
||||
'reservations.type.tour': 'Tur',
|
||||
'reservations.type.tour': 'Tur berpemandu',
|
||||
'reservations.type.parking': 'Parkir',
|
||||
'reservations.type.other': 'Lainnya',
|
||||
'reservations.type.bus': 'Bus', // same-as-en
|
||||
|
||||
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Tampilan penambahan',
|
||||
'tours.title': 'Tur',
|
||||
'tours.empty.title': 'Belum ada tur',
|
||||
'tours.empty.body': 'Rencanakan tur atau impor berkas GPX atau KML untuk memulai.',
|
||||
'tours.empty.body':
|
||||
'Rencanakan tur atau impor berkas GPX atau KML untuk memulai. Sudah memesan tur berpemandu? Itu masuk ke Pemesanan.',
|
||||
'tours.addToDay': 'Tambahkan ke hari',
|
||||
'tours.badge.wanderer': 'wanderer',
|
||||
'tours.badge.gpx': 'Impor GPX',
|
||||
|
||||
@@ -3459,7 +3459,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.trip-bookings.summary':
|
||||
"Questa scheda contiene tutto ciò che è prenotato per il viaggio, tranne i trasporti: alloggi, tavoli, biglietti, tour e parcheggi. Ogni prenotazione è una scheda, una riga dell'elenco o una barra nella cronologia, con codice, documenti, viaggiatori e costo. Clicca su una prenotazione per aprirne i dettagli.",
|
||||
'help.ctx.trip-bookings.bullet.1':
|
||||
"«Prenotazione manuale», all'estremità destra della barra, apre «Nuova prenotazione». Crea sei tipi di prenotazione: «Alloggio», «Ristorante», «Evento», Tour, «Parcheggio» e «Altro». Voli, treni e altri trasporti si trovano nella scheda «Trasporti» e qui non compaiono mai.",
|
||||
"«Prenotazione manuale», all'estremità destra della barra, apre «Nuova prenotazione». Crea sei tipi di prenotazione: «Alloggio», «Ristorante», «Evento», Visita guidata, «Parcheggio» e «Altro». Voli, treni e altri trasporti si trovano nella scheda «Trasporti» e qui non compaiono mai.",
|
||||
'help.ctx.trip-bookings.bullet.2':
|
||||
"L'icona di download prima di «Prenotazione manuale», «Importa conferme di prenotazione», carica le conferme perché TREK le legga automaticamente: EML, PDF, PKPass, HTML o TXT, al massimo cinque file da 10 MB, e foto se il modello di IA legge le immagini. L'icona compare solo se il server è in grado di leggerle.",
|
||||
'help.ctx.trip-bookings.bullet.3':
|
||||
@@ -3501,7 +3501,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.create-booking.step.1':
|
||||
"Clicca «Prenotazione manuale» all'estremità destra della barra. Si apre «Nuova prenotazione», con il titolo e due etichette nell'intestazione in alto.",
|
||||
'help.guide.create-booking.step.2':
|
||||
"Clicca l'etichetta del tipo nella fascia in alto, che in una nuova prenotazione indica «Altro», e scegli il «Tipo di prenotazione». Da questa scheda puoi creare sei tipi: Alloggio, Ristorante, Evento, Tour, Parcheggio e Altro. Il modulo cambia in base alla scelta: solo «Alloggio» usa un intervallo di giorni invece delle date.",
|
||||
"Clicca l'etichetta del tipo nella fascia in alto, che in una nuova prenotazione indica «Altro», e scegli il «Tipo di prenotazione». Da questa scheda puoi creare sei tipi: Alloggio, Ristorante, Evento, Visita guidata, Parcheggio e Altro. Il modulo cambia in base alla scelta: solo «Alloggio» usa un intervallo di giorni invece delle date.",
|
||||
'help.guide.create-booking.step.3':
|
||||
"Scrivi il titolo nella fascia in alto, nel campo sopra le etichette. È l'unico campo obbligatorio e «Aggiungi» resta disattivato finché non inserisci qualcosa.",
|
||||
'help.guide.create-booking.step.4':
|
||||
|
||||
@@ -61,7 +61,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.type.car': 'Auto',
|
||||
'reservations.type.cruise': 'Crociera',
|
||||
'reservations.type.event': 'Evento',
|
||||
'reservations.type.tour': 'Tour', // same-as-en
|
||||
'reservations.type.tour': 'Visita guidata',
|
||||
'reservations.type.parking': 'Parcheggio',
|
||||
'reservations.type.other': 'Altro',
|
||||
'reservations.type.bus': 'Autobus',
|
||||
|
||||
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Aggiungi vista',
|
||||
'tours.title': 'Escursioni',
|
||||
'tours.empty.title': 'Nessuna escursione ancora',
|
||||
'tours.empty.body': "Pianifica un'escursione o importa un file GPX o KML per iniziare.",
|
||||
'tours.empty.body':
|
||||
"Pianifica un'escursione o importa un file GPX o KML per iniziare. Hai prenotato una visita guidata? Va in Prenotazioni.",
|
||||
'tours.addToDay': 'Aggiungi a un giorno',
|
||||
'tours.badge.wanderer': 'wanderer',
|
||||
'tours.badge.gpx': 'Importazione GPX',
|
||||
|
||||
@@ -3297,7 +3297,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.trip-bookings.summary':
|
||||
'このタブには、移動以外の旅行の予約がすべて入ります。宿泊、レストランの予約、チケット、ツアー、駐車場などです。予約はそれぞれカード、リストの行、タイムラインのバーで表示され、予約コード、書類、同行者、費用が含まれます。予約をクリックすると詳細が開きます。',
|
||||
'help.ctx.trip-bookings.bullet.1':
|
||||
'バーの右端の「手動予約」で「新しい予約」が開きます。作成できる予約は「宿泊」「レストラン」「イベント」「ツアー」「駐車場」「その他」の6種類です。飛行機、電車などの移動は「移動」タブにあり、ここには表示されません。',
|
||||
'バーの右端の「手動予約」で「新しい予約」が開きます。作成できる予約は「宿泊」「レストラン」「イベント」「ガイドツアー」「駐車場」「その他」の6種類です。飛行機、電車などの移動は「移動」タブにあり、ここには表示されません。',
|
||||
'help.ctx.trip-bookings.bullet.2':
|
||||
'「手動予約」の前にあるダウンロードアイコン「予約確認書のインポート」で確認書をアップロードすると、TREKが自動で読み取ります。対応形式はEML、PDF、PKPass、HTML、TXTで、1ファイル10 MBまで、最大5ファイルです。AIモデルが画像を読める場合は写真も使えます。このアイコンは、サーバーが読み取りに対応している場合だけ表示されます。',
|
||||
'help.ctx.trip-bookings.bullet.3':
|
||||
@@ -3337,7 +3337,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.create-booking.step.1':
|
||||
'バーの右端にある「手動予約」をクリックします。「新しい予約」が開き、上部のヘッダーにタイトルと2つのラベルが表示されます。',
|
||||
'help.guide.create-booking.step.2':
|
||||
'上部の帯にあるタイプのラベル(新しい予約では「その他」)をクリックし、「予約タイプ」を選びます。このタブで作れるのは「宿泊」「レストラン」「イベント」「ツアー」「駐車場」「その他」の6種類です。選んだタイプに応じてフォームが変わります。日付ではなく日の範囲を使うのは「宿泊」だけです。',
|
||||
'上部の帯にあるタイプのラベル(新しい予約では「その他」)をクリックし、「予約タイプ」を選びます。このタブで作れるのは「宿泊」「レストラン」「イベント」「ガイドツアー」「駐車場」「その他」の6種類です。選んだタイプに応じてフォームが変わります。日付ではなく日の範囲を使うのは「宿泊」だけです。',
|
||||
'help.guide.create-booking.step.3':
|
||||
'上部の帯の、ラベルの上にある欄にタイトルを入力します。必須の項目はこれだけで、何か入力するまで「追加」はグレーのままです。',
|
||||
'help.guide.create-booking.step.4':
|
||||
|
||||
@@ -60,7 +60,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.type.car': 'レンタカー',
|
||||
'reservations.type.cruise': 'クルーズ',
|
||||
'reservations.type.event': 'イベント',
|
||||
'reservations.type.tour': 'ツアー',
|
||||
'reservations.type.tour': 'ガイドツアー',
|
||||
'reservations.type.parking': '駐車場',
|
||||
'reservations.type.other': 'その他',
|
||||
'reservations.type.bus': 'バス',
|
||||
|
||||
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': '追加する項目',
|
||||
'tours.title': 'ツアー',
|
||||
'tours.empty.title': 'ツアーはまだありません',
|
||||
'tours.empty.body': 'ツアーを計画するか、GPXまたはKMLファイルをインポートしてください。',
|
||||
'tours.empty.body':
|
||||
'ツアーを計画するか、GPXまたはKMLファイルをインポートしてください。ガイドツアーを予約済みですか?それは予約に入れてください。',
|
||||
'tours.addToDay': '日程に追加',
|
||||
'tours.badge.wanderer': 'wanderer',
|
||||
'tours.badge.gpx': 'GPXインポート',
|
||||
|
||||
@@ -3136,7 +3136,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.trip-bookings.summary':
|
||||
'이 탭에는 교통을 뺀 여행 예약이 모두 있어요: 숙소, 식당 자리, 티켓, 투어, 주차. 예약마다 카드, 목록의 줄, 타임라인의 막대로 보이고, 코드, 문서, 동행자, 비용이 함께 있어요. 예약을 클릭하면 상세 정보가 열려요.',
|
||||
'help.ctx.trip-bookings.bullet.1':
|
||||
'바 오른쪽 끝의 ‘직접 예약’으로 ‘새 예약’을 열어요. 여섯 종류의 예약을 만들 수 있어요: ‘숙박’, ‘레스토랑’, ‘이벤트’, ‘투어’, ‘주차’, ‘기타’. 항공편, 기차 등 교통은 ‘교통’ 탭에 있고 여기에는 나오지 않아요.',
|
||||
'바 오른쪽 끝의 ‘직접 예약’으로 ‘새 예약’을 열어요. 여섯 종류의 예약을 만들 수 있어요: ‘숙박’, ‘레스토랑’, ‘이벤트’, ‘가이드 투어’, ‘주차’, ‘기타’. 항공편, 기차 등 교통은 ‘교통’ 탭에 있고 여기에는 나오지 않아요.',
|
||||
'help.ctx.trip-bookings.bullet.2':
|
||||
'‘직접 예약’ 앞의 다운로드 아이콘 ‘예약 확인서 가져오기’로 확인서를 올리면 TREK가 자동으로 읽어요: EML, PDF, PKPass, HTML, TXT 파일을 각 10MB까지 최대 다섯 개, AI 모델이 이미지를 읽을 수 있으면 사진도 돼요. 서버가 읽을 수 있을 때만 이 아이콘이 보여요.',
|
||||
'help.ctx.trip-bookings.bullet.3':
|
||||
@@ -3176,7 +3176,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.create-booking.step.1':
|
||||
'바 오른쪽 끝의 “직접 예약”을 클릭하세요. “새 예약”이 열리고, 맨 위 헤더에 제목과 배지 두 개가 있어요.',
|
||||
'help.guide.create-booking.step.2':
|
||||
'맨 위 띠의 유형 라벨(새 예약에서는 “기타”)을 클릭하고 “예약 유형”을 고르세요. 이 탭에서는 “숙박”, “레스토랑”, “이벤트”, “투어”, “주차”, “기타” 여섯 가지를 만들 수 있어요. 고른 유형에 따라 양식이 바뀌고, 날짜 대신 기간을 쓰는 건 “숙박”뿐이에요.',
|
||||
'맨 위 띠의 유형 라벨(새 예약에서는 “기타”)을 클릭하고 “예약 유형”을 고르세요. 이 탭에서는 “숙박”, “레스토랑”, “이벤트”, “가이드 투어”, “주차”, “기타” 여섯 가지를 만들 수 있어요. 고른 유형에 따라 양식이 바뀌고, 날짜 대신 기간을 쓰는 건 “숙박”뿐이에요.',
|
||||
'help.guide.create-booking.step.3':
|
||||
'맨 위 띠, 배지 위의 입력란에 제목을 입력하세요. 필수 항목은 제목뿐이고, 입력하기 전에는 “추가”가 회색으로 비활성화돼요.',
|
||||
'help.guide.create-booking.step.4':
|
||||
|
||||
@@ -60,7 +60,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.type.car': '차량',
|
||||
'reservations.type.cruise': '크루즈',
|
||||
'reservations.type.event': '이벤트',
|
||||
'reservations.type.tour': '투어',
|
||||
'reservations.type.tour': '가이드 투어',
|
||||
'reservations.type.parking': '주차',
|
||||
'reservations.type.other': '기타',
|
||||
'reservations.type.bus': '버스',
|
||||
|
||||
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': '추가 항목 보기',
|
||||
'tours.title': '투어',
|
||||
'tours.empty.title': '아직 투어가 없습니다',
|
||||
'tours.empty.body': '투어를 계획하거나 GPX 또는 KML 파일을 가져와 시작하세요.',
|
||||
'tours.empty.body':
|
||||
'투어를 계획하거나 GPX 또는 KML 파일을 가져와 시작하세요. 가이드 투어를 예약했나요? 그건 예약에 넣으세요.',
|
||||
'tours.addToDay': '일정에 추가',
|
||||
'tours.badge.wanderer': 'wanderer',
|
||||
'tours.badge.gpx': 'GPX 가져오기',
|
||||
|
||||
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Weergave toevoegen',
|
||||
'tours.title': 'Tochten',
|
||||
'tours.empty.title': 'Nog geen tochten',
|
||||
'tours.empty.body': 'Plan een tocht of importeer een GPX- of KML-bestand om te beginnen.',
|
||||
'tours.empty.body':
|
||||
'Plan een tocht of importeer een GPX- of KML-bestand om te beginnen. Een rondleiding geboekt? Die hoort bij Boekingen.',
|
||||
'tours.addToDay': 'Aan dag toevoegen',
|
||||
'tours.badge.wanderer': 'wanderer',
|
||||
'tours.badge.gpx': 'GPX-import',
|
||||
|
||||
@@ -3406,7 +3406,7 @@ const help: TranslationStrings = {
|
||||
'help.ctx.trip-bookings.summary':
|
||||
'Na tej karcie jest wszystko, co zarezerwowano na podróż, oprócz transportu: noclegi, stoliki, bilety, wycieczki i parkingi. Każda rezerwacja to karta, wiersz listy lub pasek na osi czasu, z kodem, dokumentami, podróżnymi i kosztem. Kliknij rezerwację, aby otworzyć szczegóły.',
|
||||
'help.ctx.trip-bookings.bullet.1':
|
||||
'„Rezerwacja ręczna” na prawym końcu paska otwiera „Nowa rezerwacja”. Można w ten sposób utworzyć sześć rodzajów rezerwacji: „Nocleg”, „Restauracja”, „Wydarzenie”, „Wycieczka”, parking i „Inne”. Loty, pociągi i inny transport są w zakładce „Transport” i nigdy się tu nie pojawiają.',
|
||||
'„Rezerwacja ręczna” na prawym końcu paska otwiera „Nowa rezerwacja”. Można w ten sposób utworzyć sześć rodzajów rezerwacji: „Nocleg”, „Restauracja”, „Wydarzenie”, „Wycieczka z przewodnikiem”, parking i „Inne”. Loty, pociągi i inny transport są w zakładce „Transport” i nigdy się tu nie pojawiają.',
|
||||
'help.ctx.trip-bookings.bullet.2':
|
||||
'Ikona pobierania przed „Rezerwacja ręczna”, czyli „Importuj potwierdzenia rezerwacji”, przesyła potwierdzenia, żeby TREK mógł je automatycznie odczytać: EML, PDF, PKPass, HTML lub TXT, maksymalnie pięć plików po 10 MB, a także zdjęcia, jeśli model AI odczytuje obrazy. Ikona jest widoczna tylko wtedy, gdy serwer potrafi je odczytać.',
|
||||
'help.ctx.trip-bookings.bullet.3':
|
||||
@@ -3448,7 +3448,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.create-booking.step.1':
|
||||
'Kliknij „Rezerwacja ręczna” na prawym końcu paska. Otworzy się „Nowa rezerwacja” z tytułem i dwiema plakietkami w nagłówku u góry.',
|
||||
'help.guide.create-booking.step.2':
|
||||
'Kliknij etykietę typu w górnym pasku, która przy nowej rezerwacji ma napis „Inne”, i wybierz „Rodzaj rezerwacji”. W tej zakładce utworzysz sześć typów: Nocleg, Restauracja, Wydarzenie, Wycieczka, Parking i Inne. Formularz zmienia się zależnie od wyboru: tylko „Nocleg” zamiast dat używa zakresu dni.',
|
||||
'Kliknij etykietę typu w górnym pasku, która przy nowej rezerwacji ma napis „Inne”, i wybierz „Rodzaj rezerwacji”. W tej zakładce utworzysz sześć typów: Nocleg, Restauracja, Wydarzenie, Wycieczka z przewodnikiem, Parking i Inne. Formularz zmienia się zależnie od wyboru: tylko „Nocleg” zamiast dat używa zakresu dni.',
|
||||
'help.guide.create-booking.step.3':
|
||||
'Wpisz tytuł w górnym pasku, w polu nad plakietkami. To jedyne wymagane pole, a przycisk „Dodaj” pozostaje wyszarzony, dopóki czegoś nie wpiszesz.',
|
||||
'help.guide.create-booking.step.4':
|
||||
|
||||
@@ -61,7 +61,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.searchLocation': 'Szukaj stacji, portu, adresu...',
|
||||
'reservations.type.cruise': 'Rejs',
|
||||
'reservations.type.event': 'Wydarzenie',
|
||||
'reservations.type.tour': 'Wycieczka',
|
||||
'reservations.type.tour': 'Wycieczka z przewodnikiem',
|
||||
'reservations.type.parking': 'Parking', // same-as-en
|
||||
'reservations.type.other': 'Inne',
|
||||
'reservations.type.bus': 'Autobus',
|
||||
|
||||
@@ -7,7 +7,8 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Widok dodawania',
|
||||
'tours.title': 'Wycieczki',
|
||||
'tours.empty.title': 'Nie ma jeszcze wycieczek',
|
||||
'tours.empty.body': 'Zaplanuj wycieczkę lub zaimportuj plik GPX albo KML, aby rozpocząć.',
|
||||
'tours.empty.body':
|
||||
'Zaplanuj wycieczkę lub zaimportuj plik GPX albo KML, aby rozpocząć. Zarezerwowano wycieczkę z przewodnikiem? Jej miejsce jest w Rezerwacjach.',
|
||||
'tours.addToDay': 'Dodaj do dnia',
|
||||
'tours.badge.wanderer': 'wanderer',
|
||||
'tours.badge.gpx': 'Import GPX',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user