merge dev and reconcile roadtrip planning

This commit is contained in:
Maurice
2026-09-12 15:45:34 +02:00
106 changed files with 2780 additions and 244 deletions
+13
View File
@@ -0,0 +1,13 @@
import { schoolHolidayCatalogSchema, schoolHolidayRegionDetailSchema, type SchoolHolidayCountryRequest, type SchoolHolidayRegionRequest } from '@trek/shared'
import apiClient from './client'
const base = '/school-holiday-catalog'
export const schoolHolidayCatalogApi = {
catalog: () => apiClient.get(base).then(r => schoolHolidayCatalogSchema.parse(r.data)),
region: (id: number) => apiClient.get(`${base}/regions/${id}`).then(r => schoolHolidayRegionDetailSchema.parse(r.data)),
createCountry: (country: SchoolHolidayCountryRequest) => apiClient.post(`${base}/countries`, country),
deleteCountry: (code: string) => apiClient.delete(`${base}/countries/${code}`),
createRegion: (country: string, region: SchoolHolidayRegionRequest) => apiClient.post(`${base}/countries/${country}/regions`, region).then(r => schoolHolidayRegionDetailSchema.parse(r.data)),
updateRegion: (id: number, region: SchoolHolidayRegionRequest) => apiClient.put(`${base}/regions/${id}`, region).then(r => schoolHolidayRegionDetailSchema.parse(r.data)),
deleteRegion: (id: number, revision: number) => apiClient.delete(`${base}/regions/${id}`, { params: { revision } }),
}
@@ -473,7 +473,7 @@ describe('AddonManager', () => {
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3:8b', 'llama3:8b'], urls));
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
render(<AddonManager />);
await screen.findByText('Installed on the server');
@@ -483,9 +483,9 @@ describe('AddonManager', () => {
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
// qwen3:8b is already installed, so the recommended row offers "Use" instead of "Pull"
// qwen3.5:4b is already installed, so the recommended row offers "Use" instead of "Pull"
await user.click(screen.getByRole('button', { name: 'Use' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
});
@@ -545,7 +545,7 @@ describe('AddonManager', () => {
addonsRoute([llmAddon({ provider: 'local' })]),
http.get('/api/admin/llm/local/models', () => {
modelCalls += 1;
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3:8b', size: 1 }] });
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
}),
http.post('/api/admin/llm/local/pull', async ({ request }) => {
pulled = await request.json();
@@ -565,8 +565,8 @@ describe('AddonManager', () => {
expect(screen.getByText('starting…')).toBeInTheDocument();
await screen.findByText('Model pulled');
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3:8b' });
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
});
+1 -1
View File
@@ -308,7 +308,7 @@ const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [
{ id: 'qwen3:8b', label: 'Qwen3 — 8B', note: 'Recommended · best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0', recommended: true, vision: false },
{ id: 'qwen3.5:4b', label: 'Qwen3.5 — 4B', note: 'Recommended · small and quick on CPU, 3.4 GB download, 256K context (thinking auto-disabled) · Apache-2.0', recommended: true, vision: true },
]
/**
@@ -192,7 +192,7 @@ export default function CategoryManager() {
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="font-semibold text-content">{t('categories.title')}</h2>
<p className="text-xs mt-1 text-content-muted">{t('categories.subtitle')}</p>
<p className="mt-1 text-caption text-content-faint">{t('categories.subtitle')}</p>
</div>
<button type="button" onClick={handleStartCreate}
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
@@ -156,7 +156,7 @@ export default function PackingTemplateManager() {
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
<div>
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.packingTemplates.subtitle')}</p>
<p className="mt-1 text-caption text-content-faint">{t('admin.packingTemplates.subtitle')}</p>
</div>
<button type="button" onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors">
@@ -0,0 +1,160 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { within } from '@testing-library/react'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import { server } from '../../../tests/helpers/msw/server'
import { resetAllStores } from '../../../tests/helpers/store'
import SchoolHolidayCatalog from './SchoolHolidayCatalog'
import SchoolHolidayRegionEditor from './SchoolHolidayRegionEditor'
import { fetchSchoolHolidayRegionOptions } from '../Vacay/holidayRegions'
import { useSchoolHolidayCountries } from '../Vacay/useSchoolHolidayCountries'
import { useVacayStore } from '../../store/vacayStore'
import type { SchoolHolidayRegionDetail } from '@trek/shared'
const region: SchoolHolidayRegionDetail = { id: 7, code: 'US-MANUAL-7', country: 'US', name: 'Seattle schools', revision: 1, holidays: [{ name: 'Winter break', startDate: '2026-12-20', endDate: '2027-01-06' }] }
const catalog = { countries: [{ code: 'US', name: 'USA' }], regions: [region] }
beforeEach(() => resetAllStores())
function enterDate(label: string, value: string) {
const picker = within(screen.getByRole('group', { name: label }))
fireEvent.click(picker.getByRole('button', { name: /enter.*manually/i }))
const input = picker.getByRole('textbox')
fireEvent.change(input, { target: { value } })
fireEvent.blur(input)
}
describe('manual school holiday administration', () => {
it('creates a country, a district and its named periods', async () => {
let created = false
const saves: unknown[] = []
server.use(
http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: created ? catalog.countries : [], regions: [] })),
http.post('/api/school-holiday-catalog/countries', async ({ request }) => { expect(await request.json()).toEqual({ code: 'US', name: 'USA' }); created = true; return HttpResponse.json(catalog.countries[0]) }),
http.post('/api/school-holiday-catalog/countries/US/regions', async ({ request }) => { saves.push(await request.json()); return HttpResponse.json(region) }),
)
render(<SchoolHolidayCatalog />)
await waitFor(() => expect(screen.getByRole('button', { name: 'Add country' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: 'Add country' }))
fireEvent.change(screen.getByLabelText('Country'), { target: { value: 'USA' } })
fireEvent.change(screen.getByLabelText('Country code (e.g. US)'), { target: { value: 'us' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'Add region' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: 'Add region' }))
fireEvent.change(screen.getByLabelText('Region or school district'), { target: { value: 'Seattle schools' } })
fireEvent.click(screen.getByRole('button', { name: 'Add holiday period' }))
fireEvent.change(screen.getByLabelText('Holiday name'), { target: { value: 'Winter break' } })
enterDate('Start date', '2026-12-20')
enterDate('End date', '2027-01-06')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(saves).toEqual([{ name: region.name, revision: 0, holidays: region.holidays }]))
await waitFor(() => expect(screen.queryByLabelText('Holiday name')).not.toBeInTheDocument())
})
it('opens existing periods and keeps edits after a failed save', async () => {
server.use(
http.get('/api/school-holiday-catalog', () => HttpResponse.json(catalog)),
http.get('/api/school-holiday-catalog/regions/7', () => HttpResponse.json(region)),
http.put('/api/school-holiday-catalog/regions/7', () => HttpResponse.json({ error: 'This region changed. Reopen it before saving again.' }, { status: 409 })),
)
render(<SchoolHolidayCatalog />)
fireEvent.click(await screen.findByRole('button', { name: 'Seattle schools' }))
await screen.findByDisplayValue('Winter break')
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled())
fireEvent.change(screen.getByLabelText('Holiday name'), { target: { value: 'New name' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByRole('alert')).toHaveTextContent('This region changed')
expect(screen.getByLabelText('Holiday name')).toHaveValue('New name')
})
it('validates names and confirms discarding changes', async () => {
const save = vi.fn()
const close = vi.fn()
render(<SchoolHolidayRegionEditor region={region} busy={false} error="" onClose={close} onSave={save} />)
fireEvent.change(screen.getByLabelText('Holiday name'), { target: { value: ' ' } })
fireEvent.submit(screen.getByRole('button', { name: 'Save' }).closest('form')!)
expect(save).not.toHaveBeenCalled()
expect(screen.getByRole('alert')).toHaveTextContent('Check the names and dates')
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.getByText('Discard unsaved changes?')).toBeInTheDocument()
expect(close).not.toHaveBeenCalled()
})
it('removes a period and saves an existing region without changing its revision token', () => {
const save = vi.fn()
render(<SchoolHolidayRegionEditor region={region} busy={false} error="" onClose={vi.fn()} onSave={save} />)
fireEvent.click(screen.getByRole('button', { name: 'Delete Winter break' }))
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(save).toHaveBeenCalledWith({ name: region.name, revision: 1, holidays: [] })
})
it('blocks offline edits but still lets the dialog close', () => {
const close = vi.fn()
render(<SchoolHolidayRegionEditor region={region} busy={false} offline error="Offline" onClose={close} onSave={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(close).toHaveBeenCalledOnce()
})
it('keeps deletion conflicts visible and allows retrying the catalog', async () => {
server.use(
http.get('/api/school-holiday-catalog', () => HttpResponse.json(catalog)),
http.delete('/api/school-holiday-catalog/regions/7', ({ request }) => {
expect(new URL(request.url).searchParams.get('revision')).toBe('1')
return HttpResponse.json({ error: 'Region is in use' }, { status: 409 })
}),
)
render(<SchoolHolidayCatalog />)
fireEvent.click(await screen.findByRole('button', { name: 'Delete Seattle schools' }))
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Region is in use')
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
expect(screen.getByRole('button', { name: 'Seattle schools' })).toBeInTheDocument()
})
it('keeps national API calendars selectable alongside manual regions', async () => {
server.use(http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: [{ code: 'IE', name: 'Ireland' }], regions: [{ ...region, country: 'IE', code: 'IE-MANUAL-7' }] })))
expect(await fetchSchoolHolidayRegionOptions('IE')).toEqual([{ value: 'IE', label: 'IE' }, { value: 'IE-MANUAL-7', label: region.name }])
})
it('keeps API regions available if loading the manual catalog fails', async () => {
server.use(
http.get('/api/school-holiday-catalog', () => HttpResponse.json({}, { status: 502 })),
http.get('/api/addons/vacay/school-holidays/regions/DE', () => HttpResponse.json({ subdivisions: [{ code: 'DE-BY', name: [{ language: 'EN', text: 'Bavaria' }] }] })),
)
expect(await fetchSchoolHolidayRegionOptions('DE')).toEqual([{ value: 'DE-BY', label: 'Bavaria' }])
await expect(fetchSchoolHolidayRegionOptions('US')).rejects.toThrow()
})
it('offers USA independently of provider countries and fetches only local regions', async () => {
const provider = vi.fn(() => HttpResponse.json([]))
server.use(http.get('/api/school-holiday-catalog', () => HttpResponse.json(catalog)), http.get('/api/addons/vacay/school-holidays/regions/US', provider))
function Countries() { const { countries } = useSchoolHolidayCountries(); return <>{countries.map(country => <span key={country.value}>{country.label}</span>)}</> }
render(<Countries />)
expect(await screen.findByText('USA')).toBeInTheDocument()
expect(await fetchSchoolHolidayRegionOptions('US')).toEqual([{ value: 'US-MANUAL-7', label: 'Seattle schools' }])
expect(provider).not.toHaveBeenCalled()
})
it('keeps manual regions usable when a supported country provider fails', async () => {
server.use(
http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: [{ code: 'DE', name: 'Germany' }], regions: [{ ...region, country: 'DE', code: 'DE-MANUAL-7' }] })),
http.get('/api/addons/vacay/school-holidays/regions/DE', () => HttpResponse.json({}, { status: 502 })),
)
expect(await fetchSchoolHolidayRegionOptions('DE')).toEqual([{ value: 'DE-MANUAL-7', label: region.name }])
})
it('renders manual dates with inclusive boundaries without touching leave entries', async () => {
server.use(http.get('/api/school-holiday-catalog/regions/7/holidays/2026', () => HttpResponse.json(region.holidays)))
useVacayStore.setState({
selectedYear: 2026,
plan: { id: 1, school_holidays_enabled: true, holidays_enabled: false, holidays_region: null, block_weekends: true, company_holidays_enabled: true, carry_over_enabled: false, holiday_calendars: [{ id: 1, plan_id: 1, region: region.code, label: 'School', color: '#a5f3fc', sort_order: 0, type: 'school_holiday' }] },
entries: [{ plan_id: 1, user_id: 1, date: '2026-12-23', fraction: 1, kind: 'vacation' }],
})
const entries = useVacayStore.getState().entries
await useVacayStore.getState().loadHolidays(2026)
expect(useVacayStore.getState().holidays['2026-12-20']).toBeDefined()
expect(useVacayStore.getState().holidays['2026-12-31']).toBeDefined()
expect(useVacayStore.getState().entries).toBe(entries)
})
});
@@ -0,0 +1,65 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useSchoolHolidayCatalog } from './useSchoolHolidayCatalog'
import SchoolHolidayRegionEditor, { holidayButtonClass, holidayInputClass } from './SchoolHolidayRegionEditor'
import ConfirmDialog from '../shared/ConfirmDialog'
import ErrorBoundary from '../shared/ErrorBoundary'
import CustomSelect from '../shared/CustomSelect'
export default function SchoolHolidayCatalog() {
return <ErrorBoundary boundaryId="school-holiday-catalog"><Catalog /></ErrorBoundary>
}
function Catalog() {
const { t } = useTranslation()
const state = useSchoolHolidayCatalog()
const [country, setCountry] = useState('')
const [adding, setAdding] = useState(false)
const [code, setCode] = useState('')
const [name, setName] = useState('')
const [remove, setRemove] = useState<{ label: string; action: () => Promise<boolean> } | null>(null)
const selectedCountry = state.catalog.countries.find(item => item.code === country)?.code || state.catalog.countries[0]?.code || ''
const regions = state.catalog.regions.filter(region => region.country === selectedCountry)
const disabled = state.busy || state.loading || state.offline
const addRegion = () => state.setEditor({ id: 0, country: selectedCountry, name: '', code: '', revision: 0, holidays: [] })
return <section className="space-y-4 rounded-2xl border border-edge bg-surface-card p-4 sm:p-6">
<div>
<h2 className="font-semibold text-content">{t('schoolCatalog.title')}</h2>
<p className="mt-1 text-caption text-content-faint">{t('schoolCatalog.hint')}</p>
</div>
{state.offline && <p role="status" className="text-body text-warning">{t('schoolCatalog.offline')}</p>}
{state.loading && <p role="status" className="text-body text-content-muted">{t('common.loading')}</p>}
{state.error && !state.editor && <div role="alert" className="space-y-2 text-body text-danger">{state.error}<button type="button" disabled={disabled} onClick={() => void state.refresh()} className={`${holidayButtonClass} block`}>{t('schoolCatalog.retry')}</button></div>}
<fieldset disabled={disabled} className="space-y-4">
{state.catalog.countries.length > 0 && <div className="flex items-end gap-2">
<div className="min-w-0 flex-1 space-y-1 text-body"><span>{t('schoolCatalog.country')}</span>
<CustomSelect value={selectedCountry} onChange={value => setCountry(String(value))} options={state.catalog.countries.map(item => ({ value: item.code, label: item.name }))} placeholder={t('schoolCatalog.country')} searchable disabled={disabled} />
</div>
<button type="button" disabled={adding} title={t('schoolCatalog.addCountry')} aria-label={t('schoolCatalog.addCountry')} onClick={() => setAdding(true)} className={`${holidayButtonClass} inline-flex shrink-0 items-center justify-center`}><Plus size={18} /></button>
<button type="button" disabled={regions.length > 0} title={t('schoolCatalog.deleteHint')} className={holidayButtonClass} aria-label={t('schoolCatalog.deleteCountry')} onClick={() => setRemove({ label: selectedCountry, action: () => state.deleteCountry(selectedCountry) })}><Trash2 size={18} /></button>
</div>}
{!adding && state.catalog.countries.length === 0 && <button type="button" onClick={() => setAdding(true)} className={`${holidayButtonClass} flex items-center gap-2`}><Plus size={16} />{t('schoolCatalog.addCountry')}</button>}
{adding && <form className="space-y-3 rounded-xl border border-edge p-3" onSubmit={async event => {
event.preventDefault()
if (await state.createCountry({ code, name: name.trim() })) { setCountry(code); setAdding(false); setCode(''); setName('') }
}}>
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.country')}</span><input autoFocus required maxLength={100} className={holidayInputClass} value={name} onChange={event => setName(event.target.value)} /></label>
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.code')}</span><input required pattern="[A-Z]{2}" maxLength={2} className={holidayInputClass} value={code} onChange={event => setCode(event.target.value.toUpperCase())} placeholder="US" /></label>
<div className="flex gap-2"><button type="submit" className={holidayButtonClass}>{t('common.save')}</button><button type="button" onClick={() => setAdding(false)} className={holidayButtonClass}>{t('common.cancel')}</button></div>
</form>}
{selectedCountry && <>
{regions.length === 0 && <p className="text-body text-content-muted">{t('schoolCatalog.empty')}</p>}
<ul className="divide-y divide-edge">{regions.map(region => <li key={region.id} className="flex items-center gap-2 py-2">
<button type="button" className={`${holidayButtonClass} min-w-0 flex-1 text-left`} onClick={() => void state.openRegion(region.id)}>{region.name}</button>
<button type="button" title={t('schoolCatalog.addRegion')} aria-label={t('schoolCatalog.addRegion')} className={`${holidayButtonClass} inline-flex shrink-0 items-center justify-center`} onClick={addRegion}><Plus size={18} /></button>
<button type="button" aria-label={`${t('common.delete')} ${region.name}`} className={holidayButtonClass} onClick={() => setRemove({ label: region.name, action: () => state.deleteRegion(region.id, region.revision) })}><Trash2 size={18} /></button>
</li>)}</ul>
{regions.length === 0 && <button type="button" className={`${holidayButtonClass} flex items-center gap-2`} onClick={addRegion}><Plus size={16} />{t('schoolCatalog.addRegion')}</button>}
</>}
</fieldset>
{state.editor && <SchoolHolidayRegionEditor key={state.editor.id} region={state.editor} busy={state.busy} offline={state.offline} error={state.offline ? t('schoolCatalog.offline') : state.error} onClose={() => state.setEditor(null)} onSave={body => { if (state.editor) void state.saveRegion(state.editor.country, state.editor.id, body) }} />}
<ConfirmDialog isOpen={Boolean(remove)} onClose={() => setRemove(null)} title={t('common.delete')} message={`${remove?.label || ''}. ${t('schoolCatalog.deleteHint')}`} onConfirm={() => { if (remove) void remove.action(); setRemove(null) }} />
</section>
}
@@ -0,0 +1,75 @@
import { useId, useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { schoolHolidayRegionRequestSchema, type SchoolHolidayRegionDetail, type SchoolHolidayRegionRequest } from '@trek/shared'
import { useTranslation } from '../../i18n'
import Modal from '../shared/Modal'
import ConfirmDialog from '../shared/ConfirmDialog'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
export const holidayInputClass = 'w-full rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content'
export const holidayButtonClass = 'rounded-lg border border-edge px-3 py-2 text-body font-medium text-content hover:bg-surface-hover disabled:opacity-50'
export default function SchoolHolidayRegionEditor({ region, busy, offline = false, error, onClose, onSave }: {
region: SchoolHolidayRegionDetail
busy: boolean
offline?: boolean
error: string
onClose: () => void
onSave: (body: SchoolHolidayRegionRequest) => void
}) {
const { t } = useTranslation()
const fieldId = useId()
const [name, setName] = useState(region.name)
const [holidays, setHolidays] = useState(region.holidays.map((holiday, index) => ({ ...holiday, key: index })))
const [nextKey, setNextKey] = useState(holidays.length)
const [discard, setDiscard] = useState(false)
const [invalid, setInvalid] = useState(false)
const body = { name, revision: region.revision, holidays: holidays.map(({ name, startDate, endDate }) => ({ name, startDate, endDate })) }
const dirty = name !== region.name || JSON.stringify(body.holidays) !== JSON.stringify(region.holidays)
const close = () => { if (!busy) { if (dirty) setDiscard(true); else onClose() } }
return <>
<Modal isOpen onClose={close} title={t('schoolCatalog.region')} size="xl">
<form className="space-y-4" onSubmit={event => {
event.preventDefault()
const parsed = schoolHolidayRegionRequestSchema.safeParse(body)
setInvalid(!parsed.success)
if (parsed.success) onSave(parsed.data)
}}>
<fieldset disabled={busy || offline} className="space-y-4">
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.region')}</span>
<input autoFocus required maxLength={150} className={holidayInputClass} value={name} onChange={event => setName(event.target.value)} />
</label>
<p className="text-caption text-content-muted">{t('schoolCatalog.periodHint')}</p>
{holidays.map((holiday, index) => <div key={holiday.key} className="space-y-2 rounded-xl border border-edge p-3">
<div className="space-y-1 text-body">
<label htmlFor={`${fieldId}-holiday-${holiday.key}`}>{t('schoolCatalog.name')}</label>
<div className="flex items-stretch gap-2">
<input id={`${fieldId}-holiday-${holiday.key}`} required maxLength={150} className={`${holidayInputClass} min-w-0 flex-1`} value={holiday.name} onChange={event => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, name: event.target.value } : row))} />
<button type="button" className="flex shrink-0 items-center justify-center rounded-[10px] border border-edge px-[9px] text-content-faint transition-colors hover:border-[color:var(--text-faint)] hover:text-content disabled:opacity-50" aria-label={`${t('common.delete')} ${holiday.name || index + 1}`} onClick={() => setHolidays(rows => rows.filter(row => row.key !== holiday.key))}><Trash2 size={13} /></button>
</div>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div role="group" aria-label={t('schoolCatalog.start')} className="min-w-0 space-y-1 text-caption"><span>{t('schoolCatalog.start')}</span>
<CustomDatePicker key={String(busy || offline)} value={holiday.startDate} placeholder={t('schoolCatalog.start')} max={holiday.endDate || undefined} onChange={startDate => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, startDate } : row))} />
</div>
<div role="group" aria-label={t('schoolCatalog.end')} className="min-w-0 space-y-1 text-caption"><span>{t('schoolCatalog.end')}</span>
<CustomDatePicker key={String(busy || offline)} value={holiday.endDate} placeholder={t('schoolCatalog.end')} min={holiday.startDate || undefined} onChange={endDate => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, endDate } : row))} />
</div>
</div>
</div>)}
<button type="button" disabled={holidays.length >= 500} className={`${holidayButtonClass} flex items-center gap-2`} onClick={() => {
setHolidays(rows => [...rows, { key: nextKey, name: '', startDate: '', endDate: '' }]); setNextKey(key => key + 1)
}}><Plus size={16} />{t('schoolCatalog.addPeriod')}</button>
{invalid && <p role="alert" className="text-body text-danger">{t('schoolCatalog.invalid')}</p>}
{error && <p role="alert" className="text-body text-danger">{error}</p>}
</fieldset>
<div className="flex justify-end gap-2 border-t border-edge pt-4">
<button type="button" className={holidayButtonClass} onClick={close}>{t('common.cancel')}</button>
<button type="submit" disabled={busy || offline} aria-busy={busy} className="rounded-lg bg-accent px-4 py-2 text-body font-semibold text-accent-text disabled:opacity-50">{busy ? t('common.loading') : t('common.save')}</button>
</div>
</form>
</Modal>
<ConfirmDialog isOpen={discard} onClose={() => setDiscard(false)} onConfirm={onClose} title={t('common.cancel')} message={t('schoolCatalog.discard')} />
</>
}
@@ -0,0 +1,79 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { SchoolHolidayCatalog, SchoolHolidayCountryRequest, SchoolHolidayRegionDetail, SchoolHolidayRegionRequest } from '@trek/shared'
import { schoolHolidayCatalogRepo as catalogRepo } from '../../repo/schoolHolidayCatalogRepo'
import { useTranslation } from '../../i18n'
import { useNetworkMode } from '../../hooks/useNetworkMode'
export function useSchoolHolidayCatalog() {
const { t } = useTranslation()
const { offline } = useNetworkMode()
const [catalog, setCatalog] = useState<SchoolHolidayCatalog>({ countries: [], regions: [] })
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [editor, setEditor] = useState<SchoolHolidayRegionDetail | null>(null)
const mounted = useRef(true)
const pending = useRef(false)
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
const reload = useCallback(async () => {
catalogRepo.requireOnline()
const catalog = await catalogRepo.catalog()
if (mounted.current) setCatalog(catalog)
}, [])
useEffect(() => {
let cancelled = false
if (offline) { setLoading(false); return }
setLoading(true)
catalogRepo.catalog().then(catalog => {
if (!cancelled) { setCatalog(catalog); setError('') }
}).catch(() => { if (!cancelled) setError(t('common.error')) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [offline, t])
async function run(action: () => Promise<unknown>, after?: () => void) {
if (pending.current) return false
pending.current = true
setBusy(true)
setError('')
try {
catalogRepo.requireOnline()
await action()
if (mounted.current) after?.()
try {
await reload()
} catch {
if (mounted.current) setError(t('schoolCatalog.refreshError'))
}
return true
} catch (failure) {
if (mounted.current) {
const message = failure instanceof Error ? failure.message : ''
const http = failure as { response?: { data?: { error?: string } } }
setError(http.response?.data?.error || message || t('common.error'))
}
return false
} finally {
pending.current = false
if (mounted.current) setBusy(false)
}
}
return {
catalog, loading, busy, error, offline, editor, setEditor,
refresh: () => run(async () => {}),
createCountry: (country: SchoolHolidayCountryRequest) => run(() => catalogRepo.createCountry(country)),
deleteCountry: (code: string) => run(() => catalogRepo.deleteCountry(code)),
openRegion: (id: number) => run(async () => {
const region = await catalogRepo.region(id)
if (mounted.current) setEditor(region)
}),
saveRegion: (country: string, id: number, body: SchoolHolidayRegionRequest) => run(
() => id ? catalogRepo.updateRegion(id, body) : catalogRepo.createRegion(country, body),
() => setEditor(null),
),
deleteRegion: (id: number, revision: number) => run(() => catalogRepo.deleteRegion(id, revision)),
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
import { Navigation, LocateFixed, Locate } from 'lucide-react'
import type { TrackingMode, GeoWatchErrorCode } from '../../hooks/useGeolocation'
import { useTranslation } from '../../i18n'
@@ -77,7 +78,7 @@ export default function LocationButton({ mode, error, errorCode, onClick, bottom
cursor: 'pointer',
background: isActive ? '#3b82f6' : 'var(--bg-card, white)',
color: isActive ? 'white' : (error ? '#ef4444' : 'var(--text-muted, #6b7280)'),
boxShadow: '0 2px 10px rgba(0,0,0,0.25)',
boxShadow: MAP_CONTROL_SHADOW,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -1,5 +1,6 @@
import { Map as MapIcon, Satellite } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
export type BaseLayer = 'default' | 'satellite'
@@ -18,7 +19,7 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
boxShadow: MAP_CONTROL_SHADOW,
}}>
<button
type="button"
@@ -229,6 +229,24 @@ describe('MapView', () => {
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
})
it('FE-COMP-MAPVIEW-006b: a caller-coloured route takes its own core and casing', () => {
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={[{ line: '#ff9f0a', casing: '#c2740a' }]} />)
const [casing, core] = screen.getAllByTestId('polyline')
.map(el => JSON.parse(el.getAttribute('data-path-options') as string))
expect(casing.color).toBe('#c2740a')
expect(core.color).toBe('#ff9f0a')
})
it('FE-COMP-MAPVIEW-006c: a line with no colour of its own keeps the route blue', () => {
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={[undefined]} />)
const [casing, core] = screen.getAllByTestId('polyline')
.map(el => JSON.parse(el.getAttribute('data-path-options') as string))
expect(casing.color).toBe('#0a5cc2')
expect(core.color).toBe('#0a84ff')
})
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
render(<MapView route={null} />)
expect(screen.queryByTestId('polyline')).toBeNull()
@@ -1566,6 +1566,21 @@ describe('MapViewGL', () => {
expect(features[0].geometry.coordinates).toEqual([[2, 48], [3, 49]])
})
it('FE-COMP-MAPVIEWGL-048b: a caller-coloured route rides its colours on the feature', async () => {
const routeSource = geoSource()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-route' ? routeSource : null))
render(<MapViewGL places={[]} fitKey={1} route={[[[48, 2], [49, 3]], [[50, 4], [51, 5]]]}
routeColors={[{ line: '#ff9f0a', casing: '#c2740a' }, undefined]} />)
await act(async () => {})
const { features } = lastData(routeSource)
expect(features[0].properties.color).toBe('#ff9f0a')
expect(features[0].properties.casing).toBe('#c2740a')
// No colour means the day route's own blue, which the layer paint falls back to.
expect(features[1].properties.color).toBeNull()
})
it('FE-COMP-MAPVIEWGL-049: unusable GPX geometry is skipped instead of breaking the layer', async () => {
const gpxSource = geoSource()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-gpx' ? gpxSource : null))
@@ -0,0 +1,90 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { TripRouteOverviewPill, TripRouteOverviewPanel } from './TripRouteOverview'
import type { TripRouteOverview } from './useTripRouteOverview'
const overview = (over: Partial<TripRouteOverview> = {}): TripRouteOverview => ({
days: [
{ dayId: 1, dayNumber: 1, date: '2025-06-01', title: null, color: { line: '#0a84ff', casing: '#0a5cc2' }, lines: [], segments: [], distance: 82000, duration: 3600, modes: ['driving'] },
{ dayId: 2, dayNumber: 2, date: '2025-06-02', title: 'Coast road', color: { line: '#ff9f0a', casing: '#c2740a' }, lines: [], segments: [], distance: 140500, duration: 7200, modes: ['driving', 'walking'] },
],
lines: [], lineColors: [], segments: [], focusPoints: [],
totalDistance: 222500, totalDuration: 10800, loading: false,
...over,
})
describe('TripRouteOverviewPill', () => {
it('FE-MAP-TROU-001: says which way it will take you, and reports its state', () => {
const onToggle = vi.fn()
const { rerender } = render(<TripRouteOverviewPill active={false} onToggle={onToggle} />)
const button = screen.getByTestId('trip-overview-pill')
expect(button).toHaveAttribute('aria-pressed', 'false')
expect(button).toHaveAttribute('aria-label', 'map.overview.show')
fireEvent.click(button)
expect(onToggle).toHaveBeenCalledTimes(1)
rerender(<TripRouteOverviewPill active onToggle={onToggle} />)
expect(screen.getByTestId('trip-overview-pill')).toHaveAttribute('aria-label', 'map.overview.hide')
})
})
describe('TripRouteOverviewPanel', () => {
it('FE-MAP-TROU-002: leads with the trip total in the reader\'s own unit', () => {
const { rerender } = render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
expect(screen.getByText('222.5 km')).toBeInTheDocument()
rerender(<TripRouteOverviewPanel overview={overview()} unit="imperial" />)
expect(screen.getByText('138.3 mi')).toBeInTheDocument()
})
it('FE-MAP-TROU-003: names every day, its distance and how it is travelled', () => {
render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
expect(screen.getByText('dayplan.dayN')).toBeInTheDocument()
// A day with a title is named by it rather than by its number.
expect(screen.getByText('Coast road')).toBeInTheDocument()
expect(screen.getByText('82 km')).toBeInTheDocument()
expect(screen.getByText('140.5 km')).toBeInTheDocument()
})
it('FE-MAP-TROU-004: marks the total as partial while legs are still coming in', () => {
const { container, rerender } = render(<TripRouteOverviewPanel overview={overview({ loading: true })} unit="metric" />)
expect(container.textContent).toContain('…')
rerender(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
expect(container.textContent).not.toContain('…')
})
it('FE-MAP-TROU-005: a day row picks that day when the shell offers it', () => {
const onSelectDay = vi.fn()
render(<TripRouteOverviewPanel overview={overview()} unit="metric" selectedDayId={2} onSelectDay={onSelectDay} />)
fireEvent.click(screen.getByText('Coast road'))
expect(onSelectDay).toHaveBeenCalledWith(2)
})
it('FE-MAP-TROU-006: read-only shells get rows, not buttons', () => {
render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
expect(screen.queryAllByRole('button')).toHaveLength(0)
})
it('FE-MAP-TROU-008: hugs its rows, and takes the cap the shell gives it', () => {
const { container, rerender } = render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
const card = () => screen.getByTestId('trip-overview-panel')
expect(card().style.width).toBe('fit-content')
expect(card().style.maxWidth).toBe('320px')
// The phone shell asks for a narrower card so the map keeps the space.
rerender(<TripRouteOverviewPanel overview={overview()} unit="metric" maxWidth={240} />)
expect(card().style.maxWidth).toBe('240px')
expect(container).not.toBeEmptyDOMElement()
})
it('FE-MAP-TROU-007: draws nothing at all for a trip with no routed day', () => {
const { container } = render(<TripRouteOverviewPanel overview={overview({ days: [], totalDistance: 0 })} unit="metric" />)
expect(container).toBeEmptyDOMElement()
})
})
@@ -0,0 +1,130 @@
import { Route } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { profileIcon } from '../Planner/DayPlanSidebarRouteConnector'
import { formatDistance } from '../../utils/units'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
import type { TripRouteOverview as Overview } from './useTripRouteOverview'
import type { DistanceUnit } from '../../types'
/**
* Toggle for the whole-trip route overview (#1736). Same frosted shell as the other
* map controls, so it lines up with the compass and the layer switcher.
*/
export function TripRouteOverviewPill({ active, onToggle }: { active: boolean; onToggle: () => void }) {
const { t } = useTranslation()
const label = active ? t('map.overview.hide') : t('map.overview.show')
return (
<div style={{
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}>
<button
type="button"
onClick={onToggle}
aria-label={label}
title={label}
aria-pressed={active}
data-testid="trip-overview-pill"
className={active ? 'text-accent' : 'text-content-muted'}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: 'transparent', padding: 0,
transition: 'background 0.14s, color 0.14s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Route size={17} strokeWidth={2} />
</button>
</div>
)
}
/**
* The trip's stages and its total distance, read off the overview the map is drawing.
*
* One component for both shells: the phone and the desktop place it differently but the
* rows are the same rows, and a second copy of them is a second thing to keep in step.
*/
export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelectDay, maxWidth = 320 }: {
overview: Overview
unit: DistanceUnit
selectedDayId?: number | null
onSelectDay?: (dayId: number) => void
/** How wide the card may grow before day names start to ellipsize. */
maxWidth?: number
}) {
const { t } = useTranslation()
if (!overview.days.length) return null
return (
<div
data-testid="trip-overview-panel"
style={{
pointerEvents: 'auto', borderRadius: 14, overflow: 'hidden',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
// Sized to its rows rather than to the space available: a trip of short day
// names left a band of empty card over the map, which on a phone is most of
// what there is to look at.
width: 'fit-content', maxWidth,
}}
>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, padding: '10px 14px 8px' }}>
<span className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', textTransform: 'uppercase', letterSpacing: 0.4 }}>
{t('map.overview.total')}
</span>
{/* Same tier as the label beside it: the two read as one line rather than as a
heading with a number stuck under it. */}
<span className="text-content" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600 }}>
{formatDistance(overview.totalDistance / 1000, unit)}
{/* Still routing: the number is a partial sum, and saying so beats a total
that silently grows while you read it. */}
{overview.loading && <span className="text-content-muted" style={{ fontWeight: 400 }}>{' '}…</span>}
</span>
</div>
<div style={{ maxHeight: 220, overflowY: 'auto', borderTop: '1px solid var(--border-primary)' }}>
{overview.days.map(day => {
const label = day.title || t('dayplan.dayN', { n: day.dayNumber })
const row = (
<>
<span style={{ width: 8, height: 8, borderRadius: 999, background: day.color.line, flexShrink: 0 }} aria-hidden />
<span className="text-content" style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label}
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0 }} className="text-content-muted">
{day.modes.map(mode => {
const Icon = profileIcon(mode)
return <Icon key={mode} size={12} strokeWidth={2} aria-hidden />
})}
</span>
<span className="text-content-muted" style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>
{formatDistance(day.distance / 1000, unit)}
</span>
</>
)
const style = {
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '7px 14px', textAlign: 'left' as const,
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
background: day.dayId === selectedDayId ? 'var(--bg-hover)' : 'transparent',
border: 'none',
}
return onSelectDay ? (
<button key={day.dayId} type="button" onClick={() => onSelectDay(day.dayId)} style={{ ...style, cursor: 'pointer' }}>
{row}
</button>
) : (
<div key={day.dayId} style={style}>{row}</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest'
import { buildDayRouteRuns } from './dayRoutePlan'
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
const at = (lat: number, lng: number, order: number, extra: Record<string, unknown> = {}) =>
buildAssignment({ day_id: 1, order_index: order, place: buildPlace({ lat, lng }), ...extra })
const inputs = (over: Partial<{
days: Day[]; assignments: AssignmentsMap; reservations: Reservation[]
accommodations: Accommodation[]; optimizeFromAccommodation: boolean | undefined
}> = {}) => ({
days: [buildDay({ id: 1, day_number: 1 })],
assignments: {},
reservations: [],
accommodations: [],
optimizeFromAccommodation: false,
...over,
})
describe('buildDayRouteRuns', () => {
it('FE-MAP-DRP-001: orders a day\'s located stops into one run', () => {
const runs = buildDayRouteRuns(1, inputs({
assignments: { '1': [at(48.86, 2.35, 1), at(45.76, 4.83, 0)] },
}))
expect(runs).toHaveLength(1)
// order_index, not the order they happen to sit in the array.
expect(runs[0].map(p => p.lat)).toEqual([45.76, 48.86])
expect(runs[0].every(p => p.isPlace)).toBe(true)
})
it('FE-MAP-DRP-002: drops a stop with no coordinates', () => {
const runs = buildDayRouteRuns(1, inputs({
assignments: {
'1': [
at(48.86, 2.35, 0),
buildAssignment({ day_id: 1, order_index: 1, place: buildPlace({ lat: null, lng: null }) }),
at(45.76, 4.83, 2),
],
},
}))
expect(runs[0]).toHaveLength(2)
})
it('FE-MAP-DRP-003: a lone stop is no drive at all', () => {
expect(buildDayRouteRuns(1, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
})
it('FE-MAP-DRP-004: carries the per-leg travel modes the router resolves against', () => {
const runs = buildDayRouteRuns(1, inputs({
assignments: {
'1': [
at(48.86, 2.35, 0, { leg_transport_mode: 'walking' }),
at(48.88, 2.36, 1, { incoming_leg_transport_mode: 'cycling' }),
],
},
}))
expect(runs[0][0].leg_transport_mode).toBe('walking')
expect(runs[0][1].incoming_leg_transport_mode).toBe('cycling')
})
it('FE-MAP-DRP-005: two real places stay one run however far apart they are', () => {
// Paris → Tokyo is far past MAX_DRIVE_KM, but both are real places someone
// planned, so the run stands: the reachability guard only ever splits a leg
// that touches a booking endpoint (#2133).
const runs = buildDayRouteRuns(1, inputs({
assignments: { '1': [at(48.86, 2.35, 0), at(35.68, 139.69, 1)] },
}))
expect(runs).toHaveLength(1)
})
it('FE-MAP-DRP-006: bookends the day with its accommodation when the setting is on', () => {
const accommodation = {
id: 1, trip_id: 1, place_lat: 48.80, place_lng: 2.30,
start_day_id: 1, end_day_id: 1,
} as unknown as Accommodation
const runs = buildDayRouteRuns(1, inputs({
assignments: { '1': [at(48.86, 2.35, 0), at(48.88, 2.36, 1)] },
accommodations: [accommodation],
optimizeFromAccommodation: true,
}))
const flat = runs.flat()
expect(flat.some(p => p.lat === 48.80 && !p.isPlace)).toBe(true)
})
it('FE-MAP-DRP-007: leaves the hotel out when the setting is off', () => {
const accommodation = {
id: 1, trip_id: 1, place_lat: 48.80, place_lng: 2.30,
start_day_id: 1, end_day_id: 1,
} as unknown as Accommodation
const runs = buildDayRouteRuns(1, inputs({
assignments: { '1': [at(48.86, 2.35, 0), at(48.88, 2.36, 1)] },
accommodations: [accommodation],
optimizeFromAccommodation: false,
}))
expect(runs.flat().some(p => p.lat === 48.80)).toBe(false)
})
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([])
})
})
+169
View File
@@ -0,0 +1,169 @@
import { withHotelBookends } from './RouteCalculator'
import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay } from '../../utils/dayMerge'
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
import { withinDriveRange } from '../../utils/geo'
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']
/** A waypoint on a day's drive, carrying the per-leg modes `resolveLegMode` reads off it. */
export interface DayRoutePoint {
lat: number
lng: number
isPlace: boolean
leg_transport_mode?: string | null
incoming_leg_transport_mode?: string | null
}
/** Everything the plan depends on, passed in rather than read from the store, so the
* builder stays pure and one caller can run it over every day of a trip at once. */
export interface DayRouteInputs {
days: Day[]
assignments: AssignmentsMap
reservations: Reservation[]
accommodations: Accommodation[]
optimizeFromAccommodation: boolean | undefined
}
/**
* The runs of waypoints a day's route is drawn from, hotel bookends folded in.
*
* Lifted out of `useRouteCalculation` unchanged so the trip-wide overview draws the
* same line the day view does. Every rule below is load-bearing and issue-numbered —
* a second copy of it would drift the moment either surface got the next fix.
*/
export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRoutePoint[][] {
const { days: allDays, assignments, reservations: allReservations, accommodations, optimizeFromAccommodation } = input
const da = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const dayOrder = (id: number | null | undefined): number | null => {
if (id == null) return null
const d = allDays.find(x => x.id === id)
return d ? (d.day_number ?? allDays.indexOf(d)) : null
}
const thisOrder = dayOrder(dayId)
// The order the day plan shows is the order the map has to draw, so take it from
// the same place the plan does rather than rebuilding it here. getTransportForDay
// brings the span filter, the hotel/assignment exclusions and the leg expansion with
// it; getMergedItems places each leg by its own saved position, falling back to its
// time. Notes carry no coordinates, so none are passed.
const dayTransports = thisOrder == null ? [] : getTransportForDay({
reservations: allReservations.filter(r => TRANSPORT_TYPES.includes(r.type)),
dayId,
dayAssignmentIds: da.map(a => a.id),
days: allDays,
})
const merged = getMergedItems({ dayAssignments: da, dayNotes: [], dayTransports, dayId })
type Entry =
| { kind: 'place'; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null }
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number; carrier: boolean }
const entries: Entry[] = merged.flatMap((item): Entry[] => {
if (item.type === 'place') {
const a = item.data
if (!a.place?.lat || !a.place?.lng) return []
return [{
kind: 'place', lat: a.place.lat, lng: a.place.lng, pos: item.sortKey, time: a.place?.place_time ?? null,
// Per-segment travel mode (#1281): mode of the leg leaving this place.
mode: (a as { leg_transport_mode?: string | null }).leg_transport_mode ?? null,
// Boundary-leg mode (#1281 follow-up): mode of the leg arriving at this place.
incoming: (a as { incoming_leg_transport_mode?: string | null }).incoming_leg_transport_mode ?? null,
}]
}
if (item.type !== 'transport') return []
const { from, to } = getTransportRouteEndpoints(item.data, dayId)
return [{ kind: 'transport', from, to, pos: item.sortKey, carrier: isCarrierTransport(item.data) }]
})
// Group located places into driving runs. A transport WITH a location anchors the
// route to its departure point then breaks the run (you don't drive the flight); its
// arrival starts the next. A transport WITHOUT a location is ignored entirely. A run
// is only a real drive when it contains at least one actual place, or two back-to-back
// transports pair into a phantom [airport → airport] road route (#1394). A booking
// endpoint also has to be REACHABLE from the stop before it, or a long-haul arrival
// sorted in among the day's local stops gets stapled to one of them (#2133).
const runs: DayRoutePoint[][] = []
let currentRun: DayRoutePoint[] = []
let runHasPlace = false
const closeRun = () => {
if (currentRun.length >= 2 && runHasPlace) runs.push(currentRun)
currentRun = []
runHasPlace = false
}
for (const entry of entries) {
if (entry.kind === 'place') {
const prev = currentRun[currentRun.length - 1]
// The open run may be nothing but a far-away arrival endpoint — break rather
// than draw the ocean.
if (prev && !prev.isPlace && !withinDriveRange(prev, entry)) closeRun()
currentRun.push({ lat: entry.lat, lng: entry.lng, isPlace: true, leg_transport_mode: entry.mode, incoming_leg_transport_mode: entry.incoming })
runHasPlace = true
} else if (entry.from || entry.to) {
const prev = currentRun[currentRun.length - 1]
if (entry.from && (!prev || withinDriveRange(prev, entry.from))) currentRun.push({ ...entry.from, isPlace: false })
closeRun()
if (entry.to) currentRun.push({ ...entry.to, isPlace: false })
}
}
closeRun()
// Bookend the route with the day's accommodation, so the drawn line matches the
// sidebar's hotel legs.
const day = allDays.find(d => d.id === dayId)
const bookends = day && optimizeFromAccommodation !== false
? getDayBookendHotels(day, allDays, accommodations)
: null
const flatPts: DayRoutePoint[] = []
for (const e of entries) {
if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng, isPlace: true, leg_transport_mode: e.mode, incoming_leg_transport_mode: e.incoming })
else { if (e.from) flatPts.push({ ...e.from, isPlace: false }); if (e.to) flatPts.push({ ...e.to, isPlace: false }) }
}
// A hotel bookend point is not a place-assignment, so isPlace: false — resolveLegMode
// falls through to the day default for hotel-adjacent legs unless the place endpoint
// carries its own override.
const hotelPt = (a?: Accommodation): DayRoutePoint | null =>
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng, isPlace: false } : null
// Only draw a hotel bookend when the leg is a real drive: a place before check-in
// (#1465), a later "home" stop on the checkout day (#1465), or a transport endpoint on
// an arrival/departure day (#1321, #2133) all draw no bookend.
const contributes = (e: Entry) => e.kind === 'place' || !!e.from || !!e.to
const firstStop = entries.find(contributes)
const lastStop = [...entries].reverse().find(contributes)
const edgeInfo = (e: Entry | undefined, side: 'first' | 'last') => {
if (!e) return undefined
if (e.kind === 'place') return { isPlace: true, time: e.time, lat: e.lat, lng: e.lng }
const role: CarrierEdge = side === 'first'
? (e.from ? 'departure' : 'arrival')
: (e.to ? 'arrival' : 'departure')
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null }
}
// 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
// check-out hotel its origin, which the no-time bookend default respects (#2157).
const dayHasCarrier = dayTransports.some(r => hasCarrierEndpointOnDay(r, dayId))
const firstWay = flatPts[0]
const lastWay = flatPts[flatPts.length - 1]
const morningHotel = hotelPt(bookends?.morning)
const eveningHotel = hotelPt(bookends?.evening)
const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'), dayHasCarrier)
&& (!morningHotel || !firstWay || firstWay.isPlace || withinDriveRange(morningHotel, firstWay))
const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'), dayHasCarrier)
&& (!eveningHotel || !lastWay || lastWay.isPlace || withinDriveRange(eveningHotel, lastWay))
const runsWithHotel: DayRoutePoint[][] = withHotelBookends(
runs,
firstWay,
lastWay,
drawMorning ? morningHotel : null,
drawEvening ? eveningHotel : null,
)
// Transfer day with no activities: you check out of one accommodation and into
// another, so there are no waypoints for withHotelBookends to attach a leg to.
if (runsWithHotel.length === 0 && drawMorning && drawEvening) {
const m = hotelPt(bookends?.morning)
const e = hotelPt(bookends?.evening)
if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e])
}
return runsWithHotel
}
@@ -0,0 +1,10 @@
/**
* The drop shadow every floating map control carries.
*
* Tighter and darker than `--sidebar-shadow`, which is built for a panel resting on
* the app background: over map tiles its 0.10 alpha spread over 32px all but
* disappears, so a round control read as painted onto the map rather than floating
* above it. This is the locate button's shadow, which was always the odd one out for
* exactly that reason — the others were the ones that needed to match it.
*/
export const MAP_CONTROL_SHADOW = '0 2px 10px rgba(0,0,0,0.25)'
@@ -0,0 +1,139 @@
import { renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { dayColor } from '../Roadtrip/dayColors'
import { useTripRouteOverview } from './useTripRouteOverview'
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
import type { AssignmentsMap, RouteSegment } from '../../types'
vi.mock('./RouteCalculator', async (importActual) => {
const actual = await importActual<typeof import('./RouteCalculator')>()
return { ...actual, calculateRouteWithLegs: vi.fn() }
})
const { calculateRouteWithLegs } = await import('./RouteCalculator')
const leg = (distance: number, duration: number): RouteSegment => ({
mid: [0, 0], from: [0, 0], to: [0, 0],
distance, duration,
distanceText: `${Math.round(distance / 1000)} km`,
durationText: '1 h', walkingText: '5 h', drivingText: '1 h',
})
const at = (lat: number, lng: number, order: number, dayId: number, extra: Record<string, unknown> = {}) =>
buildAssignment({ day_id: dayId, order_index: order, place: buildPlace({ lat, lng }), ...extra })
const DAYS = [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 2, day_number: 2 })]
const ASSIGNMENTS: AssignmentsMap = {
'1': [at(48.86, 2.35, 0, 1), at(48.88, 2.36, 1, 1)],
'2': [at(45.76, 4.83, 0, 2), at(45.78, 4.85, 1, 2)],
}
const render = (enabled = true, assignments: AssignmentsMap = ASSIGNMENTS, days = DAYS) =>
renderHook(
({ assignments: a }: { assignments: AssignmentsMap }) =>
useTripRouteOverview(7, days, a, [], [], 'driving', enabled),
{ initialProps: { assignments } },
)
beforeEach(() => {
vi.mocked(calculateRouteWithLegs).mockReset()
vi.mocked(calculateRouteWithLegs).mockResolvedValue({
coordinates: [[48.86, 2.35], [48.87, 2.355], [48.88, 2.36]],
distance: 12000, duration: 900,
legs: [leg(12000, 900)],
})
})
describe('useTripRouteOverview', () => {
it('FE-MAP-TRO-001: does nothing at all until it is switched on', () => {
const { result } = render(false)
expect(result.current.days).toEqual([])
expect(result.current.totalDistance).toBe(0)
expect(result.current.loading).toBe(false)
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
})
it('FE-MAP-TRO-002: sums every day into the trip total', async () => {
const { result } = render()
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days).toHaveLength(2)
expect(result.current.days.map(d => d.distance)).toEqual([12000, 12000])
expect(result.current.totalDistance).toBe(24000)
expect(result.current.totalDuration).toBe(1800)
})
it('FE-MAP-TRO-003: gives each day the colour the road trip draws it in', async () => {
const { result } = render()
await waitFor(() => expect(result.current.loading).toBe(false))
const colors = result.current.days.map(d => d.color)
// The same palette either way, so a day does not change colour when the trip is
// read as a road trip instead of an overview.
expect(colors[0]).toEqual(dayColor(1))
expect(colors[1]).toEqual(dayColor(2))
// Every drawn line is labelled with the colour of the day it belongs to.
expect(result.current.lineColors).toHaveLength(result.current.lines.length)
expect(new Set(result.current.lineColors.map(c => c.line)).size).toBe(2)
})
it('FE-MAP-TRO-004: reports the modes each day is travelled in', async () => {
const { result } = render(true, {
'1': [at(48.86, 2.35, 0, 1, { leg_transport_mode: 'walking' }), at(48.88, 2.36, 1, 1)],
'2': ASSIGNMENTS['2'],
})
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days[0].modes).toEqual(['walking'])
expect(result.current.days[1].modes).toEqual(['driving'])
expect(result.current.days[0].segments[0].mode).toBe('walking')
})
it('FE-MAP-TRO-005: draws straight lines first and keeps them when routing refuses', async () => {
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new Error('429'))
const { result } = render()
await waitFor(() => expect(result.current.loading).toBe(false))
// The shape of the trip is still on the map...
expect(result.current.lines).toHaveLength(2)
expect(result.current.lines[0]).toEqual([[48.86, 2.35], [48.88, 2.36]])
// ...but a leg that never answered contributes no distance.
expect(result.current.totalDistance).toBe(0)
})
it('FE-MAP-TRO-006: a day with nothing to drive is left out', async () => {
const { result } = render(true, { '1': ASSIGNMENTS['1'], '2': [at(45.76, 4.83, 0, 2)] })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days.map(d => d.dayId)).toEqual([1])
})
it('FE-MAP-TRO-007: frames the whole trip, not one day of it', async () => {
const { result } = render()
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.focusPoints.length).toBe(result.current.lines.flat().length)
expect(result.current.focusPoints.length).toBeGreaterThan(2)
})
it('FE-MAP-TRO-008: renaming a place does not re-route the trip', async () => {
const { result, rerender } = render()
await waitFor(() => expect(result.current.loading).toBe(false))
const calls = vi.mocked(calculateRouteWithLegs).mock.calls.length
expect(calls).toBeGreaterThan(0)
// A new object identity with the same geometry: the plan memo re-runs, the
// routing round must not.
rerender({
assignments: {
...ASSIGNMENTS,
'1': ASSIGNMENTS['1'].map(a => ({ ...a, place: { ...a.place, name: 'Renamed' } })),
},
})
await waitFor(() => expect(result.current.loading).toBe(false))
expect(vi.mocked(calculateRouteWithLegs).mock.calls.length).toBe(calls)
})
})
@@ -0,0 +1,227 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { calculateRouteWithLegs, type RouteProfileKey } from './RouteCalculator'
import { buildDayRouteRuns, type DayRoutePoint } from './dayRoutePlan'
import { resolveLegMode } from '../Planner/legMode'
import { useSettingsStore } from '../../store/settingsStore'
import { dayColor } from '../Roadtrip/dayColors'
import type { Accommodation, AssignmentsMap, Day, Reservation, RouteSegment } from '../../types'
/** One travel day of the overview: the roads it covers, in its own colour. */
export interface TripOverviewDay {
dayId: number
dayNumber: number
date: string | null
title: string | null
/** The core and casing this day is drawn in — the road trip's palette, so a day is
* the same colour whichever way the trip is being read. */
color: { line: string; casing: string }
/** One polyline per run of the day, `[lat, lng]`. */
lines: [number, number][][]
segments: RouteSegment[]
/** Metres and seconds, summed over the day's legs. */
distance: number
duration: number
/** The modes this day is actually travelled in, first use first. */
modes: string[]
}
export interface TripRouteOverview {
days: TripOverviewDay[]
/** Every day's polylines flattened in trip order — what the map draws. */
lines: [number, number][][]
/** The colour of each entry of `lines`, same index. */
lineColors: { line: string; casing: string }[]
segments: RouteSegment[]
/** Every drawn coordinate, so the map can frame the whole trip at once. */
focusPoints: [number, number][]
totalDistance: number
totalDuration: number
/** True until every leg has answered — the totals are a partial sum until then. */
loading: boolean
}
const EMPTY: TripRouteOverview = {
days: [], lines: [], lineColors: [], segments: [], focusPoints: [],
totalDistance: 0, totalDuration: 0, loading: false,
}
/** Neighbouring legs of one run that resolve to the same mode travel as one request,
* exactly as the single-day route builds them. */
interface Chunk { points: DayRoutePoint[]; mode: string }
function chunkRun(run: DayRoutePoint[], dayDefaultMode: string): Chunk[] {
const chunks: Chunk[] = []
let i = 0
while (i < run.length - 1) {
const mode = resolveLegMode(run[i], run[i + 1], dayDefaultMode)
let end = i + 1
while (end < run.length - 1 && resolveLegMode(run[end], run[end + 1], dayDefaultMode) === mode) end++
chunks.push({ points: run.slice(i, end + 1), mode })
i = end
}
return chunks
}
const straight = (points: DayRoutePoint[]): [number, number][] => points.map(p => [p.lat, p.lng])
/**
* Every travel day's route at once, each day in its own colour, with the trip's total
* distance (#1736).
*
* The geometry is the day view's own — `buildDayRouteRuns` is the same builder
* `useRouteCalculation` draws the selected day from, so a day looks identical whether
* you are looking at it alone or at the whole trip. Only the aggregation is new.
*
* Legs go out a few at a time rather than all at once: a fortnight is dozens of legs and
* the routing host is usually a shared OSRM. RouteCalculator's cache is shared with the
* day route and the sidebar connectors, so the day already on screen costs nothing.
*/
export function useTripRouteOverview(
tripId: number | null,
days: Day[],
assignments: AssignmentsMap,
reservations: Reservation[],
accommodations: Accommodation[],
profile: RouteProfileKey,
enabled: boolean,
): TripRouteOverview {
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
// Leg text is formatted at compute time, so a km↔mi switch has to re-run (#1300).
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const [result, setResult] = useState<TripRouteOverview>(EMPTY)
const abortRef = useRef<AbortController | null>(null)
const plan = useMemo(() => {
if (!enabled) return []
return [...days]
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
.map(day => ({
day,
runs: buildDayRouteRuns(day.id, { days, assignments, reservations, accommodations, optimizeFromAccommodation }),
}))
.filter(entry => entry.runs.length > 0)
}, [enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation])
// Only geometry and mode decide whether legs have to be fetched again: renaming a
// place or editing its notes must not fire a routing round.
const planKey = useMemo(
() => plan.map(({ day, runs }) => `${day.id}@${day.default_transport_mode ?? ''}:${runs
.map(run => run.map(p => `${p.lat.toFixed(5)},${p.lng.toFixed(5)},${p.leg_transport_mode ?? ''},${p.incoming_leg_transport_mode ?? ''}`).join('|'))
.join('/')}`).join(';'),
[plan],
)
useEffect(() => {
abortRef.current?.abort()
if (!plan.length) { setResult(EMPTY); return }
const controller = new AbortController()
abortRef.current = controller
const colorFor = (day: Day) => dayColor(day.day_number ?? 0)
// Chunk every day up front, so the routed answers can be filed by position and
// reassembled in order however they come back.
const chunked = plan.map(({ day, runs }) => {
const dayDefaultMode = day.default_transport_mode || profile
return { day, runs: runs.map(run => chunkRun(run, dayDefaultMode)) }
})
const assemble = (
routed: (({ coordinates: [number, number][]; legs: RouteSegment[] }) | null)[][][],
): TripRouteOverview => {
const overviewDays: TripOverviewDay[] = chunked.map(({ day, runs }, d) => {
const lines: [number, number][][] = []
const segments: RouteSegment[] = []
runs.forEach((chunks, r) => {
const polyline: [number, number][] = []
chunks.forEach((chunk, c) => {
const answer = routed[d][r][c]
const coords = answer && answer.coordinates.length >= 2 ? answer.coordinates : straight(chunk.points)
for (const point of coords) {
// Drop the point shared with the previous chunk so concatenated legs
// don't leave a duplicate at each junction.
const last = polyline[polyline.length - 1]
if (last && last[0] === point[0] && last[1] === point[1]) continue
polyline.push(point)
}
if (answer) for (const leg of answer.legs) segments.push({ ...leg, mode: chunk.mode })
})
if (polyline.length >= 2) lines.push(polyline)
})
const modes: string[] = []
for (const chunk of runs.flat()) if (!modes.includes(chunk.mode)) modes.push(chunk.mode)
return {
dayId: day.id,
dayNumber: day.day_number ?? 0,
date: day.date ?? null,
title: day.title ?? null,
color: colorFor(day),
lines,
segments,
distance: segments.reduce((sum, s) => sum + s.distance, 0),
duration: segments.reduce((sum, s) => sum + s.duration, 0),
modes,
}
})
const lines = overviewDays.flatMap(d => d.lines)
return {
days: overviewDays,
lines,
lineColors: overviewDays.flatMap(d => d.lines.map(() => d.color)),
segments: overviewDays.flatMap(d => d.segments),
focusPoints: lines.flat(),
totalDistance: overviewDays.reduce((sum, d) => sum + d.distance, 0),
totalDuration: overviewDays.reduce((sum, d) => sum + d.duration, 0),
loading: false,
}
}
const routed = chunked.map(({ runs }) => runs.map(chunks => chunks.map(() => null as
({ coordinates: [number, number][]; legs: RouteSegment[] }) | null)))
// Straight lines first so the shape of the trip is on screen immediately, then the
// real roads replace them — the same two-step the single-day route draws with.
setResult({ ...assemble(routed), loading: true })
;(async () => {
const tasks: (() => Promise<void>)[] = []
chunked.forEach(({ day, runs }, d) => {
runs.forEach((chunks, r) => {
chunks.forEach((chunk, c) => {
tasks.push(async () => {
try {
const answer = await calculateRouteWithLegs(
chunk.points.map(p => ({ lat: p.lat, lng: p.lng })),
{ signal: controller.signal, profile: chunk.mode, tripId, dayId: day.id },
)
routed[d][r][c] = { coordinates: answer.coordinates, legs: answer.legs }
} catch {
// Routing refused this leg (usually a rate limit) — it keeps its
// straight line and contributes no distance, exactly as a day route's
// failed leg does.
}
})
})
})
})
// Small pool on purpose, the same one the sidebar connectors use: a fortnight is
// dozens of legs and the routing host is often a shared OSRM.
let next = 0
const worker = async () => {
while (next < tasks.length && !controller.signal.aborted) await tasks[next++]()
}
await Promise.all(Array.from({ length: Math.min(6, tasks.length) }, worker))
if (!controller.signal.aborted) setResult(assemble(routed))
})()
return () => controller.abort()
// planKey is derived from the same inputs as plan, so keying on the string is
// equivalent while staying stable across unrelated renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [planKey, profile, tripId, distanceUnit])
return result
}
@@ -4,7 +4,7 @@ import type { RouteSegment } from '../../types'
// Walking gets the foot icon; a plugin route profile ('plugin:…') gets the bolt —
// its legs carry the profile-true durationText anyway, the icon just signals that
// the time came from a plugin router (e.g. EV routing with charge time folded in).
function profileIcon(profile: string) {
export function profileIcon(profile: string) {
if (profile === 'walking') return Footprints
if (profile.startsWith('plugin:')) return Zap
return Car
@@ -1,4 +1,4 @@
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-093
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-095
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -558,6 +558,35 @@ describe('ReservationModal', () => {
});
});
it('FE-PLANNER-RESMODAL-094: an outside pointer closes the file picker while an inside pointer keeps it open', async () => {
const res = buildReservation({ id: 5 });
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
render(<ReservationModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
const pickerItem = screen.getByText('invoice.pdf');
fireEvent.pointerDown(pickerItem);
expect(screen.getByText('invoice.pdf')).toBeInTheDocument();
fireEvent.pointerDown(document.body);
expect(screen.queryByText('invoice.pdf')).not.toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-095: closing and reopening the modal resets the file picker', async () => {
const res = buildReservation({ id: 5 });
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
const { rerender } = render(<ReservationModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
expect(screen.getByText('invoice.pdf')).toBeInTheDocument();
rerender(<ReservationModal {...defaultProps} isOpen={false} reservation={res} files={[unattachedFile]} />);
rerender(<ReservationModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
expect(screen.queryByText('invoice.pdf')).not.toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-040: removing pending file removes it from list', async () => {
render(<ReservationModal {...defaultProps} reservation={null} />);
@@ -84,6 +84,20 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
// Travelers assigned to this booking (#1517) — seeded on open, persisted after the save resolves.
const [travelerIds, setTravelerIds] = useState<Set<number>>(new Set())
const filePickerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!showFilePicker) return
const closeOnOutsidePointer = (event: PointerEvent) => {
if (!filePickerRef.current?.contains(event.target as Node)) setShowFilePicker(false)
}
document.addEventListener('pointerdown', closeOnOutsidePointer)
return () => document.removeEventListener('pointerdown', closeOnOutsidePointer)
}, [showFilePicker])
useEffect(() => {
if (!isOpen) setShowFilePicker(false)
}, [isOpen])
const assignmentOptions = useMemo(
() => buildAssignmentOptions(days, assignments, t, locale),
@@ -731,7 +745,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
</button>}
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
<div style={{ position: 'relative' }}>
<div ref={filePickerRef} style={{ position: 'relative' }}>
<button type="button" onClick={() => setShowFilePicker(v => !v)} className="text-content-faint" style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
@@ -1,4 +1,4 @@
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-061
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-064
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -259,6 +259,35 @@ describe('TransportModal', () => {
});
});
it('FE-PLANNER-TRANSMODAL-063: an outside pointer closes the file picker while an inside pointer keeps it open', async () => {
const res = buildReservation({ id: 5, type: 'flight' });
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
const pickerItem = screen.getByText('invoice.pdf');
fireEvent.pointerDown(pickerItem);
expect(screen.getByText('invoice.pdf')).toBeInTheDocument();
fireEvent.pointerDown(document.body);
expect(screen.queryByText('invoice.pdf')).not.toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-064: closing and reopening the modal resets the file picker', async () => {
const res = buildReservation({ id: 5, type: 'flight' });
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
const { rerender } = render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
expect(screen.getByText('invoice.pdf')).toBeInTheDocument();
rerender(<TransportModal {...defaultProps} isOpen={false} reservation={res} files={[unattachedFile]} />);
rerender(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
expect(screen.queryByText('invoice.pdf')).not.toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-022: removing pending file removes it from list', async () => {
render(<TransportModal {...defaultProps} reservation={null} />);
@@ -234,6 +234,20 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
// Travelers assigned to this booking (#1517) — seeded on open, persisted after save.
const [travelerIds, setTravelerIds] = useState<Set<number>>(new Set())
const fileInputRef = useRef<HTMLInputElement>(null)
const filePickerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!showFilePicker) return
const closeOnOutsidePointer = (event: PointerEvent) => {
if (!filePickerRef.current?.contains(event.target as Node)) setShowFilePicker(false)
}
document.addEventListener('pointerdown', closeOnOutsidePointer)
return () => document.removeEventListener('pointerdown', closeOnOutsidePointer)
}, [showFilePicker])
useEffect(() => {
if (!isOpen) setShowFilePicker(false)
}, [isOpen])
useEffect(() => {
if (!isOpen) return
@@ -1182,7 +1196,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
</button>}
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
<div style={{ position: 'relative' }}>
<div ref={filePickerRef} style={{ position: 'relative' }}>
<button type="button" onClick={() => setShowFilePicker(v => !v)} className="text-content-faint" style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
+14 -10
View File
@@ -6,7 +6,7 @@ import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import apiClient from '../../api/client'
import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from './holidayRegions'
import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries'
import { useSchoolHolidayCountries } from './useSchoolHolidayCountries'
import { windowMonths } from '../../vacay/yearWindow'
import type { VacayHolidayCalendar, VacayYearSettings } from '../../types'
@@ -38,12 +38,13 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
}).catch(() => {})
}, [language])
const { countries: schoolHolidayCountries, error: schoolCountryError } = useSchoolHolidayCountries()
if (!plan) return null
const toggle = (key: string) => updatePlan({ [key]: !plan[key] })
const publicHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => (cal.type ?? 'public_holiday') === 'public_holiday')
const schoolHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => cal.type === 'school_holiday')
const schoolHolidayCountries = countries.filter(country => country.value in SCHOOL_HOLIDAY_COUNTRY_CONFIG)
return (
<div className="space-y-4">
@@ -216,6 +217,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
value={plan.school_holidays_enabled}
onChange={() => toggle('school_holidays_enabled')}
/>
{schoolCountryError && <p role="alert" className="text-caption text-danger">{schoolCountryError}</p>}
{plan.school_holidays_enabled && (
<div className="ml-7 mt-2 space-y-2">
{schoolHolidayCalendars.length === 0 && (
@@ -444,18 +446,18 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg
* apart from "this country has no regions"; on the list alone both are empty.
*/
function useRegionOptions(country: string, calendarType: 'public_holiday' | 'school_holiday') {
const [loaded, setLoaded] = useState<{ country: string; options: { value: string; label: string }[] }>({ country: '', options: [] })
const [loaded, setLoaded] = useState<{ country: string; options: { value: string; label: string }[]; failed?: boolean }>({ country: '', options: [] })
useEffect(() => {
if (!country) return
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
let stale = false
load(country).then(options => { if (!stale) setLoaded({ country, options }) })
load(country).then(options => { if (!stale) setLoaded({ country, options }) }).catch(() => { if (!stale) setLoaded({ country, options: [], failed: true }) })
return () => { stale = true }
}, [calendarType, country])
const ready = loaded.country === country
return { regions: ready ? loaded.options : [], loadingRegions: Boolean(country) && !ready }
return { regions: ready ? loaded.options : [], regionError: ready && loaded.failed, loadingRegions: Boolean(country) && !ready }
}
// ── Existing calendar row (inline edit) ──────────────────────────────────────
@@ -474,7 +476,7 @@ function CalendarRow({ cal, countries, calendarType, onUpdate, onDelete }: {
const [baseRegion] = cal.region.split('|')
const selectedCountry = baseRegion.split('-')[0]
const selectedRegion = cal.region.includes('|group:') || baseRegion.includes('-') ? cal.region : ''
const { regions } = useRegionOptions(selectedCountry, calendarType)
const { regions, regionError } = useRegionOptions(selectedCountry, calendarType)
useEffect(() => { setLocalColor(cal.color) }, [cal.color])
useEffect(() => { setLocalLabel(cal.label || '') }, [cal.label])
@@ -516,9 +518,10 @@ function CalendarRow({ cal, countries, calendarType, onUpdate, onDelete }: {
placeholder={t('vacay.selectCountry')}
searchable
/>
{regionError && <p role="alert" className="text-caption text-danger">{t('schoolCatalog.loadError')}</p>}
{regions.length > 0 && (
<CustomSelect
value={selectedRegion}
value={selectedRegion || (regions.some(option => option.value === selectedCountry) ? selectedCountry : '')}
onChange={v => onUpdate({ region: String(v) })}
options={regions}
placeholder={t('vacay.selectRegion')}
@@ -555,12 +558,12 @@ function AddCalendarForm({ countries, calendarType, onAdd, onCancel, defaultColo
const [baseRegion] = region.split('|')
const selectedCountry = baseRegion.split('-')[0] || ''
const selectedRegion = region.includes('|group:') || baseRegion.includes('-') ? region : ''
const { regions, loadingRegions } = useRegionOptions(selectedCountry, calendarType)
const { regions, loadingRegions, regionError } = useRegionOptions(selectedCountry, calendarType)
// Adding is blocked while the region list is on its way: an empty list would
// otherwise pass as "this country needs no region" and create a calendar that
// draws nothing (#1813). The button shows a spinner so the wait is visible.
const canAdd = Boolean(selectedCountry) && !loadingRegions && (regions.length === 0 || selectedRegion !== '')
const canAdd = Boolean(selectedCountry) && !loadingRegions && !regionError && (regions.length === 0 || regions.some(option => option.value === region))
const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
const [showColorPicker, setShowColorPicker] = useState(false)
@@ -597,9 +600,10 @@ function AddCalendarForm({ countries, calendarType, onAdd, onCancel, defaultColo
placeholder={t('vacay.selectCountry')}
searchable
/>
{regionError && <p role="alert" className="text-caption text-danger">{t('schoolCatalog.loadError')}</p>}
{regions.length > 0 && (
<CustomSelect
value={selectedRegion}
value={selectedRegion || (regions.some(option => option.value === selectedCountry) ? selectedCountry : '')}
onChange={v => setRegion(String(v))}
options={regions}
placeholder={t('vacay.selectRegion')}
@@ -112,10 +112,10 @@ describe('fetchSchoolHolidayRegionOptions', () => {
])
})
it('FE-COMP-VCYREG-008: a failing region request degrades to no regions', async () => {
it('FE-COMP-VCYREG-008: a failing request cannot masquerade as a country without regions', async () => {
server.use(http.get('/api/addons/vacay/school-holidays/regions/:country', () =>
HttpResponse.json({ error: 'down' }, { status: 500 })))
expect(await fetchSchoolHolidayRegionOptions('NL')).toEqual([])
await expect(fetchSchoolHolidayRegionOptions('NL')).rejects.toThrow('could not be loaded')
})
})
+16 -6
View File
@@ -1,3 +1,4 @@
import { schoolHolidayCatalogRepo } from '../../repo/schoolHolidayCatalogRepo'
import apiClient from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries'
@@ -77,23 +78,32 @@ function activeLanguage(): string {
export async function fetchSchoolHolidayRegionOptions(country: string, lang = activeLanguage()): Promise<{ value: string; label: string }[]> {
const config = SCHOOL_HOLIDAY_COUNTRY_CONFIG[country]
if (!config || config.strategy === 'country') return []
const catalog = await schoolHolidayCatalogRepo.catalog().catch(error => {
if (!config) throw error
return { countries: [], regions: [] }
})
const manual = catalog.regions.filter(region => region.country === country).map(region => ({ value: region.code, label: region.name }))
if (!config) return manual
if (config.strategy === 'country') {
return manual.length ? [{ value: country, label: country }, ...manual] : []
}
try {
const r = await apiClient.get(`/addons/vacay/school-holidays/regions/${country}`)
if (config.strategy === 'groups') {
return flattenOptions(r.data.groups, lang)
return [...manual, ...flattenOptions(r.data.groups, lang)
.map(opt => ({
value: `${country}|group:${opt.value}`,
label: opt.label,
}))
}))]
.sort((a, b) => a.label.localeCompare(b.label))
}
return flattenOptions(r.data.subdivisions, lang)
.map(opt => ({ value: opt.value, label: opt.label }))
return [...manual, ...flattenOptions(r.data.subdivisions, lang)
.map(opt => ({ value: opt.value, label: opt.label }))]
.sort((a, b) => a.label.localeCompare(b.label))
} catch {
return []
if (manual.length) return manual
throw new Error('School holiday regions could not be loaded')
}
}
@@ -0,0 +1,25 @@
import { useEffect, useState } from 'react'
import { schoolHolidayCatalogRepo } from '../../repo/schoolHolidayCatalogRepo'
import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries'
import { getIntlLanguage, useTranslation } from '../../i18n'
export function useSchoolHolidayCountries(enabled = true) {
const { language, t } = useTranslation()
const [manual, setManual] = useState<{ code: string; name: string }[]>([])
const [error, setError] = useState('')
useEffect(() => {
if (!enabled) return
let stale = false
schoolHolidayCatalogRepo.catalog().then(catalog => {
if (!stale) {
setManual(catalog.countries.filter(country => catalog.regions.some(region => region.country === country.code)))
setError('')
}
}).catch(() => { if (!stale) setError(t('schoolCatalog.loadError')) })
return () => { stale = true }
}, [enabled, t])
const names = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' })
const countries = new Map(Object.keys(SCHOOL_HOLIDAY_COUNTRY_CONFIG).map(code => [code, names.of(code) || code]))
for (const country of manual) if (!countries.has(country.code)) countries.set(country.code, country.name)
return { countries: [...countries].map(([value, label]) => ({ value, label })).sort((a, b) => a.label.localeCompare(b.label)), error }
}
+9 -171
View File
@@ -3,16 +3,12 @@ import { isServiceStopType } from '../components/Roadtrip/roadtripModel'
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useTripStore } from '../store/tripStore'
import { useSettingsStore } from '../store/settingsStore'
import { calculateRouteWithLegs, withHotelBookends, type RouteProfileKey } from '../components/Map/RouteCalculator'
import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay } from '../utils/dayMerge'
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../utils/dayOrder'
import { withinDriveRange } from '../utils/geo'
import { calculateRouteWithLegs, type RouteProfileKey } from '../components/Map/RouteCalculator'
import { buildDayRouteRuns, TRANSPORT_TYPES } from '../components/Map/dayRoutePlan'
import { resolveLegMode } from '../components/Planner/legMode'
import type { TripStoreState } from '../store/tripStore'
import type { RouteSegment, RouteResult, RouteVia, Accommodation } from '../types'
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']
const NO_ACCOMMODATIONS: Accommodation[] = []
/**
@@ -47,174 +43,16 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
if (!dayId || !enabled) { setRoute(null); setRouteSegments([]); setRouteVias([]); return }
// Read directly from store (not a render-phase ref) so callers after optimistic
// updates or non-optimistic deletes always see the latest assignments.
const currentAssignments = useTripStore.getState().assignments || {}
const da = (currentAssignments[String(dayId)] || []).filter(a => mirrorServiceStops || !isServiceStopType(a.place?.stop_type)).slice().sort((a, b) => a.order_index - b.order_index)
const allReservations = useTripStore.getState().reservations || []
const allDays = useTripStore.getState().days || []
const dayOrder = (id: number | null | undefined): number | null => {
if (id == null) return null
const d = allDays.find(x => x.id === id)
return d ? ((d as any).day_number ?? allDays.indexOf(d)) : null
}
const thisOrder = dayOrder(dayId)
// The order the day plan shows is the order the map has to draw, so take it from
// the same place the plan does rather than rebuilding it here. The old builder
// read the BOOKING's position and never expanded metadata.legs, while a
// multi-leg booking stores its position per leg — so a layover flight was
// dropped from the waypoint list entirely and the airport before it was joined
// to the airport after it in one road run across an ocean (#2071).
//
// getTransportForDay brings the span filter, the hotel/assignment exclusions and
// the leg expansion with it; getMergedItems places each leg by its own saved
// position, falling back to its time. Notes carry no coordinates, so none are
// passed.
const dayTransports = thisOrder == null ? [] : getTransportForDay({
reservations: allReservations.filter(r => TRANSPORT_TYPES.includes(r.type)),
dayId,
dayAssignmentIds: da.map(a => a.id),
const state = useTripStore.getState()
const allDays = state.days || []
const runsWithHotel = buildDayRouteRuns(dayId, {
days: allDays,
assignments: Object.fromEntries(Object.entries(state.assignments || {}).map(([id, entries]) => [id, entries.filter(a => mirrorServiceStops || !isServiceStopType(a.place?.stop_type))])),
reservations: state.reservations || [],
accommodations,
optimizeFromAccommodation,
})
const merged = getMergedItems({ dayAssignments: da, dayNotes: [], dayTransports, dayId })
// Build a unified list of places + transports sorted by effective position.
type Entry =
| { kind: 'place'; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null }
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number; carrier: boolean }
const entries: Entry[] = merged.flatMap((item): Entry[] => {
if (item.type === 'place') {
const a = item.data
if (!a.place?.lat || !a.place?.lng) return []
return [{
kind: 'place', lat: a.place.lat, lng: a.place.lng, pos: item.sortKey, time: a.place?.place_time ?? null,
// Per-segment travel mode (#1281): mode of the leg leaving this place.
mode: (a as { leg_transport_mode?: string | null }).leg_transport_mode ?? null,
// Boundary-leg mode (#1281 follow-up): mode of the leg arriving at this place.
incoming: (a as { incoming_leg_transport_mode?: string | null }).incoming_leg_transport_mode ?? null,
}]
}
if (item.type !== 'transport') return []
const { from, to } = getTransportRouteEndpoints(item.data, dayId)
return [{ kind: 'transport', from, to, pos: item.sortKey, carrier: isCarrierTransport(item.data) }]
})
// Group located places into driving runs.
// - A transport WITH a location anchors the route to its departure point (you
// travel there), then breaks the run (you don't drive the flight/train); its
// arrival point starts the next run.
// - A transport WITHOUT a location is ignored entirely — the places around it
// connect directly, as if the booking weren't there.
// A run is only a real drive when it contains at least one actual place. Two
// back-to-back transports (e.g. two flights on one day) would otherwise pair the
// first's arrival point with the second's departure point into a phantom
// [airport → airport] road route — that is the flight itself, not a drive (#1394).
//
// A booking endpoint also has to be REACHABLE from the stop before it. The day's
// order is a wall clock with no timezone behind it, so a long-haul arrival whose
// departure clock reads late sorts after the day's local stops — and its departure
// airport, an ocean away, then gets stapled to the last of them. The router answers
// that pair with NoRoute and the whole chunk falls back to a straight line, which is
// the long ray the map draws (#2133). Distance is only ever consulted for a leg
// touching a transport endpoint: two real places far apart are a drive someone
// planned, an airport that far from the stop before it never is.
type RunPoint = { lat: number; lng: number; isPlace: boolean; leg_transport_mode?: string | null; incoming_leg_transport_mode?: string | null }
const runs: RunPoint[][] = []
let currentRun: RunPoint[] = []
let runHasPlace = false
const closeRun = () => {
if (currentRun.length >= 2 && runHasPlace) runs.push(currentRun)
currentRun = []
runHasPlace = false
}
for (const entry of entries) {
if (entry.kind === 'place') {
const prev = currentRun[currentRun.length - 1]
// The open run may be nothing but a far-away arrival endpoint — break rather
// than draw the ocean.
if (prev && !prev.isPlace && !withinDriveRange(prev, entry)) closeRun()
currentRun.push({ lat: entry.lat, lng: entry.lng, isPlace: true, leg_transport_mode: entry.mode, incoming_leg_transport_mode: entry.incoming })
runHasPlace = true
} else if (entry.from || entry.to) {
const prev = currentRun[currentRun.length - 1]
if (entry.from && (!prev || withinDriveRange(prev, entry.from))) currentRun.push({ ...entry.from, isPlace: false })
closeRun()
if (entry.to) currentRun.push({ ...entry.to, isPlace: false })
}
}
closeRun()
// Bookend the route with the day's accommodation: a hotel → first-stop run and
// a last-stop → hotel run, so the drawn line matches the sidebar's hotel legs.
// getDayBookendHotels returns the morning/evening hotel (they differ only on a
// transfer day) and already filters to accommodations that have coordinates.
const day = allDays.find(d => d.id === dayId)
const bookends = day && optimizeFromAccommodation !== false
? getDayBookendHotels(day, allDays, accommodations)
: null
const flatPts: RunPoint[] = []
for (const e of entries) {
if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng, isPlace: true, leg_transport_mode: e.mode, incoming_leg_transport_mode: e.incoming })
else { if (e.from) flatPts.push({ ...e.from, isPlace: false }); if (e.to) flatPts.push({ ...e.to, isPlace: false }) }
}
// A hotel bookend point is not a place-assignment, so isPlace: false — resolveLegMode
// falls through to the day default for hotel-adjacent legs unless the place endpoint
// carries its own override.
const hotelPt = (a?: Accommodation): RunPoint | null =>
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng, isPlace: false } : null
// Only draw a hotel bookend when the leg is a real drive. You start/end the day at a hotel
// when you slept there / sleep there tonight; on the hotel's own check-in or check-out day
// the leg holds only when the edge stop is a PLACE timed after check-in / before check-out
// (you dropped bags first, or swung back before checking out). A place before check-in (an
// airport you reach first, #1465), a later "home" stop on the checkout day (#1465), or a
// transport endpoint on an arrival/departure day (#1321, S7) all draw no bookend.
// A carrier endpoint at an edge additionally kills its own leg outright, however
// near the hotel it sits: you flew out of that airport, so nothing drove back from
// it tonight, and you flew into that one, so nothing drove to it this morning
// (#2133). Which of a transport's two points sits at the edge depends on its span —
// an overnight flight contributes only its departure on the day it leaves.
const contributes = (e: Entry) => e.kind === 'place' || !!e.from || !!e.to
const firstStop = entries.find(contributes)
const lastStop = [...entries].reverse().find(contributes)
const edgeInfo = (e: Entry | undefined, side: 'first' | 'last') => {
if (!e) return undefined
if (e.kind === 'place') return { isPlace: true, time: e.time, lat: e.lat, lng: e.lng }
const role: CarrierEdge = side === 'first'
? (e.from ? 'departure' : 'arrival')
: (e.to ? 'arrival' : 'departure')
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null }
}
// 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
// check-out hotel its origin, which the no-time bookend default respects (#2157).
const dayHasCarrier = dayTransports.some(r => hasCarrierEndpointOnDay(r, dayId))
const firstWay = flatPts[0]
const lastWay = flatPts[flatPts.length - 1]
const morningHotel = hotelPt(bookends?.morning)
const eveningHotel = hotelPt(bookends?.evening)
// Same reachability test as the run builder: a hotel is not joined to a point no
// one could have driven between.
const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'), dayHasCarrier)
&& (!morningHotel || !firstWay || firstWay.isPlace || withinDriveRange(morningHotel, firstWay))
const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'), dayHasCarrier)
&& (!eveningHotel || !lastWay || lastWay.isPlace || withinDriveRange(eveningHotel, lastWay))
const runsWithHotel: RunPoint[][] = withHotelBookends(
runs,
firstWay,
lastWay,
drawMorning ? morningHotel : null,
drawEvening ? eveningHotel : null,
)
// Transfer day with no activities: you check out of one accommodation and into
// another, so there are no waypoints for withHotelBookends to attach a leg to.
// Draw the hotel → hotel transfer directly. Gated on both bookends being real
// (drawMorning/drawEvening already exclude the #1321 arrival fallback) and the two
// hotels being distinct, so an ordinary same-hotel rest day still draws nothing.
if (runsWithHotel.length === 0 && drawMorning && drawEvening) {
const m = hotelPt(bookends?.morning)
const e = hotelPt(bookends?.evening)
if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e])
}
const straightLines = (): [number, number][][] =>
runsWithHotel.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
@@ -1,3 +1,4 @@
import SchoolHolidayCatalog from '../../../components/Admin/SchoolHolidayCatalog'
import { useState } from 'react'
import {
ArrowUp,
@@ -214,6 +215,7 @@ export default function MAdmin() {
<div className="space-y-4">
<MAdminPackingTemplateManager />
<MAdminCategoryManager />
<SchoolHolidayCatalog />
</div>
)}
{activeTab === 'addons' && (
@@ -353,7 +353,7 @@ const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [
{ id: 'qwen3:8b', label: 'Qwen3 — 8B', note: 'Recommended · best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0', recommended: true, vision: false },
{ id: 'qwen3.5:4b', label: 'Qwen3.5 — 4B', note: 'Recommended · small and quick on CPU, 3.4 GB download, 256K context (thinking auto-disabled) · Apache-2.0', recommended: true, vision: true },
]
/**
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { MapViewAuto } from '../../../../components/Map/MapViewAuto'
import { MapCompassPill, type CompassMap } from '../../../../components/Map/MapCompassPill'
import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../../../../components/Map/TripRouteOverview'
import PoiCategoryPill from '../../../../components/Map/PoiCategoryPill'
import { usePoiExplore } from '../../../../components/Map/usePoiExplore'
import { useSettingsStore } from '../../../../store/settingsStore'
@@ -29,6 +30,7 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
const poi = usePoiExplore()
const [glMap, setGlMap] = useState<CompassMap | null>(null)
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const mapActive = shell.view === 'map'
@@ -44,14 +46,16 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
tripId={planner.tripId}
places={planner.mapPlaces}
dayPlaces={planner.dayPlaces}
route={planner.route}
route={planner.overviewActive ? planner.tripOverview.lines : planner.route}
routeColors={planner.overviewActive ? planner.tripOverview.lineColors : undefined}
focusPoints={planner.overviewActive ? planner.tripOverview.focusPoints : undefined}
routeVias={planner.routeVias}
showTransitRoutes={planner.transitRoutesShown}
// The route toggle belongs to one day, so the map needs that day to know
// which automated transports may ride it (#2019).
days={planner.days}
selectedDayId={planner.selectedDayId}
routeSegments={planner.routeSegments}
routeSegments={planner.overviewActive ? planner.tripOverview.segments : planner.routeSegments}
selectedPlaceId={planner.selectedPlaceId}
onMarkerClick={planner.handleMarkerClick}
// Tap on empty map = deselect, same contract as desktop.
@@ -104,6 +108,29 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
<MapCompassPill map={glMap} />
</div>
)}
{/* Whole-trip overview (#1736): the stages stack above their toggle on the right,
clear of the round-controls band below it and of the compass and Leaflet's
base-layer switcher, which both sit in the bottom-LEFT corner. The offset is
Tailwind rather than inline because the compass band is identified by being
the one element with an inline --bottom-nav-h, and a second would make that
ambiguous. */}
{mapActive && !planner.roadtripActive && (
<div className="pointer-events-none absolute left-3 right-3 z-[25] flex flex-col items-end gap-2 bottom-[calc(var(--bottom-nav-h,84px)+58px)]">
{planner.overviewActive && (
<TripRouteOverviewPanel
overview={planner.tripOverview}
unit={distanceUnit}
selectedDayId={planner.selectedDayId}
onSelectDay={planner.handleSelectDay}
// Tighter than the desktop card: the map is the whole screen here, so a
// long day name ellipsizes rather than eating another 80px of it.
maxWidth={240}
/>
)}
<TripRouteOverviewPill active={planner.overviewShown} onToggle={planner.toggleOverview} />
</div>
)}
</div>
)
}
@@ -8,7 +8,7 @@ import { getIntlLanguage, useTranslation } from '../../../i18n'
import { useToast } from '../../../components/shared/Toast'
import apiClient from '../../../api/client'
import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from '../../../components/Vacay/holidayRegions'
import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../../vacay/schoolHolidayCountries'
import { useSchoolHolidayCountries } from '../../../components/Vacay/useSchoolHolidayCountries'
import { windowMonths } from '../../../vacay/yearWindow'
import { FALLBACK_PERSON_COLOR } from './vacayDayModel'
import type { VacayHolidayCalendar, VacayYearSettings } from '../../../types'
@@ -49,13 +49,14 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
}).catch(() => {})
}, [open, language])
const { countries: schoolHolidayCountries, error: schoolCountryError } = useSchoolHolidayCountries(open)
if (!plan) return null
// Public and school calendars live in the same holiday_calendars list, split by type
// (a null type predates the school-holiday feature, so it counts as a public holiday).
const publicHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => (cal.type ?? 'public_holiday') === 'public_holiday')
const schoolHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => cal.type === 'school_holiday')
const schoolHolidayCountries = countries.filter(country => country.value in SCHOOL_HOLIDAY_COUNTRY_CONFIG)
const weekendDays: number[] = plan.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]
const weekdayChips = [
@@ -220,6 +221,7 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
</div>
<MToggle checked={plan.school_holidays_enabled} onChange={() => updatePlan({ school_holidays_enabled: !plan.school_holidays_enabled })} ariaLabel={t('vacay.schoolHolidays')} />
</div>
{schoolCountryError && <p role="alert" className="text-caption text-danger">{schoolCountryError}</p>}
{plan.school_holidays_enabled && (
<>
{schoolHolidayCalendars.map(cal => (
@@ -452,18 +454,18 @@ function ColorSwatch({ color, onPick }: { color: string; onPick: (color: string)
* apart from "this country has no regions"; on the list alone both are empty.
*/
function useRegionOptions(country: string, calendarType: 'public_holiday' | 'school_holiday') {
const [loaded, setLoaded] = useState<{ country: string; options: Option[] }>({ country: '', options: [] })
const [loaded, setLoaded] = useState<{ country: string; options: Option[]; failed?: boolean }>({ country: '', options: [] })
useEffect(() => {
if (!country) return
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
let stale = false
load(country).then(options => { if (!stale) setLoaded({ country, options }) })
load(country).then(options => { if (!stale) setLoaded({ country, options }) }).catch(() => { if (!stale) setLoaded({ country, options: [], failed: true }) })
return () => { stale = true }
}, [country, calendarType])
const ready = loaded.country === country
return { regions: ready ? loaded.options : [], loadingRegions: Boolean(country) && !ready }
return { regions: ready ? loaded.options : [], regionError: ready && loaded.failed, loadingRegions: Boolean(country) && !ready }
}
function CalendarEditor({ cal, countries, calendarType = 'public_holiday', onUpdate, onDelete }: {
@@ -480,7 +482,7 @@ function CalendarEditor({ cal, countries, calendarType = 'public_holiday', onUpd
const [baseRegion] = cal.region.split('|')
const country = baseRegion.split('-')[0]
const region = cal.region.includes('|group:') || baseRegion.includes('-') ? cal.region : ''
const { regions } = useRegionOptions(country, calendarType)
const { regions, regionError } = useRegionOptions(country, calendarType)
useEffect(() => { setLabel(cal.label || '') }, [cal.label])
@@ -501,8 +503,9 @@ function CalendarEditor({ cal, countries, calendarType = 'public_holiday', onUpd
</MIconBtn>
</div>
<SelectRow value={country} placeholder={t('vacay.selectCountry')} options={countries} onChange={v => onUpdate({ region: v })} />
{regionError && <p role="alert" className="text-caption text-danger">{t('schoolCatalog.loadError')}</p>}
{regions.length > 0 && (
<SelectRow value={region} placeholder={t('vacay.selectRegion')} options={regions} onChange={v => onUpdate({ region: v })} />
<SelectRow value={region || (regions.some(option => option.value === country) ? country : '')} placeholder={t('vacay.selectRegion')} options={regions} onChange={v => onUpdate({ region: v })} />
)}
</div>
)
@@ -522,12 +525,12 @@ function AddCalendarDraft({ countries, calendarType = 'public_holiday', onAdd, o
const [baseRegion] = region.split('|')
const country = baseRegion.split('-')[0] || ''
const selectedRegion = region.includes('|group:') || baseRegion.includes('-') ? region : ''
const { regions, loadingRegions } = useRegionOptions(country, calendarType)
const { regions, loadingRegions, regionError } = useRegionOptions(country, calendarType)
// Adding is blocked while the region list is on its way: an empty list would
// otherwise pass as "this country needs no region" and create a calendar that
// draws nothing (#1813). The button shows a spinner so the wait is visible.
const canAdd = Boolean(country) && !loadingRegions && (regions.length === 0 || selectedRegion !== '')
const canAdd = Boolean(country) && !loadingRegions && !regionError && (regions.length === 0 || regions.some(option => option.value === region))
return (
<div className="mt-2 rounded-[14px] border-[1.5px] border-dashed border-[color:var(--m-rowbr)] p-3">
@@ -544,8 +547,9 @@ function AddCalendarDraft({ countries, calendarType = 'public_holiday', onAdd, o
</MIconBtn>
</div>
<SelectRow value={country} placeholder={t('vacay.selectCountry')} options={countries} onChange={setRegion} />
{regionError && <p role="alert" className="text-caption text-danger">{t('schoolCatalog.loadError')}</p>}
{regions.length > 0 && (
<SelectRow value={selectedRegion} placeholder={t('vacay.selectRegion')} options={regions} onChange={setRegion} />
<SelectRow value={selectedRegion || (regions.some(option => option.value === country) ? country : '')} placeholder={t('vacay.selectRegion')} options={regions} onChange={setRegion} />
)}
<button
type="button"
+2
View File
@@ -1,3 +1,4 @@
import SchoolHolidayCatalog from '../components/Admin/SchoolHolidayCatalog'
import React, { Fragment } from 'react'
import { adminApi } from '../api/client'
import DevNotificationsPanel from '../components/Admin/DevNotificationsPanel'
@@ -151,6 +152,7 @@ function AdminPageDesktop(): React.ReactElement {
<div className="space-y-6">
<PackingTemplateManager />
<CategoryManager />
<SchoolHolidayCatalog />
</div>
)}
+29 -4
View File
@@ -6,6 +6,7 @@ import { useCanDo } from '../store/permissionsStore'
import { useSettingsStore } from '../store/settingsStore'
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
import { MapCompassPill, type CompassMap } from '../components/Map/MapCompassPill'
import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../components/Map/TripRouteOverview'
import { getCached, fetchPhoto } from '../services/photoService'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
import RoadtripModeSwitch from '../components/Roadtrip/RoadtripModeSwitch'
@@ -253,6 +254,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
overviewActive, tripOverview, toggleOverview, overviewShown,
followTrack, roadtripViaCounts,
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
@@ -335,6 +337,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
// page level so it has tripMembers / base currency / current user available.
const meId = useAuthStore(s => s.user?.id ?? -1)
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const costsBase = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
// Transit search departs against a real date, so the whole Automated mode —
// the day-header tram button and the modal's mode switch — is off without one.
@@ -405,8 +408,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
tripId={tripId}
places={roadtripActive ? roadtripMapPlaces : mapPlaces}
dayPlaces={dayPlaces}
route={roadtripActive ? roadtripMapLines : route}
routeColors={roadtripActive ? roadtripLineColors : undefined}
route={roadtripActive ? roadtripMapLines : overviewActive ? tripOverview.lines : route}
routeColors={roadtripActive ? roadtripLineColors : overviewActive ? tripOverview.lineColors : undefined}
routeVias={roadtripActive ? roadtripMapVias : routeVias}
dayBoundaryControls={roadtripActive ? dayBoundaryControls : undefined}
accessLines={roadtripActive ? roadtripRoutes.accessLines : undefined}
@@ -415,7 +418,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
// know which automated transports may ride it (#2019).
days={days}
selectedDayId={selectedDayId}
routeSegments={roadtripActive ? roadtripRoutes.segments : routeSegments}
routeSegments={roadtripActive ? roadtripRoutes.segments : overviewActive ? tripOverview.segments : routeSegments}
selectedPlaceId={selectedPlaceId}
onMarkerClick={handleMarkerClick}
onMapClick={handleMapClick}
@@ -449,7 +452,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onRouteClick={roadtripActive && can('day_edit', trip) ? addRoadtripVia : undefined}
roadtripVias={roadtripActive ? roadtripVias.byDay : undefined}
alternativeRoutes={alternativeOverlays}
focusPoints={mapFocusPoints}
focusPoints={overviewActive ? tripOverview.focusPoints : mapFocusPoints}
clusterLoosely={roadtripActive}
activeAlternative={highlightedAlternative}
onChooseAlternative={chooseRouteAlternative}
@@ -476,6 +479,28 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
</div>
)}
{/* Bottom-RIGHT. Not the top corridor between the panels, which is already
contested by the POI bar and the collapse tabs (#2247); and not the
bottom-left corner, where Leaflet's base-layer switcher sits at
z-index 1000 and would cover this. The right corner is free on both
renderers — the locate button that lives there is phone-only. */}
{!roadtripActive && (
<div className="hidden md:flex" style={{
position: 'absolute', bottom: 18, right: mapInsetRight + 14, zIndex: 26,
pointerEvents: 'none', flexDirection: 'column', alignItems: 'flex-end', gap: 8,
}}>
{overviewActive && (
<TripRouteOverviewPanel
overview={tripOverview}
unit={distanceUnit}
selectedDayId={selectedDayId}
onSelectDay={handleSelectDay}
/>
)}
<TripRouteOverviewPill active={overviewShown} onToggle={toggleOverview} />
</div>
)}
{(poiPillEnabled || glMap) && (
<div className="hidden md:flex" style={{
position: 'absolute', top: 14,
@@ -452,7 +452,9 @@ describe('TripPlannerPage — plan tab', () => {
it('FE-PAGE-TPW-013: the collapse buttons toggle each side panel', () => {
renderPage()
const [leftBtn, rightBtn] = screen.getAllByRole('button')
// By name, not by position: the map layer carries controls of its own, so the
// tabs are not simply the first two buttons on the page.
const [leftBtn, rightBtn] = screen.getAllByLabelText('common.collapse')
fireEvent.click(leftBtn)
expect(hookState.toggleLeft).toHaveBeenCalled()
@@ -469,9 +471,8 @@ describe('TripPlannerPage — plan tab', () => {
// announce themselves with nothing but a hover colour (#2247).
it('FE-PAGE-TPW-013b: each collapse tab is named for what it does', () => {
renderPage({ leftHidden: true, rightHidden: false })
const [leftBtn, rightBtn] = screen.getAllByRole('button')
expect(leftBtn).toHaveAttribute('aria-label', 'trip.mobilePlan')
expect(rightBtn).toHaveAttribute('aria-label', 'common.collapse')
expect(screen.getByLabelText('trip.mobilePlan')).toBeInTheDocument()
expect(screen.getByLabelText('common.collapse')).toBeInTheDocument()
})
// At a foldable's ~860px the viewport centre sits under the Places panel, so the
@@ -488,6 +489,17 @@ describe('TripPlannerPage — plan tab', () => {
expect(cluster.style.left).toBe('calc(350px + 0.5 * (100% - 350px - 0px))')
})
// Leaflet's base-layer switcher owns the bottom-LEFT corner at z-index 1000, so an
// overview control placed there is simply covered by it (#1736).
it('FE-PAGE-TPW-013d: the trip-overview control hugs the right edge, clear of the layer switcher', () => {
renderPage()
const stack = screen.getByTestId('trip-overview-pill').closest('[style*="position: absolute"]') as HTMLElement
expect(stack).not.toBeNull()
expect(stack.style.right).not.toBe('')
expect(stack.style.left).toBe('')
})
it('FE-PAGE-TPW-014: a collapsed panel keeps its toggle but drops the hover highlight', () => {
renderPage({ leftHidden: true, rightHidden: true })
@@ -30,6 +30,7 @@ import { useAutomaticDayPoints } from '../../components/Roadtrip/useAutomaticDay
import { useDayBoundaries } from '../../components/Roadtrip/useDayBoundaries'
import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag'
import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow'
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch'
@@ -379,6 +380,17 @@ export function useTripPlanner() {
// transport in the trip (#2019).
const transitRoutesShown = routeShown && selectedDayId != null
const [routeProfile, setRouteProfile] = useState<string>('driving')
// Whole-trip route overview (#1736): every day's route at once, each in its own
// colour. Per trip and per session like road trip mode — it answers "what does the
// whole thing look like", which is a question you ask of one trip, not a preference.
const [overviewShown, setOverviewShown] = useState<boolean>(() => sessionStorage.getItem(`trip-overview-${tripId}`) === '1')
const toggleOverview = useCallback(() => {
setOverviewShown(prev => {
const next = !prev
sessionStorage.setItem(`trip-overview-${tripId}`, next ? '1' : '0')
return next
})
}, [tripId])
const [fitKey, setFitKey] = useState<number>(0)
const initialFitTripId = useRef<number | null>(null)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
@@ -549,6 +561,10 @@ export function useTripPlanner() {
}, [places, placesCategoryFilter, placesFilter, assignments, expandedDayIds, selectedDayId, days, tripAccommodations, reservations])
const { route, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile, tripAccommodations)
// Road trip mode already draws the whole trip its own way, so the overview stands
// down there rather than drawing a second set of lines over it.
const overviewActive = overviewShown && !roadtripMode
const tripOverview = useTripRouteOverview(tripId, days, assignments, reservations, tripAccommodations, routeProfile, overviewActive)
// Road trip mode reads the whole trip, not the selected day, so it owns its own legs.
// Passing no days while the mode is off keeps it inert — no routing requests, no state.
@@ -2158,6 +2174,7 @@ export function useTripPlanner() {
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
overviewShown, toggleOverview, overviewActive, tripOverview,
followTrack, roadtripViaCounts,
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
@@ -0,0 +1,10 @@
import { schoolHolidayCatalogApi } from '../api/schoolHolidayCatalog'
import { isEffectivelyOffline } from '../sync/networkMode'
// Instance administration is online-only: a shared catalog must not replay stale edits.
export const schoolHolidayCatalogRepo = {
...schoolHolidayCatalogApi,
requireOnline() {
if (isEffectivelyOffline()) throw new Error('School holiday administration requires a connection')
},
}
+4 -1
View File
@@ -1,3 +1,4 @@
import { manualSchoolRegionId } from '@trek/shared'
import { create } from 'zustand'
import apiClient from '../api/client'
import { useAuthStore } from './authStore'
@@ -113,6 +114,8 @@ const api: VacayApi = {
updateStats: (year, days, targetUserId) => ax.put(`/addons/vacay/stats/${year}`, { vacation_days: days, target_user_id: targetUserId } satisfies VacayUpdateStatsRequest).then((r: AxiosResponse) => r.data),
getHolidays: (year, country) => ax.get(`/addons/vacay/holidays/${year}/${country}`).then((r: AxiosResponse) => r.data),
getSchoolHolidays: (year, country, subdivision, group) => {
const manualId = subdivision ? manualSchoolRegionId(subdivision) : null
if (manualId) return ax.get(`/school-holiday-catalog/regions/${manualId}/holidays/${year}`).then((r: AxiosResponse) => r.data)
const params = new URLSearchParams()
if (group) params.set('group', group)
const qs = params.toString()
@@ -416,7 +419,7 @@ export const useVacayStore = create<VacayState>((set, get) => ({
for (const cy of calendarYears) {
try {
if ((cal.type ?? 'public_holiday') === 'school_holiday') {
if (!isSchoolHolidayCountrySupported(country)) continue
if (!isSchoolHolidayCountrySupported(country) && !manualSchoolRegionId(cal.region)) continue
const data = await api.getSchoolHolidays(cy, country, subdivision, group)
data.forEach((h: VacaySchoolHolidayRaw) => {
if (!h.startDate) return
@@ -1,6 +1,7 @@
import { http, HttpResponse } from 'msw';
export const vacayHandlers = [
http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: [], regions: [] })),
http.get('/api/addons/vacay/plan', () => {
return HttpResponse.json({
plan: {
@@ -368,7 +368,7 @@ describe('MAdminAddonManager', () => {
it('FE-MOB-AADD-017: the local provider lists installed models and selecting a chip fills the model field', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3:8b', 'llama3:8b'], urls));
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
render(<MAdminAddonManager />);
await screen.findByText('Installed on the server');
@@ -378,9 +378,9 @@ describe('MAdminAddonManager', () => {
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
// qwen3:8b is installed, so the recommended row offers "Use" instead of "Pull".
// qwen3.5:4b is installed, so the recommended row offers "Use" instead of "Pull".
await user.click(screen.getByRole('button', { name: 'Use' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
});
@@ -449,7 +449,7 @@ describe('MAdminAddonManager', () => {
addonsRoute([llmAddon({ provider: 'local' })]),
http.get('/api/admin/llm/local/models', () => {
modelCalls += 1;
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3:8b', size: 1 }] });
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
}),
http.post('/api/admin/llm/local/pull', async ({ request }) => {
pulled = await request.json();
@@ -470,8 +470,8 @@ describe('MAdminAddonManager', () => {
expect(screen.getByText('starting…')).toBeInTheDocument();
await screen.findByText('Model pulled');
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3:8b' });
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
// Reloaded models now contain the pulled one, so the row switches to "Selected".
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
});
+20
View File
@@ -4535,6 +4535,26 @@ function runMigrations(db: Database.Database): void {
db.exec('ALTER TABLE places ADD COLUMN fill_percent INTEGER');
}
},
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS school_holiday_countries (
code TEXT PRIMARY KEY, name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS school_holiday_regions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
country TEXT NOT NULL REFERENCES school_holiday_countries(code),
name TEXT NOT NULL COLLATE NOCASE, revision INTEGER NOT NULL DEFAULT 1,
UNIQUE(country, name)
);
CREATE TABLE IF NOT EXISTS school_holiday_periods (
id INTEGER PRIMARY KEY AUTOINCREMENT,
region_id INTEGER NOT NULL REFERENCES school_holiday_regions(id) ON DELETE CASCADE,
name TEXT NOT NULL, start_date TEXT NOT NULL, end_date TEXT NOT NULL,
CHECK (end_date >= start_date)
);
CREATE INDEX IF NOT EXISTS idx_school_holiday_periods_region ON school_holiday_periods(region_id);
`);
},
() => {
const cols = db.prepare("SELECT name FROM pragma_table_info('day_assignments')").all() as Array<{ name: string }>;
if (!cols.some(c => c.name === 'end_day')) {
+2 -1
View File
@@ -27,6 +27,7 @@ import { CategoriesModule } from './categories/categories.module';
import { TagsModule } from './tags/tags.module';
import { NotificationsModule } from './notifications/notifications.module';
import { AtlasModule } from './atlas/atlas.module';
import { SchoolHolidaysModule } from './school-holidays/school-holidays.module';
import { VacayModule } from './vacay/vacay.module';
import { PackingModule } from './packing/packing.module';
import { BudgetModule } from './budget/budget.module';
@@ -79,7 +80,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module';
* migrated.
*/
@Module({
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
providers: [
// Default-deny: a route is authenticated unless it carries @Public() or
// @OptionalAuth(), or declares its own @UseGuards chain. Protection used to
@@ -0,0 +1,47 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query, UseGuards, BadRequestException } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import { schoolHolidayCountryRequestSchema, schoolHolidayRegionRequestSchema } from '@trek/shared';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/admin.guard';
import { SchoolHolidaysService } from './school-holidays.service';
export class SchoolHolidayCountryDto extends createZodDto(schoolHolidayCountryRequestSchema) {}
export class SchoolHolidayRegionDto extends createZodDto(schoolHolidayRegionRequestSchema) {}
@Controller('api/school-holiday-catalog')
@UseGuards(JwtAuthGuard)
export class SchoolHolidaysController {
constructor(private readonly holidays: SchoolHolidaysService) {}
@Get()
catalog() { return this.holidays.catalog(); }
@Get('regions/:id')
region(@Param('id', ParseIntPipe) id: number) { return this.holidays.region(id); }
@Get('regions/:id/holidays/:year')
forYear(@Param('id', ParseIntPipe) id: number, @Param('year') year: string) {
if (!/^\d{4}$/.test(year)) throw new BadRequestException('Invalid year');
return this.holidays.holidays(id, year);
}
@Post('countries')
@UseGuards(AdminGuard)
createCountry(@Body() body: SchoolHolidayCountryDto) { return this.holidays.createCountry(body); }
@Delete('countries/:code')
@UseGuards(AdminGuard)
deleteCountry(@Param('code') code: string) { return this.holidays.deleteCountry(code); }
@Post('countries/:code/regions')
@UseGuards(AdminGuard)
createRegion(@Param('code') code: string, @Body() body: SchoolHolidayRegionDto) { return this.holidays.createRegion(code, body); }
@Put('regions/:id')
@UseGuards(AdminGuard)
updateRegion(@Param('id', ParseIntPipe) id: number, @Body() body: SchoolHolidayRegionDto) { return this.holidays.updateRegion(id, body); }
@Delete('regions/:id')
@UseGuards(AdminGuard)
deleteRegion(@Param('id', ParseIntPipe) id: number, @Query('revision', ParseIntPipe) revision: number) { return this.holidays.deleteRegion(id, revision); }
}
@@ -0,0 +1,95 @@
import { HttpException } from '@nestjs/common';
import { z } from 'zod';
import { idSchema, schoolHolidayCountryRequestSchema, schoolHolidayRegionRequestSchema, type SchoolHolidayCountryRequest, type SchoolHolidayRegionRequest } from '@trek/shared';
import { McpController, Tool, type McpContext, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_NON_IDEMPOTENT, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, errorResult, ok } from '../../nest-mcp';
import { adminRequired } from '../../mcp/tools/_shared';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { SchoolHolidaysService } from './school-holidays.service';
@McpController()
export class SchoolHolidaysMcp {
constructor(private readonly holidays: SchoolHolidaysService, private readonly guards: McpToolGuardsService) {}
private adminWrite(ctx: McpContext, write: () => unknown) {
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
try { return ok(write()); }
catch (error) {
if (error instanceof HttpException || error instanceof z.ZodError) return errorResult(error.message);
throw error;
}
}
@Tool({
name: 'list_manual_school_holiday_regions',
description: 'List the global manual school holiday catalog. Use regions[].code verbatim as the region with add_holiday_calendar and type school_holiday. These calendars require no external API.',
inputSchema: {}, annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'vacay', mode: 'read' },
})
catalog() { return ok(this.holidays.catalog()); }
@Tool({
name: 'get_manual_school_holiday_region',
description: 'Read a manual school region, its current revision and all named holiday periods. Read before updating: updates replace the complete list of periods, and the current revision is required.',
inputSchema: { regionId: idSchema }, annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'vacay', mode: 'read' },
})
region({ regionId }: { regionId: number }) { return ok(this.holidays.region(regionId)); }
@Tool({
name: 'list_manual_school_holidays',
description: 'Read named school breaks for a manual region and year, including breaks spanning a year boundary. Both boundary dates are included.',
inputSchema: { regionId: idSchema, year: z.number().int().min(1000).max(9999) },
annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'vacay', mode: 'read' },
})
forYear({ regionId, year }: { regionId: number; year: number }) { return ok({ holidays: this.holidays.holidays(regionId, String(year)) }); }
@Tool({
name: 'create_manual_school_holiday_country',
description: 'Admin only. Create a country in the instance-wide manual school holiday catalog with its two-letter uppercase country code and display name. Check the catalog first to avoid duplicates.',
inputSchema: schoolHolidayCountryRequestSchema.shape, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'vacay', mode: 'write' },
})
createCountry(body: SchoolHolidayCountryRequest, ctx: McpContext) {
return this.adminWrite(ctx, () => this.holidays.createCountry(schoolHolidayCountryRequestSchema.parse(body)));
}
@Tool({
name: 'create_manual_school_holiday_region',
description: 'Admin only. Create a region or school district in an existing manual country, with named holiday periods. Use revision 0 for creation. Dates are inclusive YYYY-MM-DD. All users can select this region.',
inputSchema: { country: schoolHolidayCountryRequestSchema.shape.code, ...schoolHolidayRegionRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, access: { group: 'vacay', mode: 'write' },
})
createRegion({ country, ...body }: SchoolHolidayRegionRequest & { country: string }, ctx: McpContext) {
return this.adminWrite(ctx, () => this.holidays.createRegion(country, schoolHolidayRegionRequestSchema.parse(body)));
}
@Tool({
name: 'update_manual_school_holiday_region',
description: 'Admin only. Rename a global school region and replace its complete list of holiday periods. First read get_manual_school_holiday_region and preserve periods that should remain. Supply its current revision; stale updates are rejected. Changes affect every calendar using this region.',
inputSchema: { regionId: idSchema, ...schoolHolidayRegionRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'vacay', mode: 'write' },
})
updateRegion({ regionId, ...body }: SchoolHolidayRegionRequest & { regionId: number }, ctx: McpContext) {
return this.adminWrite(ctx, () => this.holidays.updateRegion(regionId, schoolHolidayRegionRequestSchema.parse(body)));
}
@Tool({
name: 'delete_manual_school_holiday_region',
description: 'Admin only. Delete a global manual school region and its periods using the current revision. Regions selected by a vacation calendar cannot be deleted.',
inputSchema: { regionId: idSchema, revision: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'vacay', mode: 'write' },
})
deleteRegion({ regionId, revision }: { regionId: number; revision: number }, ctx: McpContext) {
return this.adminWrite(ctx, () => this.holidays.deleteRegion(regionId, revision));
}
@Tool({
name: 'delete_manual_school_holiday_country',
description: 'Admin only. Delete an empty country from the global manual school holiday catalog. Remove its unused regions first.',
inputSchema: { code: schoolHolidayCountryRequestSchema.shape.code },
annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'vacay', mode: 'write' },
})
deleteCountry({ code }: { code: string }, ctx: McpContext) {
return this.adminWrite(ctx, () => this.holidays.deleteCountry(code));
}
}
@@ -0,0 +1,8 @@
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { Module } from '@nestjs/common';
import { SchoolHolidaysController } from './school-holidays.controller';
import { SchoolHolidaysService } from './school-holidays.service';
import { SchoolHolidaysMcp } from './school-holidays.mcp';
@Module({ imports: [McpSharedModule], controllers: [SchoolHolidaysController], providers: [SchoolHolidaysService, SchoolHolidaysMcp] })
export class SchoolHolidaysModule {}
@@ -0,0 +1,96 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import type { SchoolHolidayCatalog, SchoolHolidayCountryRequest, SchoolHolidayPeriod, SchoolHolidayRegion, SchoolHolidayRegionDetail, SchoolHolidayRegionRequest } from '@trek/shared';
import { DatabaseService } from '../database/database.service';
@Injectable()
export class SchoolHolidaysService {
constructor(private readonly db: DatabaseService) {}
catalog(): SchoolHolidayCatalog {
return {
countries: this.db.all<SchoolHolidayCountryRequest>('SELECT code, name FROM school_holiday_countries ORDER BY name, code'),
regions: this.db.all<SchoolHolidayRegion>("SELECT *, country || '-MANUAL-' || id AS code FROM school_holiday_regions ORDER BY name, id"),
};
}
country(code: string): SchoolHolidayCountryRequest {
const country = this.db.get<SchoolHolidayCountryRequest>('SELECT code, name FROM school_holiday_countries WHERE code = ?', code);
if (!country) throw new NotFoundException('Country not found');
return country;
}
createCountry(country: SchoolHolidayCountryRequest) {
const inserted = this.db.run('INSERT OR IGNORE INTO school_holiday_countries (code, name) VALUES (?, ?)', country.code, country.name);
if (!inserted.changes) throw new ConflictException('Country already exists');
return country;
}
deleteCountry(code: string) {
return this.db.transaction(() => {
this.country(code);
if (this.db.get('SELECT id FROM school_holiday_regions WHERE country = ? LIMIT 1', code)) {
throw new ConflictException('Remove the regions before deleting this country');
}
this.db.run('DELETE FROM school_holiday_countries WHERE code = ?', code);
return { success: true };
});
}
region(id: number): SchoolHolidayRegionDetail {
const region = this.db.get<SchoolHolidayRegion>("SELECT *, country || '-MANUAL-' || id AS code FROM school_holiday_regions WHERE id = ?", id);
if (!region) throw new NotFoundException('Region not found');
return { ...region, holidays: this.db.all<SchoolHolidayPeriod>('SELECT name, start_date AS startDate, end_date AS endDate FROM school_holiday_periods WHERE region_id = ? ORDER BY start_date, end_date, name', id) };
}
private checkName(country: string, name: string, id: number) {
if (this.db.get('SELECT id FROM school_holiday_regions WHERE country = ? AND name = ? COLLATE NOCASE AND id != ?', country, name, id)) {
throw new ConflictException('A region with this name already exists');
}
}
private writePeriods(id: number, holidays: SchoolHolidayPeriod[]) {
this.db.run('DELETE FROM school_holiday_periods WHERE region_id = ?', id);
const insert = this.db.prepare('INSERT INTO school_holiday_periods (region_id, name, start_date, end_date) VALUES (?, ?, ?, ?)');
for (const holiday of holidays) insert.run(id, holiday.name, holiday.startDate, holiday.endDate);
}
createRegion(country: string, body: SchoolHolidayRegionRequest) {
return this.db.transaction(() => {
this.country(country);
this.checkName(country, body.name, 0);
if (body.revision !== 0) throw new ConflictException('New regions must have revision zero');
const inserted = this.db.run('INSERT INTO school_holiday_regions (country, name) VALUES (?, ?)', country, body.name);
const id = Number(inserted.lastInsertRowid);
this.writePeriods(id, body.holidays);
return this.region(id);
});
}
updateRegion(id: number, body: SchoolHolidayRegionRequest) {
return this.db.transaction(() => {
const region = this.region(id);
this.checkName(region.country, body.name, id);
const updated = this.db.run('UPDATE school_holiday_regions SET name = ?, revision = revision + 1 WHERE id = ? AND revision = ?', body.name, id, body.revision);
if (!updated.changes) throw new ConflictException('This region changed. Reopen it before saving again.');
this.writePeriods(id, body.holidays);
return this.region(id);
});
}
deleteRegion(id: number, revision: number) {
return this.db.transaction(() => {
const region = this.region(id);
if (region.revision !== revision) throw new ConflictException('This region changed. Reload before deleting it.');
if (this.db.get("SELECT id FROM vacay_holiday_calendars WHERE type = 'school_holiday' AND region = ? LIMIT 1", region.code)) {
throw new ConflictException('This region is used by vacation calendars and cannot be deleted');
}
this.db.run('DELETE FROM school_holiday_periods WHERE region_id = ?', id);
this.db.run('DELETE FROM school_holiday_regions WHERE id = ?', id);
return { success: true };
});
}
holidays(id: number, year: string): SchoolHolidayPeriod[] {
return this.region(id).holidays.filter(holiday => holiday.startDate <= `${year}-12-31` && holiday.endDate >= `${year}-01-01`);
}
}
+12 -1
View File
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Injectable, BadRequestException } from '@nestjs/common';
import { manualSchoolRegionId } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService } from '../database/database.service';
import { NotificationsService } from '../notifications/notifications.service';
@@ -578,6 +579,7 @@ export class VacayService {
// -------------------------------------------------------------------------
addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined, type: 'public_holiday' | 'school_holiday' = 'public_holiday') {
this.validateManualRegion(region, type);
const result = this.db.run(
'INSERT INTO vacay_holiday_calendars (plan_id, type, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
planId, type, region, label || null, color || (type === 'school_holiday' ? '#a5f3fc' : '#fecaca'), sortOrder ?? 0
@@ -595,6 +597,7 @@ export class VacayService {
): VacayHolidayCalendar | null {
const cal = this.db.get<VacayHolidayCalendar>('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?', calId, planId);
if (!cal) return null;
this.validateManualRegion(body.region ?? cal.region, body.type ?? cal.type);
const { region, label, color, sort_order, type } = body;
const updates: string[] = [];
const params: (string | number | null)[] = [];
@@ -620,6 +623,14 @@ export class VacayService {
return true;
}
private validateManualRegion(code: string, type: string) {
if (!code.includes('-MANUAL-')) return;
const id = manualSchoolRegionId(code);
if (type !== 'school_holiday' || !id || !this.db.get('SELECT id FROM school_holiday_regions WHERE id = ? AND country = ?', id, code.slice(0, 2))) {
throw new BadRequestException('Unknown manual school holiday region');
}
}
// -------------------------------------------------------------------------
// User colors
// -------------------------------------------------------------------------
@@ -0,0 +1,178 @@
import { beforeAll, afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import { Test } from '@nestjs/testing';
import type { INestApplication } from '@nestjs/common';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
const Database = require('better-sqlite3');
return { db: new Database(':memory:') };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {} }));
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { SchoolHolidaysModule } from '../../src/nest/school-holidays/school-holidays.module';
import { SchoolHolidaysService } from '../../src/nest/school-holidays/school-holidays.service';
import { SchoolHolidaysMcp } from '../../src/nest/school-holidays/school-holidays.mcp';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { validateBodyContracts } from '../../src/nest/common/validate-body-contracts';
import { createTestRegistry } from '../../src/nest-mcp';
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../src/mcp/nest-mcp-policy';
const base = '/api/school-holiday-catalog';
const winter = { name: 'Winter break', startDate: '2026-12-20', endDate: '2027-01-06' };
let app: INestApplication;
let service: SchoolHolidaysService;
beforeAll(async () => {
db.pragma('foreign_keys = ON');
createTables(db);
runMigrations(db);
db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'admin', 'admin@test.local', '', 'admin'), (2, 'member', 'member@test.local', '', 'user')").run();
const module = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, SchoolHolidaysModule] }).compile();
app = module.createNestApplication();
app.use(cookieParser());
app.useGlobalPipes(new ZodValidationPipe());
app.useGlobalFilters(new TrekExceptionFilter());
await app.init();
service = app.get(SchoolHolidaysService);
});
afterAll(async () => { await app.close(); db.close(); });
beforeEach(() => {
db.exec('DELETE FROM vacay_holiday_calendars; DELETE FROM school_holiday_periods; DELETE FROM school_holiday_regions; DELETE FROM school_holiday_countries;');
});
function seed() {
service.createCountry({ code: 'US', name: 'USA' });
return service.createRegion('US', { name: 'Seattle schools', revision: 0, holidays: [winter] });
}
describe('global manual school holidays', () => {
it('requires authentication and admin rights for every write', async () => {
await request(app.getHttpServer()).get(base).expect(401);
await request(app.getHttpServer()).post(`${base}/countries`).send({ code: 'US', name: 'USA' }).expect(401);
for (const [method, path, body] of [
['post', '/countries', { code: 'US', name: 'USA' }],
['post', '/countries/US/regions', { name: 'District', revision: 0, holidays: [] }],
['put', '/regions/1', { name: 'District', revision: 1, holidays: [] }],
['delete', '/countries/US', {}], ['delete', '/regions/1?revision=1', {}],
] as const) {
await request(app.getHttpServer())[method](`${base}${path}`).set('Cookie', sessionCookie(2)).send(body).expect(403);
}
expect(() => validateBodyContracts(app)).not.toThrow();
});
it('lets any member discover regions and read dates without external requests', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const country = await request(app.getHttpServer()).post(`${base}/countries`).set('Cookie', sessionCookie(1)).send({ code: 'US', name: 'USA' }).expect(201);
expect(country.body.code).toBe('US');
const created = await request(app.getHttpServer()).post(`${base}/countries/US/regions`).set('Cookie', sessionCookie(1)).send({ name: 'Seattle', revision: 0, holidays: [winter] }).expect(201);
const catalog = await request(app.getHttpServer()).get(base).set('Cookie', sessionCookie(2)).expect(200);
expect(catalog.body.regions[0].code).toBe(`US-MANUAL-${created.body.id}`);
for (const year of [2026, 2027]) {
const holidays = await request(app.getHttpServer()).get(`${base}/regions/${created.body.id}/holidays/${year}`).set('Cookie', sessionCookie(2)).expect(200);
expect(holidays.body).toEqual([winter]);
}
expect(service.holidays(created.body.id, '2025')).toEqual([]);
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
it('validates dates and body shape before writing', async () => {
service.createCountry({ code: 'US', name: 'USA' });
for (const holidays of [[{ ...winter, endDate: '2026-01-01' }], [{ ...winter, startDate: '2026-02-30' }], [{ ...winter, name: ' ' }]]) {
await request(app.getHttpServer()).post(`${base}/countries/US/regions`).set('Cookie', sessionCookie(1)).send({ name: 'Seattle', revision: 0, holidays }).expect(400);
}
expect(service.catalog().regions).toEqual([]);
});
it('preserves ids on rename and rejects stale saves atomically', async () => {
const region = seed();
const updated = await request(app.getHttpServer()).put(`${base}/regions/${region.id}`).set('Cookie', sessionCookie(1)).send({ name: 'Renamed', revision: 1, holidays: [] }).expect(200);
expect(updated.body).toMatchObject({ code: region.code, revision: 2, holidays: [] });
await request(app.getHttpServer()).put(`${base}/regions/${region.id}`).set('Cookie', sessionCookie(1)).send({ name: 'Stale', revision: 1, holidays: [winter] }).expect(409);
expect(service.region(region.id).name).toBe('Renamed');
expect(service.region(region.id).holidays).toEqual([]);
await request(app.getHttpServer()).delete(`${base}/regions/${region.id}?revision=1`).set('Cookie', sessionCookie(1)).expect(409);
});
it('rejects duplicates, missing countries and nonzero initial revisions', () => {
seed();
expect(() => service.createCountry({ code: 'US', name: 'America' })).toThrow('already exists');
expect(() => service.createRegion('US', { name: 'SEATTLE SCHOOLS', revision: 0, holidays: [] })).toThrow('already exists');
expect(() => service.createRegion('CA', { name: 'School', revision: 0, holidays: [] })).toThrow('not found');
expect(() => service.createRegion('US', { name: 'School', revision: 1, holidays: [] })).toThrow('revision zero');
const other = service.createRegion('US', { name: 'Other', revision: 0, holidays: [] });
expect(() => service.updateRegion(other.id, { name: 'Seattle schools', revision: 1, holidays: [] })).toThrow('already exists');
});
it('protects used regions and deletes unused regions with their periods', async () => {
const region = seed();
db.prepare('INSERT OR IGNORE INTO vacay_plans (id, owner_id) VALUES (1, 1)').run();
db.prepare("INSERT INTO vacay_holiday_calendars (plan_id, type, region) VALUES (1, 'school_holiday', ?)").run(region.code);
await request(app.getHttpServer()).delete(`${base}/regions/${region.id}?revision=1`).set('Cookie', sessionCookie(1)).expect(409);
await request(app.getHttpServer()).delete(`${base}/countries/US`).set('Cookie', sessionCookie(1)).expect(409);
db.exec('DELETE FROM vacay_holiday_calendars');
await request(app.getHttpServer()).delete(`${base}/regions/${region.id}?revision=1`).set('Cookie', sessionCookie(1)).expect(200);
expect(db.prepare('SELECT * FROM school_holiday_periods').all()).toEqual([]);
await request(app.getHttpServer()).delete(`${base}/countries/US`).set('Cookie', sessionCookie(1)).expect(200);
expect(service.catalog()).toEqual({ countries: [], regions: [] });
});
it('reports missing regions and malformed year and id parameters', async () => {
const region = seed();
await request(app.getHttpServer()).get(`${base}/regions/${region.id}`).set('Cookie', sessionCookie(2)).expect(200);
await request(app.getHttpServer()).get(`${base}/regions/999999`).set('Cookie', sessionCookie(2)).expect(404);
await request(app.getHttpServer()).get(`${base}/regions/invalid`).set('Cookie', sessionCookie(2)).expect(400);
await request(app.getHttpServer()).get(`${base}/regions/${region.id}/holidays/xx`).set('Cookie', sessionCookie(2)).expect(400);
await request(app.getHttpServer()).delete(`${base}/countries/CA`).set('Cookie', sessionCookie(1)).expect(404);
});
it('exposes the same manual catalog and periods through MCP', () => {
const region = seed();
const mcp = app.get(SchoolHolidaysMcp);
expect(() => createTestRegistry([mcp], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess })).not.toThrow();
expect(JSON.stringify(mcp.catalog())).toContain(region.code);
expect(JSON.stringify(mcp.forYear({ regionId: region.id, year: 2027 }))).toContain('Winter break');
});
it('denies every MCP mutation to normal users without changing the catalog', () => {
const region = seed();
const mcp = app.get(SchoolHolidaysMcp);
const member = { userId: 2, scopes: null, isStaticToken: false };
const before = service.region(region.id);
const writes = [
() => mcp.createCountry({ code: 'CA', name: 'Canada' }, member),
() => mcp.createRegion({ country: 'US', name: 'Other', revision: 0, holidays: [] }, member),
() => mcp.updateRegion({ regionId: region.id, name: 'Changed', revision: 1, holidays: [] }, member),
() => mcp.deleteRegion({ regionId: region.id, revision: 1 }, member),
() => mcp.deleteCountry({ code: 'US' }, member),
];
for (const write of writes) expect(write()).toHaveProperty('isError', true);
expect(service.region(region.id)).toEqual(before);
expect(service.catalog().countries).toHaveLength(1);
expect(service.catalog().regions).toHaveLength(1);
});
it('supports the complete admin MCP workflow with validation and revision protection', () => {
const mcp = app.get(SchoolHolidaysMcp);
const admin = { userId: 1, scopes: null, isStaticToken: false };
expect(mcp.createCountry({ code: 'US', name: 'USA' }, admin)).not.toHaveProperty('isError', true);
expect(mcp.createRegion({ country: 'US', name: 'Seattle', revision: 0, holidays: [winter] }, admin)).not.toHaveProperty('isError', true);
const region = service.catalog().regions[0];
expect(JSON.stringify(mcp.region({ regionId: region.id }))).toContain('Winter break');
const edit = { regionId: region.id, name: 'Seattle Public Schools', revision: 1, holidays: [{ ...winter, name: 'Winter holiday' }] };
expect(mcp.updateRegion(edit, admin)).not.toHaveProperty('isError', true);
expect(mcp.updateRegion(edit, admin)).toHaveProperty('isError', true);
expect(mcp.updateRegion({ ...edit, revision: 2, holidays: [{ ...winter, endDate: '2025-01-01' }] }, admin)).toHaveProperty('isError', true);
expect(service.region(region.id)).toMatchObject({ revision: 2, holidays: [{ ...winter, name: 'Winter holiday' }] });
expect(mcp.deleteCountry({ code: 'US' }, admin)).toHaveProperty('isError', true);
expect(mcp.deleteRegion({ regionId: region.id, revision: 1 }, admin)).toHaveProperty('isError', true);
expect(mcp.deleteRegion({ regionId: region.id, revision: 2 }, admin)).not.toHaveProperty('isError', true);
expect(mcp.deleteCountry({ code: 'US' }, admin)).not.toHaveProperty('isError', true);
expect(service.catalog()).toEqual({ countries: [], regions: [] });
});
});
@@ -1,4 +1,6 @@
import { createTestRegistry, type McpRegistry } from '../../src/nest-mcp';
import { SchoolHolidaysMcp } from '../../src/nest/school-holidays/school-holidays.mcp';
import { SchoolHolidaysService } from '../../src/nest/school-holidays/school-holidays.service';
import { db } from '../../src/db/database';
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../src/mcp/nest-mcp-policy';
import { AssignmentsMcp } from '../../src/nest/assignments/assignments.mcp';
@@ -221,6 +223,7 @@ export function createMcpTestRegistry(): McpRegistry {
new AssignmentsMcp(assignmentsService, daysService, authService, guards),
new CollabMcp(collabService, authService, addonsService, guards),
new VacayMcp(new VacayService(dbService, realtimeService, notificationsStub()), authService, addonsService),
new SchoolHolidaysMcp(new SchoolHolidaysService(dbService), guards),
new TripsMcp(tripsService, todoService, collabService, authService, calendarService, membersService, readModelService, addonsService, guards),
new TripPromptsMcp(tripsService, readModelService, packingService, addonsService),
new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService, queryHelpersService, placePhotoCache), authService, guards),
+3
View File
@@ -27,6 +27,9 @@ import type { RateLimitService } from '../../src/nest/common/rate-limit.service'
// Keep in sync with schema.ts + migrations.ts. Intentionally excluded: categories, addons,
// photo_providers, photo_provider_fields, schema_version (seed/config data, not user data).
const RESET_TABLES = [
'school_holiday_periods',
'school_holiday_regions',
'school_holiday_countries',
// Collab
'file_links',
'collab_message_reactions',
@@ -309,6 +309,19 @@ describe('updatePlan', () => {
// ── addHolidayCalendar ────────────────────────────────────────────────────────
describe('addHolidayCalendar', () => {
it('validates manual region references for creates and updates', () => {
const { plan } = setupUserWithPlan();
testDb.prepare("INSERT INTO school_holiday_countries (code, name) VALUES ('US', 'USA')").run();
const inserted = testDb.prepare("INSERT INTO school_holiday_regions (country, name) VALUES ('US', 'Seattle')").run();
const code = `US-MANUAL-${inserted.lastInsertRowid}`;
const calendar = svc.addHolidayCalendar(plan.id, code, null, undefined, 0, undefined, 'school_holiday');
expect(calendar.region).toBe(code);
expect(svc.updateHolidayCalendar(calendar.id, plan.id, { label: 'School' }, undefined)?.region).toBe(code);
expect(() => svc.updateHolidayCalendar(calendar.id, plan.id, { type: 'public_holiday' }, undefined)).toThrow('Unknown manual');
for (const region of ['US-MANUAL-0', 'US-MANUAL-999999', `CA-MANUAL-${inserted.lastInsertRowid}`]) {
expect(() => svc.addHolidayCalendar(plan.id, region, null, undefined, 0, undefined, 'school_holiday')).toThrow('Unknown manual');
}
});
it('VACAY-SVC-019: inserts a new calendar row and returns the calendar object', () => {
const { plan } = setupUserWithPlan();
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'تم حظر الوصول إلى الموقع. تحقق من إعدادات الجهاز؛ فالتطبيق المثبت لديه إذن موقع خاص به منفصل عن المتصفح.',
'map.location.unavailable': 'تعذر تحديد موقعك.',
'map.location.timeout': 'استغرق تحديد موقعك وقتًا طويلًا. حاول مرة أخرى تحت سماء مكشوفة.',
'map.overview.show': 'عرض الرحلة كاملة',
'map.overview.hide': 'إخفاء الرحلة كاملة',
'map.overview.total': 'المسافة الإجمالية',
};
export default map;
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError': 'تم حفظ التغيير، لكن تعذر تحديث القائمة. حاول تحميلها مجددًا.',
'schoolCatalog.title': 'العطلات المدرسية',
'schoolCatalog.hint': 'أدر المناطق المشتركة. يمكن للجميع اختيارها في إعدادات الإجازات.',
'schoolCatalog.periodHint': 'يشمل كلا التاريخين. أضف الفترات لكل عام دراسي.',
'schoolCatalog.region': 'المنطقة أو المنطقة التعليمية',
'schoolCatalog.name': 'اسم العطلة',
'schoolCatalog.start': 'تاريخ البدء',
'schoolCatalog.end': 'تاريخ الانتهاء',
'schoolCatalog.addPeriod': 'إضافة فترة عطلة',
'schoolCatalog.invalid': 'تحقق من الأسماء والتواريخ.',
'schoolCatalog.discard': 'تجاهل التغييرات غير المحفوظة؟',
'schoolCatalog.country': 'البلد',
'schoolCatalog.deleteHint': 'احذف المناطق أولاً. لا يمكن حذف المناطق المستخدمة.',
'schoolCatalog.deleteCountry': 'حذف البلد',
'schoolCatalog.addCountry': 'إضافة بلد',
'schoolCatalog.code': 'رمز البلد (مثل US)',
'schoolCatalog.empty': 'لا توجد مناطق بعد.',
'schoolCatalog.addRegion': 'إضافة منطقة',
'schoolCatalog.offline': 'اتصل بالإنترنت لإدارة العطلات المدرسية.',
'schoolCatalog.loadError': 'تعذر تحميل المناطق. أعد فتح الإعدادات للمحاولة مجددًا.',
'schoolCatalog.retry': 'إعادة المحاولة',
'vacay.subtitle': 'خطط وأدر أيام الإجازة',
'vacay.settings': 'الإعدادات',
'vacay.year': 'السنة',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'O acesso à localização está bloqueado. Verifique as configurações do aparelho; um app instalado tem permissão de localização própria, separada do navegador.',
'map.location.unavailable': 'Não foi possível determinar sua localização.',
'map.location.timeout': 'A localização demorou demais. Tente de novo com uma visão mais aberta do céu.',
'map.overview.show': 'Mostrar a viagem inteira',
'map.overview.hide': 'Ocultar a viagem inteira',
'map.overview.total': 'Distância total',
};
export default map;
+22
View File
@@ -1,6 +1,28 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError':
'A alteração foi salva, mas a lista não pôde ser atualizada. Tente carregá-la novamente.',
'schoolCatalog.title': 'Férias escolares',
'schoolCatalog.hint': 'Gerencie regiões compartilhadas. Todos podem selecioná-las nas configurações de férias.',
'schoolCatalog.periodHint': 'As duas datas estão incluídas. Adicione períodos para cada ano letivo.',
'schoolCatalog.region': 'Região ou distrito escolar',
'schoolCatalog.name': 'Nome das férias',
'schoolCatalog.start': 'Data inicial',
'schoolCatalog.end': 'Data final',
'schoolCatalog.addPeriod': 'Adicionar período',
'schoolCatalog.invalid': 'Confira os nomes e as datas.',
'schoolCatalog.discard': 'Descartar alterações não salvas?',
'schoolCatalog.country': 'País',
'schoolCatalog.deleteHint': 'Remova primeiro as regiões. Regiões em uso não podem ser excluídas.',
'schoolCatalog.deleteCountry': 'Excluir país',
'schoolCatalog.addCountry': 'Adicionar país',
'schoolCatalog.code': 'Código do país (ex.: US)',
'schoolCatalog.empty': 'Nenhuma região ainda.',
'schoolCatalog.addRegion': 'Adicionar região',
'schoolCatalog.offline': 'Conecte-se à internet para gerenciar as férias escolares.',
'schoolCatalog.loadError': 'Não foi possível carregar as regiões. Abra as configurações novamente.',
'schoolCatalog.retry': 'Tentar novamente',
'vacay.subtitle': 'Planeje e gerencie dias de férias',
'vacay.settings': 'Configurações',
'vacay.year': 'Ano',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'L’accés a la ubicació està bloquejat. Revisa la configuració del dispositiu; una app instal·lada té el seu propi permís d’ubicació, separat del navegador.',
'map.location.unavailable': 'No s’ha pogut determinar la teva ubicació.',
'map.location.timeout': 'La localització ha trigat massa. Torna-ho a provar amb una vista més clara del cel.',
'map.overview.show': 'Mostra tot el viatge',
'map.overview.hide': 'Amaga tot el viatge',
'map.overview.total': 'Distància total',
};
export default map;
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError': 'El canvi s’ha desat, però no s’ha pogut actualitzar la llista. Torna a carregar-la.',
'schoolCatalog.title': 'Vacances escolars',
'schoolCatalog.hint': 'Gestiona regions compartides. Tothom les pot triar als ajustos de vacances.',
'schoolCatalog.periodHint': 'Ambdues dates estan incloses. Afegeix períodes per a cada curs escolar.',
'schoolCatalog.region': 'Regió o districte escolar',
'schoolCatalog.name': 'Nom de les vacances',
'schoolCatalog.start': 'Data inicial',
'schoolCatalog.end': 'Data final',
'schoolCatalog.addPeriod': 'Afegeix un període',
'schoolCatalog.invalid': 'Revisa els noms i les dates.',
'schoolCatalog.discard': 'Vols descartar els canvis sense desar?',
'schoolCatalog.country': 'País',
'schoolCatalog.deleteHint': 'Elimina primer les regions. Les regions en ús no es poden eliminar.',
'schoolCatalog.deleteCountry': 'Elimina el país',
'schoolCatalog.addCountry': 'Afegeix un país',
'schoolCatalog.code': 'Codi del país (p. ex. US)',
'schoolCatalog.empty': 'Encara no hi ha regions.',
'schoolCatalog.addRegion': 'Afegeix una regió',
'schoolCatalog.offline': 'Connecta’t a Internet per gestionar les vacances escolars.',
'schoolCatalog.loadError': 'No s’han pogut carregar les regions. Torna a obrir els ajustos.',
'schoolCatalog.retry': 'Torna-ho a provar',
'vacay.subtitle': 'Planifica i gestiona dies de vacances',
'vacay.settings': 'Configuració',
'vacay.year': 'Any',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Přístup k poloze je zablokován. Zkontrolujte nastavení zařízení; nainstalovaná aplikace má vlastní oprávnění k poloze, nezávislé na prohlížeči.',
'map.location.unavailable': 'Vaši polohu se nepodařilo zjistit.',
'map.location.timeout': 'Zjišťování polohy trvalo příliš dlouho. Zkuste to znovu s volným výhledem na oblohu.',
'map.overview.show': 'Zobrazit celou cestu',
'map.overview.hide': 'Skrýt celou cestu',
'map.overview.total': 'Celková vzdálenost',
};
export default map;
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError': 'Změna byla uložena, ale seznam se nepodařilo obnovit. Zkuste jej načíst znovu.',
'schoolCatalog.title': 'Školní prázdniny',
'schoolCatalog.hint': 'Spravujte sdílené regiony. Všichni je mohou vybrat v nastavení dovolené.',
'schoolCatalog.periodHint': 'Oba krajní dny jsou zahrnuty. Přidejte období pro každý školní rok.',
'schoolCatalog.region': 'Region nebo školní obvod',
'schoolCatalog.name': 'Název prázdnin',
'schoolCatalog.start': 'Datum začátku',
'schoolCatalog.end': 'Datum konce',
'schoolCatalog.addPeriod': 'Přidat období',
'schoolCatalog.invalid': 'Zkontrolujte názvy a data.',
'schoolCatalog.discard': 'Zahodit neuložené změny?',
'schoolCatalog.country': 'Země',
'schoolCatalog.deleteHint': 'Nejprve odstraňte regiony. Používané regiony nelze smazat.',
'schoolCatalog.deleteCountry': 'Smazat zemi',
'schoolCatalog.addCountry': 'Přidat zemi',
'schoolCatalog.code': 'Kód země (např. US)',
'schoolCatalog.empty': 'Zatím žádné regiony.',
'schoolCatalog.addRegion': 'Přidat region',
'schoolCatalog.offline': 'Pro správu školních prázdnin se připojte k internetu.',
'schoolCatalog.loadError': 'Regiony se nepodařilo načíst. Znovu otevřete nastavení.',
'schoolCatalog.retry': 'Zkusit znovu',
'vacay.subtitle': 'Plánování a správa dovolené',
'vacay.settings': 'Nastavení',
'vacay.year': 'Rok',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Der Standortzugriff ist blockiert. Prüfe die Geräteeinstellungen; eine installierte App hat eine eigene Standortberechtigung, unabhängig vom Browser.',
'map.location.unavailable': 'Dein Standort konnte nicht ermittelt werden.',
'map.location.timeout': 'Die Standortbestimmung hat zu lange gedauert. Versuche es mit freier Sicht zum Himmel erneut.',
'map.overview.show': 'Ganze Reise anzeigen',
'map.overview.hide': 'Ganze Reise ausblenden',
'map.overview.total': 'Gesamtstrecke',
};
export default map;
+22
View File
@@ -1,6 +1,28 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError':
'Die Änderung wurde gespeichert, aber die Liste konnte nicht neu geladen werden. Versuche es erneut.',
'schoolCatalog.title': 'Schulferien',
'schoolCatalog.hint': 'Verwalte gemeinsame Ferienregionen. Alle können sie in den Urlaubseinstellungen auswählen.',
'schoolCatalog.periodHint': 'Beide Grenztage zählen mit. Ergänze Zeiträume für jedes Schuljahr.',
'schoolCatalog.region': 'Region oder Schulbezirk',
'schoolCatalog.name': 'Name der Ferien',
'schoolCatalog.start': 'Startdatum',
'schoolCatalog.end': 'Enddatum',
'schoolCatalog.addPeriod': 'Ferienzeitraum hinzufügen',
'schoolCatalog.invalid': 'Prüfe Namen und Datumsangaben.',
'schoolCatalog.discard': 'Ungespeicherte Änderungen verwerfen?',
'schoolCatalog.country': 'Land',
'schoolCatalog.deleteHint': 'Entferne zuerst die Regionen. Verwendete Regionen können nicht gelöscht werden.',
'schoolCatalog.deleteCountry': 'Land löschen',
'schoolCatalog.addCountry': 'Land hinzufügen',
'schoolCatalog.code': 'Ländercode (z. B. US)',
'schoolCatalog.empty': 'Noch keine Regionen vorhanden.',
'schoolCatalog.addRegion': 'Region hinzufügen',
'schoolCatalog.offline': 'Verbinde dich mit dem Internet, um Schulferien zu verwalten.',
'schoolCatalog.loadError': 'Ferienregionen konnten nicht geladen werden. Öffne die Einstellungen erneut.',
'schoolCatalog.retry': 'Erneut versuchen',
'vacay.subtitle': 'Urlaubstage planen und verwalten',
'vacay.settings': 'Einstellungen',
'vacay.year': 'Jahr',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Location access is blocked. Check your device settings; an installed app has its own location permission, separate from the browser.',
'map.location.unavailable': 'Your location could not be determined.',
'map.location.timeout': 'Locating you took too long. Try again with a clearer view of the sky.',
'map.overview.show': 'Show whole trip',
'map.overview.hide': 'Hide whole trip',
'map.overview.total': 'Total distance',
};
export default map;
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError': 'The change was saved, but the list could not be refreshed. Retry loading it.',
'schoolCatalog.title': 'School holidays',
'schoolCatalog.hint': 'Manage shared school holiday regions. Everyone can select them in vacation settings.',
'schoolCatalog.periodHint': 'Dates include both days. Add periods for each school year.',
'schoolCatalog.region': 'Region or school district',
'schoolCatalog.name': 'Holiday name',
'schoolCatalog.start': 'Start date',
'schoolCatalog.end': 'End date',
'schoolCatalog.addPeriod': 'Add holiday period',
'schoolCatalog.invalid': 'Check the names and dates.',
'schoolCatalog.discard': 'Discard unsaved changes?',
'schoolCatalog.country': 'Country',
'schoolCatalog.deleteHint': 'Remove regions first. Regions used by calendars cannot be deleted.',
'schoolCatalog.deleteCountry': 'Delete country',
'schoolCatalog.addCountry': 'Add country',
'schoolCatalog.code': 'Country code (e.g. US)',
'schoolCatalog.empty': 'No regions yet.',
'schoolCatalog.addRegion': 'Add region',
'schoolCatalog.offline': 'Connect to the internet to manage school holidays.',
'schoolCatalog.loadError': 'Could not load school holiday regions. Reopen settings to retry.',
'schoolCatalog.retry': 'Retry',
'vacay.subtitle': 'Plan and manage vacation days',
'vacay.settings': 'Settings',
'vacay.year': 'Year',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'El acceso a la ubicación está bloqueado. Revisa los ajustes del dispositivo; una app instalada tiene su propio permiso de ubicación, independiente del navegador.',
'map.location.unavailable': 'No se pudo determinar tu ubicación.',
'map.location.timeout': 'La localización tardó demasiado. Inténtalo de nuevo con una vista más despejada del cielo.',
'map.overview.show': 'Mostrar todo el viaje',
'map.overview.hide': 'Ocultar todo el viaje',
'map.overview.total': 'Distancia total',
};
export default map;
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError': 'El cambio se guardó, pero no se pudo actualizar la lista. Intenta cargarla de nuevo.',
'schoolCatalog.title': 'Vacaciones escolares',
'schoolCatalog.hint': 'Gestiona regiones compartidas. Todos pueden elegirlas en los ajustes de vacaciones.',
'schoolCatalog.periodHint': 'Ambas fechas están incluidas. Añade períodos para cada curso escolar.',
'schoolCatalog.region': 'Región o distrito escolar',
'schoolCatalog.name': 'Nombre de las vacaciones',
'schoolCatalog.start': 'Fecha de inicio',
'schoolCatalog.end': 'Fecha de fin',
'schoolCatalog.addPeriod': 'Añadir período',
'schoolCatalog.invalid': 'Revisa los nombres y las fechas.',
'schoolCatalog.discard': '¿Descartar los cambios sin guardar?',
'schoolCatalog.country': 'País',
'schoolCatalog.deleteHint': 'Elimina primero las regiones. Las regiones en uso no se pueden eliminar.',
'schoolCatalog.deleteCountry': 'Eliminar país',
'schoolCatalog.addCountry': 'Añadir país',
'schoolCatalog.code': 'Código de país (p. ej. US)',
'schoolCatalog.empty': 'Todavía no hay regiones.',
'schoolCatalog.addRegion': 'Añadir región',
'schoolCatalog.offline': 'Conéctate a Internet para gestionar las vacaciones escolares.',
'schoolCatalog.loadError': 'No se pudieron cargar las regiones. Vuelve a abrir los ajustes.',
'schoolCatalog.retry': 'Reintentar',
'vacay.subtitle': 'Planifica y gestiona días de vacaciones',
'vacay.settings': 'Ajustes',
'vacay.year': 'Año',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'L’accès à la position est bloqué. Vérifiez les réglages de l’appareil. Une app installée possède sa propre autorisation de localisation, distincte du navigateur.',
'map.location.unavailable': 'Votre position n’a pas pu être déterminée.',
'map.location.timeout': 'La localisation a pris trop de temps. Réessayez avec une vue plus dégagée du ciel.',
'map.overview.show': 'Afficher tout le voyage',
'map.overview.hide': 'Masquer tout le voyage',
'map.overview.total': 'Distance totale',
};
export default map;
+22
View File
@@ -1,6 +1,28 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError':
'La modification a été enregistrée, mais la liste n’a pas pu être actualisée. Réessayez de la charger.',
'schoolCatalog.title': 'Vacances scolaires',
'schoolCatalog.hint': 'Gérez les régions partagées. Chacun peut les choisir dans les paramètres des congés.',
'schoolCatalog.periodHint': 'Les deux dates sont incluses. Ajoutez les périodes de chaque année scolaire.',
'schoolCatalog.region': 'Région ou district scolaire',
'schoolCatalog.name': 'Nom des vacances',
'schoolCatalog.start': 'Date de début',
'schoolCatalog.end': 'Date de fin',
'schoolCatalog.addPeriod': 'Ajouter une période',
'schoolCatalog.invalid': 'Vérifiez les noms et les dates.',
'schoolCatalog.discard': 'Abandonner les modifications non enregistrées ?',
'schoolCatalog.country': 'Pays',
'schoolCatalog.deleteHint': 'Supprimez les régions en premier. Les régions utilisées ne peuvent pas être supprimées.',
'schoolCatalog.deleteCountry': 'Supprimer le pays',
'schoolCatalog.addCountry': 'Ajouter un pays',
'schoolCatalog.code': 'Code du pays (ex. US)',
'schoolCatalog.empty': 'Aucune région pour le moment.',
'schoolCatalog.addRegion': 'Ajouter une région',
'schoolCatalog.offline': 'Connectez-vous à Internet pour gérer les vacances scolaires.',
'schoolCatalog.loadError': 'Impossible de charger les régions. Rouvrez les paramètres pour réessayer.',
'schoolCatalog.retry': 'Réessayer',
'vacay.subtitle': 'Planifiez et gérez vos jours de congés',
'vacay.settings': 'Paramètres',
'vacay.year': 'Année',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Η πρόσβαση στην τοποθεσία είναι αποκλεισμένη. Ελέγξτε τις ρυθμίσεις της συσκευής. Μια εγκατεστημένη εφαρμογή έχει δική της άδεια τοποθεσίας, ξεχωριστή από το πρόγραμμα περιήγησης.',
'map.location.unavailable': 'Δεν ήταν δυνατός ο προσδιορισμός της τοποθεσίας σας.',
'map.location.timeout': 'Ο εντοπισμός της θέσης σας άργησε πολύ. Δοκιμάστε ξανά με καθαρή θέα στον ουρανό.',
'map.overview.show': 'Εμφάνιση όλου του ταξιδιού',
'map.overview.hide': 'Απόκρυψη όλου του ταξιδιού',
'map.overview.total': 'Συνολική απόσταση',
};
export default map;
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError': 'Η αλλαγή αποθηκεύτηκε, αλλά η λίστα δεν ανανεώθηκε. Δοκιμάστε να τη φορτώσετε ξανά.',
'schoolCatalog.title': 'Σχολικές διακοπές',
'schoolCatalog.hint': 'Διαχειριστείτε κοινές περιοχές. Όλοι μπορούν να τις επιλέξουν στις ρυθμίσεις αδειών.',
'schoolCatalog.periodHint': 'Περιλαμβάνονται και οι δύο ημερομηνίες. Προσθέστε περιόδους για κάθε σχολικό έτος.',
'schoolCatalog.region': 'Περιοχή ή σχολική περιφέρεια',
'schoolCatalog.name': 'Όνομα διακοπών',
'schoolCatalog.start': 'Ημερομηνία έναρξης',
'schoolCatalog.end': 'Ημερομηνία λήξης',
'schoolCatalog.addPeriod': 'Προσθήκη περιόδου',
'schoolCatalog.invalid': 'Ελέγξτε τα ονόματα και τις ημερομηνίες.',
'schoolCatalog.discard': 'Απόρριψη μη αποθηκευμένων αλλαγών;',
'schoolCatalog.country': 'Χώρα',
'schoolCatalog.deleteHint': 'Αφαιρέστε πρώτα τις περιοχές. Οι περιοχές που χρησιμοποιούνται δεν διαγράφονται.',
'schoolCatalog.deleteCountry': 'Διαγραφή χώρας',
'schoolCatalog.addCountry': 'Προσθήκη χώρας',
'schoolCatalog.code': 'Κωδικός χώρας (π.χ. US)',
'schoolCatalog.empty': 'Δεν υπάρχουν ακόμη περιοχές.',
'schoolCatalog.addRegion': 'Προσθήκη περιοχής',
'schoolCatalog.offline': 'Συνδεθείτε στο διαδίκτυο για να διαχειριστείτε τις σχολικές διακοπές.',
'schoolCatalog.loadError': 'Δεν ήταν δυνατή η φόρτωση των περιοχών. Ανοίξτε ξανά τις ρυθμίσεις.',
'schoolCatalog.retry': 'Δοκιμή ξανά',
'vacay.subtitle': 'Σχεδιάστε και διαχειριστείτε ημέρες διακοπών',
'vacay.settings': 'Ρυθμίσεις',
'vacay.year': 'Έτος',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'A helyhozzáférés le van tiltva. Ellenőrizd a készülék beállításait; a telepített alkalmazásnak saját helyengedélye van, a böngészőtől függetlenül.',
'map.location.unavailable': 'A tartózkodási helyed nem határozható meg.',
'map.location.timeout': 'A helymeghatározás túl sokáig tartott. Próbáld újra szabad ég alatt.',
'map.overview.show': 'Teljes utazás megjelenítése',
'map.overview.hide': 'Teljes utazás elrejtése',
'map.overview.total': 'Teljes távolság',
};
export default map;
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError': 'A módosítás mentve, de a lista nem frissült. Próbáld újra betölteni.',
'schoolCatalog.title': 'Iskolai szünetek',
'schoolCatalog.hint': 'Kezeld a közös régiókat. Mindenki kiválaszthatja őket a szabadság beállításaiban.',
'schoolCatalog.periodHint': 'Mindkét határnap beleszámít. Adj hozzá időszakokat minden tanévhez.',
'schoolCatalog.region': 'Régió vagy tankerület',
'schoolCatalog.name': 'Szünet neve',
'schoolCatalog.start': 'Kezdő dátum',
'schoolCatalog.end': 'Záró dátum',
'schoolCatalog.addPeriod': 'Időszak hozzáadása',
'schoolCatalog.invalid': 'Ellenőrizd a neveket és a dátumokat.',
'schoolCatalog.discard': 'Elveted a nem mentett módosításokat?',
'schoolCatalog.country': 'Ország',
'schoolCatalog.deleteHint': 'Először távolítsd el a régiókat. A használt régiók nem törölhetők.',
'schoolCatalog.deleteCountry': 'Ország törlése',
'schoolCatalog.addCountry': 'Ország hozzáadása',
'schoolCatalog.code': 'Országkód (pl. US)',
'schoolCatalog.empty': 'Még nincsenek régiók.',
'schoolCatalog.addRegion': 'Régió hozzáadása',
'schoolCatalog.offline': 'Az iskolai szünetek kezeléséhez csatlakozz az internethez.',
'schoolCatalog.loadError': 'A régiók nem tölthetők be. Nyisd meg újra a beállításokat.',
'schoolCatalog.retry': 'Újrapróbálkozás',
'vacay.subtitle': 'Szabadságnapok tervezése és kezelése',
'vacay.settings': 'Beállítások',
'vacay.year': 'Év',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Akses lokasi diblokir. Periksa pengaturan perangkat; aplikasi yang terpasang punya izin lokasi sendiri, terpisah dari browser.',
'map.location.unavailable': 'Lokasi Anda tidak dapat ditentukan.',
'map.location.timeout': 'Penentuan lokasi terlalu lama. Coba lagi di tempat dengan pandangan langit yang lebih terbuka.',
'map.overview.show': 'Tampilkan seluruh perjalanan',
'map.overview.hide': 'Sembunyikan seluruh perjalanan',
'map.overview.total': 'Jarak total',
};
export default map;
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError': 'Perubahan disimpan, tetapi daftar gagal diperbarui. Coba muat ulang.',
'schoolCatalog.title': 'Liburan sekolah',
'schoolCatalog.hint': 'Kelola wilayah bersama. Semua orang dapat memilihnya di pengaturan cuti.',
'schoolCatalog.periodHint': 'Kedua tanggal termasuk. Tambahkan periode untuk setiap tahun ajaran.',
'schoolCatalog.region': 'Wilayah atau distrik sekolah',
'schoolCatalog.name': 'Nama liburan',
'schoolCatalog.start': 'Tanggal mulai',
'schoolCatalog.end': 'Tanggal selesai',
'schoolCatalog.addPeriod': 'Tambah periode liburan',
'schoolCatalog.invalid': 'Periksa nama dan tanggal.',
'schoolCatalog.discard': 'Buang perubahan yang belum disimpan?',
'schoolCatalog.country': 'Negara',
'schoolCatalog.deleteHint': 'Hapus wilayah terlebih dahulu. Wilayah yang digunakan tidak dapat dihapus.',
'schoolCatalog.deleteCountry': 'Hapus negara',
'schoolCatalog.addCountry': 'Tambah negara',
'schoolCatalog.code': 'Kode negara (mis. US)',
'schoolCatalog.empty': 'Belum ada wilayah.',
'schoolCatalog.addRegion': 'Tambah wilayah',
'schoolCatalog.offline': 'Hubungkan ke internet untuk mengelola liburan sekolah.',
'schoolCatalog.loadError': 'Wilayah gagal dimuat. Buka kembali pengaturan.',
'schoolCatalog.retry': 'Coba lagi',
'vacay.subtitle': 'Rencanakan dan kelola hari cuti',
'vacay.settings': 'Pengaturan',
'vacay.year': 'Tahun',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'L’accesso alla posizione è bloccato. Controlla le impostazioni del dispositivo; un’app installata ha un proprio permesso di localizzazione, separato dal browser.',
'map.location.unavailable': 'Impossibile determinare la tua posizione.',
'map.location.timeout': 'La localizzazione ha richiesto troppo tempo. Riprova con una visuale più libera del cielo.',
'map.overview.show': 'Mostra tutto il viaggio',
'map.overview.hide': 'Nascondi tutto il viaggio',
'map.overview.total': 'Distanza totale',
};
export default map;
+22
View File
@@ -1,6 +1,28 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError':
'La modifica è stata salvata, ma non è stato possibile aggiornare l’elenco. Riprova a caricarlo.',
'schoolCatalog.title': 'Vacanze scolastiche',
'schoolCatalog.hint': 'Gestisci le regioni condivise. Tutti possono sceglierle nelle impostazioni delle ferie.',
'schoolCatalog.periodHint': 'Entrambe le date sono incluse. Aggiungi i periodi di ogni anno scolastico.',
'schoolCatalog.region': 'Regione o distretto scolastico',
'schoolCatalog.name': 'Nome delle vacanze',
'schoolCatalog.start': 'Data di inizio',
'schoolCatalog.end': 'Data di fine',
'schoolCatalog.addPeriod': 'Aggiungi periodo',
'schoolCatalog.invalid': 'Controlla i nomi e le date.',
'schoolCatalog.discard': 'Scartare le modifiche non salvate?',
'schoolCatalog.country': 'Paese',
'schoolCatalog.deleteHint': 'Rimuovi prima le regioni. Le regioni in uso non possono essere eliminate.',
'schoolCatalog.deleteCountry': 'Elimina paese',
'schoolCatalog.addCountry': 'Aggiungi paese',
'schoolCatalog.code': 'Codice paese (es. US)',
'schoolCatalog.empty': 'Nessuna regione presente.',
'schoolCatalog.addRegion': 'Aggiungi regione',
'schoolCatalog.offline': 'Connettiti a Internet per gestire le vacanze scolastiche.',
'schoolCatalog.loadError': 'Impossibile caricare le regioni. Riapri le impostazioni.',
'schoolCatalog.retry': 'Riprova',
'vacay.subtitle': 'Pianifica e gestisci i giorni di ferie',
'vacay.settings': 'Impostazioni',
'vacay.year': 'Anno',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': '位置情報へのアクセスがブロックされています。端末の設定を確認してください。インストールしたアプリにはブラウザとは別の位置情報の許可があります。',
'map.location.unavailable': '現在地を特定できませんでした。',
'map.location.timeout': '位置情報の取得に時間がかかりすぎました。空がよく見える場所でもう一度お試しください。',
'map.overview.show': '旅程全体を表示',
'map.overview.hide': '旅程全体を非表示',
'map.overview.total': '合計距離',
};
export default map;
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError': '変更は保存されましたが、一覧を更新できませんでした。再読み込みしてください。',
'schoolCatalog.title': '学校の休暇',
'schoolCatalog.hint': '共有する地域を管理します。全員が休暇設定で選択できます。',
'schoolCatalog.periodHint': '開始日と終了日を含みます。各学年の期間を追加してください。',
'schoolCatalog.region': '地域または学区',
'schoolCatalog.name': '休暇名',
'schoolCatalog.start': '開始日',
'schoolCatalog.end': '終了日',
'schoolCatalog.addPeriod': '休暇期間を追加',
'schoolCatalog.invalid': '名前と日付を確認してください。',
'schoolCatalog.discard': '未保存の変更を破棄しますか?',
'schoolCatalog.country': '国',
'schoolCatalog.deleteHint': '先に地域を削除してください。使用中の地域は削除できません。',
'schoolCatalog.deleteCountry': '国を削除',
'schoolCatalog.addCountry': '国を追加',
'schoolCatalog.code': '国コード(例:US)',
'schoolCatalog.empty': '地域はまだありません。',
'schoolCatalog.addRegion': '地域を追加',
'schoolCatalog.offline': '学校の休暇を管理するにはインターネットに接続してください。',
'schoolCatalog.loadError': '地域を読み込めませんでした。設定を開き直してください。',
'schoolCatalog.retry': '再試行',
'vacay.subtitle': '休暇日数の計画と管理',
'vacay.settings': '設定',
'vacay.year': '年',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': '위치 접근이 차단되어 있습니다. 기기 설정을 확인하세요. 설치된 앱은 브라우저와 별도의 위치 권한을 사용합니다.',
'map.location.unavailable': '현재 위치를 확인할 수 없습니다.',
'map.location.timeout': '위치를 확인하는 데 시간이 너무 오래 걸렸습니다. 하늘이 잘 보이는 곳에서 다시 시도하세요.',
'map.overview.show': '전체 여행 보기',
'map.overview.hide': '전체 여행 숨기기',
'map.overview.total': '총 거리',
};
export default map;
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError': '변경 사항은 저장했지만 목록을 새로 고치지 못했습니다. 다시 불러오세요.',
'schoolCatalog.title': '학교 방학',
'schoolCatalog.hint': '공유 지역을 관리하세요. 누구나 휴가 설정에서 선택할 수 있습니다.',
'schoolCatalog.periodHint': '시작일과 종료일을 모두 포함합니다. 학년도별 기간을 추가하세요.',
'schoolCatalog.region': '지역 또는 학군',
'schoolCatalog.name': '방학 이름',
'schoolCatalog.start': '시작일',
'schoolCatalog.end': '종료일',
'schoolCatalog.addPeriod': '방학 기간 추가',
'schoolCatalog.invalid': '이름과 날짜를 확인하세요.',
'schoolCatalog.discard': '저장하지 않은 변경 사항을 취소할까요?',
'schoolCatalog.country': '국가',
'schoolCatalog.deleteHint': '먼저 지역을 삭제하세요. 사용 중인 지역은 삭제할 수 없습니다.',
'schoolCatalog.deleteCountry': '국가 삭제',
'schoolCatalog.addCountry': '국가 추가',
'schoolCatalog.code': '국가 코드(예: US)',
'schoolCatalog.empty': '아직 지역이 없습니다.',
'schoolCatalog.addRegion': '지역 추가',
'schoolCatalog.offline': '학교 방학을 관리하려면 인터넷에 연결하세요.',
'schoolCatalog.loadError': '지역을 불러오지 못했습니다. 설정을 다시 여세요.',
'schoolCatalog.retry': '다시 시도',
'vacay.subtitle': '휴가 일수를 계획하고 관리하세요',
'vacay.settings': '설정',
'vacay.year': '연도',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Locatietoegang is geblokkeerd. Controleer de apparaatinstellingen; een geïnstalleerde app heeft een eigen locatiemachtiging, los van de browser.',
'map.location.unavailable': 'Je locatie kon niet worden bepaald.',
'map.location.timeout': 'Het bepalen van je locatie duurde te lang. Probeer het opnieuw met vrij zicht op de lucht.',
'map.overview.show': 'Hele reis tonen',
'map.overview.hide': 'Hele reis verbergen',
'map.overview.total': 'Totale afstand',
};
export default map;
+22
View File
@@ -1,6 +1,28 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError':
'De wijziging is opgeslagen, maar de lijst kon niet worden vernieuwd. Probeer deze opnieuw te laden.',
'schoolCatalog.title': 'Schoolvakanties',
'schoolCatalog.hint': 'Beheer gedeelde vakantieregio’s. Iedereen kan ze kiezen in de vakantie-instellingen.',
'schoolCatalog.periodHint': 'Beide datums tellen mee. Voeg perioden voor elk schooljaar toe.',
'schoolCatalog.region': 'Regio of schooldistrict',
'schoolCatalog.name': 'Naam van de vakantie',
'schoolCatalog.start': 'Begindatum',
'schoolCatalog.end': 'Einddatum',
'schoolCatalog.addPeriod': 'Vakantieperiode toevoegen',
'schoolCatalog.invalid': 'Controleer de namen en datums.',
'schoolCatalog.discard': 'Niet-opgeslagen wijzigingen verwijderen?',
'schoolCatalog.country': 'Land',
'schoolCatalog.deleteHint': 'Verwijder eerst de regio’s. Regio’s die worden gebruikt kunnen niet worden verwijderd.',
'schoolCatalog.deleteCountry': 'Land verwijderen',
'schoolCatalog.addCountry': 'Land toevoegen',
'schoolCatalog.code': 'Landcode (bijv. US)',
'schoolCatalog.empty': 'Nog geen regio’s.',
'schoolCatalog.addRegion': 'Regio toevoegen',
'schoolCatalog.offline': 'Maak verbinding met internet om schoolvakanties te beheren.',
'schoolCatalog.loadError': 'Vakantieregio’s konden niet worden geladen. Open de instellingen opnieuw.',
'schoolCatalog.retry': 'Opnieuw proberen',
'vacay.subtitle': 'Plan en beheer vakantiedagen',
'vacay.settings': 'Instellingen',
'vacay.year': 'Jaar',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Dostęp do lokalizacji jest zablokowany. Sprawdź ustawienia urządzenia; zainstalowana aplikacja ma własne uprawnienie do lokalizacji, niezależne od przeglądarki.',
'map.location.unavailable': 'Nie udało się ustalić Twojej lokalizacji.',
'map.location.timeout': 'Ustalanie lokalizacji trwało zbyt długo. Spróbuj ponownie przy lepszej widoczności nieba.',
'map.overview.show': 'Pokaż całą podróż',
'map.overview.hide': 'Ukryj całą podróż',
'map.overview.total': 'Łączny dystans',
};
export default map;
+22
View File
@@ -1,6 +1,28 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError':
'Zmiana została zapisana, ale nie udało się odświeżyć listy. Spróbuj wczytać ją ponownie.',
'schoolCatalog.title': 'Ferie szkolne',
'schoolCatalog.hint': 'Zarządzaj wspólnymi regionami. Każdy może je wybrać w ustawieniach urlopu.',
'schoolCatalog.periodHint': 'Obie daty są wliczone. Dodaj okresy na każdy rok szkolny.',
'schoolCatalog.region': 'Region lub okręg szkolny',
'schoolCatalog.name': 'Nazwa ferii',
'schoolCatalog.start': 'Data rozpoczęcia',
'schoolCatalog.end': 'Data zakończenia',
'schoolCatalog.addPeriod': 'Dodaj okres',
'schoolCatalog.invalid': 'Sprawdź nazwy i daty.',
'schoolCatalog.discard': 'Odrzucić niezapisane zmiany?',
'schoolCatalog.country': 'Kraj',
'schoolCatalog.deleteHint': 'Najpierw usuń regiony. Nie można usunąć używanych regionów.',
'schoolCatalog.deleteCountry': 'Usuń kraj',
'schoolCatalog.addCountry': 'Dodaj kraj',
'schoolCatalog.code': 'Kod kraju (np. US)',
'schoolCatalog.empty': 'Brak regionów.',
'schoolCatalog.addRegion': 'Dodaj region',
'schoolCatalog.offline': 'Połącz się z internetem, aby zarządzać feriami szkolnymi.',
'schoolCatalog.loadError': 'Nie udało się wczytać regionów. Otwórz ponownie ustawienia.',
'schoolCatalog.retry': 'Spróbuj ponownie',
'vacay.subtitle': 'Planuj i zarządzaj dniami urlopu',
'vacay.settings': 'Ustawienia',
'vacay.year': 'Rok',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Доступ к геопозиции заблокирован. Проверьте настройки устройства; у установленного приложения есть собственное разрешение на геопозицию, отдельное от браузера.',
'map.location.unavailable': 'Не удалось определить ваше местоположение.',
'map.location.timeout': 'Определение местоположения заняло слишком много времени. Попробуйте ещё раз под открытым небом.',
'map.overview.show': 'Показать всю поездку',
'map.overview.hide': 'Скрыть всю поездку',
'map.overview.total': 'Общее расстояние',
};
export default map;
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError': 'Изменение сохранено, но обновить список не удалось. Попробуйте загрузить его снова.',
'schoolCatalog.title': 'Школьные каникулы',
'schoolCatalog.hint': 'Управляйте общими регионами. Каждый может выбрать их в настройках отпуска.',
'schoolCatalog.periodHint': 'Обе даты включены. Добавляйте периоды на каждый учебный год.',
'schoolCatalog.region': 'Регион или школьный округ',
'schoolCatalog.name': 'Название каникул',
'schoolCatalog.start': 'Дата начала',
'schoolCatalog.end': 'Дата окончания',
'schoolCatalog.addPeriod': 'Добавить период',
'schoolCatalog.invalid': 'Проверьте названия и даты.',
'schoolCatalog.discard': 'Отменить несохранённые изменения?',
'schoolCatalog.country': 'Страна',
'schoolCatalog.deleteHint': 'Сначала удалите регионы. Используемые регионы удалить нельзя.',
'schoolCatalog.deleteCountry': 'Удалить страну',
'schoolCatalog.addCountry': 'Добавить страну',
'schoolCatalog.code': 'Код страны (например, US)',
'schoolCatalog.empty': 'Регионов пока нет.',
'schoolCatalog.addRegion': 'Добавить регион',
'schoolCatalog.offline': 'Подключитесь к интернету для управления школьными каникулами.',
'schoolCatalog.loadError': 'Не удалось загрузить регионы. Откройте настройки заново.',
'schoolCatalog.retry': 'Повторить',
'vacay.subtitle': 'Планируйте и управляйте днями отпуска',
'vacay.settings': 'Настройки',
'vacay.year': 'Год',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Platsåtkomst är blockerad. Kontrollera enhetens inställningar; en installerad app har ett eget platstillstånd, skilt från webbläsaren.',
'map.location.unavailable': 'Din plats kunde inte fastställas.',
'map.location.timeout': 'Platsbestämningen tog för lång tid. Försök igen med friare sikt mot himlen.',
'map.overview.show': 'Visa hela resan',
'map.overview.hide': 'Dölj hela resan',
'map.overview.total': 'Total sträcka',
};
export default map;
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError': 'Ändringen sparades, men listan kunde inte uppdateras. Försök läsa in den igen.',
'schoolCatalog.title': 'Skollov',
'schoolCatalog.hint': 'Hantera gemensamma regioner. Alla kan välja dem i semesterinställningarna.',
'schoolCatalog.periodHint': 'Båda datumen ingår. Lägg till perioder för varje läsår.',
'schoolCatalog.region': 'Region eller skoldistrikt',
'schoolCatalog.name': 'Lovets namn',
'schoolCatalog.start': 'Startdatum',
'schoolCatalog.end': 'Slutdatum',
'schoolCatalog.addPeriod': 'Lägg till lovperiod',
'schoolCatalog.invalid': 'Kontrollera namn och datum.',
'schoolCatalog.discard': 'Kasta osparade ändringar?',
'schoolCatalog.country': 'Land',
'schoolCatalog.deleteHint': 'Ta bort regionerna först. Regioner som används kan inte tas bort.',
'schoolCatalog.deleteCountry': 'Ta bort land',
'schoolCatalog.addCountry': 'Lägg till land',
'schoolCatalog.code': 'Landskod (t.ex. US)',
'schoolCatalog.empty': 'Inga regioner ännu.',
'schoolCatalog.addRegion': 'Lägg till region',
'schoolCatalog.offline': 'Anslut till internet för att hantera skollov.',
'schoolCatalog.loadError': 'Kunde inte läsa in regionerna. Öppna inställningarna igen.',
'schoolCatalog.retry': 'Försök igen',
'vacay.subtitle': 'Planera och hantera semesterdagar',
'vacay.settings': 'Inställningar',
'vacay.year': 'År',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Konum erişimi engellendi. Cihaz ayarlarını kontrol edin; yüklü bir uygulamanın tarayıcıdan ayrı kendi konum izni vardır.',
'map.location.unavailable': 'Konumunuz belirlenemedi.',
'map.location.timeout': 'Konum belirleme çok uzun sürdü. Gökyüzünü daha iyi gören bir yerde tekrar deneyin.',
'map.overview.show': 'Tüm geziyi göster',
'map.overview.hide': 'Tüm geziyi gizle',
'map.overview.total': 'Toplam mesafe',
};
export default map;
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError': 'Değişiklik kaydedildi, ancak liste yenilenemedi. Yeniden yüklemeyi deneyin.',
'schoolCatalog.title': 'Okul tatilleri',
'schoolCatalog.hint': 'Ortak bölgeleri yönetin. Herkes izin ayarlarından bunları seçebilir.',
'schoolCatalog.periodHint': 'Her iki tarih de dahildir. Her öğretim yılı için dönemler ekleyin.',
'schoolCatalog.region': 'Bölge veya okul bölgesi',
'schoolCatalog.name': 'Tatil adı',
'schoolCatalog.start': 'Başlangıç tarihi',
'schoolCatalog.end': 'Bitiş tarihi',
'schoolCatalog.addPeriod': 'Tatil dönemi ekle',
'schoolCatalog.invalid': 'Adları ve tarihleri kontrol edin.',
'schoolCatalog.discard': 'Kaydedilmemiş değişiklikler silinsin mi?',
'schoolCatalog.country': 'Ülke',
'schoolCatalog.deleteHint': 'Önce bölgeleri kaldırın. Kullanılan bölgeler silinemez.',
'schoolCatalog.deleteCountry': 'Ülkeyi sil',
'schoolCatalog.addCountry': 'Ülke ekle',
'schoolCatalog.code': 'Ülke kodu (ör. US)',
'schoolCatalog.empty': 'Henüz bölge yok.',
'schoolCatalog.addRegion': 'Bölge ekle',
'schoolCatalog.offline': 'Okul tatillerini yönetmek için internete bağlanın.',
'schoolCatalog.loadError': 'Bölgeler yüklenemedi. Ayarları yeniden açın.',
'schoolCatalog.retry': 'Tekrar dene',
'vacay.subtitle': 'Tatil günlerini planlayın ve yönetin',
'vacay.settings': 'Ayarlar',
'vacay.year': 'Yıl',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Доступ до геолокації заблоковано. Перевірте налаштування пристрою; встановлений застосунок має власний дозвіл на геолокацію, окремий від браузера.',
'map.location.unavailable': 'Не вдалося визначити ваше місцезнаходження.',
'map.location.timeout': 'Визначення місцезнаходження тривало надто довго. Спробуйте ще раз просто неба.',
'map.overview.show': 'Показати всю подорож',
'map.overview.hide': 'Сховати всю подорож',
'map.overview.total': 'Загальна відстань',
};
export default map;
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError': 'Зміну збережено, але список не вдалося оновити. Спробуйте завантажити його знову.',
'schoolCatalog.title': 'Шкільні канікули',
'schoolCatalog.hint': 'Керуйте спільними регіонами. Кожен може вибрати їх у налаштуваннях відпустки.',
'schoolCatalog.periodHint': 'Обидві дати включено. Додавайте періоди на кожен навчальний рік.',
'schoolCatalog.region': 'Регіон або шкільний округ',
'schoolCatalog.name': 'Назва канікул',
'schoolCatalog.start': 'Дата початку',
'schoolCatalog.end': 'Дата завершення',
'schoolCatalog.addPeriod': 'Додати період',
'schoolCatalog.invalid': 'Перевірте назви й дати.',
'schoolCatalog.discard': 'Відкинути незбережені зміни?',
'schoolCatalog.country': 'Країна',
'schoolCatalog.deleteHint': 'Спочатку видаліть регіони. Регіони, що використовуються, видалити не можна.',
'schoolCatalog.deleteCountry': 'Видалити країну',
'schoolCatalog.addCountry': 'Додати країну',
'schoolCatalog.code': 'Код країни (наприклад, US)',
'schoolCatalog.empty': 'Регіонів поки немає.',
'schoolCatalog.addRegion': 'Додати регіон',
'schoolCatalog.offline': 'Підключіться до інтернету для керування шкільними канікулами.',
'schoolCatalog.loadError': 'Не вдалося завантажити регіони. Відкрийте налаштування знову.',
'schoolCatalog.retry': 'Спробувати ще раз',
'vacay.subtitle': 'Плануйте й керуйте днями відпустки',
'vacay.settings': 'Налаштування',
'vacay.year': 'Рік',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Quyền truy cập vị trí đang bị chặn. Hãy kiểm tra cài đặt thiết bị; ứng dụng đã cài đặt có quyền vị trí riêng, tách biệt với trình duyệt.',
'map.location.unavailable': 'Không thể xác định vị trí của bạn.',
'map.location.timeout': 'Việc xác định vị trí mất quá nhiều thời gian. Hãy thử lại ở nơi nhìn thấy bầu trời thoáng hơn.',
'map.overview.show': 'Hiện toàn bộ chuyến đi',
'map.overview.hide': 'Ẩn toàn bộ chuyến đi',
'map.overview.total': 'Tổng quãng đường',
};
export default map;
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError': 'Thay đổi đã được lưu nhưng không thể làm mới danh sách. Hãy thử tải lại.',
'schoolCatalog.title': 'Kỳ nghỉ học',
'schoolCatalog.hint': 'Quản lý các khu vực dùng chung. Mọi người có thể chọn trong cài đặt nghỉ phép.',
'schoolCatalog.periodHint': 'Bao gồm cả hai ngày. Thêm các khoảng nghỉ cho mỗi năm học.',
'schoolCatalog.region': 'Khu vực hoặc học khu',
'schoolCatalog.name': 'Tên kỳ nghỉ',
'schoolCatalog.start': 'Ngày bắt đầu',
'schoolCatalog.end': 'Ngày kết thúc',
'schoolCatalog.addPeriod': 'Thêm kỳ nghỉ',
'schoolCatalog.invalid': 'Kiểm tra tên và ngày.',
'schoolCatalog.discard': 'Hủy các thay đổi chưa lưu?',
'schoolCatalog.country': 'Quốc gia',
'schoolCatalog.deleteHint': 'Xóa khu vực trước. Không thể xóa khu vực đang được sử dụng.',
'schoolCatalog.deleteCountry': 'Xóa quốc gia',
'schoolCatalog.addCountry': 'Thêm quốc gia',
'schoolCatalog.code': 'Mã quốc gia (ví dụ: US)',
'schoolCatalog.empty': 'Chưa có khu vực.',
'schoolCatalog.addRegion': 'Thêm khu vực',
'schoolCatalog.offline': 'Kết nối internet để quản lý kỳ nghỉ học.',
'schoolCatalog.loadError': 'Không thể tải khu vực. Hãy mở lại cài đặt.',
'schoolCatalog.retry': 'Thử lại',
'vacay.subtitle': 'Lập kế hoạch và quản lý ngày nghỉ',
'vacay.settings': 'Cài đặt',
'vacay.year': 'Năm',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': '定位權限已被封鎖。請檢查裝置設定,已安裝的應用程式擁有與瀏覽器分開的定位權限。',
'map.location.unavailable': '無法判斷你的位置。',
'map.location.timeout': '定位花費的時間過長。請在能看到天空的開闊位置重試。',
'map.overview.show': '顯示整趟行程',
'map.overview.hide': '隱藏整趟行程',
'map.overview.total': '總距離',
};
export default map;
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError': '變更已儲存,但無法重新整理清單。請重試載入。',
'schoolCatalog.title': '學校假期',
'schoolCatalog.hint': '管理共用地區。所有使用者都可以在休假設定中選擇。',
'schoolCatalog.periodHint': '包含開始與結束當天。請為每個學年新增假期。',
'schoolCatalog.region': '地區或學區',
'schoolCatalog.name': '假期名稱',
'schoolCatalog.start': '開始日期',
'schoolCatalog.end': '結束日期',
'schoolCatalog.addPeriod': '新增假期',
'schoolCatalog.invalid': '請檢查名稱與日期。',
'schoolCatalog.discard': '捨棄尚未儲存的變更?',
'schoolCatalog.country': '國家',
'schoolCatalog.deleteHint': '請先刪除地區。使用中的地區無法刪除。',
'schoolCatalog.deleteCountry': '刪除國家',
'schoolCatalog.addCountry': '新增國家',
'schoolCatalog.code': '國家代碼(如 US)',
'schoolCatalog.empty': '尚無地區。',
'schoolCatalog.addRegion': '新增地區',
'schoolCatalog.offline': '請連接網際網路以管理學校假期。',
'schoolCatalog.loadError': '無法載入地區。請重新開啟設定。',
'schoolCatalog.retry': '重試',
'vacay.subtitle': '規劃和管理假期',
'vacay.settings': '設定',
'vacay.year': '年份',
+3
View File
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': '定位权限已被阻止。请检查设备设置,已安装的应用拥有与浏览器分开的定位权限。',
'map.location.unavailable': '无法确定你的位置。',
'map.location.timeout': '定位耗时过长。请在能看到天空的开阔位置重试。',
'map.overview.show': '显示整个行程',
'map.overview.hide': '隐藏整个行程',
'map.overview.total': '总距离',
};
export default map;
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'schoolCatalog.refreshError': '更改已保存,但无法刷新列表。请重试加载。',
'schoolCatalog.title': '学校假期',
'schoolCatalog.hint': '管理共享地区。所有用户都可以在休假设置中选择。',
'schoolCatalog.periodHint': '包含开始和结束当天。请为每个学年添加假期。',
'schoolCatalog.region': '地区或学区',
'schoolCatalog.name': '假期名称',
'schoolCatalog.start': '开始日期',
'schoolCatalog.end': '结束日期',
'schoolCatalog.addPeriod': '添加假期',
'schoolCatalog.invalid': '请检查名称和日期。',
'schoolCatalog.discard': '放弃未保存的更改?',
'schoolCatalog.country': '国家',
'schoolCatalog.deleteHint': '请先删除地区。正在使用的地区无法删除。',
'schoolCatalog.deleteCountry': '删除国家',
'schoolCatalog.addCountry': '添加国家',
'schoolCatalog.code': '国家代码(如 US)',
'schoolCatalog.empty': '尚无地区。',
'schoolCatalog.addRegion': '添加地区',
'schoolCatalog.offline': '请连接互联网以管理学校假期。',
'schoolCatalog.loadError': '无法加载地区。请重新打开设置。',
'schoolCatalog.retry': '重试',
'vacay.subtitle': '规划和管理假期',
'vacay.settings': '设置',
'vacay.year': '年份',
+2
View File
@@ -82,3 +82,5 @@ export * from './roadtrip/planning.schema';
export * from './roadtrip/search.schema';
export * from './roadtrip/google-import.schema';
export * from './roadtrip/charging.schema';
export * from './vacay/school-holiday-catalog.schema';

Some files were not shown because too many files have changed in this diff Show More