fix(holidays): Canada and UK substitute forward, not back onto Christmas

Boxing Day was landing on 25 Dec 2026, on top of Christmas Day. The shared
observed() helper applied the US federal Sat->Fri rule to every country, so
Sat 26 Dec moved backwards. Canada and the UK substitute forwards to the next
free weekday instead.

Two neighbouring bugs came out of the same helper:

- Hand-built date strings underflowed. A holiday on the 1st shifting back
  produced '2022-01-00', an impossible date, for US/CA/UK New Year's Day in
  2022 and 2028 and Canada Day in 2023 and 2028. Date math now goes through a
  real Date so month and year boundaries carry.
- Nothing stopped two holidays claiming one date. Christmas and Boxing Day
  collided in 2022 and 2026 for both CA and UK. Substitution now steps over
  dates already taken, matching the published pairings (gov.uk 2022: Boxing
  Day Mon 26 Dec, Christmas Day substitute Tue 27 Dec).

US federal keeps its own backwards rule, and now crosses the year boundary
correctly: 1 Jan 2022 observed Fri 31 Dec 2021, per OPM.

Correcting a date orphans the event already on disk, since the event ID embeds
the observed date and the toggle-off path looked events up by exact ID. Both
toggles now sweep by title slug and year instead, so turning a calendar off,
or back on, clears what an older build left at the wrong date.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmow72MHKMVCQCvybwhMJm
This commit is contained in:
Your Name
2026-07-31 15:04:39 -05:00
co-authored by Claude Opus 5
parent 4736f049db
commit 7bc3b80b70
5 changed files with 257 additions and 72 deletions
+25 -20
View File
@@ -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 {
+2
View File
@@ -31,6 +31,8 @@ export {
getUKHolidays,
HOLIDAY_COUNTRIES,
holidayEventId,
holidayCalendarIds,
strayHolidayEvents,
} from './lib/holidays.js'
export { useProfile } from './hooks/useProfile.js'
+85 -30
View File
@@ -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
View File
@@ -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)
+118
View File
@@ -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, [])
})