Merge pull request #283 from peerloomllc/bugfix/holiday-observed-dates
fix(holidays): Canada and UK substitute forward, not back onto Christmas
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
// on sibling-device sync, never on local writes.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { HOLIDAY_COUNTRIES, holidayEventId } from '../../ui-shared/index.js'
|
||||
import { HOLIDAY_COUNTRIES, holidayEventId, holidayCalendarIds, strayHolidayEvents } from '../../ui-shared/index.js'
|
||||
import { REMINDER_OPTIONS } from '../lib/reminderOptions.js'
|
||||
|
||||
// Injected by electron/scripts/bundle-ui.sh from electron/package.json#version
|
||||
@@ -126,7 +126,26 @@ export function SettingsModal ({ tokens, profile, updateProfile, db, sync, event
|
||||
const colors = meta?.colors ?? []
|
||||
const desc = meta?.desc ?? 'Public Holiday'
|
||||
const thisYear = new Date().getFullYear()
|
||||
const years = [thisYear, thisYear + 1]
|
||||
const newActive = new Set(activeCountries)
|
||||
// IDs the calendars that stay on after this toggle still need.
|
||||
const otherIds = () => {
|
||||
const keep = new Set()
|
||||
for (const { code: otherCode, fn: otherFn } of HOLIDAY_COUNTRIES) {
|
||||
if (otherCode === code || !newActive.has(otherCode)) continue
|
||||
for (const id of holidayCalendarIds(otherFn, years)) keep.add(id)
|
||||
}
|
||||
return keep
|
||||
}
|
||||
// Remove this calendar's stored events that `keepIds` does not claim. Matched
|
||||
// by title slug rather than exact ID so events sitting at a date an older
|
||||
// build computed wrongly are still found.
|
||||
const keepAndSweep = async keepIds => {
|
||||
for (const ev of strayHolidayEvents(events, fn, years, keepIds)) {
|
||||
await db?.localDeleteEvent(ev.date, ev.id).catch(() => {})
|
||||
setEvents?.(prev => prev.filter(e => e.id !== ev.id))
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (on) {
|
||||
newActive.add(code)
|
||||
@@ -151,27 +170,13 @@ export function SettingsModal ({ tokens, profile, updateProfile, db, sync, event
|
||||
existingKeys.add(key)
|
||||
}
|
||||
}
|
||||
// Turning a calendar on also repairs it: drop anything it left behind at
|
||||
// a date an older build computed wrongly, keeping the dates it and the
|
||||
// other active calendars still want.
|
||||
await keepAndSweep(new Set([...otherIds(), ...holidayCalendarIds(fn, years)]))
|
||||
} else {
|
||||
newActive.delete(code)
|
||||
// Keep IDs still needed by other still-active countries
|
||||
const keepIds = new Set()
|
||||
for (const { code: otherCode, fn: otherFn } of HOLIDAY_COUNTRIES) {
|
||||
if (otherCode === code || !newActive.has(otherCode)) continue
|
||||
for (const yr of [thisYear, thisYear + 1]) {
|
||||
for (const h of otherFn(yr)) keepIds.add(holidayEventId(h))
|
||||
}
|
||||
}
|
||||
for (const yr of [thisYear, thisYear + 1]) {
|
||||
for (const h of fn(yr)) {
|
||||
const id = holidayEventId(h)
|
||||
if (keepIds.has(id)) continue
|
||||
const ev = (events ?? []).find(e => e.id === id)
|
||||
if (ev) {
|
||||
await db?.localDeleteEvent(ev.date, ev.id).catch(() => {})
|
||||
setEvents?.(prev => prev.filter(e => e.id !== id))
|
||||
}
|
||||
}
|
||||
}
|
||||
await keepAndSweep(otherIds())
|
||||
}
|
||||
await updateProfile({ holidayCountries: [...newActive] }).catch(() => {})
|
||||
} finally {
|
||||
|
||||
@@ -31,6 +31,8 @@ export {
|
||||
getUKHolidays,
|
||||
HOLIDAY_COUNTRIES,
|
||||
holidayEventId,
|
||||
holidayCalendarIds,
|
||||
strayHolidayEvents,
|
||||
} from './lib/holidays.js'
|
||||
|
||||
export { useProfile } from './hooks/useProfile.js'
|
||||
|
||||
@@ -6,6 +6,46 @@
|
||||
function pad (n) { return String(n).padStart(2, '0') }
|
||||
function ymd (y, m, d) { return `${y}-${pad(m)}-${pad(d)}` }
|
||||
|
||||
// Date math that goes through a real Date so month and year boundaries carry.
|
||||
// Building `ymd(y, m, d - 1)` by hand produced strings like '2022-01-00' when a
|
||||
// holiday on the 1st shifted backwards.
|
||||
function shift (y, m, d, delta) {
|
||||
const dt = new Date(y, m - 1, d + delta)
|
||||
return ymd(dt.getFullYear(), dt.getMonth() + 1, dt.getDate())
|
||||
}
|
||||
function parseYmd (s) {
|
||||
const [y, m, d] = s.split('-').map(Number)
|
||||
return new Date(y, m - 1, d)
|
||||
}
|
||||
function dayOfWeek (s) { return parseYmd(s).getDay() }
|
||||
function isWeekend (s) { const g = dayOfWeek(s); return g === 0 || g === 6 }
|
||||
function nextDay (s) {
|
||||
const dt = parseYmd(s)
|
||||
dt.setDate(dt.getDate() + 1)
|
||||
return ymd(dt.getFullYear(), dt.getMonth() + 1, dt.getDate())
|
||||
}
|
||||
|
||||
// UK "substitute day" rule, which Canadian federal practice also follows: a
|
||||
// holiday landing on a weekend moves FORWARD to the next weekday that no other
|
||||
// holiday already occupies. Never backwards - that is a US-only convention, and
|
||||
// applying it here is what put Boxing Day on 25 Dec 2026 (Sat 26 Dec → Fri 25).
|
||||
// Holidays already on a weekday never move, so they claim their dates first and
|
||||
// a substitute steps over them (Christmas Sun 25 Dec 2022 → Tue 27, because
|
||||
// Boxing Day keeps Mon 26).
|
||||
function applySubstitutes (list) {
|
||||
const out = list.map(h => ({ ...h }))
|
||||
const taken = new Set(out.filter(h => !isWeekend(h.date)).map(h => h.date))
|
||||
const moved = out.filter(h => isWeekend(h.date))
|
||||
.sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : 0)
|
||||
for (const h of moved) {
|
||||
let d = h.date
|
||||
do { d = nextDay(d) } while (isWeekend(d) || taken.has(d))
|
||||
taken.add(d)
|
||||
h.date = d
|
||||
}
|
||||
return out.sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : 0)
|
||||
}
|
||||
|
||||
// Computus (Anonymous Gregorian algorithm) → { month, day } of Easter Sunday.
|
||||
export function computeEaster (year) {
|
||||
const a = year % 19
|
||||
@@ -26,11 +66,12 @@ export function computeEaster (year) {
|
||||
}
|
||||
|
||||
export function getUSFederalHolidays (year) {
|
||||
// Observed date: Sat→Fri, Sun→Mon
|
||||
// US federal observed date: Sat→Fri, Sun→Mon. This backwards shift is a US
|
||||
// convention only; Canada and the UK move forwards instead.
|
||||
function observed (y, m, d) {
|
||||
const dow = new Date(y, m - 1, d).getDay()
|
||||
if (dow === 6) return ymd(y, m, d - 1)
|
||||
if (dow === 0) return ymd(y, m, d + 1)
|
||||
if (dow === 6) return shift(y, m, d, -1)
|
||||
if (dow === 0) return shift(y, m, d, 1)
|
||||
return ymd(y, m, d)
|
||||
}
|
||||
// Nth weekday of month: e.g. nthWeekday(year,1,1,3) = 3rd Monday of Jan
|
||||
@@ -64,12 +105,6 @@ export function getUSFederalHolidays (year) {
|
||||
}
|
||||
|
||||
export function getCanadaHolidays (year) {
|
||||
function observed (y, m, d) {
|
||||
const dow = new Date(y, m - 1, d).getDay()
|
||||
if (dow === 6) return ymd(y, m, d - 1)
|
||||
if (dow === 0) return ymd(y, m, d + 1)
|
||||
return ymd(y, m, d)
|
||||
}
|
||||
function nthWeekday (y, m, weekday, n) {
|
||||
const first = new Date(y, m - 1, 1).getDay()
|
||||
let d = 1 + (weekday - first + 7) % 7 + (n - 1) * 7
|
||||
@@ -85,18 +120,18 @@ export function getCanadaHolidays (year) {
|
||||
const dow = new Date(year, 4, 24).getDay()
|
||||
return ymd(year, 5, 24 - ((dow - 1 + 7) % 7))
|
||||
}
|
||||
return [
|
||||
{ title: "New Year's Day", date: observed(year, 1, 1) },
|
||||
return applySubstitutes([
|
||||
{ title: "New Year's Day", date: ymd(year, 1, 1) },
|
||||
{ title: 'Good Friday', date: easterOffset(-2) },
|
||||
{ title: 'Victoria Day', date: victoriaDay() },
|
||||
{ title: 'Canada Day', date: observed(year, 7, 1) },
|
||||
{ title: 'Canada Day', date: ymd(year, 7, 1) },
|
||||
{ title: 'Labour Day', date: nthWeekday(year, 9, 1, 1) },
|
||||
{ title: 'National Day for Truth and Reconciliation', date: observed(year, 9, 30) },
|
||||
{ title: 'National Day for Truth and Reconciliation', date: ymd(year, 9, 30) },
|
||||
{ title: 'Thanksgiving', date: nthWeekday(year, 10, 1, 2) },
|
||||
{ title: 'Remembrance Day', date: observed(year, 11, 11) },
|
||||
{ title: 'Christmas Day', date: observed(year, 12, 25) },
|
||||
{ title: 'Boxing Day', date: observed(year, 12, 26) },
|
||||
]
|
||||
{ title: 'Remembrance Day', date: ymd(year, 11, 11) },
|
||||
{ title: 'Christmas Day', date: ymd(year, 12, 25) },
|
||||
{ title: 'Boxing Day', date: ymd(year, 12, 26) },
|
||||
])
|
||||
}
|
||||
|
||||
export function getBitcoinHolidays (year) {
|
||||
@@ -109,12 +144,6 @@ export function getBitcoinHolidays (year) {
|
||||
}
|
||||
|
||||
export function getUKHolidays (year) {
|
||||
function observed (y, m, d) {
|
||||
const dow = new Date(y, m - 1, d).getDay()
|
||||
if (dow === 6) return ymd(y, m, d - 1)
|
||||
if (dow === 0) return ymd(y, m, d + 1)
|
||||
return ymd(y, m, d)
|
||||
}
|
||||
function nthWeekday (y, m, weekday, n) {
|
||||
const first = new Date(y, m - 1, 1).getDay()
|
||||
let d = 1 + (weekday - first + 7) % 7 + (n - 1) * 7
|
||||
@@ -131,16 +160,16 @@ export function getUKHolidays (year) {
|
||||
const d = new Date(easter); d.setDate(d.getDate() + days)
|
||||
return ymd(d.getFullYear(), d.getMonth() + 1, d.getDate())
|
||||
}
|
||||
return [
|
||||
{ title: "New Year's Day", date: observed(year, 1, 1) },
|
||||
return applySubstitutes([
|
||||
{ title: "New Year's Day", date: ymd(year, 1, 1) },
|
||||
{ title: 'Good Friday', date: easterOffset(-2) },
|
||||
{ title: 'Easter Monday', date: easterOffset(1) },
|
||||
{ title: 'Early May Bank Holiday', date: nthWeekday(year, 5, 1, 1)},
|
||||
{ title: 'Spring Bank Holiday', date: lastWeekday(year, 5, 1) },
|
||||
{ title: 'Summer Bank Holiday', date: lastWeekday(year, 8, 1) },
|
||||
{ title: 'Christmas Day', date: observed(year, 12, 25) },
|
||||
{ title: 'Boxing Day', date: observed(year, 12, 26) },
|
||||
]
|
||||
{ title: 'Christmas Day', date: ymd(year, 12, 25) },
|
||||
{ title: 'Boxing Day', date: ymd(year, 12, 26) },
|
||||
])
|
||||
}
|
||||
|
||||
// Subscribable holiday calendars. `color`/`desc` override the per-event
|
||||
@@ -157,6 +186,32 @@ export const HOLIDAY_COUNTRIES = [
|
||||
// Stable ID for a holiday calendar event — shared between platforms so the
|
||||
// same holiday never double-imports across devices.
|
||||
export function holidayEventId (h) {
|
||||
const slug = h.title.replace(/\s+/g, '-').toLowerCase()
|
||||
return 'holiday-' + h.date + '-' + slug
|
||||
return 'holiday-' + h.date + '-' + holidaySlug(h.title)
|
||||
}
|
||||
|
||||
function holidaySlug (title) { return title.replace(/\s+/g, '-').toLowerCase() }
|
||||
|
||||
const HOLIDAY_ID_RE = /^holiday-(\d{4})-\d{2}-\d{2}-(.+)$/
|
||||
|
||||
// Every event ID `fn`'s calendar produces across `years`.
|
||||
export function holidayCalendarIds (fn, years) {
|
||||
const ids = new Set()
|
||||
for (const y of years) for (const h of fn(y)) ids.add(holidayEventId(h))
|
||||
return ids
|
||||
}
|
||||
|
||||
// Stored holiday events belonging to `fn`'s calendar over `years` that `keepIds`
|
||||
// does not claim. Matched on the title slug and year, NOT on the whole ID: the
|
||||
// ID embeds the observed date, so correcting a date (Boxing Day off 25 Dec)
|
||||
// orphans the event already on disk and an exact-ID lookup can never find it
|
||||
// again. `keepIds` carries what other still-active calendars need, so a holiday
|
||||
// two countries share is never swept out from under the other one.
|
||||
export function strayHolidayEvents (events, fn, years, keepIds = new Set()) {
|
||||
const slugs = new Set()
|
||||
for (const y of years) for (const h of fn(y)) slugs.add(holidaySlug(h.title))
|
||||
return (events ?? []).filter(e => {
|
||||
if (e?.creatorId !== 'system' || keepIds.has(e.id)) return false
|
||||
const m = HOLIDAY_ID_RE.exec(e.id ?? '')
|
||||
return !!m && years.includes(Number(m[1])) && slugs.has(m[2])
|
||||
})
|
||||
}
|
||||
|
||||
+27
-22
@@ -26,6 +26,7 @@ import {
|
||||
expandRecurring, stepRecurrenceDate, fmtDate, parseDate,
|
||||
formatTime, formatRelativeTime, todayStr, dateStr,
|
||||
getUSFederalHolidays, getCanadaHolidays, getBitcoinHolidays, getUKHolidays, HOLIDAY_COUNTRIES,
|
||||
holidayEventId, holidayCalendarIds, strayHolidayEvents,
|
||||
useProfile, useRsvps, useGroups, useEvents,
|
||||
emitter, Tour,
|
||||
} from '../ui-shared/index.js'
|
||||
@@ -7224,8 +7225,8 @@ function ProfileTab ({ profile, groups, onUpdateProfile, db, events, setEvents,
|
||||
{/* Holidays */}
|
||||
{(() => {
|
||||
const thisYear = new Date().getFullYear()
|
||||
const slug = t => t.replace(/\s+/g, '-').toLowerCase()
|
||||
const makeId = h => 'holiday-' + h.date + '-' + slug(h.title)
|
||||
const years = [thisYear, thisYear + 1]
|
||||
const makeId = holidayEventId
|
||||
const allCountries = HOLIDAY_COUNTRIES
|
||||
// Toggle state tracked explicitly in profile to avoid shared-ID false positives
|
||||
const activeCountries = new Set(profile?.holidayCountries ?? [])
|
||||
@@ -7237,12 +7238,30 @@ function ProfileTab ({ profile, groups, onUpdateProfile, db, events, setEvents,
|
||||
const colors = meta?.colors ?? []
|
||||
const desc = meta?.desc ?? 'Public Holiday'
|
||||
const newActive = new Set(activeCountries)
|
||||
// IDs the calendars that stay on after this toggle still need.
|
||||
const otherIds = () => {
|
||||
const keep = new Set()
|
||||
for (const { code: otherCode, fn: otherFn } of allCountries) {
|
||||
if (otherCode === code || !newActive.has(otherCode)) continue
|
||||
for (const id of holidayCalendarIds(otherFn, years)) keep.add(id)
|
||||
}
|
||||
return keep
|
||||
}
|
||||
// Remove this calendar's stored events that `keepIds` does not claim.
|
||||
// Matched by title slug rather than exact ID so events sitting at a
|
||||
// date an older build computed wrongly are still found.
|
||||
const keepAndSweep = async keepIds => {
|
||||
for (const ev of strayHolidayEvents(events, fn, years, keepIds)) {
|
||||
await db?.localDeleteEvent(ev.date, ev.id).catch(() => {})
|
||||
setEvents(prev => prev.filter(e => e.id !== ev.id))
|
||||
}
|
||||
}
|
||||
if (on) {
|
||||
newActive.add(code)
|
||||
// Import holidays; skip any already in calendar by shared ID or same date+title
|
||||
const existingIds = new Set((events ?? []).map(e => e.id))
|
||||
const existingKeys = new Set((events ?? []).map(e => e.date + '|' + e.title))
|
||||
for (const yr of [thisYear, thisYear + 1]) {
|
||||
for (const yr of years) {
|
||||
for (const h of fn(yr)) {
|
||||
const id = makeId(h)
|
||||
const key = h.date + '|' + h.title
|
||||
@@ -7262,27 +7281,13 @@ function ProfileTab ({ profile, groups, onUpdateProfile, db, events, setEvents,
|
||||
existingKeys.add(key)
|
||||
}
|
||||
}
|
||||
// Turning a calendar on also repairs it: drop anything it left
|
||||
// behind at a date an older build computed wrongly, keeping the
|
||||
// dates it and the other active calendars still want.
|
||||
await keepAndSweep(new Set([...otherIds(), ...holidayCalendarIds(fn, years)]))
|
||||
} else {
|
||||
newActive.delete(code)
|
||||
// Keep IDs still needed by other still-active countries
|
||||
const keepIds = new Set()
|
||||
for (const { code: otherCode, fn: otherFn } of allCountries) {
|
||||
if (otherCode === code || !newActive.has(otherCode)) continue
|
||||
for (const yr of [thisYear, thisYear + 1]) {
|
||||
for (const h of otherFn(yr)) keepIds.add(makeId(h))
|
||||
}
|
||||
}
|
||||
for (const yr of [thisYear, thisYear + 1]) {
|
||||
for (const h of fn(yr)) {
|
||||
const id = makeId(h)
|
||||
if (keepIds.has(id)) continue
|
||||
const ev = (events ?? []).find(e => e.id === id)
|
||||
if (ev) {
|
||||
await db?.localDeleteEvent(ev.date, ev.id).catch(() => {})
|
||||
setEvents(prev => prev.filter(e => e.id !== id))
|
||||
}
|
||||
}
|
||||
}
|
||||
await keepAndSweep(otherIds())
|
||||
}
|
||||
await onUpdateProfile({ holidayCountries: [...newActive] }).catch(() => {})
|
||||
setHolidayWorking(false)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Canada / UK Boxing Day was landing on 25 Dec 2026, on top of Christmas Day.
|
||||
// Cause: the shared `observed()` applied the US Sat->Fri rule, so Sat 26 Dec
|
||||
// moved BACKWARDS onto the 25th. Canada and the UK substitute forwards instead.
|
||||
// Two neighbours came out of the same code: hand-built date strings underflowed
|
||||
// to '2022-01-00' when a holiday on the 1st shifted back, and nothing stopped
|
||||
// two holidays claiming one date.
|
||||
// (bugfix/holiday-observed-dates)
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const load = import('../src/ui-shared/lib/holidays.js')
|
||||
|
||||
const YEARS = [2021, 2022, 2023, 2024, 2025, 2026, 2027, 2028, 2029, 2030, 2031, 2032]
|
||||
const dateOf = (list, title) => list.find(h => h.title === title)?.date
|
||||
// 'YYYY-MM-DD' -> local Date, without the UTC shift `new Date(str)` applies.
|
||||
const parse = s => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d) }
|
||||
const isWeekend = s => [0, 6].includes(parse(s).getDay())
|
||||
|
||||
// ── the reported bug ──────────────────────────────────────────────────────
|
||||
test('Boxing Day 2026 substitutes forward to Mon 28 Dec, not back onto Christmas', async () => {
|
||||
const { getCanadaHolidays, getUKHolidays } = await load
|
||||
for (const fn of [getCanadaHolidays, getUKHolidays]) {
|
||||
const hs = fn(2026)
|
||||
assert.equal(dateOf(hs, 'Christmas Day'), '2026-12-25') // Fri, stays put
|
||||
assert.equal(dateOf(hs, 'Boxing Day'), '2026-12-28') // Sat 26 -> Mon 28
|
||||
}
|
||||
})
|
||||
|
||||
// ── the substitute rule in general ────────────────────────────────────────
|
||||
test('Canada and UK holidays never land on a weekend', async () => {
|
||||
const { getCanadaHolidays, getUKHolidays } = await load
|
||||
for (const year of YEARS) {
|
||||
for (const fn of [getCanadaHolidays, getUKHolidays]) {
|
||||
for (const h of fn(year)) {
|
||||
assert.ok(!isWeekend(h.date), `${h.title} ${year} fell on a weekend: ${h.date}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('no two holidays in one calendar share a date', async () => {
|
||||
const { getCanadaHolidays, getUKHolidays, getUSFederalHolidays } = await load
|
||||
for (const year of YEARS) {
|
||||
for (const fn of [getCanadaHolidays, getUKHolidays, getUSFederalHolidays]) {
|
||||
const dates = fn(year).map(h => h.date)
|
||||
assert.equal(new Set(dates).size, dates.length, `duplicate date in ${year}: ${dates}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('a weekday holiday keeps its date and the weekend one steps over it', async () => {
|
||||
const { getUKHolidays } = await load
|
||||
// gov.uk 2022: Boxing Day Mon 26 Dec, Christmas Day substitute Tue 27 Dec.
|
||||
// Christmas is listed first, so this only holds if weekday dates are reserved
|
||||
// before any substitute is placed.
|
||||
const hs = getUKHolidays(2022)
|
||||
assert.equal(dateOf(hs, 'Boxing Day'), '2022-12-26')
|
||||
assert.equal(dateOf(hs, 'Christmas Day'), '2022-12-27')
|
||||
})
|
||||
|
||||
test('substitutes match the published Canadian federal dates', async () => {
|
||||
const { getCanadaHolidays } = await load
|
||||
const y2023 = getCanadaHolidays(2023)
|
||||
assert.equal(dateOf(y2023, 'Canada Day'), '2023-07-03') // Sat 1 -> Mon 3
|
||||
assert.equal(dateOf(y2023, 'Remembrance Day'), '2023-11-13') // Sat 11 -> Mon 13
|
||||
const y2027 = getCanadaHolidays(2027)
|
||||
assert.equal(dateOf(y2027, 'Christmas Day'), '2027-12-27') // Sat 25 -> Mon 27
|
||||
assert.equal(dateOf(y2027, 'Boxing Day'), '2027-12-28') // Sun 26 -> Tue 28
|
||||
})
|
||||
|
||||
// ── date arithmetic ───────────────────────────────────────────────────────
|
||||
test('every holiday date is a real calendar date', async () => {
|
||||
const { getCanadaHolidays, getUKHolidays, getUSFederalHolidays, getBitcoinHolidays } = await load
|
||||
for (const year of YEARS) {
|
||||
for (const fn of [getCanadaHolidays, getUKHolidays, getUSFederalHolidays, getBitcoinHolidays]) {
|
||||
for (const h of fn(year)) {
|
||||
assert.match(h.date, /^\d{4}-\d{2}-\d{2}$/, `${h.title} ${year}: ${h.date}`)
|
||||
// Round-tripping catches '2022-01-00', which matches the shape above.
|
||||
const d = parse(h.date)
|
||||
const back = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
assert.equal(back, h.date, `${h.title} ${year} is not a real date`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('US federal keeps its own backwards Sat->Fri rule, across the year boundary', async () => {
|
||||
const { getUSFederalHolidays } = await load
|
||||
// Sat 1 Jan 2022 was observed Fri 31 Dec 2021, per OPM.
|
||||
assert.equal(dateOf(getUSFederalHolidays(2022), "New Year's Day"), '2021-12-31')
|
||||
assert.equal(dateOf(getUSFederalHolidays(2028), "New Year's Day"), '2027-12-31')
|
||||
// Sun 25 Dec 2022 -> Mon 26 Dec.
|
||||
assert.equal(dateOf(getUSFederalHolidays(2022), 'Christmas Day'), '2022-12-26')
|
||||
})
|
||||
|
||||
// ── cleaning up events stranded by a corrected date ───────────────────────
|
||||
const sysEvent = (id, date) => ({ id, date, creatorId: 'system', title: 'x' })
|
||||
|
||||
test('strayHolidayEvents finds an event left at the old wrong date', async () => {
|
||||
const { strayHolidayEvents, getCanadaHolidays, holidayCalendarIds } = await load
|
||||
const years = [2026, 2027]
|
||||
const stranded = sysEvent('holiday-2026-12-25-boxing-day', '2026-12-25')
|
||||
const correct = sysEvent('holiday-2026-12-28-boxing-day', '2026-12-28')
|
||||
const keep = holidayCalendarIds(getCanadaHolidays, years)
|
||||
const stray = strayHolidayEvents([stranded, correct], getCanadaHolidays, years, keep)
|
||||
assert.deepEqual(stray.map(e => e.id), [stranded.id])
|
||||
})
|
||||
|
||||
test('strayHolidayEvents leaves user events and other calendars alone', async () => {
|
||||
const { strayHolidayEvents, getCanadaHolidays, getUSFederalHolidays, holidayCalendarIds } = await load
|
||||
const years = [2026, 2027]
|
||||
const mine = { id: 'evt-1', date: '2026-12-25', creatorId: 'me', title: 'Boxing Day' }
|
||||
const usKept = sysEvent('holiday-2026-12-25-christmas-day', '2026-12-25')
|
||||
const outOfWindow = sysEvent('holiday-2024-12-25-boxing-day', '2024-12-25')
|
||||
const keep = holidayCalendarIds(getUSFederalHolidays, years)
|
||||
const stray = strayHolidayEvents([mine, usKept, outOfWindow], getCanadaHolidays, years, keep)
|
||||
assert.deepEqual(stray, [])
|
||||
})
|
||||
Reference in New Issue
Block a user