feat(vacay): add manual school holiday calendars

This commit is contained in:
Maurice
2026-09-12 11:37:57 +02:00
committed by Maurice
parent 4f8605b529
commit 41b18f970c
55 changed files with 1562 additions and 34 deletions
@@ -0,0 +1,47 @@
import {
manualSchoolRegionId,
schoolHolidayCountryRequestSchema,
schoolHolidayPeriodSchema,
schoolHolidayRegionRequestSchema,
} from './school-holiday-catalog.schema';
import { describe, expect, it } from 'vitest';
describe('manual school holiday contracts', () => {
it('accepts a named break spanning New Year and leap days', () => {
expect(
schoolHolidayPeriodSchema.parse({ name: ' Winter ', startDate: '2026-12-20', endDate: '2027-01-06' }).name,
).toBe('Winter');
expect(
schoolHolidayPeriodSchema.safeParse({ name: 'Leap day', startDate: '2028-02-29', endDate: '2028-02-29' }).success,
).toBe(true);
});
it.each([
['2026-02-29', '2026-03-01'],
['2026-04-31', '2026-05-01'],
['2026-12-20', '2026-12-19'],
['2026-01-01', '2028-01-01'],
])('rejects invalid or unbounded dates %s to %s', (startDate, endDate) => {
expect(schoolHolidayPeriodSchema.safeParse({ name: 'Break', startDate, endDate }).success).toBe(false);
});
it('requires a country code, names, revision and bounded periods', () => {
expect(schoolHolidayCountryRequestSchema.safeParse({ code: '../', name: 'USA' }).success).toBe(false);
expect(schoolHolidayCountryRequestSchema.safeParse({ code: 'US', name: ' ' }).success).toBe(false);
expect(schoolHolidayRegionRequestSchema.safeParse({ name: 'District', revision: 0, holidays: [] }).success).toBe(
true,
);
expect(schoolHolidayRegionRequestSchema.safeParse({ name: 'District', holidays: [] }).success).toBe(false);
expect(
schoolHolidayRegionRequestSchema.safeParse({
name: 'District',
revision: 0,
holidays: Array(501).fill({ name: 'Break', startDate: '2026-01-01', endDate: '2026-01-02' }),
}).success,
).toBe(false);
});
it('keeps manual ids distinct from provider subdivisions', () => {
expect(manualSchoolRegionId('US-MANUAL-12')).toBe(12);
for (const code of ['US-WA', 'NL|group:NL-NO', 'US-MANUAL-0', 'US-MANUAL-1junk'])
expect(manualSchoolRegionId(code)).toBeNull();
});
});
@@ -0,0 +1,52 @@
import { idSchema, nonEmptyString } from '../common/primitives.schema';
import { z } from 'zod';
export const schoolHolidayCountryRequestSchema = z.object({
code: z.string().regex(/^[A-Z]{2}$/),
name: nonEmptyString.max(100),
});
export const schoolHolidayPeriodSchema = z
.object({
name: nonEmptyString.max(150),
startDate: z.string().date(),
endDate: z.string().date(),
})
.refine((period) => period.endDate >= period.startDate, {
message: 'End date must be on or after start date',
path: ['endDate'],
})
.refine((period) => Date.parse(period.endDate) - Date.parse(period.startDate) <= 366 * 86400000, {
message: 'A holiday period must not exceed one year',
path: ['endDate'],
});
export const schoolHolidayRegionRequestSchema = z.object({
name: nonEmptyString.max(150),
revision: z.number().int().nonnegative(),
holidays: z.array(schoolHolidayPeriodSchema).max(500),
});
export const schoolHolidayRegionSchema = z.object({
id: idSchema,
country: z.string(),
name: z.string(),
code: z.string(),
revision: z.number().int(),
});
export const schoolHolidayCatalogSchema = z.object({
countries: z.array(schoolHolidayCountryRequestSchema),
regions: z.array(schoolHolidayRegionSchema),
});
export const schoolHolidayRegionDetailSchema = schoolHolidayRegionSchema.extend({
holidays: z.array(schoolHolidayPeriodSchema),
});
export type SchoolHolidayCountryRequest = z.infer<typeof schoolHolidayCountryRequestSchema>;
export type SchoolHolidayPeriod = z.infer<typeof schoolHolidayPeriodSchema>;
export type SchoolHolidayRegionRequest = z.infer<typeof schoolHolidayRegionRequestSchema>;
export type SchoolHolidayRegion = z.infer<typeof schoolHolidayRegionSchema>;
export type SchoolHolidayCatalog = z.infer<typeof schoolHolidayCatalogSchema>;
export type SchoolHolidayRegionDetail = z.infer<typeof schoolHolidayRegionDetailSchema>;
export function manualSchoolRegionId(code: string): number | null {
const match = /^[A-Z]{2}-MANUAL-([1-9][0-9]*)$/.exec(code);
return match ? Number(match[1]) : null;
}