feat(vacay): add manual school holiday calendars

This commit is contained in:
Maurice
2026-09-12 11:37:57 +02:00
committed by Maurice
parent 4f8605b529
commit 41b18f970c
55 changed files with 1562 additions and 34 deletions
+13
View File
@@ -0,0 +1,13 @@
import { schoolHolidayCatalogSchema, schoolHolidayRegionDetailSchema, type SchoolHolidayCountryRequest, type SchoolHolidayRegionRequest } from '@trek/shared'
import apiClient from './client'
const base = '/school-holiday-catalog'
export const schoolHolidayCatalogApi = {
catalog: () => apiClient.get(base).then(r => schoolHolidayCatalogSchema.parse(r.data)),
region: (id: number) => apiClient.get(`${base}/regions/${id}`).then(r => schoolHolidayRegionDetailSchema.parse(r.data)),
createCountry: (country: SchoolHolidayCountryRequest) => apiClient.post(`${base}/countries`, country),
deleteCountry: (code: string) => apiClient.delete(`${base}/countries/${code}`),
createRegion: (country: string, region: SchoolHolidayRegionRequest) => apiClient.post(`${base}/countries/${country}/regions`, region).then(r => schoolHolidayRegionDetailSchema.parse(r.data)),
updateRegion: (id: number, region: SchoolHolidayRegionRequest) => apiClient.put(`${base}/regions/${id}`, region).then(r => schoolHolidayRegionDetailSchema.parse(r.data)),
deleteRegion: (id: number, revision: number) => apiClient.delete(`${base}/regions/${id}`, { params: { revision } }),
}
@@ -192,7 +192,7 @@ export default function CategoryManager() {
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
<h2 className="font-semibold text-content">{t('categories.title')}</h2> <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> </div>
<button type="button" onClick={handleStartCreate} <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"> 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 className="p-5 border-b border-slate-100 flex items-center justify-between">
<div> <div>
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2> <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> </div>
<button type="button" onClick={() => setShowCreate(true)} <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"> 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)),
}
}
+14 -10
View File
@@ -6,7 +6,7 @@ import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import apiClient from '../../api/client' import apiClient from '../../api/client'
import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from './holidayRegions' import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from './holidayRegions'
import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries' import { useSchoolHolidayCountries } from './useSchoolHolidayCountries'
import { windowMonths } from '../../vacay/yearWindow' import { windowMonths } from '../../vacay/yearWindow'
import type { VacayHolidayCalendar, VacayYearSettings } from '../../types' import type { VacayHolidayCalendar, VacayYearSettings } from '../../types'
@@ -38,12 +38,13 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
}).catch(() => {}) }).catch(() => {})
}, [language]) }, [language])
const { countries: schoolHolidayCountries, error: schoolCountryError } = useSchoolHolidayCountries()
if (!plan) return null if (!plan) return null
const toggle = (key: string) => updatePlan({ [key]: !plan[key] }) const toggle = (key: string) => updatePlan({ [key]: !plan[key] })
const publicHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => (cal.type ?? 'public_holiday') === '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 schoolHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => cal.type === 'school_holiday')
const schoolHolidayCountries = countries.filter(country => country.value in SCHOOL_HOLIDAY_COUNTRY_CONFIG)
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -216,6 +217,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
value={plan.school_holidays_enabled} value={plan.school_holidays_enabled}
onChange={() => toggle('school_holidays_enabled')} onChange={() => toggle('school_holidays_enabled')}
/> />
{schoolCountryError && <p role="alert" className="text-caption text-danger">{schoolCountryError}</p>}
{plan.school_holidays_enabled && ( {plan.school_holidays_enabled && (
<div className="ml-7 mt-2 space-y-2"> <div className="ml-7 mt-2 space-y-2">
{schoolHolidayCalendars.length === 0 && ( {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. * apart from "this country has no regions"; on the list alone both are empty.
*/ */
function useRegionOptions(country: string, calendarType: 'public_holiday' | 'school_holiday') { 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(() => { useEffect(() => {
if (!country) return if (!country) return
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
let stale = false 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 } return () => { stale = true }
}, [calendarType, country]) }, [calendarType, country])
const ready = loaded.country === 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) ────────────────────────────────────── // ── Existing calendar row (inline edit) ──────────────────────────────────────
@@ -474,7 +476,7 @@ function CalendarRow({ cal, countries, calendarType, onUpdate, onDelete }: {
const [baseRegion] = cal.region.split('|') const [baseRegion] = cal.region.split('|')
const selectedCountry = baseRegion.split('-')[0] const selectedCountry = baseRegion.split('-')[0]
const selectedRegion = cal.region.includes('|group:') || baseRegion.includes('-') ? cal.region : '' 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(() => { setLocalColor(cal.color) }, [cal.color])
useEffect(() => { setLocalLabel(cal.label || '') }, [cal.label]) useEffect(() => { setLocalLabel(cal.label || '') }, [cal.label])
@@ -516,9 +518,10 @@ function CalendarRow({ cal, countries, calendarType, onUpdate, onDelete }: {
placeholder={t('vacay.selectCountry')} placeholder={t('vacay.selectCountry')}
searchable searchable
/> />
{regionError && <p role="alert" className="text-caption text-danger">{t('schoolCatalog.loadError')}</p>}
{regions.length > 0 && ( {regions.length > 0 && (
<CustomSelect <CustomSelect
value={selectedRegion} value={selectedRegion || (regions.some(option => option.value === selectedCountry) ? selectedCountry : '')}
onChange={v => onUpdate({ region: String(v) })} onChange={v => onUpdate({ region: String(v) })}
options={regions} options={regions}
placeholder={t('vacay.selectRegion')} placeholder={t('vacay.selectRegion')}
@@ -555,12 +558,12 @@ function AddCalendarForm({ countries, calendarType, onAdd, onCancel, defaultColo
const [baseRegion] = region.split('|') const [baseRegion] = region.split('|')
const selectedCountry = baseRegion.split('-')[0] || '' const selectedCountry = baseRegion.split('-')[0] || ''
const selectedRegion = region.includes('|group:') || baseRegion.includes('-') ? region : '' 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 // 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 // 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. // 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 PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
const [showColorPicker, setShowColorPicker] = useState(false) const [showColorPicker, setShowColorPicker] = useState(false)
@@ -597,9 +600,10 @@ function AddCalendarForm({ countries, calendarType, onAdd, onCancel, defaultColo
placeholder={t('vacay.selectCountry')} placeholder={t('vacay.selectCountry')}
searchable searchable
/> />
{regionError && <p role="alert" className="text-caption text-danger">{t('schoolCatalog.loadError')}</p>}
{regions.length > 0 && ( {regions.length > 0 && (
<CustomSelect <CustomSelect
value={selectedRegion} value={selectedRegion || (regions.some(option => option.value === selectedCountry) ? selectedCountry : '')}
onChange={v => setRegion(String(v))} onChange={v => setRegion(String(v))}
options={regions} options={regions}
placeholder={t('vacay.selectRegion')} 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', () => server.use(http.get('/api/addons/vacay/school-holidays/regions/:country', () =>
HttpResponse.json({ error: 'down' }, { status: 500 }))) HttpResponse.json({ error: 'down' }, { status: 500 })))
expect(await fetchSchoolHolidayRegionOptions('NL')).toEqual([]) await expect(fetchSchoolHolidayRegionOptions('NL')).rejects.toThrow('could not be loaded')
}) })
}) })
+16 -6
View File
@@ -1,3 +1,4 @@
import { schoolHolidayCatalogRepo } from '../../repo/schoolHolidayCatalogRepo'
import apiClient from '../../api/client' import apiClient from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries' 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 }[]> { export async function fetchSchoolHolidayRegionOptions(country: string, lang = activeLanguage()): Promise<{ value: string; label: string }[]> {
const config = SCHOOL_HOLIDAY_COUNTRY_CONFIG[country] 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 { try {
const r = await apiClient.get(`/addons/vacay/school-holidays/regions/${country}`) const r = await apiClient.get(`/addons/vacay/school-holidays/regions/${country}`)
if (config.strategy === 'groups') { if (config.strategy === 'groups') {
return flattenOptions(r.data.groups, lang) return [...manual, ...flattenOptions(r.data.groups, lang)
.map(opt => ({ .map(opt => ({
value: `${country}|group:${opt.value}`, value: `${country}|group:${opt.value}`,
label: opt.label, label: opt.label,
})) }))]
.sort((a, b) => a.label.localeCompare(b.label)) .sort((a, b) => a.label.localeCompare(b.label))
} }
return flattenOptions(r.data.subdivisions, lang) return [...manual, ...flattenOptions(r.data.subdivisions, lang)
.map(opt => ({ value: opt.value, label: opt.label })) .map(opt => ({ value: opt.value, label: opt.label }))]
.sort((a, b) => a.label.localeCompare(b.label)) .sort((a, b) => a.label.localeCompare(b.label))
} catch { } 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 }
}
@@ -1,3 +1,4 @@
import SchoolHolidayCatalog from '../../../components/Admin/SchoolHolidayCatalog'
import { useState } from 'react' import { useState } from 'react'
import { import {
ArrowUp, ArrowUp,
@@ -214,6 +215,7 @@ export default function MAdmin() {
<div className="space-y-4"> <div className="space-y-4">
<MAdminPackingTemplateManager /> <MAdminPackingTemplateManager />
<MAdminCategoryManager /> <MAdminCategoryManager />
<SchoolHolidayCatalog />
</div> </div>
)} )}
{activeTab === 'addons' && ( {activeTab === 'addons' && (
@@ -8,7 +8,7 @@ import { getIntlLanguage, useTranslation } from '../../../i18n'
import { useToast } from '../../../components/shared/Toast' import { useToast } from '../../../components/shared/Toast'
import apiClient from '../../../api/client' import apiClient from '../../../api/client'
import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from '../../../components/Vacay/holidayRegions' 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 { windowMonths } from '../../../vacay/yearWindow'
import { FALLBACK_PERSON_COLOR } from './vacayDayModel' import { FALLBACK_PERSON_COLOR } from './vacayDayModel'
import type { VacayHolidayCalendar, VacayYearSettings } from '../../../types' import type { VacayHolidayCalendar, VacayYearSettings } from '../../../types'
@@ -49,13 +49,14 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
}).catch(() => {}) }).catch(() => {})
}, [open, language]) }, [open, language])
const { countries: schoolHolidayCountries, error: schoolCountryError } = useSchoolHolidayCountries(open)
if (!plan) return null if (!plan) return null
// Public and school calendars live in the same holiday_calendars list, split by type // 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). // (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 publicHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => (cal.type ?? 'public_holiday') === 'public_holiday')
const schoolHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => cal.type === 'school_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 weekendDays: number[] = plan.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]
const weekdayChips = [ const weekdayChips = [
@@ -220,6 +221,7 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
</div> </div>
<MToggle checked={plan.school_holidays_enabled} onChange={() => updatePlan({ school_holidays_enabled: !plan.school_holidays_enabled })} ariaLabel={t('vacay.schoolHolidays')} /> <MToggle checked={plan.school_holidays_enabled} onChange={() => updatePlan({ school_holidays_enabled: !plan.school_holidays_enabled })} ariaLabel={t('vacay.schoolHolidays')} />
</div> </div>
{schoolCountryError && <p role="alert" className="text-caption text-danger">{schoolCountryError}</p>}
{plan.school_holidays_enabled && ( {plan.school_holidays_enabled && (
<> <>
{schoolHolidayCalendars.map(cal => ( {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. * apart from "this country has no regions"; on the list alone both are empty.
*/ */
function useRegionOptions(country: string, calendarType: 'public_holiday' | 'school_holiday') { 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(() => { useEffect(() => {
if (!country) return if (!country) return
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
let stale = false 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 } return () => { stale = true }
}, [country, calendarType]) }, [country, calendarType])
const ready = loaded.country === 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 }
} }
function CalendarEditor({ cal, countries, calendarType = 'public_holiday', onUpdate, onDelete }: { 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 [baseRegion] = cal.region.split('|')
const country = baseRegion.split('-')[0] const country = baseRegion.split('-')[0]
const region = cal.region.includes('|group:') || baseRegion.includes('-') ? cal.region : '' 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]) useEffect(() => { setLabel(cal.label || '') }, [cal.label])
@@ -501,8 +503,9 @@ function CalendarEditor({ cal, countries, calendarType = 'public_holiday', onUpd
</MIconBtn> </MIconBtn>
</div> </div>
<SelectRow value={country} placeholder={t('vacay.selectCountry')} options={countries} onChange={v => onUpdate({ region: v })} /> <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 && ( {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> </div>
) )
@@ -522,12 +525,12 @@ function AddCalendarDraft({ countries, calendarType = 'public_holiday', onAdd, o
const [baseRegion] = region.split('|') const [baseRegion] = region.split('|')
const country = baseRegion.split('-')[0] || '' const country = baseRegion.split('-')[0] || ''
const selectedRegion = region.includes('|group:') || baseRegion.includes('-') ? region : '' 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 // 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 // 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. // 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 ( return (
<div className="mt-2 rounded-[14px] border-[1.5px] border-dashed border-[color:var(--m-rowbr)] p-3"> <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> </MIconBtn>
</div> </div>
<SelectRow value={country} placeholder={t('vacay.selectCountry')} options={countries} onChange={setRegion} /> <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 && ( {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 <button
type="button" type="button"
+2
View File
@@ -1,3 +1,4 @@
import SchoolHolidayCatalog from '../components/Admin/SchoolHolidayCatalog'
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import { adminApi } from '../api/client' import { adminApi } from '../api/client'
import DevNotificationsPanel from '../components/Admin/DevNotificationsPanel' import DevNotificationsPanel from '../components/Admin/DevNotificationsPanel'
@@ -151,6 +152,7 @@ function AdminPageDesktop(): React.ReactElement {
<div className="space-y-6"> <div className="space-y-6">
<PackingTemplateManager /> <PackingTemplateManager />
<CategoryManager /> <CategoryManager />
<SchoolHolidayCatalog />
</div> </div>
)} )}
@@ -0,0 +1,10 @@
import { schoolHolidayCatalogApi } from '../api/schoolHolidayCatalog'
import { isEffectivelyOffline } from '../sync/networkMode'
// Instance administration is online-only: a shared catalog must not replay stale edits.
export const schoolHolidayCatalogRepo = {
...schoolHolidayCatalogApi,
requireOnline() {
if (isEffectivelyOffline()) throw new Error('School holiday administration requires a connection')
},
}
+4 -1
View File
@@ -1,3 +1,4 @@
import { manualSchoolRegionId } from '@trek/shared'
import { create } from 'zustand' import { create } from 'zustand'
import apiClient from '../api/client' import apiClient from '../api/client'
import { useAuthStore } from './authStore' 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), 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), getHolidays: (year, country) => ax.get(`/addons/vacay/holidays/${year}/${country}`).then((r: AxiosResponse) => r.data),
getSchoolHolidays: (year, country, subdivision, group) => { 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() const params = new URLSearchParams()
if (group) params.set('group', group) if (group) params.set('group', group)
const qs = params.toString() const qs = params.toString()
@@ -416,7 +419,7 @@ export const useVacayStore = create<VacayState>((set, get) => ({
for (const cy of calendarYears) { for (const cy of calendarYears) {
try { try {
if ((cal.type ?? 'public_holiday') === 'school_holiday') { 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) const data = await api.getSchoolHolidays(cy, country, subdivision, group)
data.forEach((h: VacaySchoolHolidayRaw) => { data.forEach((h: VacaySchoolHolidayRaw) => {
if (!h.startDate) return if (!h.startDate) return
@@ -1,6 +1,7 @@
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
export const vacayHandlers = [ export const vacayHandlers = [
http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: [], regions: [] })),
http.get('/api/addons/vacay/plan', () => { http.get('/api/addons/vacay/plan', () => {
return HttpResponse.json({ return HttpResponse.json({
plan: { plan: {
+20
View File
@@ -4534,6 +4534,26 @@ function runMigrations(db: Database.Database): void {
db.exec('ALTER TABLE places ADD COLUMN fill_percent INTEGER'); 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);
`);
},
]; ];
if (currentVersion < migrations.length) { if (currentVersion < migrations.length) {
+2 -1
View File
@@ -27,6 +27,7 @@ import { CategoriesModule } from './categories/categories.module';
import { TagsModule } from './tags/tags.module'; import { TagsModule } from './tags/tags.module';
import { NotificationsModule } from './notifications/notifications.module'; import { NotificationsModule } from './notifications/notifications.module';
import { AtlasModule } from './atlas/atlas.module'; import { AtlasModule } from './atlas/atlas.module';
import { SchoolHolidaysModule } from './school-holidays/school-holidays.module';
import { VacayModule } from './vacay/vacay.module'; import { VacayModule } from './vacay/vacay.module';
import { PackingModule } from './packing/packing.module'; import { PackingModule } from './packing/packing.module';
import { BudgetModule } from './budget/budget.module'; import { BudgetModule } from './budget/budget.module';
@@ -79,7 +80,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module';
* migrated. * migrated.
*/ */
@Module({ @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: [ providers: [
// Default-deny: a route is authenticated unless it carries @Public() or // Default-deny: a route is authenticated unless it carries @Public() or
// @OptionalAuth(), or declares its own @UseGuards chain. Protection used to // @OptionalAuth(), or declares its own @UseGuards chain. Protection used to
@@ -0,0 +1,47 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query, UseGuards, BadRequestException } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import { schoolHolidayCountryRequestSchema, schoolHolidayRegionRequestSchema } from '@trek/shared';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/admin.guard';
import { SchoolHolidaysService } from './school-holidays.service';
export class SchoolHolidayCountryDto extends createZodDto(schoolHolidayCountryRequestSchema) {}
export class SchoolHolidayRegionDto extends createZodDto(schoolHolidayRegionRequestSchema) {}
@Controller('api/school-holiday-catalog')
@UseGuards(JwtAuthGuard)
export class SchoolHolidaysController {
constructor(private readonly holidays: SchoolHolidaysService) {}
@Get()
catalog() { return this.holidays.catalog(); }
@Get('regions/:id')
region(@Param('id', ParseIntPipe) id: number) { return this.holidays.region(id); }
@Get('regions/:id/holidays/:year')
forYear(@Param('id', ParseIntPipe) id: number, @Param('year') year: string) {
if (!/^\d{4}$/.test(year)) throw new BadRequestException('Invalid year');
return this.holidays.holidays(id, year);
}
@Post('countries')
@UseGuards(AdminGuard)
createCountry(@Body() body: SchoolHolidayCountryDto) { return this.holidays.createCountry(body); }
@Delete('countries/:code')
@UseGuards(AdminGuard)
deleteCountry(@Param('code') code: string) { return this.holidays.deleteCountry(code); }
@Post('countries/:code/regions')
@UseGuards(AdminGuard)
createRegion(@Param('code') code: string, @Body() body: SchoolHolidayRegionDto) { return this.holidays.createRegion(code, body); }
@Put('regions/:id')
@UseGuards(AdminGuard)
updateRegion(@Param('id', ParseIntPipe) id: number, @Body() body: SchoolHolidayRegionDto) { return this.holidays.updateRegion(id, body); }
@Delete('regions/:id')
@UseGuards(AdminGuard)
deleteRegion(@Param('id', ParseIntPipe) id: number, @Query('revision', ParseIntPipe) revision: number) { return this.holidays.deleteRegion(id, revision); }
}
@@ -0,0 +1,95 @@
import { HttpException } from '@nestjs/common';
import { z } from 'zod';
import { idSchema, schoolHolidayCountryRequestSchema, schoolHolidayRegionRequestSchema, type SchoolHolidayCountryRequest, type SchoolHolidayRegionRequest } from '@trek/shared';
import { McpController, Tool, type McpContext, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_NON_IDEMPOTENT, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, errorResult, ok } from '../../nest-mcp';
import { adminRequired } from '../../mcp/tools/_shared';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { SchoolHolidaysService } from './school-holidays.service';
@McpController()
export class SchoolHolidaysMcp {
constructor(private readonly holidays: SchoolHolidaysService, private readonly guards: McpToolGuardsService) {}
private adminWrite(ctx: McpContext, write: () => unknown) {
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
try { return ok(write()); }
catch (error) {
if (error instanceof HttpException || error instanceof z.ZodError) return errorResult(error.message);
throw error;
}
}
@Tool({
name: 'list_manual_school_holiday_regions',
description: 'List the global manual school holiday catalog. Use regions[].code verbatim as the region with add_holiday_calendar and type school_holiday. These calendars require no external API.',
inputSchema: {}, annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'vacay', mode: 'read' },
})
catalog() { return ok(this.holidays.catalog()); }
@Tool({
name: 'get_manual_school_holiday_region',
description: 'Read a manual school region, its current revision and all named holiday periods. Read before updating: updates replace the complete list of periods, and the current revision is required.',
inputSchema: { regionId: idSchema }, annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'vacay', mode: 'read' },
})
region({ regionId }: { regionId: number }) { return ok(this.holidays.region(regionId)); }
@Tool({
name: 'list_manual_school_holidays',
description: 'Read named school breaks for a manual region and year, including breaks spanning a year boundary. Both boundary dates are included.',
inputSchema: { regionId: idSchema, year: z.number().int().min(1000).max(9999) },
annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'vacay', mode: 'read' },
})
forYear({ regionId, year }: { regionId: number; year: number }) { return ok({ holidays: this.holidays.holidays(regionId, String(year)) }); }
@Tool({
name: 'create_manual_school_holiday_country',
description: 'Admin only. Create a country in the instance-wide manual school holiday catalog with its two-letter uppercase country code and display name. Check the catalog first to avoid duplicates.',
inputSchema: schoolHolidayCountryRequestSchema.shape, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'vacay', mode: 'write' },
})
createCountry(body: SchoolHolidayCountryRequest, ctx: McpContext) {
return this.adminWrite(ctx, () => this.holidays.createCountry(schoolHolidayCountryRequestSchema.parse(body)));
}
@Tool({
name: 'create_manual_school_holiday_region',
description: 'Admin only. Create a region or school district in an existing manual country, with named holiday periods. Use revision 0 for creation. Dates are inclusive YYYY-MM-DD. All users can select this region.',
inputSchema: { country: schoolHolidayCountryRequestSchema.shape.code, ...schoolHolidayRegionRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, access: { group: 'vacay', mode: 'write' },
})
createRegion({ country, ...body }: SchoolHolidayRegionRequest & { country: string }, ctx: McpContext) {
return this.adminWrite(ctx, () => this.holidays.createRegion(country, schoolHolidayRegionRequestSchema.parse(body)));
}
@Tool({
name: 'update_manual_school_holiday_region',
description: 'Admin only. Rename a global school region and replace its complete list of holiday periods. First read get_manual_school_holiday_region and preserve periods that should remain. Supply its current revision; stale updates are rejected. Changes affect every calendar using this region.',
inputSchema: { regionId: idSchema, ...schoolHolidayRegionRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'vacay', mode: 'write' },
})
updateRegion({ regionId, ...body }: SchoolHolidayRegionRequest & { regionId: number }, ctx: McpContext) {
return this.adminWrite(ctx, () => this.holidays.updateRegion(regionId, schoolHolidayRegionRequestSchema.parse(body)));
}
@Tool({
name: 'delete_manual_school_holiday_region',
description: 'Admin only. Delete a global manual school region and its periods using the current revision. Regions selected by a vacation calendar cannot be deleted.',
inputSchema: { regionId: idSchema, revision: z.number().int().positive() },
annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'vacay', mode: 'write' },
})
deleteRegion({ regionId, revision }: { regionId: number; revision: number }, ctx: McpContext) {
return this.adminWrite(ctx, () => this.holidays.deleteRegion(regionId, revision));
}
@Tool({
name: 'delete_manual_school_holiday_country',
description: 'Admin only. Delete an empty country from the global manual school holiday catalog. Remove its unused regions first.',
inputSchema: { code: schoolHolidayCountryRequestSchema.shape.code },
annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'vacay', mode: 'write' },
})
deleteCountry({ code }: { code: string }, ctx: McpContext) {
return this.adminWrite(ctx, () => this.holidays.deleteCountry(code));
}
}
@@ -0,0 +1,8 @@
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { Module } from '@nestjs/common';
import { SchoolHolidaysController } from './school-holidays.controller';
import { SchoolHolidaysService } from './school-holidays.service';
import { SchoolHolidaysMcp } from './school-holidays.mcp';
@Module({ imports: [McpSharedModule], controllers: [SchoolHolidaysController], providers: [SchoolHolidaysService, SchoolHolidaysMcp] })
export class SchoolHolidaysModule {}
@@ -0,0 +1,96 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import type { SchoolHolidayCatalog, SchoolHolidayCountryRequest, SchoolHolidayPeriod, SchoolHolidayRegion, SchoolHolidayRegionDetail, SchoolHolidayRegionRequest } from '@trek/shared';
import { DatabaseService } from '../database/database.service';
@Injectable()
export class SchoolHolidaysService {
constructor(private readonly db: DatabaseService) {}
catalog(): SchoolHolidayCatalog {
return {
countries: this.db.all<SchoolHolidayCountryRequest>('SELECT code, name FROM school_holiday_countries ORDER BY name, code'),
regions: this.db.all<SchoolHolidayRegion>("SELECT *, country || '-MANUAL-' || id AS code FROM school_holiday_regions ORDER BY name, id"),
};
}
country(code: string): SchoolHolidayCountryRequest {
const country = this.db.get<SchoolHolidayCountryRequest>('SELECT code, name FROM school_holiday_countries WHERE code = ?', code);
if (!country) throw new NotFoundException('Country not found');
return country;
}
createCountry(country: SchoolHolidayCountryRequest) {
const inserted = this.db.run('INSERT OR IGNORE INTO school_holiday_countries (code, name) VALUES (?, ?)', country.code, country.name);
if (!inserted.changes) throw new ConflictException('Country already exists');
return country;
}
deleteCountry(code: string) {
return this.db.transaction(() => {
this.country(code);
if (this.db.get('SELECT id FROM school_holiday_regions WHERE country = ? LIMIT 1', code)) {
throw new ConflictException('Remove the regions before deleting this country');
}
this.db.run('DELETE FROM school_holiday_countries WHERE code = ?', code);
return { success: true };
});
}
region(id: number): SchoolHolidayRegionDetail {
const region = this.db.get<SchoolHolidayRegion>("SELECT *, country || '-MANUAL-' || id AS code FROM school_holiday_regions WHERE id = ?", id);
if (!region) throw new NotFoundException('Region not found');
return { ...region, holidays: this.db.all<SchoolHolidayPeriod>('SELECT name, start_date AS startDate, end_date AS endDate FROM school_holiday_periods WHERE region_id = ? ORDER BY start_date, end_date, name', id) };
}
private checkName(country: string, name: string, id: number) {
if (this.db.get('SELECT id FROM school_holiday_regions WHERE country = ? AND name = ? COLLATE NOCASE AND id != ?', country, name, id)) {
throw new ConflictException('A region with this name already exists');
}
}
private writePeriods(id: number, holidays: SchoolHolidayPeriod[]) {
this.db.run('DELETE FROM school_holiday_periods WHERE region_id = ?', id);
const insert = this.db.prepare('INSERT INTO school_holiday_periods (region_id, name, start_date, end_date) VALUES (?, ?, ?, ?)');
for (const holiday of holidays) insert.run(id, holiday.name, holiday.startDate, holiday.endDate);
}
createRegion(country: string, body: SchoolHolidayRegionRequest) {
return this.db.transaction(() => {
this.country(country);
this.checkName(country, body.name, 0);
if (body.revision !== 0) throw new ConflictException('New regions must have revision zero');
const inserted = this.db.run('INSERT INTO school_holiday_regions (country, name) VALUES (?, ?)', country, body.name);
const id = Number(inserted.lastInsertRowid);
this.writePeriods(id, body.holidays);
return this.region(id);
});
}
updateRegion(id: number, body: SchoolHolidayRegionRequest) {
return this.db.transaction(() => {
const region = this.region(id);
this.checkName(region.country, body.name, id);
const updated = this.db.run('UPDATE school_holiday_regions SET name = ?, revision = revision + 1 WHERE id = ? AND revision = ?', body.name, id, body.revision);
if (!updated.changes) throw new ConflictException('This region changed. Reopen it before saving again.');
this.writePeriods(id, body.holidays);
return this.region(id);
});
}
deleteRegion(id: number, revision: number) {
return this.db.transaction(() => {
const region = this.region(id);
if (region.revision !== revision) throw new ConflictException('This region changed. Reload before deleting it.');
if (this.db.get("SELECT id FROM vacay_holiday_calendars WHERE type = 'school_holiday' AND region = ? LIMIT 1", region.code)) {
throw new ConflictException('This region is used by vacation calendars and cannot be deleted');
}
this.db.run('DELETE FROM school_holiday_periods WHERE region_id = ?', id);
this.db.run('DELETE FROM school_holiday_regions WHERE id = ?', id);
return { success: true };
});
}
holidays(id: number, year: string): SchoolHolidayPeriod[] {
return this.region(id).holidays.filter(holiday => holiday.startDate <= `${year}-12-31` && holiday.endDate >= `${year}-01-01`);
}
}
+12 -1
View File
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable, BadRequestException } from '@nestjs/common';
import { manualSchoolRegionId } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service'; import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService } from '../database/database.service'; import { DatabaseService } from '../database/database.service';
import { NotificationsService } from '../notifications/notifications.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') { 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( const result = this.db.run(
'INSERT INTO vacay_holiday_calendars (plan_id, type, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?, ?)', '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 planId, type, region, label || null, color || (type === 'school_holiday' ? '#a5f3fc' : '#fecaca'), sortOrder ?? 0
@@ -595,6 +597,7 @@ export class VacayService {
): VacayHolidayCalendar | null { ): VacayHolidayCalendar | null {
const cal = this.db.get<VacayHolidayCalendar>('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?', calId, planId); const cal = this.db.get<VacayHolidayCalendar>('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?', calId, planId);
if (!cal) return null; if (!cal) return null;
this.validateManualRegion(body.region ?? cal.region, body.type ?? cal.type);
const { region, label, color, sort_order, type } = body; const { region, label, color, sort_order, type } = body;
const updates: string[] = []; const updates: string[] = [];
const params: (string | number | null)[] = []; const params: (string | number | null)[] = [];
@@ -620,6 +623,14 @@ export class VacayService {
return true; 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 // 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 { 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 { db } from '../../src/db/database';
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../src/mcp/nest-mcp-policy'; import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../src/mcp/nest-mcp-policy';
import { AssignmentsMcp } from '../../src/nest/assignments/assignments.mcp'; import { AssignmentsMcp } from '../../src/nest/assignments/assignments.mcp';
@@ -221,6 +223,7 @@ export function createMcpTestRegistry(): McpRegistry {
new AssignmentsMcp(assignmentsService, daysService, authService, guards), new AssignmentsMcp(assignmentsService, daysService, authService, guards),
new CollabMcp(collabService, authService, addonsService, guards), new CollabMcp(collabService, authService, addonsService, guards),
new VacayMcp(new VacayService(dbService, realtimeService, notificationsStub()), authService, addonsService), 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 TripsMcp(tripsService, todoService, collabService, authService, calendarService, membersService, readModelService, addonsService, guards),
new TripPromptsMcp(tripsService, readModelService, packingService, addonsService), new TripPromptsMcp(tripsService, readModelService, packingService, addonsService),
new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService, queryHelpersService, placePhotoCache), authService, guards), new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService, queryHelpersService, placePhotoCache), authService, guards),
+3
View File
@@ -27,6 +27,9 @@ import type { RateLimitService } from '../../src/nest/common/rate-limit.service'
// Keep in sync with schema.ts + migrations.ts. Intentionally excluded: categories, addons, // 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). // photo_providers, photo_provider_fields, schema_version (seed/config data, not user data).
const RESET_TABLES = [ const RESET_TABLES = [
'school_holiday_periods',
'school_holiday_regions',
'school_holiday_countries',
// Collab // Collab
'file_links', 'file_links',
'collab_message_reactions', 'collab_message_reactions',
@@ -309,6 +309,19 @@ describe('updatePlan', () => {
// ── addHolidayCalendar ──────────────────────────────────────────────────────── // ── addHolidayCalendar ────────────────────────────────────────────────────────
describe('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', () => { it('VACAY-SVC-019: inserts a new calendar row and returns the calendar object', () => {
const { plan } = setupUserWithPlan(); const { plan } = setupUserWithPlan();
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': 'خطط وأدر أيام الإجازة',
'vacay.settings': 'الإعدادات', 'vacay.settings': 'الإعدادات',
'vacay.year': 'السنة', 'vacay.year': 'السنة',
+22
View File
@@ -1,6 +1,28 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': 'Planeje e gerencie dias de férias',
'vacay.settings': 'Configurações', 'vacay.settings': 'Configurações',
'vacay.year': 'Ano', 'vacay.year': 'Ano',
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': 'Planifica i gestiona dies de vacances',
'vacay.settings': 'Configuració', 'vacay.settings': 'Configuració',
'vacay.year': 'Any', 'vacay.year': 'Any',
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': 'Plánování a správa dovolené',
'vacay.settings': 'Nastavení', 'vacay.settings': 'Nastavení',
'vacay.year': 'Rok', 'vacay.year': 'Rok',
+22
View File
@@ -1,6 +1,28 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': 'Urlaubstage planen und verwalten',
'vacay.settings': 'Einstellungen', 'vacay.settings': 'Einstellungen',
'vacay.year': 'Jahr', 'vacay.year': 'Jahr',
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': 'Plan and manage vacation days',
'vacay.settings': 'Settings', 'vacay.settings': 'Settings',
'vacay.year': 'Year', 'vacay.year': 'Year',
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': 'Planifica y gestiona días de vacaciones',
'vacay.settings': 'Ajustes', 'vacay.settings': 'Ajustes',
'vacay.year': 'Año', 'vacay.year': 'Año',
+22
View File
@@ -1,6 +1,28 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': 'Planifiez et gérez vos jours de congés',
'vacay.settings': 'Paramètres', 'vacay.settings': 'Paramètres',
'vacay.year': 'Année', 'vacay.year': 'Année',
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': 'Σχεδιάστε και διαχειριστείτε ημέρες διακοπών',
'vacay.settings': 'Ρυθμίσεις', 'vacay.settings': 'Ρυθμίσεις',
'vacay.year': 'Έτος', 'vacay.year': 'Έτος',
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': 'Szabadságnapok tervezése és kezelése',
'vacay.settings': 'Beállítások', 'vacay.settings': 'Beállítások',
'vacay.year': 'Év', 'vacay.year': 'Év',
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': 'Rencanakan dan kelola hari cuti',
'vacay.settings': 'Pengaturan', 'vacay.settings': 'Pengaturan',
'vacay.year': 'Tahun', 'vacay.year': 'Tahun',
+22
View File
@@ -1,6 +1,28 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': 'Pianifica e gestisci i giorni di ferie',
'vacay.settings': 'Impostazioni', 'vacay.settings': 'Impostazioni',
'vacay.year': 'Anno', 'vacay.year': 'Anno',
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': '休暇日数の計画と管理',
'vacay.settings': '設定', 'vacay.settings': '設定',
'vacay.year': '年', 'vacay.year': '年',
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': '휴가 일수를 계획하고 관리하세요',
'vacay.settings': '설정', 'vacay.settings': '설정',
'vacay.year': '연도', 'vacay.year': '연도',
+22
View File
@@ -1,6 +1,28 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': 'Plan en beheer vakantiedagen',
'vacay.settings': 'Instellingen', 'vacay.settings': 'Instellingen',
'vacay.year': 'Jaar', 'vacay.year': 'Jaar',
+22
View File
@@ -1,6 +1,28 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': 'Planuj i zarządzaj dniami urlopu',
'vacay.settings': 'Ustawienia', 'vacay.settings': 'Ustawienia',
'vacay.year': 'Rok', 'vacay.year': 'Rok',
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': 'Планируйте и управляйте днями отпуска',
'vacay.settings': 'Настройки', 'vacay.settings': 'Настройки',
'vacay.year': 'Год', 'vacay.year': 'Год',
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': 'Planera och hantera semesterdagar',
'vacay.settings': 'Inställningar', 'vacay.settings': 'Inställningar',
'vacay.year': 'År', 'vacay.year': 'År',
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': 'Tatil günlerini planlayın ve yönetin',
'vacay.settings': 'Ayarlar', 'vacay.settings': 'Ayarlar',
'vacay.year': 'Yıl', 'vacay.year': 'Yıl',
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': 'Плануйте й керуйте днями відпустки',
'vacay.settings': 'Налаштування', 'vacay.settings': 'Налаштування',
'vacay.year': 'Рік', 'vacay.year': 'Рік',
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': 'Lập kế hoạch và quản lý ngày nghỉ',
'vacay.settings': 'Cài đặt', 'vacay.settings': 'Cài đặt',
'vacay.year': 'Năm', 'vacay.year': 'Năm',
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': '規劃和管理假期',
'vacay.settings': '設定', 'vacay.settings': '設定',
'vacay.year': '年份', 'vacay.year': '年份',
+21
View File
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types'; import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = { 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.subtitle': '规划和管理假期',
'vacay.settings': '设置', 'vacay.settings': '设置',
'vacay.year': '年份', 'vacay.year': '年份',
+2
View File
@@ -75,3 +75,5 @@ export * from './i18n/languages';
export * from './plugin-permissions'; export * from './plugin-permissions';
// Plugin settings contracts: the settings-field descriptor + admin instance-config wire shapes. // Plugin settings contracts: the settings-field descriptor + admin instance-config wire shapes.
export * from './plugins/plugins.schema'; export * from './plugins/plugins.schema';
export * from './vacay/school-holiday-catalog.schema';
@@ -0,0 +1,47 @@
import {
manualSchoolRegionId,
schoolHolidayCountryRequestSchema,
schoolHolidayPeriodSchema,
schoolHolidayRegionRequestSchema,
} from './school-holiday-catalog.schema';
import { describe, expect, it } from 'vitest';
describe('manual school holiday contracts', () => {
it('accepts a named break spanning New Year and leap days', () => {
expect(
schoolHolidayPeriodSchema.parse({ name: ' Winter ', startDate: '2026-12-20', endDate: '2027-01-06' }).name,
).toBe('Winter');
expect(
schoolHolidayPeriodSchema.safeParse({ name: 'Leap day', startDate: '2028-02-29', endDate: '2028-02-29' }).success,
).toBe(true);
});
it.each([
['2026-02-29', '2026-03-01'],
['2026-04-31', '2026-05-01'],
['2026-12-20', '2026-12-19'],
['2026-01-01', '2028-01-01'],
])('rejects invalid or unbounded dates %s to %s', (startDate, endDate) => {
expect(schoolHolidayPeriodSchema.safeParse({ name: 'Break', startDate, endDate }).success).toBe(false);
});
it('requires a country code, names, revision and bounded periods', () => {
expect(schoolHolidayCountryRequestSchema.safeParse({ code: '../', name: 'USA' }).success).toBe(false);
expect(schoolHolidayCountryRequestSchema.safeParse({ code: 'US', name: ' ' }).success).toBe(false);
expect(schoolHolidayRegionRequestSchema.safeParse({ name: 'District', revision: 0, holidays: [] }).success).toBe(
true,
);
expect(schoolHolidayRegionRequestSchema.safeParse({ name: 'District', holidays: [] }).success).toBe(false);
expect(
schoolHolidayRegionRequestSchema.safeParse({
name: 'District',
revision: 0,
holidays: Array(501).fill({ name: 'Break', startDate: '2026-01-01', endDate: '2026-01-02' }),
}).success,
).toBe(false);
});
it('keeps manual ids distinct from provider subdivisions', () => {
expect(manualSchoolRegionId('US-MANUAL-12')).toBe(12);
for (const code of ['US-WA', 'NL|group:NL-NO', 'US-MANUAL-0', 'US-MANUAL-1junk'])
expect(manualSchoolRegionId(code)).toBeNull();
});
});
@@ -0,0 +1,52 @@
import { idSchema, nonEmptyString } from '../common/primitives.schema';
import { z } from 'zod';
export const schoolHolidayCountryRequestSchema = z.object({
code: z.string().regex(/^[A-Z]{2}$/),
name: nonEmptyString.max(100),
});
export const schoolHolidayPeriodSchema = z
.object({
name: nonEmptyString.max(150),
startDate: z.string().date(),
endDate: z.string().date(),
})
.refine((period) => period.endDate >= period.startDate, {
message: 'End date must be on or after start date',
path: ['endDate'],
})
.refine((period) => Date.parse(period.endDate) - Date.parse(period.startDate) <= 366 * 86400000, {
message: 'A holiday period must not exceed one year',
path: ['endDate'],
});
export const schoolHolidayRegionRequestSchema = z.object({
name: nonEmptyString.max(150),
revision: z.number().int().nonnegative(),
holidays: z.array(schoolHolidayPeriodSchema).max(500),
});
export const schoolHolidayRegionSchema = z.object({
id: idSchema,
country: z.string(),
name: z.string(),
code: z.string(),
revision: z.number().int(),
});
export const schoolHolidayCatalogSchema = z.object({
countries: z.array(schoolHolidayCountryRequestSchema),
regions: z.array(schoolHolidayRegionSchema),
});
export const schoolHolidayRegionDetailSchema = schoolHolidayRegionSchema.extend({
holidays: z.array(schoolHolidayPeriodSchema),
});
export type SchoolHolidayCountryRequest = z.infer<typeof schoolHolidayCountryRequestSchema>;
export type SchoolHolidayPeriod = z.infer<typeof schoolHolidayPeriodSchema>;
export type SchoolHolidayRegionRequest = z.infer<typeof schoolHolidayRegionRequestSchema>;
export type SchoolHolidayRegion = z.infer<typeof schoolHolidayRegionSchema>;
export type SchoolHolidayCatalog = z.infer<typeof schoolHolidayCatalogSchema>;
export type SchoolHolidayRegionDetail = z.infer<typeof schoolHolidayRegionDetailSchema>;
export function manualSchoolRegionId(code: string): number | null {
const match = /^[A-Z]{2}-MANUAL-([1-9][0-9]*)$/.exec(code);
return match ? Number(match[1]) : null;
}
+11 -1
View File
@@ -65,7 +65,17 @@ Each toggle's icon in the toolbar is the marker it places, so you can see what a
- **Vacation year** — Calendar, Fiscal or Hire date, as described under Leave year above. Unlike the rest of this panel it is personal to you rather than to the plan. - **Vacation year** — Calendar, Fiscal or Hire date, as described under Leave year above. Unlike the rest of this panel it is personal to you rather than to the plan.
- **Company holidays** — enable a shared company holiday layer that any fused user can edit. - **Company holidays** — enable a shared company holiday layer that any fused user can edit.
- **Public holidays** — add one or more country/region holiday calendars so that public holidays appear on the grid. Holiday data is fetched from the nager.at public holiday API. Each calendar has a label, a colour, and a country or region selector (sub-national regions such as German states or Swiss cantons are supported). - **Public holidays** — add one or more country/region holiday calendars so that public holidays appear on the grid. Holiday data is fetched from the nager.at public holiday API. Each calendar has a label, a colour, and a country or region selector (sub-national regions such as German states or Swiss cantons are supported).
- **School holidays** — a second holiday layer with its own toggle, independent of the public one and purely visual. Each calendar has an optional label, a colour and a country; where the source splits a country up you also pick a region (a German state, a Swiss canton, a French académie) or a school holiday group (Belgium, the Netherlands), and the calendar cannot be added until you have. A few countries publish a single national calendar (Estonia, Ireland, Serbia) and need nothing beyond the country. School holiday data comes from the OpenHolidays API rather than nager.at and covers fewer countries, so only the supported ones appear in the picker. On the grid a school holiday day gets a coloured band along the bottom of the cell, split into up to three segments when several calendars cover the same day, and a day inside a break that carries nothing else (no logged entry, no company holiday, no public holiday) is washed in the calendar colour. School holidays never deduct from anyone's allowance. - **School holidays**: a second holiday layer with its own toggle, independent of the public one and purely visual. Each calendar has an optional label, a colour and a country; where the source splits a country up you also pick a region (a German state, a Swiss canton, a French académie) or a school holiday group (Belgium, the Netherlands), and the calendar cannot be added until you have. A few countries publish a single national calendar (Estonia, Ireland, Serbia) and need nothing beyond the country. School holiday data comes from OpenHolidays or from the manual regions maintained by your administrator. The picker includes both sources. On the grid a school holiday day gets a coloured band along the bottom of the cell, split into up to three segments when several calendars cover the same day, and a day inside a break that carries nothing else (no logged entry, no company holiday, no public holiday) is washed in the calendar colour. School holidays never deduct from anyone's allowance.
## Manually maintained school holidays
Administrators manage the shared catalog under **Admin > Personalization > School holidays**. Add a country with its two-letter country code (for example, US or CA), then add a region or school district. Open a region to name its breaks and enter their start and end dates. Both dates are inclusive, and a break can cross New Year. Add the actual dates for each school year; periods do not repeat automatically.
Users enable **School holidays** in Vacay settings, add a calendar, and select the country and manual region. This uses no external holiday API. Supported automatic calendars remain available alongside manual regions. A manual region can also be added to an API-supported country. The bottom editing toolbar is unchanged, and school holidays never consume leave entitlement.
The catalog belongs to the whole instance. Renaming a region keeps existing calendar selections intact. A region used by a calendar cannot be deleted, and countries must be empty before deletion. Concurrent edits are rejected instead of overwriting another administrator's work. Administration requires an internet connection. Reopen vacation settings to refresh the region list after an administrator changes it.
> **AI / MCP:** Use `list_manual_school_holiday_regions` to discover regions, then pass `regions[].code` to `add_holiday_calendar` with type `school_holiday`. Read dated periods with `list_manual_school_holidays`. Administrators can also create countries and regions through `create_manual_school_holiday_country` and `create_manual_school_holiday_region`. Use `get_manual_school_holiday_region` before `update_manual_school_holiday_region`: send its current revision and the full list of periods to keep. The update can rename the region and add, change or remove periods. `delete_manual_school_holiday_region` and `delete_manual_school_holiday_country` enforce the same deletion restrictions as the admin UI. These global changes require an admin account; normal users can read the catalog and manage their own calendar selections.
## Inviting collaborators ## Inviting collaborators