/** * Date-math helpers shared by the E2E specs. The whole point of this module * is that no spec ever hardcodes a year: every date used in an assertion is * derived, at run time, from "today" or from the seeded child's birth date. * * All dates here are UTC-normalized `Date`s at midnight and ISO date * strings (`YYYY-MM-DD`) — the same representation the app's calendar * grid (`CalendarDay.tsx`) uses (`new Date(date + 'T00:00:00Z')`). */ export function toIsoDate(date: Date): string { return date.toISOString().slice(0, 10) } export function addDays(date: Date, days: number): Date { const result = new Date(date.getTime()) result.setUTCDate(result.getUTCDate() + days) return result } /** * Easter Sunday for `year`, via the Anonymous Gregorian (Meeus/Jones/ * Butcher) algorithm. Pure date math, no external calendar data — this is * intentionally independent of the backend's `swedish_calendar.json` cache, * so the test computes its own expectation rather than asking the system * under test what the system under test should show. */ export function easterSunday(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 monthPlusDayEncoded = h + l - 7 * m + 114 const month = Math.floor(monthPlusDayEncoded / 31) // 3 = March, 4 = April const day = (monthPlusDayEncoded % 31) + 1 return new Date(Date.UTC(year, month - 1, day)) } /** The first Easter Sunday on or after `from` (whole-day comparison). */ export function nextEasterOnOrAfter(from: Date): Date { let year = from.getUTCFullYear() let candidate = easterSunday(year) while (candidate.getTime() < from.getTime()) { year += 1 candidate = easterSunday(year) } return candidate } /** Whit Sunday (Pingstdagen) is always exactly 49 days after Easter Sunday. */ export function whitSunday(easter: Date): Date { return addDays(easter, 49) } /** Midsummer's Day (Midsommardagen) — the Saturday falling between June 20 * and June 26 inclusive, per Swedish law. */ export function midsummerDay(year: number): Date { for (let day = 20; day <= 26; day++) { const candidate = new Date(Date.UTC(year, 5, day)) // June = month 5 if (candidate.getUTCDay() === 6) return candidate } /* istanbul ignore next -- a Saturday always falls in a 7-day window */ throw new Error(`no Saturday found in June 20-26 ${year} — impossible`) } /** * True for a Saturday, a Sunday, or a Swedish public/"de facto" free day — * i.e. exactly the set of dates the planner treats as free (see * `is_free_day`/`_FREE_CATEGORIES` in the backend's `app/utils/holidays.py` * and `app/services/allocation_service.py`; `PlanningService. * _reject_illegal_free_day` rejects a `vacation`-typed pin on any of them). * Computed purely from date math, like `easterSunday` above — deliberately * independent of the backend's `swedish_calendar.json` cache, so a test * that uses this to pick "a range with no free days in it" isn't * validating itself against the very data the system under test reads. * Not meant to be exhaustive for every calendar nuance (klämdagar are * intentionally excluded — they're workable, not free) — only for the * free days a test needs to steer clear of. */ export function isSwedishFreeDay(date: Date): boolean { const day = date.getUTCDay() if (day === 0 || day === 6) return true // Sunday or Saturday const year = date.getUTCFullYear() const iso = toIsoDate(date) const fixed = new Set([ `${year}-01-01`, // Nyårsdagen `${year}-01-06`, // Trettondedag jul `${year}-05-01`, // Första maj `${year}-06-06`, // Sveriges nationaldag `${year}-12-24`, // Julafton (de facto) `${year}-12-25`, // Juldagen `${year}-12-26`, // Annandag jul `${year}-12-31`, // Nyårsafton (de facto) ]) if (fixed.has(iso)) return true const easter = easterSunday(year) const midsummer = midsummerDay(year) const moveable = [ addDays(easter, -2), // Långfredagen addDays(easter, -1), // Påskafton (de facto) easter, // Påskdagen addDays(easter, 1), // Annandag påsk addDays(easter, 39), // Kristi himmelsfärdsdag addDays(easter, 48), // Pingstafton (de facto) addDays(easter, 49), // Pingstdagen addDays(midsummer, -1), // Midsommarafton (de facto) — midsummer day itself is always a Saturday, already covered above ].map(toIsoDate) return moveable.includes(iso) } /** Monday of the ISO week containing `date` (Mon=0 .. Sun=6 offset). */ export function mondayOfWeek(date: Date): Date { const isoWeekday = (date.getUTCDay() + 6) % 7 // 0 = Monday return addDays(date, -isoWeekday) } /** * Number of Mon–Sun calendar-week rows that the inclusive range * [start, end] touches — i.e. how many `CalendarWeek` rows a band spanning * that range should render across in the app's Monday-start grid * (see `Calendar.tsx`'s `startOffset = (firstDay.getDay() + 6) % 7`). */ export function countWeekRows(start: Date, end: Date): number { const startWeek = mondayOfWeek(start) const endWeek = mondayOfWeek(end) const diffDays = Math.round((endWeek.getTime() - startWeek.getTime()) / 86_400_000) return diffDays / 7 + 1 }