mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
merge dev and reconcile roadtrip planning
This commit is contained in:
@@ -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());
|
||||
});
|
||||
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': 'السنة',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': 'Έτος',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': '年',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': '연도',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': 'Год',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': 'Рік',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': '年份',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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': '年份',
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user