feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
import { getPublicHolidays } from "@capakraken/shared";
|
||||
|
||||
export type HolidayCalendarSeedScope = "COUNTRY" | "STATE" | "CITY";
|
||||
|
||||
export type HolidayCalendarSeedEntry = {
|
||||
date: string;
|
||||
name: string;
|
||||
isRecurringAnnual: boolean;
|
||||
};
|
||||
|
||||
export type HolidayCalendarSeedDefinition = {
|
||||
name: string;
|
||||
scopeType: HolidayCalendarSeedScope;
|
||||
countryCode: string;
|
||||
stateCode?: string;
|
||||
cityName?: string;
|
||||
priority: number;
|
||||
entries: HolidayCalendarSeedEntry[];
|
||||
};
|
||||
|
||||
type SeedContext = {
|
||||
availableCountryCodes: string[];
|
||||
availableCitiesByCountry: Record<string, string[]>;
|
||||
activeGermanStates?: string[];
|
||||
years: number[];
|
||||
};
|
||||
|
||||
type SimpleHolidayDefinition = {
|
||||
name: string;
|
||||
resolveDate: (year: number) => string;
|
||||
};
|
||||
|
||||
const COUNTRY_PRIORITY = 10;
|
||||
const STATE_PRIORITY = 20;
|
||||
const CITY_PRIORITY = 30;
|
||||
|
||||
function toIsoDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function dateUtc(year: number, month: number, day: number): Date {
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
|
||||
function addDays(date: Date, amount: number): Date {
|
||||
const next = new Date(date);
|
||||
next.setUTCDate(next.getUTCDate() + amount);
|
||||
return next;
|
||||
}
|
||||
|
||||
function computeEasterSunday(year: number): Date {
|
||||
const a = year % 19;
|
||||
const b = Math.floor(year / 100);
|
||||
const c = year % 100;
|
||||
const d = Math.floor(b / 4);
|
||||
const e = b % 4;
|
||||
const f = Math.floor((b + 8) / 25);
|
||||
const g = Math.floor((b - f + 1) / 3);
|
||||
const h = (19 * a + b - d - g + 15) % 30;
|
||||
const i = Math.floor(c / 4);
|
||||
const k = c % 4;
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
||||
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
||||
return dateUtc(year, month, day);
|
||||
}
|
||||
|
||||
function nthWeekdayOfMonth(year: number, month: number, weekday: number, occurrence: number): string {
|
||||
const firstDay = dateUtc(year, month, 1);
|
||||
const delta = (weekday - firstDay.getUTCDay() + 7) % 7;
|
||||
const day = 1 + delta + ((occurrence - 1) * 7);
|
||||
return toIsoDate(dateUtc(year, month, day));
|
||||
}
|
||||
|
||||
function lastWeekdayOfMonth(year: number, month: number, weekday: number): string {
|
||||
const lastDay = dateUtc(year, month + 1, 0);
|
||||
const delta = (lastDay.getUTCDay() - weekday + 7) % 7;
|
||||
lastDay.setUTCDate(lastDay.getUTCDate() - delta);
|
||||
return toIsoDate(lastDay);
|
||||
}
|
||||
|
||||
function observedUsFixedHoliday(year: number, month: number, day: number): string {
|
||||
const holiday = dateUtc(year, month, day);
|
||||
const weekday = holiday.getUTCDay();
|
||||
|
||||
if (weekday === 6) {
|
||||
return toIsoDate(addDays(holiday, -1));
|
||||
}
|
||||
if (weekday === 0) {
|
||||
return toIsoDate(addDays(holiday, 1));
|
||||
}
|
||||
|
||||
return toIsoDate(holiday);
|
||||
}
|
||||
|
||||
function buildEntries(years: number[], definitions: SimpleHolidayDefinition[]): HolidayCalendarSeedEntry[] {
|
||||
const entries = new Map<string, HolidayCalendarSeedEntry>();
|
||||
|
||||
for (const year of years) {
|
||||
for (const definition of definitions) {
|
||||
const date = definition.resolveDate(year);
|
||||
entries.set(date, {
|
||||
date,
|
||||
name: definition.name,
|
||||
isRecurringAnnual: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...entries.values()].sort((left, right) => left.date.localeCompare(right.date));
|
||||
}
|
||||
|
||||
function buildGermanCountryEntries(years: number[]): HolidayCalendarSeedEntry[] {
|
||||
return buildEntries(
|
||||
years,
|
||||
years.flatMap((year) =>
|
||||
getPublicHolidays(year)
|
||||
.filter((holiday) => holiday.federal)
|
||||
.map((holiday) => ({
|
||||
name: holiday.name,
|
||||
resolveDate: () => holiday.date,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function buildGermanStateEntries(years: number[], stateCode: string): HolidayCalendarSeedEntry[] {
|
||||
return buildEntries(
|
||||
years,
|
||||
years.flatMap((year) =>
|
||||
getPublicHolidays(year, stateCode)
|
||||
.filter((holiday) => !holiday.federal)
|
||||
.map((holiday) => ({
|
||||
name: holiday.name,
|
||||
resolveDate: () => holiday.date,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function buildSpanishCountryEntries(years: number[]): HolidayCalendarSeedEntry[] {
|
||||
return buildEntries(years, [
|
||||
{ name: "Ano Nuevo", resolveDate: (year) => toIsoDate(dateUtc(year, 1, 1)) },
|
||||
{ name: "Epifania del Senor", resolveDate: (year) => toIsoDate(dateUtc(year, 1, 6)) },
|
||||
{ name: "Viernes Santo", resolveDate: (year) => toIsoDate(addDays(computeEasterSunday(year), -2)) },
|
||||
{ name: "Fiesta del Trabajo", resolveDate: (year) => toIsoDate(dateUtc(year, 5, 1)) },
|
||||
{ name: "Asuncion de la Virgen", resolveDate: (year) => toIsoDate(dateUtc(year, 8, 15)) },
|
||||
{ name: "Fiesta Nacional de Espana", resolveDate: (year) => toIsoDate(dateUtc(year, 10, 12)) },
|
||||
{ name: "Todos los Santos", resolveDate: (year) => toIsoDate(dateUtc(year, 11, 1)) },
|
||||
{ name: "Dia de la Constitucion", resolveDate: (year) => toIsoDate(dateUtc(year, 12, 6)) },
|
||||
{ name: "Inmaculada Concepcion", resolveDate: (year) => toIsoDate(dateUtc(year, 12, 8)) },
|
||||
{ name: "Navidad", resolveDate: (year) => toIsoDate(dateUtc(year, 12, 25)) },
|
||||
]);
|
||||
}
|
||||
|
||||
function buildIndianCountryEntries(years: number[]): HolidayCalendarSeedEntry[] {
|
||||
return buildEntries(years, [
|
||||
{ name: "Republic Day", resolveDate: (year) => toIsoDate(dateUtc(year, 1, 26)) },
|
||||
{ name: "Good Friday", resolveDate: (year) => toIsoDate(addDays(computeEasterSunday(year), -2)) },
|
||||
{ name: "Independence Day", resolveDate: (year) => toIsoDate(dateUtc(year, 8, 15)) },
|
||||
{ name: "Gandhi Jayanti", resolveDate: (year) => toIsoDate(dateUtc(year, 10, 2)) },
|
||||
]);
|
||||
}
|
||||
|
||||
function buildUsCountryEntries(years: number[]): HolidayCalendarSeedEntry[] {
|
||||
return buildEntries(years, [
|
||||
{ name: "New Year's Day", resolveDate: (year) => observedUsFixedHoliday(year, 1, 1) },
|
||||
{ name: "Martin Luther King Jr. Day", resolveDate: (year) => nthWeekdayOfMonth(year, 1, 1, 3) },
|
||||
{ name: "Memorial Day", resolveDate: (year) => lastWeekdayOfMonth(year, 5, 1) },
|
||||
{ name: "Independence Day", resolveDate: (year) => observedUsFixedHoliday(year, 7, 4) },
|
||||
{ name: "Labor Day", resolveDate: (year) => nthWeekdayOfMonth(year, 9, 1, 1) },
|
||||
{ name: "Thanksgiving Day", resolveDate: (year) => nthWeekdayOfMonth(year, 11, 4, 4) },
|
||||
{ name: "Christmas Day", resolveDate: (year) => observedUsFixedHoliday(year, 12, 25) },
|
||||
]);
|
||||
}
|
||||
|
||||
function normalizeCountryCodes(countryCodes: string[]): Set<string> {
|
||||
return new Set(countryCodes.map((countryCode) => countryCode.trim().toUpperCase()));
|
||||
}
|
||||
|
||||
function normalizeCityLookup(availableCitiesByCountry: Record<string, string[]>): Map<string, Set<string>> {
|
||||
const lookup = new Map<string, Set<string>>();
|
||||
|
||||
for (const [countryCode, cityNames] of Object.entries(availableCitiesByCountry)) {
|
||||
lookup.set(countryCode.trim().toUpperCase(), new Set(cityNames));
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function hasCity(cityLookup: Map<string, Set<string>>, countryCode: string, cityName: string): boolean {
|
||||
return cityLookup.get(countryCode)?.has(cityName) ?? false;
|
||||
}
|
||||
|
||||
function germanStateDisplayName(stateCode: string): string {
|
||||
switch (stateCode) {
|
||||
case "BW":
|
||||
return "Baden-Wuerttemberg";
|
||||
case "BY":
|
||||
return "Bayern";
|
||||
case "HH":
|
||||
return "Hamburg";
|
||||
case "NW":
|
||||
return "Nordrhein-Westfalen";
|
||||
default:
|
||||
return stateCode;
|
||||
}
|
||||
}
|
||||
|
||||
function buildCityEntries(years: number[], definitions: SimpleHolidayDefinition[]): HolidayCalendarSeedEntry[] {
|
||||
return buildEntries(years, definitions);
|
||||
}
|
||||
|
||||
export function buildHolidayCalendarSeedDefinitions(
|
||||
context: SeedContext,
|
||||
): HolidayCalendarSeedDefinition[] {
|
||||
const availableCountries = normalizeCountryCodes(context.availableCountryCodes);
|
||||
const cityLookup = normalizeCityLookup(context.availableCitiesByCountry);
|
||||
const definitions: HolidayCalendarSeedDefinition[] = [];
|
||||
|
||||
if (availableCountries.has("DE")) {
|
||||
definitions.push({
|
||||
name: "Referenzfeiertage Deutschland 2026-2027",
|
||||
scopeType: "COUNTRY",
|
||||
countryCode: "DE",
|
||||
priority: COUNTRY_PRIORITY,
|
||||
entries: buildGermanCountryEntries(context.years),
|
||||
});
|
||||
|
||||
const germanStates = new Set(
|
||||
[...(context.activeGermanStates ?? []), "BY", "NW"]
|
||||
.map((stateCode) => stateCode.trim().toUpperCase())
|
||||
.filter((stateCode) => ["BW", "BY", "HH", "NW"].includes(stateCode)),
|
||||
);
|
||||
|
||||
for (const stateCode of [...germanStates].sort()) {
|
||||
const entries = buildGermanStateEntries(context.years, stateCode);
|
||||
if (entries.length === 0) {
|
||||
continue;
|
||||
}
|
||||
definitions.push({
|
||||
name: `Referenzfeiertage Deutschland - ${germanStateDisplayName(stateCode)} 2026-2027`,
|
||||
scopeType: "STATE",
|
||||
countryCode: "DE",
|
||||
stateCode,
|
||||
priority: STATE_PRIORITY,
|
||||
entries,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasCity(cityLookup, "DE", "Augsburg")) {
|
||||
definitions.push({
|
||||
name: "Referenzfeiertage Deutschland - Augsburg 2026-2027",
|
||||
scopeType: "CITY",
|
||||
countryCode: "DE",
|
||||
cityName: "Augsburg",
|
||||
priority: CITY_PRIORITY,
|
||||
entries: buildCityEntries(context.years, [
|
||||
{ name: "Augsburger Friedensfest", resolveDate: (year) => toIsoDate(dateUtc(year, 8, 8)) },
|
||||
]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (availableCountries.has("ES")) {
|
||||
definitions.push({
|
||||
name: "Referenzfeiertage Spanien 2026-2027",
|
||||
scopeType: "COUNTRY",
|
||||
countryCode: "ES",
|
||||
priority: COUNTRY_PRIORITY,
|
||||
entries: buildSpanishCountryEntries(context.years),
|
||||
});
|
||||
|
||||
if (hasCity(cityLookup, "ES", "Madrid")) {
|
||||
definitions.push({
|
||||
name: "Referenzfeiertage Spanien - Madrid 2026-2027",
|
||||
scopeType: "CITY",
|
||||
countryCode: "ES",
|
||||
cityName: "Madrid",
|
||||
priority: CITY_PRIORITY,
|
||||
entries: buildEntries(context.years, [
|
||||
{ name: "San Isidro", resolveDate: (year) => toIsoDate(dateUtc(year, 5, 15)) },
|
||||
{ name: "Nuestra Senora de la Almudena", resolveDate: (year) => toIsoDate(dateUtc(year, 11, 9)) },
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
if (hasCity(cityLookup, "ES", "Barcelona")) {
|
||||
definitions.push({
|
||||
name: "Referenzfeiertage Spanien - Barcelona 2026-2027",
|
||||
scopeType: "CITY",
|
||||
countryCode: "ES",
|
||||
cityName: "Barcelona",
|
||||
priority: CITY_PRIORITY,
|
||||
entries: buildEntries(context.years, [
|
||||
{ name: "La Merce", resolveDate: (year) => toIsoDate(dateUtc(year, 9, 24)) },
|
||||
{ name: "Santa Eulalia", resolveDate: (year) => toIsoDate(dateUtc(year, 2, 12)) },
|
||||
]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (availableCountries.has("IN")) {
|
||||
definitions.push({
|
||||
name: "Referenzfeiertage Indien 2026-2027",
|
||||
scopeType: "COUNTRY",
|
||||
countryCode: "IN",
|
||||
priority: COUNTRY_PRIORITY,
|
||||
entries: buildIndianCountryEntries(context.years),
|
||||
});
|
||||
}
|
||||
|
||||
if (availableCountries.has("US")) {
|
||||
definitions.push({
|
||||
name: "Referenzfeiertage USA 2026-2027",
|
||||
scopeType: "COUNTRY",
|
||||
countryCode: "US",
|
||||
priority: COUNTRY_PRIORITY,
|
||||
entries: buildUsCountryEntries(context.years),
|
||||
});
|
||||
}
|
||||
|
||||
return definitions;
|
||||
}
|
||||
Reference in New Issue
Block a user