2c46cf762d
Post-merge verification of the six merged branches turned up two real frontend defects and one stale test expectation. Settings mounts before WorkspaceContext has populated, so PartnerInvitePanel asked for invitations with an empty workspace id. The empty path segment collapses in Vite's proxy, FastAPI matches the single-segment workspace route with workspace_id="invitations", and the UUID parse 422s. Guarded the same way useWorkspaceMembers already does. PlanSyncProvider could sync a workspace id seeded from localStorage before WorkspaceContext refreshed it — a workspace a reseed had since deleted. It logged the resulting 404 and kept retrying it on every planVersion bump. A 404 now refreshes the workspace context instead, correcting the id rather than narrating the failure forever. The drag-create-delete expectation was simply out of date: vacation-typed saves correctly exclude free days, and targetRange() only skipped months ending on a Sunday, so a Monday-ending month put a Sunday in the range. It now skips any free day, computed independently of the calendar data the backend itself reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
141 lines
5.4 KiB
TypeScript
141 lines
5.4 KiB
TypeScript
/**
|
||
* 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
|
||
}
|