diff --git a/client/src/api/schoolHolidayCatalog.ts b/client/src/api/schoolHolidayCatalog.ts
new file mode 100644
index 000000000..645207f36
--- /dev/null
+++ b/client/src/api/schoolHolidayCatalog.ts
@@ -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 } }),
+}
diff --git a/client/src/components/Admin/AddonManager.test.tsx b/client/src/components/Admin/AddonManager.test.tsx
index 6be9a96ad..ad5f1ccb0 100644
--- a/client/src/components/Admin/AddonManager.test.tsx
+++ b/client/src/components/Admin/AddonManager.test.tsx
@@ -473,7 +473,7 @@ describe('AddonManager', () => {
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
- server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3:8b', 'llama3:8b'], urls));
+ server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
render();
await screen.findByText('Installed on the server');
@@ -483,9 +483,9 @@ describe('AddonManager', () => {
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
- // qwen3:8b is already installed, so the recommended row offers "Use" instead of "Pull"
+ // qwen3.5:4b is already installed, so the recommended row offers "Use" instead of "Pull"
await user.click(screen.getByRole('button', { name: 'Use' }));
- expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
+ expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
});
@@ -545,7 +545,7 @@ describe('AddonManager', () => {
addonsRoute([llmAddon({ provider: 'local' })]),
http.get('/api/admin/llm/local/models', () => {
modelCalls += 1;
- return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3:8b', size: 1 }] });
+ return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
}),
http.post('/api/admin/llm/local/pull', async ({ request }) => {
pulled = await request.json();
@@ -565,8 +565,8 @@ describe('AddonManager', () => {
expect(screen.getByText('starting…')).toBeInTheDocument();
await screen.findByText('Model pulled');
- expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3:8b' });
- expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
+ expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
+ expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
});
diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx
index 05010f7dd..c9b041a88 100644
--- a/client/src/components/Admin/AddonManager.tsx
+++ b/client/src/components/Admin/AddonManager.tsx
@@ -308,7 +308,7 @@ const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [
- { id: 'qwen3:8b', label: 'Qwen3 — 8B', note: 'Recommended · best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0', recommended: true, vision: false },
+ { id: 'qwen3.5:4b', label: 'Qwen3.5 — 4B', note: 'Recommended · small and quick on CPU, 3.4 GB download, 256K context (thinking auto-disabled) · Apache-2.0', recommended: true, vision: true },
]
/**
diff --git a/client/src/components/Admin/CategoryManager.tsx b/client/src/components/Admin/CategoryManager.tsx
index b26af8629..db20979ac 100644
--- a/client/src/components/Admin/CategoryManager.tsx
+++ b/client/src/components/Admin/CategoryManager.tsx
@@ -192,7 +192,7 @@ export default function CategoryManager() {
{t('categories.title')}
-
{t('categories.subtitle')}
+
{t('categories.subtitle')}
)}
+ {/* Bottom-RIGHT. Not the top corridor between the panels, which is already
+ contested by the POI bar and the collapse tabs (#2247); and not the
+ bottom-left corner, where Leaflet's base-layer switcher sits at
+ z-index 1000 and would cover this. The right corner is free on both
+ renderers — the locate button that lives there is phone-only. */}
+ {!roadtripActive && (
+
+ {overviewActive && (
+
+ )}
+
+
+ )}
+
{(poiPillEnabled || glMap) && (
{
it('FE-PAGE-TPW-013: the collapse buttons toggle each side panel', () => {
renderPage()
- const [leftBtn, rightBtn] = screen.getAllByRole('button')
+ // By name, not by position: the map layer carries controls of its own, so the
+ // tabs are not simply the first two buttons on the page.
+ const [leftBtn, rightBtn] = screen.getAllByLabelText('common.collapse')
fireEvent.click(leftBtn)
expect(hookState.toggleLeft).toHaveBeenCalled()
@@ -469,9 +471,8 @@ describe('TripPlannerPage — plan tab', () => {
// announce themselves with nothing but a hover colour (#2247).
it('FE-PAGE-TPW-013b: each collapse tab is named for what it does', () => {
renderPage({ leftHidden: true, rightHidden: false })
- const [leftBtn, rightBtn] = screen.getAllByRole('button')
- expect(leftBtn).toHaveAttribute('aria-label', 'trip.mobilePlan')
- expect(rightBtn).toHaveAttribute('aria-label', 'common.collapse')
+ expect(screen.getByLabelText('trip.mobilePlan')).toBeInTheDocument()
+ expect(screen.getByLabelText('common.collapse')).toBeInTheDocument()
})
// At a foldable's ~860px the viewport centre sits under the Places panel, so the
@@ -488,6 +489,17 @@ describe('TripPlannerPage — plan tab', () => {
expect(cluster.style.left).toBe('calc(350px + 0.5 * (100% - 350px - 0px))')
})
+ // Leaflet's base-layer switcher owns the bottom-LEFT corner at z-index 1000, so an
+ // overview control placed there is simply covered by it (#1736).
+ it('FE-PAGE-TPW-013d: the trip-overview control hugs the right edge, clear of the layer switcher', () => {
+ renderPage()
+
+ const stack = screen.getByTestId('trip-overview-pill').closest('[style*="position: absolute"]') as HTMLElement
+ expect(stack).not.toBeNull()
+ expect(stack.style.right).not.toBe('')
+ expect(stack.style.left).toBe('')
+ })
+
it('FE-PAGE-TPW-014: a collapsed panel keeps its toggle but drops the hover highlight', () => {
renderPage({ leftHidden: true, rightHidden: true })
diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts
index baafa87d8..1d1e71782 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.ts
+++ b/client/src/pages/tripPlanner/useTripPlanner.ts
@@ -30,6 +30,7 @@ import { useAutomaticDayPoints } from '../../components/Roadtrip/useAutomaticDay
import { useDayBoundaries } from '../../components/Roadtrip/useDayBoundaries'
import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag'
import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow'
+import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch'
@@ -379,6 +380,17 @@ export function useTripPlanner() {
// transport in the trip (#2019).
const transitRoutesShown = routeShown && selectedDayId != null
const [routeProfile, setRouteProfile] = useState('driving')
+ // Whole-trip route overview (#1736): every day's route at once, each in its own
+ // colour. Per trip and per session like road trip mode — it answers "what does the
+ // whole thing look like", which is a question you ask of one trip, not a preference.
+ const [overviewShown, setOverviewShown] = useState(() => sessionStorage.getItem(`trip-overview-${tripId}`) === '1')
+ const toggleOverview = useCallback(() => {
+ setOverviewShown(prev => {
+ const next = !prev
+ sessionStorage.setItem(`trip-overview-${tripId}`, next ? '1' : '0')
+ return next
+ })
+ }, [tripId])
const [fitKey, setFitKey] = useState(0)
const initialFitTripId = useRef(null)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
@@ -549,6 +561,10 @@ export function useTripPlanner() {
}, [places, placesCategoryFilter, placesFilter, assignments, expandedDayIds, selectedDayId, days, tripAccommodations, reservations])
const { route, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile, tripAccommodations)
+ // Road trip mode already draws the whole trip its own way, so the overview stands
+ // down there rather than drawing a second set of lines over it.
+ const overviewActive = overviewShown && !roadtripMode
+ const tripOverview = useTripRouteOverview(tripId, days, assignments, reservations, tripAccommodations, routeProfile, overviewActive)
// Road trip mode reads the whole trip, not the selected day, so it owns its own legs.
// Passing no days while the mode is off keeps it inert — no routing requests, no state.
@@ -2158,6 +2174,7 @@ export function useTripPlanner() {
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
+ overviewShown, toggleOverview, overviewActive, tripOverview,
followTrack, roadtripViaCounts,
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
diff --git a/client/src/repo/schoolHolidayCatalogRepo.ts b/client/src/repo/schoolHolidayCatalogRepo.ts
new file mode 100644
index 000000000..bc92f9b37
--- /dev/null
+++ b/client/src/repo/schoolHolidayCatalogRepo.ts
@@ -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')
+ },
+}
diff --git a/client/src/store/vacayStore.ts b/client/src/store/vacayStore.ts
index 3ed6161cd..164cc8145 100644
--- a/client/src/store/vacayStore.ts
+++ b/client/src/store/vacayStore.ts
@@ -1,3 +1,4 @@
+import { manualSchoolRegionId } from '@trek/shared'
import { create } from 'zustand'
import apiClient from '../api/client'
import { useAuthStore } from './authStore'
@@ -113,6 +114,8 @@ const api: VacayApi = {
updateStats: (year, days, targetUserId) => ax.put(`/addons/vacay/stats/${year}`, { vacation_days: days, target_user_id: targetUserId } satisfies VacayUpdateStatsRequest).then((r: AxiosResponse) => r.data),
getHolidays: (year, country) => ax.get(`/addons/vacay/holidays/${year}/${country}`).then((r: AxiosResponse) => r.data),
getSchoolHolidays: (year, country, subdivision, group) => {
+ const manualId = subdivision ? manualSchoolRegionId(subdivision) : null
+ if (manualId) return ax.get(`/school-holiday-catalog/regions/${manualId}/holidays/${year}`).then((r: AxiosResponse) => r.data)
const params = new URLSearchParams()
if (group) params.set('group', group)
const qs = params.toString()
@@ -416,7 +419,7 @@ export const useVacayStore = create((set, get) => ({
for (const cy of calendarYears) {
try {
if ((cal.type ?? 'public_holiday') === 'school_holiday') {
- if (!isSchoolHolidayCountrySupported(country)) continue
+ if (!isSchoolHolidayCountrySupported(country) && !manualSchoolRegionId(cal.region)) continue
const data = await api.getSchoolHolidays(cy, country, subdivision, group)
data.forEach((h: VacaySchoolHolidayRaw) => {
if (!h.startDate) return
diff --git a/client/tests/helpers/msw/handlers/vacay.ts b/client/tests/helpers/msw/handlers/vacay.ts
index 0f261d46f..5b68125e2 100644
--- a/client/tests/helpers/msw/handlers/vacay.ts
+++ b/client/tests/helpers/msw/handlers/vacay.ts
@@ -1,6 +1,7 @@
import { http, HttpResponse } from 'msw';
export const vacayHandlers = [
+ http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: [], regions: [] })),
http.get('/api/addons/vacay/plan', () => {
return HttpResponse.json({
plan: {
diff --git a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx
index ab2cc5eaa..96bbb29fe 100644
--- a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx
+++ b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx
@@ -368,7 +368,7 @@ describe('MAdminAddonManager', () => {
it('FE-MOB-AADD-017: the local provider lists installed models and selecting a chip fills the model field', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
- server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3:8b', 'llama3:8b'], urls));
+ server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
render();
await screen.findByText('Installed on the server');
@@ -378,9 +378,9 @@ describe('MAdminAddonManager', () => {
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
- // qwen3:8b is installed, so the recommended row offers "Use" instead of "Pull".
+ // qwen3.5:4b is installed, so the recommended row offers "Use" instead of "Pull".
await user.click(screen.getByRole('button', { name: 'Use' }));
- expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
+ expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
});
@@ -449,7 +449,7 @@ describe('MAdminAddonManager', () => {
addonsRoute([llmAddon({ provider: 'local' })]),
http.get('/api/admin/llm/local/models', () => {
modelCalls += 1;
- return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3:8b', size: 1 }] });
+ return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
}),
http.post('/api/admin/llm/local/pull', async ({ request }) => {
pulled = await request.json();
@@ -470,8 +470,8 @@ describe('MAdminAddonManager', () => {
expect(screen.getByText('starting…')).toBeInTheDocument();
await screen.findByText('Model pulled');
- expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3:8b' });
- expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
+ expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
+ expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
// Reloaded models now contain the pulled one, so the row switches to "Selected".
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
});
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts
index 34e72fe26..e162c82c8 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -4535,6 +4535,26 @@ function runMigrations(db: Database.Database): void {
db.exec('ALTER TABLE places ADD COLUMN fill_percent INTEGER');
}
},
+ () => {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS school_holiday_countries (
+ code TEXT PRIMARY KEY, name TEXT NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS school_holiday_regions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ country TEXT NOT NULL REFERENCES school_holiday_countries(code),
+ name TEXT NOT NULL COLLATE NOCASE, revision INTEGER NOT NULL DEFAULT 1,
+ UNIQUE(country, name)
+ );
+ CREATE TABLE IF NOT EXISTS school_holiday_periods (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ region_id INTEGER NOT NULL REFERENCES school_holiday_regions(id) ON DELETE CASCADE,
+ name TEXT NOT NULL, start_date TEXT NOT NULL, end_date TEXT NOT NULL,
+ CHECK (end_date >= start_date)
+ );
+ CREATE INDEX IF NOT EXISTS idx_school_holiday_periods_region ON school_holiday_periods(region_id);
+ `);
+ },
() => {
const cols = db.prepare("SELECT name FROM pragma_table_info('day_assignments')").all() as Array<{ name: string }>;
if (!cols.some(c => c.name === 'end_day')) {
diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts
index e567b7b96..d9a77caa3 100644
--- a/server/src/nest/app.module.ts
+++ b/server/src/nest/app.module.ts
@@ -27,6 +27,7 @@ import { CategoriesModule } from './categories/categories.module';
import { TagsModule } from './tags/tags.module';
import { NotificationsModule } from './notifications/notifications.module';
import { AtlasModule } from './atlas/atlas.module';
+import { SchoolHolidaysModule } from './school-holidays/school-holidays.module';
import { VacayModule } from './vacay/vacay.module';
import { PackingModule } from './packing/packing.module';
import { BudgetModule } from './budget/budget.module';
@@ -79,7 +80,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module';
* migrated.
*/
@Module({
- imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
+ imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
providers: [
// Default-deny: a route is authenticated unless it carries @Public() or
// @OptionalAuth(), or declares its own @UseGuards chain. Protection used to
diff --git a/server/src/nest/school-holidays/school-holidays.controller.ts b/server/src/nest/school-holidays/school-holidays.controller.ts
new file mode 100644
index 000000000..5fc3d40a6
--- /dev/null
+++ b/server/src/nest/school-holidays/school-holidays.controller.ts
@@ -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); }
+}
diff --git a/server/src/nest/school-holidays/school-holidays.mcp.ts b/server/src/nest/school-holidays/school-holidays.mcp.ts
new file mode 100644
index 000000000..3634082d9
--- /dev/null
+++ b/server/src/nest/school-holidays/school-holidays.mcp.ts
@@ -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));
+ }
+}
diff --git a/server/src/nest/school-holidays/school-holidays.module.ts b/server/src/nest/school-holidays/school-holidays.module.ts
new file mode 100644
index 000000000..1d3729949
--- /dev/null
+++ b/server/src/nest/school-holidays/school-holidays.module.ts
@@ -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 {}
diff --git a/server/src/nest/school-holidays/school-holidays.service.ts b/server/src/nest/school-holidays/school-holidays.service.ts
new file mode 100644
index 000000000..df35b613e
--- /dev/null
+++ b/server/src/nest/school-holidays/school-holidays.service.ts
@@ -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('SELECT code, name FROM school_holiday_countries ORDER BY name, code'),
+ regions: this.db.all("SELECT *, country || '-MANUAL-' || id AS code FROM school_holiday_regions ORDER BY name, id"),
+ };
+ }
+
+ country(code: string): SchoolHolidayCountryRequest {
+ const country = this.db.get('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("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('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`);
+ }
+}
diff --git a/server/src/nest/vacay/vacay.service.ts b/server/src/nest/vacay/vacay.service.ts
index b27ca05f6..78b1fa076 100644
--- a/server/src/nest/vacay/vacay.service.ts
+++ b/server/src/nest/vacay/vacay.service.ts
@@ -1,4 +1,5 @@
-import { Injectable } from '@nestjs/common';
+import { Injectable, BadRequestException } from '@nestjs/common';
+import { manualSchoolRegionId } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService } from '../database/database.service';
import { NotificationsService } from '../notifications/notifications.service';
@@ -578,6 +579,7 @@ export class VacayService {
// -------------------------------------------------------------------------
addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined, type: 'public_holiday' | 'school_holiday' = 'public_holiday') {
+ this.validateManualRegion(region, type);
const result = this.db.run(
'INSERT INTO vacay_holiday_calendars (plan_id, type, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
planId, type, region, label || null, color || (type === 'school_holiday' ? '#a5f3fc' : '#fecaca'), sortOrder ?? 0
@@ -595,6 +597,7 @@ export class VacayService {
): VacayHolidayCalendar | null {
const cal = this.db.get('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?', calId, planId);
if (!cal) return null;
+ this.validateManualRegion(body.region ?? cal.region, body.type ?? cal.type);
const { region, label, color, sort_order, type } = body;
const updates: string[] = [];
const params: (string | number | null)[] = [];
@@ -620,6 +623,14 @@ export class VacayService {
return true;
}
+ private validateManualRegion(code: string, type: string) {
+ if (!code.includes('-MANUAL-')) return;
+ const id = manualSchoolRegionId(code);
+ if (type !== 'school_holiday' || !id || !this.db.get('SELECT id FROM school_holiday_regions WHERE id = ? AND country = ?', id, code.slice(0, 2))) {
+ throw new BadRequestException('Unknown manual school holiday region');
+ }
+ }
+
// -------------------------------------------------------------------------
// User colors
// -------------------------------------------------------------------------
diff --git a/server/tests/e2e/school-holidays.e2e.test.ts b/server/tests/e2e/school-holidays.e2e.test.ts
new file mode 100644
index 000000000..51755e374
--- /dev/null
+++ b/server/tests/e2e/school-holidays.e2e.test.ts
@@ -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: [] });
+ });
+});
diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts
index 9bac1ba70..19cc94e4e 100644
--- a/server/tests/helpers/mcp-test-controllers.ts
+++ b/server/tests/helpers/mcp-test-controllers.ts
@@ -1,4 +1,6 @@
import { createTestRegistry, type McpRegistry } from '../../src/nest-mcp';
+import { SchoolHolidaysMcp } from '../../src/nest/school-holidays/school-holidays.mcp';
+import { SchoolHolidaysService } from '../../src/nest/school-holidays/school-holidays.service';
import { db } from '../../src/db/database';
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../src/mcp/nest-mcp-policy';
import { AssignmentsMcp } from '../../src/nest/assignments/assignments.mcp';
@@ -221,6 +223,7 @@ export function createMcpTestRegistry(): McpRegistry {
new AssignmentsMcp(assignmentsService, daysService, authService, guards),
new CollabMcp(collabService, authService, addonsService, guards),
new VacayMcp(new VacayService(dbService, realtimeService, notificationsStub()), authService, addonsService),
+ new SchoolHolidaysMcp(new SchoolHolidaysService(dbService), guards),
new TripsMcp(tripsService, todoService, collabService, authService, calendarService, membersService, readModelService, addonsService, guards),
new TripPromptsMcp(tripsService, readModelService, packingService, addonsService),
new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService, queryHelpersService, placePhotoCache), authService, guards),
diff --git a/server/tests/helpers/test-db.ts b/server/tests/helpers/test-db.ts
index f6245754b..cdef60425 100644
--- a/server/tests/helpers/test-db.ts
+++ b/server/tests/helpers/test-db.ts
@@ -27,6 +27,9 @@ import type { RateLimitService } from '../../src/nest/common/rate-limit.service'
// Keep in sync with schema.ts + migrations.ts. Intentionally excluded: categories, addons,
// photo_providers, photo_provider_fields, schema_version (seed/config data, not user data).
const RESET_TABLES = [
+ 'school_holiday_periods',
+ 'school_holiday_regions',
+ 'school_holiday_countries',
// Collab
'file_links',
'collab_message_reactions',
diff --git a/server/tests/unit/nest/vacay.service.test.ts b/server/tests/unit/nest/vacay.service.test.ts
index 6c326d0e9..7ca63831a 100644
--- a/server/tests/unit/nest/vacay.service.test.ts
+++ b/server/tests/unit/nest/vacay.service.test.ts
@@ -309,6 +309,19 @@ describe('updatePlan', () => {
// ── addHolidayCalendar ────────────────────────────────────────────────────────
describe('addHolidayCalendar', () => {
+ it('validates manual region references for creates and updates', () => {
+ const { plan } = setupUserWithPlan();
+ testDb.prepare("INSERT INTO school_holiday_countries (code, name) VALUES ('US', 'USA')").run();
+ const inserted = testDb.prepare("INSERT INTO school_holiday_regions (country, name) VALUES ('US', 'Seattle')").run();
+ const code = `US-MANUAL-${inserted.lastInsertRowid}`;
+ const calendar = svc.addHolidayCalendar(plan.id, code, null, undefined, 0, undefined, 'school_holiday');
+ expect(calendar.region).toBe(code);
+ expect(svc.updateHolidayCalendar(calendar.id, plan.id, { label: 'School' }, undefined)?.region).toBe(code);
+ expect(() => svc.updateHolidayCalendar(calendar.id, plan.id, { type: 'public_holiday' }, undefined)).toThrow('Unknown manual');
+ for (const region of ['US-MANUAL-0', 'US-MANUAL-999999', `CA-MANUAL-${inserted.lastInsertRowid}`]) {
+ expect(() => svc.addHolidayCalendar(plan.id, region, null, undefined, 0, undefined, 'school_holiday')).toThrow('Unknown manual');
+ }
+ });
it('VACAY-SVC-019: inserts a new calendar row and returns the calendar object', () => {
const { plan } = setupUserWithPlan();
diff --git a/shared/src/i18n/ar/map.ts b/shared/src/i18n/ar/map.ts
index b286749a3..c2b148c7a 100644
--- a/shared/src/i18n/ar/map.ts
+++ b/shared/src/i18n/ar/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'تم حظر الوصول إلى الموقع. تحقق من إعدادات الجهاز؛ فالتطبيق المثبت لديه إذن موقع خاص به منفصل عن المتصفح.',
'map.location.unavailable': 'تعذر تحديد موقعك.',
'map.location.timeout': 'استغرق تحديد موقعك وقتًا طويلًا. حاول مرة أخرى تحت سماء مكشوفة.',
+ 'map.overview.show': 'عرض الرحلة كاملة',
+ 'map.overview.hide': 'إخفاء الرحلة كاملة',
+ 'map.overview.total': 'المسافة الإجمالية',
};
export default map;
diff --git a/shared/src/i18n/ar/vacay.ts b/shared/src/i18n/ar/vacay.ts
index f702f392c..45e02fc3a 100644
--- a/shared/src/i18n/ar/vacay.ts
+++ b/shared/src/i18n/ar/vacay.ts
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError': 'تم حفظ التغيير، لكن تعذر تحديث القائمة. حاول تحميلها مجددًا.',
+ 'schoolCatalog.title': 'العطلات المدرسية',
+ 'schoolCatalog.hint': 'أدر المناطق المشتركة. يمكن للجميع اختيارها في إعدادات الإجازات.',
+ 'schoolCatalog.periodHint': 'يشمل كلا التاريخين. أضف الفترات لكل عام دراسي.',
+ 'schoolCatalog.region': 'المنطقة أو المنطقة التعليمية',
+ 'schoolCatalog.name': 'اسم العطلة',
+ 'schoolCatalog.start': 'تاريخ البدء',
+ 'schoolCatalog.end': 'تاريخ الانتهاء',
+ 'schoolCatalog.addPeriod': 'إضافة فترة عطلة',
+ 'schoolCatalog.invalid': 'تحقق من الأسماء والتواريخ.',
+ 'schoolCatalog.discard': 'تجاهل التغييرات غير المحفوظة؟',
+ 'schoolCatalog.country': 'البلد',
+ 'schoolCatalog.deleteHint': 'احذف المناطق أولاً. لا يمكن حذف المناطق المستخدمة.',
+ 'schoolCatalog.deleteCountry': 'حذف البلد',
+ 'schoolCatalog.addCountry': 'إضافة بلد',
+ 'schoolCatalog.code': 'رمز البلد (مثل US)',
+ 'schoolCatalog.empty': 'لا توجد مناطق بعد.',
+ 'schoolCatalog.addRegion': 'إضافة منطقة',
+ 'schoolCatalog.offline': 'اتصل بالإنترنت لإدارة العطلات المدرسية.',
+ 'schoolCatalog.loadError': 'تعذر تحميل المناطق. أعد فتح الإعدادات للمحاولة مجددًا.',
+ 'schoolCatalog.retry': 'إعادة المحاولة',
'vacay.subtitle': 'خطط وأدر أيام الإجازة',
'vacay.settings': 'الإعدادات',
'vacay.year': 'السنة',
diff --git a/shared/src/i18n/br/map.ts b/shared/src/i18n/br/map.ts
index 167655546..6fca93b95 100644
--- a/shared/src/i18n/br/map.ts
+++ b/shared/src/i18n/br/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'O acesso à localização está bloqueado. Verifique as configurações do aparelho; um app instalado tem permissão de localização própria, separada do navegador.',
'map.location.unavailable': 'Não foi possível determinar sua localização.',
'map.location.timeout': 'A localização demorou demais. Tente de novo com uma visão mais aberta do céu.',
+ 'map.overview.show': 'Mostrar a viagem inteira',
+ 'map.overview.hide': 'Ocultar a viagem inteira',
+ 'map.overview.total': 'Distância total',
};
export default map;
diff --git a/shared/src/i18n/br/vacay.ts b/shared/src/i18n/br/vacay.ts
index bdabd7de3..e2a1cd8af 100644
--- a/shared/src/i18n/br/vacay.ts
+++ b/shared/src/i18n/br/vacay.ts
@@ -1,6 +1,28 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError':
+ 'A alteração foi salva, mas a lista não pôde ser atualizada. Tente carregá-la novamente.',
+ 'schoolCatalog.title': 'Férias escolares',
+ 'schoolCatalog.hint': 'Gerencie regiões compartilhadas. Todos podem selecioná-las nas configurações de férias.',
+ 'schoolCatalog.periodHint': 'As duas datas estão incluídas. Adicione períodos para cada ano letivo.',
+ 'schoolCatalog.region': 'Região ou distrito escolar',
+ 'schoolCatalog.name': 'Nome das férias',
+ 'schoolCatalog.start': 'Data inicial',
+ 'schoolCatalog.end': 'Data final',
+ 'schoolCatalog.addPeriod': 'Adicionar período',
+ 'schoolCatalog.invalid': 'Confira os nomes e as datas.',
+ 'schoolCatalog.discard': 'Descartar alterações não salvas?',
+ 'schoolCatalog.country': 'País',
+ 'schoolCatalog.deleteHint': 'Remova primeiro as regiões. Regiões em uso não podem ser excluídas.',
+ 'schoolCatalog.deleteCountry': 'Excluir país',
+ 'schoolCatalog.addCountry': 'Adicionar país',
+ 'schoolCatalog.code': 'Código do país (ex.: US)',
+ 'schoolCatalog.empty': 'Nenhuma região ainda.',
+ 'schoolCatalog.addRegion': 'Adicionar região',
+ 'schoolCatalog.offline': 'Conecte-se à internet para gerenciar as férias escolares.',
+ 'schoolCatalog.loadError': 'Não foi possível carregar as regiões. Abra as configurações novamente.',
+ 'schoolCatalog.retry': 'Tentar novamente',
'vacay.subtitle': 'Planeje e gerencie dias de férias',
'vacay.settings': 'Configurações',
'vacay.year': 'Ano',
diff --git a/shared/src/i18n/ca/map.ts b/shared/src/i18n/ca/map.ts
index abd643901..e0bf1b992 100644
--- a/shared/src/i18n/ca/map.ts
+++ b/shared/src/i18n/ca/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'L’accés a la ubicació està bloquejat. Revisa la configuració del dispositiu; una app instal·lada té el seu propi permís d’ubicació, separat del navegador.',
'map.location.unavailable': 'No s’ha pogut determinar la teva ubicació.',
'map.location.timeout': 'La localització ha trigat massa. Torna-ho a provar amb una vista més clara del cel.',
+ 'map.overview.show': 'Mostra tot el viatge',
+ 'map.overview.hide': 'Amaga tot el viatge',
+ 'map.overview.total': 'Distància total',
};
export default map;
diff --git a/shared/src/i18n/ca/vacay.ts b/shared/src/i18n/ca/vacay.ts
index b81d0a761..d6fc8566b 100644
--- a/shared/src/i18n/ca/vacay.ts
+++ b/shared/src/i18n/ca/vacay.ts
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError': 'El canvi s’ha desat, però no s’ha pogut actualitzar la llista. Torna a carregar-la.',
+ 'schoolCatalog.title': 'Vacances escolars',
+ 'schoolCatalog.hint': 'Gestiona regions compartides. Tothom les pot triar als ajustos de vacances.',
+ 'schoolCatalog.periodHint': 'Ambdues dates estan incloses. Afegeix períodes per a cada curs escolar.',
+ 'schoolCatalog.region': 'Regió o districte escolar',
+ 'schoolCatalog.name': 'Nom de les vacances',
+ 'schoolCatalog.start': 'Data inicial',
+ 'schoolCatalog.end': 'Data final',
+ 'schoolCatalog.addPeriod': 'Afegeix un període',
+ 'schoolCatalog.invalid': 'Revisa els noms i les dates.',
+ 'schoolCatalog.discard': 'Vols descartar els canvis sense desar?',
+ 'schoolCatalog.country': 'País',
+ 'schoolCatalog.deleteHint': 'Elimina primer les regions. Les regions en ús no es poden eliminar.',
+ 'schoolCatalog.deleteCountry': 'Elimina el país',
+ 'schoolCatalog.addCountry': 'Afegeix un país',
+ 'schoolCatalog.code': 'Codi del país (p. ex. US)',
+ 'schoolCatalog.empty': 'Encara no hi ha regions.',
+ 'schoolCatalog.addRegion': 'Afegeix una regió',
+ 'schoolCatalog.offline': 'Connecta’t a Internet per gestionar les vacances escolars.',
+ 'schoolCatalog.loadError': 'No s’han pogut carregar les regions. Torna a obrir els ajustos.',
+ 'schoolCatalog.retry': 'Torna-ho a provar',
'vacay.subtitle': 'Planifica i gestiona dies de vacances',
'vacay.settings': 'Configuració',
'vacay.year': 'Any',
diff --git a/shared/src/i18n/cs/map.ts b/shared/src/i18n/cs/map.ts
index c91d98cac..c12aea4db 100644
--- a/shared/src/i18n/cs/map.ts
+++ b/shared/src/i18n/cs/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Přístup k poloze je zablokován. Zkontrolujte nastavení zařízení; nainstalovaná aplikace má vlastní oprávnění k poloze, nezávislé na prohlížeči.',
'map.location.unavailable': 'Vaši polohu se nepodařilo zjistit.',
'map.location.timeout': 'Zjišťování polohy trvalo příliš dlouho. Zkuste to znovu s volným výhledem na oblohu.',
+ 'map.overview.show': 'Zobrazit celou cestu',
+ 'map.overview.hide': 'Skrýt celou cestu',
+ 'map.overview.total': 'Celková vzdálenost',
};
export default map;
diff --git a/shared/src/i18n/cs/vacay.ts b/shared/src/i18n/cs/vacay.ts
index f7834daa3..d0c1c7a08 100644
--- a/shared/src/i18n/cs/vacay.ts
+++ b/shared/src/i18n/cs/vacay.ts
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError': 'Změna byla uložena, ale seznam se nepodařilo obnovit. Zkuste jej načíst znovu.',
+ 'schoolCatalog.title': 'Školní prázdniny',
+ 'schoolCatalog.hint': 'Spravujte sdílené regiony. Všichni je mohou vybrat v nastavení dovolené.',
+ 'schoolCatalog.periodHint': 'Oba krajní dny jsou zahrnuty. Přidejte období pro každý školní rok.',
+ 'schoolCatalog.region': 'Region nebo školní obvod',
+ 'schoolCatalog.name': 'Název prázdnin',
+ 'schoolCatalog.start': 'Datum začátku',
+ 'schoolCatalog.end': 'Datum konce',
+ 'schoolCatalog.addPeriod': 'Přidat období',
+ 'schoolCatalog.invalid': 'Zkontrolujte názvy a data.',
+ 'schoolCatalog.discard': 'Zahodit neuložené změny?',
+ 'schoolCatalog.country': 'Země',
+ 'schoolCatalog.deleteHint': 'Nejprve odstraňte regiony. Používané regiony nelze smazat.',
+ 'schoolCatalog.deleteCountry': 'Smazat zemi',
+ 'schoolCatalog.addCountry': 'Přidat zemi',
+ 'schoolCatalog.code': 'Kód země (např. US)',
+ 'schoolCatalog.empty': 'Zatím žádné regiony.',
+ 'schoolCatalog.addRegion': 'Přidat region',
+ 'schoolCatalog.offline': 'Pro správu školních prázdnin se připojte k internetu.',
+ 'schoolCatalog.loadError': 'Regiony se nepodařilo načíst. Znovu otevřete nastavení.',
+ 'schoolCatalog.retry': 'Zkusit znovu',
'vacay.subtitle': 'Plánování a správa dovolené',
'vacay.settings': 'Nastavení',
'vacay.year': 'Rok',
diff --git a/shared/src/i18n/de/map.ts b/shared/src/i18n/de/map.ts
index c02a651fd..a4b0e874b 100644
--- a/shared/src/i18n/de/map.ts
+++ b/shared/src/i18n/de/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Der Standortzugriff ist blockiert. Prüfe die Geräteeinstellungen; eine installierte App hat eine eigene Standortberechtigung, unabhängig vom Browser.',
'map.location.unavailable': 'Dein Standort konnte nicht ermittelt werden.',
'map.location.timeout': 'Die Standortbestimmung hat zu lange gedauert. Versuche es mit freier Sicht zum Himmel erneut.',
+ 'map.overview.show': 'Ganze Reise anzeigen',
+ 'map.overview.hide': 'Ganze Reise ausblenden',
+ 'map.overview.total': 'Gesamtstrecke',
};
export default map;
diff --git a/shared/src/i18n/de/vacay.ts b/shared/src/i18n/de/vacay.ts
index 5b34e5511..7f9713df4 100644
--- a/shared/src/i18n/de/vacay.ts
+++ b/shared/src/i18n/de/vacay.ts
@@ -1,6 +1,28 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError':
+ 'Die Änderung wurde gespeichert, aber die Liste konnte nicht neu geladen werden. Versuche es erneut.',
+ 'schoolCatalog.title': 'Schulferien',
+ 'schoolCatalog.hint': 'Verwalte gemeinsame Ferienregionen. Alle können sie in den Urlaubseinstellungen auswählen.',
+ 'schoolCatalog.periodHint': 'Beide Grenztage zählen mit. Ergänze Zeiträume für jedes Schuljahr.',
+ 'schoolCatalog.region': 'Region oder Schulbezirk',
+ 'schoolCatalog.name': 'Name der Ferien',
+ 'schoolCatalog.start': 'Startdatum',
+ 'schoolCatalog.end': 'Enddatum',
+ 'schoolCatalog.addPeriod': 'Ferienzeitraum hinzufügen',
+ 'schoolCatalog.invalid': 'Prüfe Namen und Datumsangaben.',
+ 'schoolCatalog.discard': 'Ungespeicherte Änderungen verwerfen?',
+ 'schoolCatalog.country': 'Land',
+ 'schoolCatalog.deleteHint': 'Entferne zuerst die Regionen. Verwendete Regionen können nicht gelöscht werden.',
+ 'schoolCatalog.deleteCountry': 'Land löschen',
+ 'schoolCatalog.addCountry': 'Land hinzufügen',
+ 'schoolCatalog.code': 'Ländercode (z. B. US)',
+ 'schoolCatalog.empty': 'Noch keine Regionen vorhanden.',
+ 'schoolCatalog.addRegion': 'Region hinzufügen',
+ 'schoolCatalog.offline': 'Verbinde dich mit dem Internet, um Schulferien zu verwalten.',
+ 'schoolCatalog.loadError': 'Ferienregionen konnten nicht geladen werden. Öffne die Einstellungen erneut.',
+ 'schoolCatalog.retry': 'Erneut versuchen',
'vacay.subtitle': 'Urlaubstage planen und verwalten',
'vacay.settings': 'Einstellungen',
'vacay.year': 'Jahr',
diff --git a/shared/src/i18n/en/map.ts b/shared/src/i18n/en/map.ts
index 700fba7e1..b14c9b528 100644
--- a/shared/src/i18n/en/map.ts
+++ b/shared/src/i18n/en/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Location access is blocked. Check your device settings; an installed app has its own location permission, separate from the browser.',
'map.location.unavailable': 'Your location could not be determined.',
'map.location.timeout': 'Locating you took too long. Try again with a clearer view of the sky.',
+ 'map.overview.show': 'Show whole trip',
+ 'map.overview.hide': 'Hide whole trip',
+ 'map.overview.total': 'Total distance',
};
export default map;
diff --git a/shared/src/i18n/en/vacay.ts b/shared/src/i18n/en/vacay.ts
index c6526bffd..7684b2b79 100644
--- a/shared/src/i18n/en/vacay.ts
+++ b/shared/src/i18n/en/vacay.ts
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError': 'The change was saved, but the list could not be refreshed. Retry loading it.',
+ 'schoolCatalog.title': 'School holidays',
+ 'schoolCatalog.hint': 'Manage shared school holiday regions. Everyone can select them in vacation settings.',
+ 'schoolCatalog.periodHint': 'Dates include both days. Add periods for each school year.',
+ 'schoolCatalog.region': 'Region or school district',
+ 'schoolCatalog.name': 'Holiday name',
+ 'schoolCatalog.start': 'Start date',
+ 'schoolCatalog.end': 'End date',
+ 'schoolCatalog.addPeriod': 'Add holiday period',
+ 'schoolCatalog.invalid': 'Check the names and dates.',
+ 'schoolCatalog.discard': 'Discard unsaved changes?',
+ 'schoolCatalog.country': 'Country',
+ 'schoolCatalog.deleteHint': 'Remove regions first. Regions used by calendars cannot be deleted.',
+ 'schoolCatalog.deleteCountry': 'Delete country',
+ 'schoolCatalog.addCountry': 'Add country',
+ 'schoolCatalog.code': 'Country code (e.g. US)',
+ 'schoolCatalog.empty': 'No regions yet.',
+ 'schoolCatalog.addRegion': 'Add region',
+ 'schoolCatalog.offline': 'Connect to the internet to manage school holidays.',
+ 'schoolCatalog.loadError': 'Could not load school holiday regions. Reopen settings to retry.',
+ 'schoolCatalog.retry': 'Retry',
'vacay.subtitle': 'Plan and manage vacation days',
'vacay.settings': 'Settings',
'vacay.year': 'Year',
diff --git a/shared/src/i18n/es/map.ts b/shared/src/i18n/es/map.ts
index 5dff94102..9478b1a0d 100644
--- a/shared/src/i18n/es/map.ts
+++ b/shared/src/i18n/es/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'El acceso a la ubicación está bloqueado. Revisa los ajustes del dispositivo; una app instalada tiene su propio permiso de ubicación, independiente del navegador.',
'map.location.unavailable': 'No se pudo determinar tu ubicación.',
'map.location.timeout': 'La localización tardó demasiado. Inténtalo de nuevo con una vista más despejada del cielo.',
+ 'map.overview.show': 'Mostrar todo el viaje',
+ 'map.overview.hide': 'Ocultar todo el viaje',
+ 'map.overview.total': 'Distancia total',
};
export default map;
diff --git a/shared/src/i18n/es/vacay.ts b/shared/src/i18n/es/vacay.ts
index 62f93e4e8..d4824973c 100644
--- a/shared/src/i18n/es/vacay.ts
+++ b/shared/src/i18n/es/vacay.ts
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError': 'El cambio se guardó, pero no se pudo actualizar la lista. Intenta cargarla de nuevo.',
+ 'schoolCatalog.title': 'Vacaciones escolares',
+ 'schoolCatalog.hint': 'Gestiona regiones compartidas. Todos pueden elegirlas en los ajustes de vacaciones.',
+ 'schoolCatalog.periodHint': 'Ambas fechas están incluidas. Añade períodos para cada curso escolar.',
+ 'schoolCatalog.region': 'Región o distrito escolar',
+ 'schoolCatalog.name': 'Nombre de las vacaciones',
+ 'schoolCatalog.start': 'Fecha de inicio',
+ 'schoolCatalog.end': 'Fecha de fin',
+ 'schoolCatalog.addPeriod': 'Añadir período',
+ 'schoolCatalog.invalid': 'Revisa los nombres y las fechas.',
+ 'schoolCatalog.discard': '¿Descartar los cambios sin guardar?',
+ 'schoolCatalog.country': 'País',
+ 'schoolCatalog.deleteHint': 'Elimina primero las regiones. Las regiones en uso no se pueden eliminar.',
+ 'schoolCatalog.deleteCountry': 'Eliminar país',
+ 'schoolCatalog.addCountry': 'Añadir país',
+ 'schoolCatalog.code': 'Código de país (p. ej. US)',
+ 'schoolCatalog.empty': 'Todavía no hay regiones.',
+ 'schoolCatalog.addRegion': 'Añadir región',
+ 'schoolCatalog.offline': 'Conéctate a Internet para gestionar las vacaciones escolares.',
+ 'schoolCatalog.loadError': 'No se pudieron cargar las regiones. Vuelve a abrir los ajustes.',
+ 'schoolCatalog.retry': 'Reintentar',
'vacay.subtitle': 'Planifica y gestiona días de vacaciones',
'vacay.settings': 'Ajustes',
'vacay.year': 'Año',
diff --git a/shared/src/i18n/fr/map.ts b/shared/src/i18n/fr/map.ts
index a40e1f64c..68854f27d 100644
--- a/shared/src/i18n/fr/map.ts
+++ b/shared/src/i18n/fr/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'L’accès à la position est bloqué. Vérifiez les réglages de l’appareil. Une app installée possède sa propre autorisation de localisation, distincte du navigateur.',
'map.location.unavailable': 'Votre position n’a pas pu être déterminée.',
'map.location.timeout': 'La localisation a pris trop de temps. Réessayez avec une vue plus dégagée du ciel.',
+ 'map.overview.show': 'Afficher tout le voyage',
+ 'map.overview.hide': 'Masquer tout le voyage',
+ 'map.overview.total': 'Distance totale',
};
export default map;
diff --git a/shared/src/i18n/fr/vacay.ts b/shared/src/i18n/fr/vacay.ts
index 2e2874efd..9ee51b121 100644
--- a/shared/src/i18n/fr/vacay.ts
+++ b/shared/src/i18n/fr/vacay.ts
@@ -1,6 +1,28 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError':
+ 'La modification a été enregistrée, mais la liste n’a pas pu être actualisée. Réessayez de la charger.',
+ 'schoolCatalog.title': 'Vacances scolaires',
+ 'schoolCatalog.hint': 'Gérez les régions partagées. Chacun peut les choisir dans les paramètres des congés.',
+ 'schoolCatalog.periodHint': 'Les deux dates sont incluses. Ajoutez les périodes de chaque année scolaire.',
+ 'schoolCatalog.region': 'Région ou district scolaire',
+ 'schoolCatalog.name': 'Nom des vacances',
+ 'schoolCatalog.start': 'Date de début',
+ 'schoolCatalog.end': 'Date de fin',
+ 'schoolCatalog.addPeriod': 'Ajouter une période',
+ 'schoolCatalog.invalid': 'Vérifiez les noms et les dates.',
+ 'schoolCatalog.discard': 'Abandonner les modifications non enregistrées ?',
+ 'schoolCatalog.country': 'Pays',
+ 'schoolCatalog.deleteHint': 'Supprimez les régions en premier. Les régions utilisées ne peuvent pas être supprimées.',
+ 'schoolCatalog.deleteCountry': 'Supprimer le pays',
+ 'schoolCatalog.addCountry': 'Ajouter un pays',
+ 'schoolCatalog.code': 'Code du pays (ex. US)',
+ 'schoolCatalog.empty': 'Aucune région pour le moment.',
+ 'schoolCatalog.addRegion': 'Ajouter une région',
+ 'schoolCatalog.offline': 'Connectez-vous à Internet pour gérer les vacances scolaires.',
+ 'schoolCatalog.loadError': 'Impossible de charger les régions. Rouvrez les paramètres pour réessayer.',
+ 'schoolCatalog.retry': 'Réessayer',
'vacay.subtitle': 'Planifiez et gérez vos jours de congés',
'vacay.settings': 'Paramètres',
'vacay.year': 'Année',
diff --git a/shared/src/i18n/gr/map.ts b/shared/src/i18n/gr/map.ts
index 63ec7ff83..37ee38c72 100644
--- a/shared/src/i18n/gr/map.ts
+++ b/shared/src/i18n/gr/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Η πρόσβαση στην τοποθεσία είναι αποκλεισμένη. Ελέγξτε τις ρυθμίσεις της συσκευής. Μια εγκατεστημένη εφαρμογή έχει δική της άδεια τοποθεσίας, ξεχωριστή από το πρόγραμμα περιήγησης.',
'map.location.unavailable': 'Δεν ήταν δυνατός ο προσδιορισμός της τοποθεσίας σας.',
'map.location.timeout': 'Ο εντοπισμός της θέσης σας άργησε πολύ. Δοκιμάστε ξανά με καθαρή θέα στον ουρανό.',
+ 'map.overview.show': 'Εμφάνιση όλου του ταξιδιού',
+ 'map.overview.hide': 'Απόκρυψη όλου του ταξιδιού',
+ 'map.overview.total': 'Συνολική απόσταση',
};
export default map;
diff --git a/shared/src/i18n/gr/vacay.ts b/shared/src/i18n/gr/vacay.ts
index 58a191303..8505df59a 100644
--- a/shared/src/i18n/gr/vacay.ts
+++ b/shared/src/i18n/gr/vacay.ts
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError': 'Η αλλαγή αποθηκεύτηκε, αλλά η λίστα δεν ανανεώθηκε. Δοκιμάστε να τη φορτώσετε ξανά.',
+ 'schoolCatalog.title': 'Σχολικές διακοπές',
+ 'schoolCatalog.hint': 'Διαχειριστείτε κοινές περιοχές. Όλοι μπορούν να τις επιλέξουν στις ρυθμίσεις αδειών.',
+ 'schoolCatalog.periodHint': 'Περιλαμβάνονται και οι δύο ημερομηνίες. Προσθέστε περιόδους για κάθε σχολικό έτος.',
+ 'schoolCatalog.region': 'Περιοχή ή σχολική περιφέρεια',
+ 'schoolCatalog.name': 'Όνομα διακοπών',
+ 'schoolCatalog.start': 'Ημερομηνία έναρξης',
+ 'schoolCatalog.end': 'Ημερομηνία λήξης',
+ 'schoolCatalog.addPeriod': 'Προσθήκη περιόδου',
+ 'schoolCatalog.invalid': 'Ελέγξτε τα ονόματα και τις ημερομηνίες.',
+ 'schoolCatalog.discard': 'Απόρριψη μη αποθηκευμένων αλλαγών;',
+ 'schoolCatalog.country': 'Χώρα',
+ 'schoolCatalog.deleteHint': 'Αφαιρέστε πρώτα τις περιοχές. Οι περιοχές που χρησιμοποιούνται δεν διαγράφονται.',
+ 'schoolCatalog.deleteCountry': 'Διαγραφή χώρας',
+ 'schoolCatalog.addCountry': 'Προσθήκη χώρας',
+ 'schoolCatalog.code': 'Κωδικός χώρας (π.χ. US)',
+ 'schoolCatalog.empty': 'Δεν υπάρχουν ακόμη περιοχές.',
+ 'schoolCatalog.addRegion': 'Προσθήκη περιοχής',
+ 'schoolCatalog.offline': 'Συνδεθείτε στο διαδίκτυο για να διαχειριστείτε τις σχολικές διακοπές.',
+ 'schoolCatalog.loadError': 'Δεν ήταν δυνατή η φόρτωση των περιοχών. Ανοίξτε ξανά τις ρυθμίσεις.',
+ 'schoolCatalog.retry': 'Δοκιμή ξανά',
'vacay.subtitle': 'Σχεδιάστε και διαχειριστείτε ημέρες διακοπών',
'vacay.settings': 'Ρυθμίσεις',
'vacay.year': 'Έτος',
diff --git a/shared/src/i18n/hu/map.ts b/shared/src/i18n/hu/map.ts
index 6b2a927d5..4ed177293 100644
--- a/shared/src/i18n/hu/map.ts
+++ b/shared/src/i18n/hu/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'A helyhozzáférés le van tiltva. Ellenőrizd a készülék beállításait; a telepített alkalmazásnak saját helyengedélye van, a böngészőtől függetlenül.',
'map.location.unavailable': 'A tartózkodási helyed nem határozható meg.',
'map.location.timeout': 'A helymeghatározás túl sokáig tartott. Próbáld újra szabad ég alatt.',
+ 'map.overview.show': 'Teljes utazás megjelenítése',
+ 'map.overview.hide': 'Teljes utazás elrejtése',
+ 'map.overview.total': 'Teljes távolság',
};
export default map;
diff --git a/shared/src/i18n/hu/vacay.ts b/shared/src/i18n/hu/vacay.ts
index 0a03fcf04..25decd0fe 100644
--- a/shared/src/i18n/hu/vacay.ts
+++ b/shared/src/i18n/hu/vacay.ts
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError': 'A módosítás mentve, de a lista nem frissült. Próbáld újra betölteni.',
+ 'schoolCatalog.title': 'Iskolai szünetek',
+ 'schoolCatalog.hint': 'Kezeld a közös régiókat. Mindenki kiválaszthatja őket a szabadság beállításaiban.',
+ 'schoolCatalog.periodHint': 'Mindkét határnap beleszámít. Adj hozzá időszakokat minden tanévhez.',
+ 'schoolCatalog.region': 'Régió vagy tankerület',
+ 'schoolCatalog.name': 'Szünet neve',
+ 'schoolCatalog.start': 'Kezdő dátum',
+ 'schoolCatalog.end': 'Záró dátum',
+ 'schoolCatalog.addPeriod': 'Időszak hozzáadása',
+ 'schoolCatalog.invalid': 'Ellenőrizd a neveket és a dátumokat.',
+ 'schoolCatalog.discard': 'Elveted a nem mentett módosításokat?',
+ 'schoolCatalog.country': 'Ország',
+ 'schoolCatalog.deleteHint': 'Először távolítsd el a régiókat. A használt régiók nem törölhetők.',
+ 'schoolCatalog.deleteCountry': 'Ország törlése',
+ 'schoolCatalog.addCountry': 'Ország hozzáadása',
+ 'schoolCatalog.code': 'Országkód (pl. US)',
+ 'schoolCatalog.empty': 'Még nincsenek régiók.',
+ 'schoolCatalog.addRegion': 'Régió hozzáadása',
+ 'schoolCatalog.offline': 'Az iskolai szünetek kezeléséhez csatlakozz az internethez.',
+ 'schoolCatalog.loadError': 'A régiók nem tölthetők be. Nyisd meg újra a beállításokat.',
+ 'schoolCatalog.retry': 'Újrapróbálkozás',
'vacay.subtitle': 'Szabadságnapok tervezése és kezelése',
'vacay.settings': 'Beállítások',
'vacay.year': 'Év',
diff --git a/shared/src/i18n/id/map.ts b/shared/src/i18n/id/map.ts
index 56454bdad..0b41df148 100644
--- a/shared/src/i18n/id/map.ts
+++ b/shared/src/i18n/id/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Akses lokasi diblokir. Periksa pengaturan perangkat; aplikasi yang terpasang punya izin lokasi sendiri, terpisah dari browser.',
'map.location.unavailable': 'Lokasi Anda tidak dapat ditentukan.',
'map.location.timeout': 'Penentuan lokasi terlalu lama. Coba lagi di tempat dengan pandangan langit yang lebih terbuka.',
+ 'map.overview.show': 'Tampilkan seluruh perjalanan',
+ 'map.overview.hide': 'Sembunyikan seluruh perjalanan',
+ 'map.overview.total': 'Jarak total',
};
export default map;
diff --git a/shared/src/i18n/id/vacay.ts b/shared/src/i18n/id/vacay.ts
index ac55fe575..fb18ce64d 100644
--- a/shared/src/i18n/id/vacay.ts
+++ b/shared/src/i18n/id/vacay.ts
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError': 'Perubahan disimpan, tetapi daftar gagal diperbarui. Coba muat ulang.',
+ 'schoolCatalog.title': 'Liburan sekolah',
+ 'schoolCatalog.hint': 'Kelola wilayah bersama. Semua orang dapat memilihnya di pengaturan cuti.',
+ 'schoolCatalog.periodHint': 'Kedua tanggal termasuk. Tambahkan periode untuk setiap tahun ajaran.',
+ 'schoolCatalog.region': 'Wilayah atau distrik sekolah',
+ 'schoolCatalog.name': 'Nama liburan',
+ 'schoolCatalog.start': 'Tanggal mulai',
+ 'schoolCatalog.end': 'Tanggal selesai',
+ 'schoolCatalog.addPeriod': 'Tambah periode liburan',
+ 'schoolCatalog.invalid': 'Periksa nama dan tanggal.',
+ 'schoolCatalog.discard': 'Buang perubahan yang belum disimpan?',
+ 'schoolCatalog.country': 'Negara',
+ 'schoolCatalog.deleteHint': 'Hapus wilayah terlebih dahulu. Wilayah yang digunakan tidak dapat dihapus.',
+ 'schoolCatalog.deleteCountry': 'Hapus negara',
+ 'schoolCatalog.addCountry': 'Tambah negara',
+ 'schoolCatalog.code': 'Kode negara (mis. US)',
+ 'schoolCatalog.empty': 'Belum ada wilayah.',
+ 'schoolCatalog.addRegion': 'Tambah wilayah',
+ 'schoolCatalog.offline': 'Hubungkan ke internet untuk mengelola liburan sekolah.',
+ 'schoolCatalog.loadError': 'Wilayah gagal dimuat. Buka kembali pengaturan.',
+ 'schoolCatalog.retry': 'Coba lagi',
'vacay.subtitle': 'Rencanakan dan kelola hari cuti',
'vacay.settings': 'Pengaturan',
'vacay.year': 'Tahun',
diff --git a/shared/src/i18n/it/map.ts b/shared/src/i18n/it/map.ts
index c42884b44..0ada52a73 100644
--- a/shared/src/i18n/it/map.ts
+++ b/shared/src/i18n/it/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'L’accesso alla posizione è bloccato. Controlla le impostazioni del dispositivo; un’app installata ha un proprio permesso di localizzazione, separato dal browser.',
'map.location.unavailable': 'Impossibile determinare la tua posizione.',
'map.location.timeout': 'La localizzazione ha richiesto troppo tempo. Riprova con una visuale più libera del cielo.',
+ 'map.overview.show': 'Mostra tutto il viaggio',
+ 'map.overview.hide': 'Nascondi tutto il viaggio',
+ 'map.overview.total': 'Distanza totale',
};
export default map;
diff --git a/shared/src/i18n/it/vacay.ts b/shared/src/i18n/it/vacay.ts
index 7b7b792a7..8bfdc07d4 100644
--- a/shared/src/i18n/it/vacay.ts
+++ b/shared/src/i18n/it/vacay.ts
@@ -1,6 +1,28 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError':
+ 'La modifica è stata salvata, ma non è stato possibile aggiornare l’elenco. Riprova a caricarlo.',
+ 'schoolCatalog.title': 'Vacanze scolastiche',
+ 'schoolCatalog.hint': 'Gestisci le regioni condivise. Tutti possono sceglierle nelle impostazioni delle ferie.',
+ 'schoolCatalog.periodHint': 'Entrambe le date sono incluse. Aggiungi i periodi di ogni anno scolastico.',
+ 'schoolCatalog.region': 'Regione o distretto scolastico',
+ 'schoolCatalog.name': 'Nome delle vacanze',
+ 'schoolCatalog.start': 'Data di inizio',
+ 'schoolCatalog.end': 'Data di fine',
+ 'schoolCatalog.addPeriod': 'Aggiungi periodo',
+ 'schoolCatalog.invalid': 'Controlla i nomi e le date.',
+ 'schoolCatalog.discard': 'Scartare le modifiche non salvate?',
+ 'schoolCatalog.country': 'Paese',
+ 'schoolCatalog.deleteHint': 'Rimuovi prima le regioni. Le regioni in uso non possono essere eliminate.',
+ 'schoolCatalog.deleteCountry': 'Elimina paese',
+ 'schoolCatalog.addCountry': 'Aggiungi paese',
+ 'schoolCatalog.code': 'Codice paese (es. US)',
+ 'schoolCatalog.empty': 'Nessuna regione presente.',
+ 'schoolCatalog.addRegion': 'Aggiungi regione',
+ 'schoolCatalog.offline': 'Connettiti a Internet per gestire le vacanze scolastiche.',
+ 'schoolCatalog.loadError': 'Impossibile caricare le regioni. Riapri le impostazioni.',
+ 'schoolCatalog.retry': 'Riprova',
'vacay.subtitle': 'Pianifica e gestisci i giorni di ferie',
'vacay.settings': 'Impostazioni',
'vacay.year': 'Anno',
diff --git a/shared/src/i18n/ja/map.ts b/shared/src/i18n/ja/map.ts
index 7e2aa1ae2..a5d7d3ca9 100644
--- a/shared/src/i18n/ja/map.ts
+++ b/shared/src/i18n/ja/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': '位置情報へのアクセスがブロックされています。端末の設定を確認してください。インストールしたアプリにはブラウザとは別の位置情報の許可があります。',
'map.location.unavailable': '現在地を特定できませんでした。',
'map.location.timeout': '位置情報の取得に時間がかかりすぎました。空がよく見える場所でもう一度お試しください。',
+ 'map.overview.show': '旅程全体を表示',
+ 'map.overview.hide': '旅程全体を非表示',
+ 'map.overview.total': '合計距離',
};
export default map;
diff --git a/shared/src/i18n/ja/vacay.ts b/shared/src/i18n/ja/vacay.ts
index b1e41d682..0de6ec6ad 100644
--- a/shared/src/i18n/ja/vacay.ts
+++ b/shared/src/i18n/ja/vacay.ts
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError': '変更は保存されましたが、一覧を更新できませんでした。再読み込みしてください。',
+ 'schoolCatalog.title': '学校の休暇',
+ 'schoolCatalog.hint': '共有する地域を管理します。全員が休暇設定で選択できます。',
+ 'schoolCatalog.periodHint': '開始日と終了日を含みます。各学年の期間を追加してください。',
+ 'schoolCatalog.region': '地域または学区',
+ 'schoolCatalog.name': '休暇名',
+ 'schoolCatalog.start': '開始日',
+ 'schoolCatalog.end': '終了日',
+ 'schoolCatalog.addPeriod': '休暇期間を追加',
+ 'schoolCatalog.invalid': '名前と日付を確認してください。',
+ 'schoolCatalog.discard': '未保存の変更を破棄しますか?',
+ 'schoolCatalog.country': '国',
+ 'schoolCatalog.deleteHint': '先に地域を削除してください。使用中の地域は削除できません。',
+ 'schoolCatalog.deleteCountry': '国を削除',
+ 'schoolCatalog.addCountry': '国を追加',
+ 'schoolCatalog.code': '国コード(例:US)',
+ 'schoolCatalog.empty': '地域はまだありません。',
+ 'schoolCatalog.addRegion': '地域を追加',
+ 'schoolCatalog.offline': '学校の休暇を管理するにはインターネットに接続してください。',
+ 'schoolCatalog.loadError': '地域を読み込めませんでした。設定を開き直してください。',
+ 'schoolCatalog.retry': '再試行',
'vacay.subtitle': '休暇日数の計画と管理',
'vacay.settings': '設定',
'vacay.year': '年',
diff --git a/shared/src/i18n/ko/map.ts b/shared/src/i18n/ko/map.ts
index d87093ad8..c20e69a9e 100644
--- a/shared/src/i18n/ko/map.ts
+++ b/shared/src/i18n/ko/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': '위치 접근이 차단되어 있습니다. 기기 설정을 확인하세요. 설치된 앱은 브라우저와 별도의 위치 권한을 사용합니다.',
'map.location.unavailable': '현재 위치를 확인할 수 없습니다.',
'map.location.timeout': '위치를 확인하는 데 시간이 너무 오래 걸렸습니다. 하늘이 잘 보이는 곳에서 다시 시도하세요.',
+ 'map.overview.show': '전체 여행 보기',
+ 'map.overview.hide': '전체 여행 숨기기',
+ 'map.overview.total': '총 거리',
};
export default map;
diff --git a/shared/src/i18n/ko/vacay.ts b/shared/src/i18n/ko/vacay.ts
index 74bad162e..c33e343cf 100644
--- a/shared/src/i18n/ko/vacay.ts
+++ b/shared/src/i18n/ko/vacay.ts
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError': '변경 사항은 저장했지만 목록을 새로 고치지 못했습니다. 다시 불러오세요.',
+ 'schoolCatalog.title': '학교 방학',
+ 'schoolCatalog.hint': '공유 지역을 관리하세요. 누구나 휴가 설정에서 선택할 수 있습니다.',
+ 'schoolCatalog.periodHint': '시작일과 종료일을 모두 포함합니다. 학년도별 기간을 추가하세요.',
+ 'schoolCatalog.region': '지역 또는 학군',
+ 'schoolCatalog.name': '방학 이름',
+ 'schoolCatalog.start': '시작일',
+ 'schoolCatalog.end': '종료일',
+ 'schoolCatalog.addPeriod': '방학 기간 추가',
+ 'schoolCatalog.invalid': '이름과 날짜를 확인하세요.',
+ 'schoolCatalog.discard': '저장하지 않은 변경 사항을 취소할까요?',
+ 'schoolCatalog.country': '국가',
+ 'schoolCatalog.deleteHint': '먼저 지역을 삭제하세요. 사용 중인 지역은 삭제할 수 없습니다.',
+ 'schoolCatalog.deleteCountry': '국가 삭제',
+ 'schoolCatalog.addCountry': '국가 추가',
+ 'schoolCatalog.code': '국가 코드(예: US)',
+ 'schoolCatalog.empty': '아직 지역이 없습니다.',
+ 'schoolCatalog.addRegion': '지역 추가',
+ 'schoolCatalog.offline': '학교 방학을 관리하려면 인터넷에 연결하세요.',
+ 'schoolCatalog.loadError': '지역을 불러오지 못했습니다. 설정을 다시 여세요.',
+ 'schoolCatalog.retry': '다시 시도',
'vacay.subtitle': '휴가 일수를 계획하고 관리하세요',
'vacay.settings': '설정',
'vacay.year': '연도',
diff --git a/shared/src/i18n/nl/map.ts b/shared/src/i18n/nl/map.ts
index 01cd0662e..5083b1925 100644
--- a/shared/src/i18n/nl/map.ts
+++ b/shared/src/i18n/nl/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Locatietoegang is geblokkeerd. Controleer de apparaatinstellingen; een geïnstalleerde app heeft een eigen locatiemachtiging, los van de browser.',
'map.location.unavailable': 'Je locatie kon niet worden bepaald.',
'map.location.timeout': 'Het bepalen van je locatie duurde te lang. Probeer het opnieuw met vrij zicht op de lucht.',
+ 'map.overview.show': 'Hele reis tonen',
+ 'map.overview.hide': 'Hele reis verbergen',
+ 'map.overview.total': 'Totale afstand',
};
export default map;
diff --git a/shared/src/i18n/nl/vacay.ts b/shared/src/i18n/nl/vacay.ts
index 70395aa75..142821e05 100644
--- a/shared/src/i18n/nl/vacay.ts
+++ b/shared/src/i18n/nl/vacay.ts
@@ -1,6 +1,28 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError':
+ 'De wijziging is opgeslagen, maar de lijst kon niet worden vernieuwd. Probeer deze opnieuw te laden.',
+ 'schoolCatalog.title': 'Schoolvakanties',
+ 'schoolCatalog.hint': 'Beheer gedeelde vakantieregio’s. Iedereen kan ze kiezen in de vakantie-instellingen.',
+ 'schoolCatalog.periodHint': 'Beide datums tellen mee. Voeg perioden voor elk schooljaar toe.',
+ 'schoolCatalog.region': 'Regio of schooldistrict',
+ 'schoolCatalog.name': 'Naam van de vakantie',
+ 'schoolCatalog.start': 'Begindatum',
+ 'schoolCatalog.end': 'Einddatum',
+ 'schoolCatalog.addPeriod': 'Vakantieperiode toevoegen',
+ 'schoolCatalog.invalid': 'Controleer de namen en datums.',
+ 'schoolCatalog.discard': 'Niet-opgeslagen wijzigingen verwijderen?',
+ 'schoolCatalog.country': 'Land',
+ 'schoolCatalog.deleteHint': 'Verwijder eerst de regio’s. Regio’s die worden gebruikt kunnen niet worden verwijderd.',
+ 'schoolCatalog.deleteCountry': 'Land verwijderen',
+ 'schoolCatalog.addCountry': 'Land toevoegen',
+ 'schoolCatalog.code': 'Landcode (bijv. US)',
+ 'schoolCatalog.empty': 'Nog geen regio’s.',
+ 'schoolCatalog.addRegion': 'Regio toevoegen',
+ 'schoolCatalog.offline': 'Maak verbinding met internet om schoolvakanties te beheren.',
+ 'schoolCatalog.loadError': 'Vakantieregio’s konden niet worden geladen. Open de instellingen opnieuw.',
+ 'schoolCatalog.retry': 'Opnieuw proberen',
'vacay.subtitle': 'Plan en beheer vakantiedagen',
'vacay.settings': 'Instellingen',
'vacay.year': 'Jaar',
diff --git a/shared/src/i18n/pl/map.ts b/shared/src/i18n/pl/map.ts
index 8462c41c9..59c40db28 100644
--- a/shared/src/i18n/pl/map.ts
+++ b/shared/src/i18n/pl/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Dostęp do lokalizacji jest zablokowany. Sprawdź ustawienia urządzenia; zainstalowana aplikacja ma własne uprawnienie do lokalizacji, niezależne od przeglądarki.',
'map.location.unavailable': 'Nie udało się ustalić Twojej lokalizacji.',
'map.location.timeout': 'Ustalanie lokalizacji trwało zbyt długo. Spróbuj ponownie przy lepszej widoczności nieba.',
+ 'map.overview.show': 'Pokaż całą podróż',
+ 'map.overview.hide': 'Ukryj całą podróż',
+ 'map.overview.total': 'Łączny dystans',
};
export default map;
diff --git a/shared/src/i18n/pl/vacay.ts b/shared/src/i18n/pl/vacay.ts
index 7a27b9e16..1548a0c89 100644
--- a/shared/src/i18n/pl/vacay.ts
+++ b/shared/src/i18n/pl/vacay.ts
@@ -1,6 +1,28 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError':
+ 'Zmiana została zapisana, ale nie udało się odświeżyć listy. Spróbuj wczytać ją ponownie.',
+ 'schoolCatalog.title': 'Ferie szkolne',
+ 'schoolCatalog.hint': 'Zarządzaj wspólnymi regionami. Każdy może je wybrać w ustawieniach urlopu.',
+ 'schoolCatalog.periodHint': 'Obie daty są wliczone. Dodaj okresy na każdy rok szkolny.',
+ 'schoolCatalog.region': 'Region lub okręg szkolny',
+ 'schoolCatalog.name': 'Nazwa ferii',
+ 'schoolCatalog.start': 'Data rozpoczęcia',
+ 'schoolCatalog.end': 'Data zakończenia',
+ 'schoolCatalog.addPeriod': 'Dodaj okres',
+ 'schoolCatalog.invalid': 'Sprawdź nazwy i daty.',
+ 'schoolCatalog.discard': 'Odrzucić niezapisane zmiany?',
+ 'schoolCatalog.country': 'Kraj',
+ 'schoolCatalog.deleteHint': 'Najpierw usuń regiony. Nie można usunąć używanych regionów.',
+ 'schoolCatalog.deleteCountry': 'Usuń kraj',
+ 'schoolCatalog.addCountry': 'Dodaj kraj',
+ 'schoolCatalog.code': 'Kod kraju (np. US)',
+ 'schoolCatalog.empty': 'Brak regionów.',
+ 'schoolCatalog.addRegion': 'Dodaj region',
+ 'schoolCatalog.offline': 'Połącz się z internetem, aby zarządzać feriami szkolnymi.',
+ 'schoolCatalog.loadError': 'Nie udało się wczytać regionów. Otwórz ponownie ustawienia.',
+ 'schoolCatalog.retry': 'Spróbuj ponownie',
'vacay.subtitle': 'Planuj i zarządzaj dniami urlopu',
'vacay.settings': 'Ustawienia',
'vacay.year': 'Rok',
diff --git a/shared/src/i18n/ru/map.ts b/shared/src/i18n/ru/map.ts
index fe33312f9..a40c752a1 100644
--- a/shared/src/i18n/ru/map.ts
+++ b/shared/src/i18n/ru/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Доступ к геопозиции заблокирован. Проверьте настройки устройства; у установленного приложения есть собственное разрешение на геопозицию, отдельное от браузера.',
'map.location.unavailable': 'Не удалось определить ваше местоположение.',
'map.location.timeout': 'Определение местоположения заняло слишком много времени. Попробуйте ещё раз под открытым небом.',
+ 'map.overview.show': 'Показать всю поездку',
+ 'map.overview.hide': 'Скрыть всю поездку',
+ 'map.overview.total': 'Общее расстояние',
};
export default map;
diff --git a/shared/src/i18n/ru/vacay.ts b/shared/src/i18n/ru/vacay.ts
index 2e058cd7b..a068eb560 100644
--- a/shared/src/i18n/ru/vacay.ts
+++ b/shared/src/i18n/ru/vacay.ts
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError': 'Изменение сохранено, но обновить список не удалось. Попробуйте загрузить его снова.',
+ 'schoolCatalog.title': 'Школьные каникулы',
+ 'schoolCatalog.hint': 'Управляйте общими регионами. Каждый может выбрать их в настройках отпуска.',
+ 'schoolCatalog.periodHint': 'Обе даты включены. Добавляйте периоды на каждый учебный год.',
+ 'schoolCatalog.region': 'Регион или школьный округ',
+ 'schoolCatalog.name': 'Название каникул',
+ 'schoolCatalog.start': 'Дата начала',
+ 'schoolCatalog.end': 'Дата окончания',
+ 'schoolCatalog.addPeriod': 'Добавить период',
+ 'schoolCatalog.invalid': 'Проверьте названия и даты.',
+ 'schoolCatalog.discard': 'Отменить несохранённые изменения?',
+ 'schoolCatalog.country': 'Страна',
+ 'schoolCatalog.deleteHint': 'Сначала удалите регионы. Используемые регионы удалить нельзя.',
+ 'schoolCatalog.deleteCountry': 'Удалить страну',
+ 'schoolCatalog.addCountry': 'Добавить страну',
+ 'schoolCatalog.code': 'Код страны (например, US)',
+ 'schoolCatalog.empty': 'Регионов пока нет.',
+ 'schoolCatalog.addRegion': 'Добавить регион',
+ 'schoolCatalog.offline': 'Подключитесь к интернету для управления школьными каникулами.',
+ 'schoolCatalog.loadError': 'Не удалось загрузить регионы. Откройте настройки заново.',
+ 'schoolCatalog.retry': 'Повторить',
'vacay.subtitle': 'Планируйте и управляйте днями отпуска',
'vacay.settings': 'Настройки',
'vacay.year': 'Год',
diff --git a/shared/src/i18n/sv/map.ts b/shared/src/i18n/sv/map.ts
index f851d4016..37130e560 100644
--- a/shared/src/i18n/sv/map.ts
+++ b/shared/src/i18n/sv/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Platsåtkomst är blockerad. Kontrollera enhetens inställningar; en installerad app har ett eget platstillstånd, skilt från webbläsaren.',
'map.location.unavailable': 'Din plats kunde inte fastställas.',
'map.location.timeout': 'Platsbestämningen tog för lång tid. Försök igen med friare sikt mot himlen.',
+ 'map.overview.show': 'Visa hela resan',
+ 'map.overview.hide': 'Dölj hela resan',
+ 'map.overview.total': 'Total sträcka',
};
export default map;
diff --git a/shared/src/i18n/sv/vacay.ts b/shared/src/i18n/sv/vacay.ts
index 5ebaff234..b22693c6a 100644
--- a/shared/src/i18n/sv/vacay.ts
+++ b/shared/src/i18n/sv/vacay.ts
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError': 'Ändringen sparades, men listan kunde inte uppdateras. Försök läsa in den igen.',
+ 'schoolCatalog.title': 'Skollov',
+ 'schoolCatalog.hint': 'Hantera gemensamma regioner. Alla kan välja dem i semesterinställningarna.',
+ 'schoolCatalog.periodHint': 'Båda datumen ingår. Lägg till perioder för varje läsår.',
+ 'schoolCatalog.region': 'Region eller skoldistrikt',
+ 'schoolCatalog.name': 'Lovets namn',
+ 'schoolCatalog.start': 'Startdatum',
+ 'schoolCatalog.end': 'Slutdatum',
+ 'schoolCatalog.addPeriod': 'Lägg till lovperiod',
+ 'schoolCatalog.invalid': 'Kontrollera namn och datum.',
+ 'schoolCatalog.discard': 'Kasta osparade ändringar?',
+ 'schoolCatalog.country': 'Land',
+ 'schoolCatalog.deleteHint': 'Ta bort regionerna först. Regioner som används kan inte tas bort.',
+ 'schoolCatalog.deleteCountry': 'Ta bort land',
+ 'schoolCatalog.addCountry': 'Lägg till land',
+ 'schoolCatalog.code': 'Landskod (t.ex. US)',
+ 'schoolCatalog.empty': 'Inga regioner ännu.',
+ 'schoolCatalog.addRegion': 'Lägg till region',
+ 'schoolCatalog.offline': 'Anslut till internet för att hantera skollov.',
+ 'schoolCatalog.loadError': 'Kunde inte läsa in regionerna. Öppna inställningarna igen.',
+ 'schoolCatalog.retry': 'Försök igen',
'vacay.subtitle': 'Planera och hantera semesterdagar',
'vacay.settings': 'Inställningar',
'vacay.year': 'År',
diff --git a/shared/src/i18n/tr/map.ts b/shared/src/i18n/tr/map.ts
index f0440ce01..d317d6f94 100644
--- a/shared/src/i18n/tr/map.ts
+++ b/shared/src/i18n/tr/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Konum erişimi engellendi. Cihaz ayarlarını kontrol edin; yüklü bir uygulamanın tarayıcıdan ayrı kendi konum izni vardır.',
'map.location.unavailable': 'Konumunuz belirlenemedi.',
'map.location.timeout': 'Konum belirleme çok uzun sürdü. Gökyüzünü daha iyi gören bir yerde tekrar deneyin.',
+ 'map.overview.show': 'Tüm geziyi göster',
+ 'map.overview.hide': 'Tüm geziyi gizle',
+ 'map.overview.total': 'Toplam mesafe',
};
export default map;
diff --git a/shared/src/i18n/tr/vacay.ts b/shared/src/i18n/tr/vacay.ts
index 7ba99a384..0d9a51544 100644
--- a/shared/src/i18n/tr/vacay.ts
+++ b/shared/src/i18n/tr/vacay.ts
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError': 'Değişiklik kaydedildi, ancak liste yenilenemedi. Yeniden yüklemeyi deneyin.',
+ 'schoolCatalog.title': 'Okul tatilleri',
+ 'schoolCatalog.hint': 'Ortak bölgeleri yönetin. Herkes izin ayarlarından bunları seçebilir.',
+ 'schoolCatalog.periodHint': 'Her iki tarih de dahildir. Her öğretim yılı için dönemler ekleyin.',
+ 'schoolCatalog.region': 'Bölge veya okul bölgesi',
+ 'schoolCatalog.name': 'Tatil adı',
+ 'schoolCatalog.start': 'Başlangıç tarihi',
+ 'schoolCatalog.end': 'Bitiş tarihi',
+ 'schoolCatalog.addPeriod': 'Tatil dönemi ekle',
+ 'schoolCatalog.invalid': 'Adları ve tarihleri kontrol edin.',
+ 'schoolCatalog.discard': 'Kaydedilmemiş değişiklikler silinsin mi?',
+ 'schoolCatalog.country': 'Ülke',
+ 'schoolCatalog.deleteHint': 'Önce bölgeleri kaldırın. Kullanılan bölgeler silinemez.',
+ 'schoolCatalog.deleteCountry': 'Ülkeyi sil',
+ 'schoolCatalog.addCountry': 'Ülke ekle',
+ 'schoolCatalog.code': 'Ülke kodu (ör. US)',
+ 'schoolCatalog.empty': 'Henüz bölge yok.',
+ 'schoolCatalog.addRegion': 'Bölge ekle',
+ 'schoolCatalog.offline': 'Okul tatillerini yönetmek için internete bağlanın.',
+ 'schoolCatalog.loadError': 'Bölgeler yüklenemedi. Ayarları yeniden açın.',
+ 'schoolCatalog.retry': 'Tekrar dene',
'vacay.subtitle': 'Tatil günlerini planlayın ve yönetin',
'vacay.settings': 'Ayarlar',
'vacay.year': 'Yıl',
diff --git a/shared/src/i18n/uk/map.ts b/shared/src/i18n/uk/map.ts
index 7ad961420..6dc038eb1 100644
--- a/shared/src/i18n/uk/map.ts
+++ b/shared/src/i18n/uk/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Доступ до геолокації заблоковано. Перевірте налаштування пристрою; встановлений застосунок має власний дозвіл на геолокацію, окремий від браузера.',
'map.location.unavailable': 'Не вдалося визначити ваше місцезнаходження.',
'map.location.timeout': 'Визначення місцезнаходження тривало надто довго. Спробуйте ще раз просто неба.',
+ 'map.overview.show': 'Показати всю подорож',
+ 'map.overview.hide': 'Сховати всю подорож',
+ 'map.overview.total': 'Загальна відстань',
};
export default map;
diff --git a/shared/src/i18n/uk/vacay.ts b/shared/src/i18n/uk/vacay.ts
index cdfe8910a..fed64b9cd 100644
--- a/shared/src/i18n/uk/vacay.ts
+++ b/shared/src/i18n/uk/vacay.ts
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError': 'Зміну збережено, але список не вдалося оновити. Спробуйте завантажити його знову.',
+ 'schoolCatalog.title': 'Шкільні канікули',
+ 'schoolCatalog.hint': 'Керуйте спільними регіонами. Кожен може вибрати їх у налаштуваннях відпустки.',
+ 'schoolCatalog.periodHint': 'Обидві дати включено. Додавайте періоди на кожен навчальний рік.',
+ 'schoolCatalog.region': 'Регіон або шкільний округ',
+ 'schoolCatalog.name': 'Назва канікул',
+ 'schoolCatalog.start': 'Дата початку',
+ 'schoolCatalog.end': 'Дата завершення',
+ 'schoolCatalog.addPeriod': 'Додати період',
+ 'schoolCatalog.invalid': 'Перевірте назви й дати.',
+ 'schoolCatalog.discard': 'Відкинути незбережені зміни?',
+ 'schoolCatalog.country': 'Країна',
+ 'schoolCatalog.deleteHint': 'Спочатку видаліть регіони. Регіони, що використовуються, видалити не можна.',
+ 'schoolCatalog.deleteCountry': 'Видалити країну',
+ 'schoolCatalog.addCountry': 'Додати країну',
+ 'schoolCatalog.code': 'Код країни (наприклад, US)',
+ 'schoolCatalog.empty': 'Регіонів поки немає.',
+ 'schoolCatalog.addRegion': 'Додати регіон',
+ 'schoolCatalog.offline': 'Підключіться до інтернету для керування шкільними канікулами.',
+ 'schoolCatalog.loadError': 'Не вдалося завантажити регіони. Відкрийте налаштування знову.',
+ 'schoolCatalog.retry': 'Спробувати ще раз',
'vacay.subtitle': 'Плануйте й керуйте днями відпустки',
'vacay.settings': 'Налаштування',
'vacay.year': 'Рік',
diff --git a/shared/src/i18n/vi/map.ts b/shared/src/i18n/vi/map.ts
index 98592065c..081fe6c22 100644
--- a/shared/src/i18n/vi/map.ts
+++ b/shared/src/i18n/vi/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': 'Quyền truy cập vị trí đang bị chặn. Hãy kiểm tra cài đặt thiết bị; ứng dụng đã cài đặt có quyền vị trí riêng, tách biệt với trình duyệt.',
'map.location.unavailable': 'Không thể xác định vị trí của bạn.',
'map.location.timeout': 'Việc xác định vị trí mất quá nhiều thời gian. Hãy thử lại ở nơi nhìn thấy bầu trời thoáng hơn.',
+ 'map.overview.show': 'Hiện toàn bộ chuyến đi',
+ 'map.overview.hide': 'Ẩn toàn bộ chuyến đi',
+ 'map.overview.total': 'Tổng quãng đường',
};
export default map;
diff --git a/shared/src/i18n/vi/vacay.ts b/shared/src/i18n/vi/vacay.ts
index 99dfd2537..5de7276b2 100644
--- a/shared/src/i18n/vi/vacay.ts
+++ b/shared/src/i18n/vi/vacay.ts
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError': 'Thay đổi đã được lưu nhưng không thể làm mới danh sách. Hãy thử tải lại.',
+ 'schoolCatalog.title': 'Kỳ nghỉ học',
+ 'schoolCatalog.hint': 'Quản lý các khu vực dùng chung. Mọi người có thể chọn trong cài đặt nghỉ phép.',
+ 'schoolCatalog.periodHint': 'Bao gồm cả hai ngày. Thêm các khoảng nghỉ cho mỗi năm học.',
+ 'schoolCatalog.region': 'Khu vực hoặc học khu',
+ 'schoolCatalog.name': 'Tên kỳ nghỉ',
+ 'schoolCatalog.start': 'Ngày bắt đầu',
+ 'schoolCatalog.end': 'Ngày kết thúc',
+ 'schoolCatalog.addPeriod': 'Thêm kỳ nghỉ',
+ 'schoolCatalog.invalid': 'Kiểm tra tên và ngày.',
+ 'schoolCatalog.discard': 'Hủy các thay đổi chưa lưu?',
+ 'schoolCatalog.country': 'Quốc gia',
+ 'schoolCatalog.deleteHint': 'Xóa khu vực trước. Không thể xóa khu vực đang được sử dụng.',
+ 'schoolCatalog.deleteCountry': 'Xóa quốc gia',
+ 'schoolCatalog.addCountry': 'Thêm quốc gia',
+ 'schoolCatalog.code': 'Mã quốc gia (ví dụ: US)',
+ 'schoolCatalog.empty': 'Chưa có khu vực.',
+ 'schoolCatalog.addRegion': 'Thêm khu vực',
+ 'schoolCatalog.offline': 'Kết nối internet để quản lý kỳ nghỉ học.',
+ 'schoolCatalog.loadError': 'Không thể tải khu vực. Hãy mở lại cài đặt.',
+ 'schoolCatalog.retry': 'Thử lại',
'vacay.subtitle': 'Lập kế hoạch và quản lý ngày nghỉ',
'vacay.settings': 'Cài đặt',
'vacay.year': 'Năm',
diff --git a/shared/src/i18n/zh-TW/map.ts b/shared/src/i18n/zh-TW/map.ts
index d5d2c5ea3..d5446d5f6 100644
--- a/shared/src/i18n/zh-TW/map.ts
+++ b/shared/src/i18n/zh-TW/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': '定位權限已被封鎖。請檢查裝置設定,已安裝的應用程式擁有與瀏覽器分開的定位權限。',
'map.location.unavailable': '無法判斷你的位置。',
'map.location.timeout': '定位花費的時間過長。請在能看到天空的開闊位置重試。',
+ 'map.overview.show': '顯示整趟行程',
+ 'map.overview.hide': '隱藏整趟行程',
+ 'map.overview.total': '總距離',
};
export default map;
diff --git a/shared/src/i18n/zh-TW/vacay.ts b/shared/src/i18n/zh-TW/vacay.ts
index 78fda1a2b..1ca8be0cc 100644
--- a/shared/src/i18n/zh-TW/vacay.ts
+++ b/shared/src/i18n/zh-TW/vacay.ts
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError': '變更已儲存,但無法重新整理清單。請重試載入。',
+ 'schoolCatalog.title': '學校假期',
+ 'schoolCatalog.hint': '管理共用地區。所有使用者都可以在休假設定中選擇。',
+ 'schoolCatalog.periodHint': '包含開始與結束當天。請為每個學年新增假期。',
+ 'schoolCatalog.region': '地區或學區',
+ 'schoolCatalog.name': '假期名稱',
+ 'schoolCatalog.start': '開始日期',
+ 'schoolCatalog.end': '結束日期',
+ 'schoolCatalog.addPeriod': '新增假期',
+ 'schoolCatalog.invalid': '請檢查名稱與日期。',
+ 'schoolCatalog.discard': '捨棄尚未儲存的變更?',
+ 'schoolCatalog.country': '國家',
+ 'schoolCatalog.deleteHint': '請先刪除地區。使用中的地區無法刪除。',
+ 'schoolCatalog.deleteCountry': '刪除國家',
+ 'schoolCatalog.addCountry': '新增國家',
+ 'schoolCatalog.code': '國家代碼(如 US)',
+ 'schoolCatalog.empty': '尚無地區。',
+ 'schoolCatalog.addRegion': '新增地區',
+ 'schoolCatalog.offline': '請連接網際網路以管理學校假期。',
+ 'schoolCatalog.loadError': '無法載入地區。請重新開啟設定。',
+ 'schoolCatalog.retry': '重試',
'vacay.subtitle': '規劃和管理假期',
'vacay.settings': '設定',
'vacay.year': '年份',
diff --git a/shared/src/i18n/zh/map.ts b/shared/src/i18n/zh/map.ts
index ddc257a2e..32dbf24e4 100644
--- a/shared/src/i18n/zh/map.ts
+++ b/shared/src/i18n/zh/map.ts
@@ -22,5 +22,8 @@ const map: TranslationStrings = {
'map.location.denied': '定位权限已被阻止。请检查设备设置,已安装的应用拥有与浏览器分开的定位权限。',
'map.location.unavailable': '无法确定你的位置。',
'map.location.timeout': '定位耗时过长。请在能看到天空的开阔位置重试。',
+ 'map.overview.show': '显示整个行程',
+ 'map.overview.hide': '隐藏整个行程',
+ 'map.overview.total': '总距离',
};
export default map;
diff --git a/shared/src/i18n/zh/vacay.ts b/shared/src/i18n/zh/vacay.ts
index ab7d6237d..be81b2c85 100644
--- a/shared/src/i18n/zh/vacay.ts
+++ b/shared/src/i18n/zh/vacay.ts
@@ -1,6 +1,27 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
+ 'schoolCatalog.refreshError': '更改已保存,但无法刷新列表。请重试加载。',
+ 'schoolCatalog.title': '学校假期',
+ 'schoolCatalog.hint': '管理共享地区。所有用户都可以在休假设置中选择。',
+ 'schoolCatalog.periodHint': '包含开始和结束当天。请为每个学年添加假期。',
+ 'schoolCatalog.region': '地区或学区',
+ 'schoolCatalog.name': '假期名称',
+ 'schoolCatalog.start': '开始日期',
+ 'schoolCatalog.end': '结束日期',
+ 'schoolCatalog.addPeriod': '添加假期',
+ 'schoolCatalog.invalid': '请检查名称和日期。',
+ 'schoolCatalog.discard': '放弃未保存的更改?',
+ 'schoolCatalog.country': '国家',
+ 'schoolCatalog.deleteHint': '请先删除地区。正在使用的地区无法删除。',
+ 'schoolCatalog.deleteCountry': '删除国家',
+ 'schoolCatalog.addCountry': '添加国家',
+ 'schoolCatalog.code': '国家代码(如 US)',
+ 'schoolCatalog.empty': '尚无地区。',
+ 'schoolCatalog.addRegion': '添加地区',
+ 'schoolCatalog.offline': '请连接互联网以管理学校假期。',
+ 'schoolCatalog.loadError': '无法加载地区。请重新打开设置。',
+ 'schoolCatalog.retry': '重试',
'vacay.subtitle': '规划和管理假期',
'vacay.settings': '设置',
'vacay.year': '年份',
diff --git a/shared/src/index.ts b/shared/src/index.ts
index 35b55b6de..1e70a09c2 100644
--- a/shared/src/index.ts
+++ b/shared/src/index.ts
@@ -82,3 +82,5 @@ export * from './roadtrip/planning.schema';
export * from './roadtrip/search.schema';
export * from './roadtrip/google-import.schema';
export * from './roadtrip/charging.schema';
+
+export * from './vacay/school-holiday-catalog.schema';
diff --git a/shared/src/roadtrip/assemble.ts b/shared/src/roadtrip/assemble.ts
index 24615ba05..03c1b5f68 100644
--- a/shared/src/roadtrip/assemble.ts
+++ b/shared/src/roadtrip/assemble.ts
@@ -171,7 +171,10 @@ export function assembleRoadtrip({
return { ...s, dwellMinutes, offRoadMeters: line ? snap!.meters : null };
});
- const drive = { warnings: [] as ReturnType['warnings'], day: null as ReturnType['day'] };
+ const drive = {
+ warnings: [] as ReturnType['warnings'],
+ day: null as ReturnType['day'],
+ };
const dryPoints: (DryPoint & { lat: number; lng: number })[] = [];
const drivingLine: [number, number][] = [];
let drivenMeters = 0;
@@ -181,9 +184,12 @@ export function assembleRoadtrip({
if (incoming?.seg) {
const incomingDrive = deriveDriveWarnings([incoming.seg], [false, false], limits, carryKm);
carryKm = incomingDrive.carryKm;
- drive.warnings.push(...incomingDrive.warnings.map(w => ({ ...w, index: i })));
+ drive.warnings.push(...incomingDrive.warnings.map((w) => ({ ...w, index: i })));
for (const dry of incomingDrive.emptyAt) {
- const at = pointAtMeters(incoming.line.map(([lat, lng]) => ({ lat, lng })), dry.drivenMeters);
+ const at = pointAtMeters(
+ incoming.line.map(([lat, lng]) => ({ lat, lng })),
+ dry.drivenMeters,
+ );
if (at) dryPoints.push({ ...dry, legIndex: -i - 1, inboundLine: incoming.line, lat: at.lat, lng: at.lng });
}
if (!incoming.seg.mode || incoming.seg.mode === 'driving') drivingSeconds += incoming.seg.duration ?? 0;
@@ -197,10 +203,20 @@ export function assembleRoadtrip({
[stops[i]!.fillPercent],
);
carryKm = outgoing.carryKm;
- drive.warnings.push(...outgoing.warnings.map(w => ({ ...w, index: i + 1 })));
+ drive.warnings.push(...outgoing.warnings.map((w) => ({ ...w, index: i + 1 })));
for (const dry of outgoing.emptyAt) {
- const at = pointAtMeters((leg?.line ?? []).map(([lat, lng]) => ({ lat, lng })), dry.intoLegKm * 1000);
- if (at) dryPoints.push({ ...dry, legIndex: i, drivenMeters: drivenMeters + dry.intoLegKm * 1000, lat: at.lat, lng: at.lng });
+ const at = pointAtMeters(
+ (leg?.line ?? []).map(([lat, lng]) => ({ lat, lng })),
+ dry.intoLegKm * 1000,
+ );
+ if (at)
+ dryPoints.push({
+ ...dry,
+ legIndex: i,
+ drivenMeters: drivenMeters + dry.intoLegKm * 1000,
+ lat: at.lat,
+ lng: at.lng,
+ });
}
if (leg && (!leg.seg.mode || leg.seg.mode === 'driving')) {
drivingLine.push(...leg.line);
diff --git a/shared/src/vacay/school-holiday-catalog.schema.spec.ts b/shared/src/vacay/school-holiday-catalog.schema.spec.ts
new file mode 100644
index 000000000..4fc276e01
--- /dev/null
+++ b/shared/src/vacay/school-holiday-catalog.schema.spec.ts
@@ -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();
+ });
+});
diff --git a/shared/src/vacay/school-holiday-catalog.schema.ts b/shared/src/vacay/school-holiday-catalog.schema.ts
new file mode 100644
index 000000000..2541ba0b2
--- /dev/null
+++ b/shared/src/vacay/school-holiday-catalog.schema.ts
@@ -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;
+export type SchoolHolidayPeriod = z.infer;
+export type SchoolHolidayRegionRequest = z.infer;
+export type SchoolHolidayRegion = z.infer;
+export type SchoolHolidayCatalog = z.infer;
+export type SchoolHolidayRegionDetail = z.infer;
+
+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;
+}
diff --git a/wiki/AI-Booking-Import.md b/wiki/AI-Booking-Import.md
index eb417ef8d..b1be546a2 100644
--- a/wiki/AI-Booking-Import.md
+++ b/wiki/AI-Booking-Import.md
@@ -37,7 +37,7 @@ When you enable the addon, a configuration panel appears directly under it in [A
- **Provider** — Local · OpenAI-compatible, OpenAI, or Anthropic.
- **Base URL** — shown for every provider except Anthropic. Defaults to `http://localhost:11434/v1` for a local Ollama server, or `https://api.openai.com/v1` for OpenAI. Point it at any OpenAI-compatible endpoint here.
- **API key** — optional for a local server (`(often not required)`), required for the cloud providers. Stored **encrypted**; it is shown masked (`••••••••`) once saved, and leaving it unchanged keeps the stored key.
-- **Model** — the model id (e.g. `qwen3:8b`, `gpt-4o`, `claude-opus-4-8`).
+- **Model** — the model id (e.g. `qwen3.5:4b`, `gpt-4o`, `claude-opus-4-8`).
If you set a provider and model here, it applies to **all users** and overrides their personal settings. Leave the panel blank to let each user bring their own model (see below).
@@ -46,7 +46,7 @@ If you set a provider and model here, it applies to **all users** and overrides
With the **Local** provider selected, the panel manages your Ollama server directly:
- **Installed on the server** lists the models Ollama already has, with a **Refresh** button. Click a model to select it.
-- **Pull a recommended model** downloads a model with a live progress bar. The one recommended model is **Qwen3 — 8B** (`qwen3:8b`) — *best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0*. Once the pull finishes it is selected automatically.
+- **Pull a recommended model** downloads a model with a live progress bar. The one recommended model is **Qwen3.5 — 4B** (`qwen3.5:4b`, 3.4 GB, 256K context): small and quick on a CPU-only host, "thinking" is disabled automatically, Apache-2.0. Like every local model it receives the document's extracted text, so scanned PDFs still need Anthropic (see below). Once the pull finishes it is selected automatically.
You can also select any other model already installed on the server, or type a model id by hand.
diff --git a/wiki/Map-Features.md b/wiki/Map-Features.md
index fe70854d4..a839f31ba 100644
--- a/wiki/Map-Features.md
+++ b/wiki/Map-Features.md
@@ -36,6 +36,16 @@ A day's route is drawn as a solid blue line — a bright core over a darker casi
A straight line is drawn immediately, then upgraded to real road geometry from a public OSRM router (or from a plugin route profile), each leg routed in the transport mode that leg carries. If routing fails, that leg stays a straight line between the two stops.
+### The whole trip at once
+
+The **Show whole trip** button in the bottom-right corner of the map swaps the single day for every travel day of the trip, each drawn in its own colour over a white casing so neighbouring days stay apart on any basemap. It is on desktop and on the phone, and your choice is remembered per trip for the rest of the session.
+
+A card above the button lists the days: each one by its title, or by its number when it has none, with an icon per travel mode it is actually driven or walked in and the distance covered that day. The trip's **Total distance** sits at the top. Picking a day in the list selects it, the same as picking it anywhere else.
+
+The total is real routed distance summed over every leg — road geometry from the router, not straight lines between stops — which is what makes it worth building a fuel estimate on. It arrives a few legs at a time: while they are still coming in the total is followed by an ellipsis to say it is a partial sum, and it settles once every leg has answered. A leg the router refuses keeps its straight line on the map and contributes nothing to the total, so a trip with an unroutable hop reads low rather than wrong. Days with fewer than two located stops have no route and are left out of the list entirely.
+
+Road trip mode already draws the whole trip its own way, so the button is not offered while it is on.
+
## GPX tracks
Tracks and routes imported from a `.gpx`, `.kml`, or `.kmz` file are drawn as lines on the map. Each track imported into a trip is given its own colour automatically, so several walks in the same area stay distinguishable without any setup.
diff --git a/wiki/Vacay.md b/wiki/Vacay.md
index f8d21f0a8..7ababd16d 100644
--- a/wiki/Vacay.md
+++ b/wiki/Vacay.md
@@ -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.
- **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).
-- **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