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:
Tim
2026-07-31 15:12:39 -05:00
committed by GitHub
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, [])
})