fix(holidays): move stored holidays onto corrected dates at launch

PR #283 fixed the date computation and made the Settings toggle repair a
calendar when switched off or back on. Someone who subscribed months ago and
never touches the toggle still saw Boxing Day on Christmas Day.

A shared useHolidayRepair hook now runs once per launch on both shells, gated
on eventsReady so it sees the real calendar rather than the empty list that
exists before the first listEvents() resolves.

Two properties make it safe to run unattended:

- It only MOVES an event whose observed date the calendar has since corrected,
  carrying the stored fields over so a reminder set on the holiday survives. It
  never re-adds a merely-absent holiday, because the user may have deleted that
  one deliberately and a launch-time pass would resurrect it every boot.
- Once the dates line up nothing is stray, so the plan comes back empty and
  nothing is written. That is what lets both shells and every linked device run
  it independently without fighting each other.

planHolidayRepair is pure so the decisions are testable without a UI. It keys
targets on the year the calendar was generated for rather than the year in the
date, since the two differ where an observed date crosses the year boundary.

Verified in the real mobile UI bundle, driven in a hidden Electron window
against a pre-fix Canada subscriber: Boxing Day moved 2026-12-25 to 2026-12-28
with its 60-minute reminder intact, Christmas Day untouched. Re-running against
the corrected data wrote nothing, and a calendar with Boxing Day deliberately
deleted stayed deleted.

Closes #150.

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:22:30 -05:00
co-authored by Claude Opus 5
parent 5da633d3c8
commit 9efc908622
6 changed files with 187 additions and 3 deletions
+4 -2
View File
@@ -9,7 +9,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
useProfile, useGroups, useEvents, useRsvps,
useProfile, useGroups, useEvents, useRsvps, useHolidayRepair,
emitter, Tour,
} from '../ui-shared/index.js'
import { Sidebar } from './components/Sidebar/index.jsx'
@@ -73,8 +73,10 @@ export default function App ({ db, notifs, sync }) {
document.body.setAttribute('data-theme', isDark ? 'dark' : 'light')
}, [isDark])
const [groups, setGroups] = useGroups(db)
const [events, setEvents] = useEvents(db)
const [events, setEvents, eventsReady] = useEvents(db)
const [myRsvps] = useRsvps(db)
// Move any holiday event still sitting at a date an older build got wrong.
useHolidayRepair(db, profile, events, setEvents, eventsReady)
const view = useViewState()
const visibleGroups = useVisibleGroups(groups)
const { saveEvent, deleteEvent } = useEventActions({
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useRef } from 'react'
import { planHolidayRepair } from '../lib/holidays.js'
// One-shot repair for holiday events left at a date an older build computed
// wrongly — Canada and the UK substituted weekend holidays backwards until
// 2026-07-31, which put Boxing Day on top of Christmas Day in 2026.
//
// The Settings toggle already repairs a calendar when you switch it off or back
// on, but someone who subscribed months ago and never touches it would keep the
// wrong dates forever. This closes that gap without asking them to do anything.
//
// Two properties make it safe to run at launch:
// - It only MOVES events whose date the calendar has since corrected. It never
// re-adds a missing holiday, because the user may have deleted that one on
// purpose and a launch-time pass would resurrect it on every boot.
// - Once the dates line up the plan is empty, so it writes nothing. That is
// what lets both shells and every linked device run it independently.
//
// Gated on `eventsReady` so it sees the real calendar rather than the empty list
// that exists before the first listEvents() resolves.
export function useHolidayRepair (db, profile, events, setEvents, eventsReady) {
const done = useRef(false)
useEffect(() => {
if (done.current || !db || !profile) return
if (eventsReady && !eventsReady.current) return
const codes = profile.holidayCountries ?? []
done.current = true
if (!codes.length) return
const thisYear = new Date().getFullYear()
const { deletes, puts } = planHolidayRepair(events, codes, [thisYear, thisYear + 1], Date.now())
if (!deletes.length && !puts.length) return
let cancelled = false
;(async () => {
for (const ev of deletes) {
try { await db.localDeleteEvent(ev.date, ev.id) } catch (e) { /* best effort */ }
}
for (const ev of puts) {
try { await db.putEvent(ev) } catch (e) { /* best effort */ }
}
if (cancelled) return
const gone = new Set(deletes.map(e => e.id))
setEvents(prev => {
const next = prev.filter(e => !gone.has(e.id))
for (const ev of puts) if (!next.some(e => e.id === ev.id)) next.push(ev)
return next
})
})()
return () => { cancelled = true }
}, [db, profile, events, setEvents, eventsReady])
}
+2
View File
@@ -33,12 +33,14 @@ export {
holidayEventId,
holidayCalendarIds,
strayHolidayEvents,
planHolidayRepair,
} from './lib/holidays.js'
export { useProfile } from './hooks/useProfile.js'
export { useRsvps } from './hooks/useRsvps.js'
export { useGroups } from './hooks/useGroups.js'
export { useEvents } from './hooks/useEvents.js'
export { useHolidayRepair } from './hooks/useHolidayRepair.js'
export { emitter } from './emitter.js'
+41
View File
@@ -215,3 +215,44 @@ export function strayHolidayEvents (events, fn, years, keepIds = new Set()) {
return !!m && years.includes(Number(m[1])) && slugs.has(m[2])
})
}
// Bring already-stored holiday events onto the dates the calendars now compute,
// for the countries in `activeCodes`. Pure: returns { deletes, puts } for the
// caller to apply.
//
// This only ever MOVES an event whose observed date was corrected. It does not
// re-add a holiday that is simply absent, because the user may have deleted that
// one deliberately and a launch-time pass would resurrect it every time. Once
// the dates line up nothing is stray, so the plan comes back empty and repeat
// runs cost nothing - which is what makes it safe on every device and shell.
export function planHolidayRepair (events, activeCodes, years, now = 0, countries = HOLIDAY_COUNTRIES) {
const active = countries.filter(c => (activeCodes ?? []).includes(c.code))
if (!active.length) return { deletes: [], puts: [] }
const keepIds = new Set()
for (const c of active) for (const id of holidayCalendarIds(c.fn, years)) keepIds.add(id)
const existing = new Set((events ?? []).map(e => e.id))
const deletes = []
const puts = []
const handled = new Set()
for (const c of active) {
// Keyed on the year the calendar was generated FOR, which is not always the
// year in the resulting date: US New Year's Day 2022 is observed 31 Dec 2021.
const target = new Map()
for (const y of years) for (const h of c.fn(y)) target.set(y + '|' + holidaySlug(h.title), h)
for (const ev of strayHolidayEvents(events, c.fn, years, keepIds)) {
if (handled.has(ev.id)) continue
const m = HOLIDAY_ID_RE.exec(ev.id)
const h = target.get(m[1] + '|' + m[2])
if (!h) continue // no date to move it to; leave it alone rather than guess
handled.add(ev.id)
deletes.push(ev)
const id = holidayEventId(h)
if (existing.has(id)) continue // corrected date already present; just drop the stray
existing.add(id)
// Carry the stored event's own fields over, so a reminder or colour the
// user set on the holiday survives the move.
puts.push({ ...ev, id, date: h.date, title: h.title, updatedAt: now })
}
}
return { deletes, puts }
}
+3 -1
View File
@@ -27,7 +27,7 @@ import {
formatTime, formatRelativeTime, todayStr, dateStr,
getUSFederalHolidays, getCanadaHolidays, getBitcoinHolidays, getUKHolidays, HOLIDAY_COUNTRIES,
holidayEventId, holidayCalendarIds, strayHolidayEvents,
useProfile, useRsvps, useGroups, useEvents,
useProfile, useRsvps, useGroups, useEvents, useHolidayRepair,
emitter, Tour,
} from '../ui-shared/index.js'
export { parseIcs, generateIcs, emitter } from '../ui-shared/index.js'
@@ -465,6 +465,8 @@ export default function App ({ db, notifs, sync }) {
const [groups, setGroups] = useGroups(db)
const [events, setEvents, eventsReady] = useEvents(db)
const [myRsvps, setMyRsvps] = useRsvps(db)
// Move any holiday event still sitting at a date an older build got wrong.
useHolidayRepair(db, profile, events, setEvents, eventsReady)
const [selectedDate, setSelectedDate] = useState(todayStr())
const [viewDate, setViewDate] = useState(() => {
const t = new Date(); return { y: t.getFullYear(), m: t.getMonth() }
+84
View File
@@ -116,3 +116,87 @@ test('strayHolidayEvents leaves user events and other calendars alone', async ()
const stray = strayHolidayEvents([mine, usKept, outOfWindow], getCanadaHolidays, years, keep)
assert.deepEqual(stray, [])
})
// ── launch-time repair of dates an older build got wrong (#150) ───────────
const YEARS_26 = [2026, 2027]
// What a pre-fix build stored for Canada: Boxing Day pulled back onto the 25th.
const WRONG_BOXING = { ...sysEvent('holiday-2026-12-25-boxing-day', '2026-12-25'), title: 'Boxing Day', reminder: 60 }
const RIGHT_XMAS = { ...sysEvent('holiday-2026-12-25-christmas-day', '2026-12-25'), title: 'Christmas Day' }
test('planHolidayRepair moves a holiday off the date an older build got wrong', async () => {
const { planHolidayRepair } = await load
const { deletes, puts } = planHolidayRepair([WRONG_BOXING, RIGHT_XMAS], ['ca'], YEARS_26, 123)
assert.deepEqual(deletes.map(e => e.id), ['holiday-2026-12-25-boxing-day'])
assert.equal(puts.length, 1)
assert.equal(puts[0].id, 'holiday-2026-12-28-boxing-day')
assert.equal(puts[0].date, '2026-12-28')
assert.equal(puts[0].title, 'Boxing Day')
assert.equal(puts[0].updatedAt, 123)
})
test('the moved event keeps the fields the user set on it', async () => {
const { planHolidayRepair } = await load
const { puts } = planHolidayRepair([WRONG_BOXING], ['ca'], YEARS_26, 0)
assert.equal(puts[0].reminder, 60)
})
test('re-running the repair is a no-op once the dates line up', async () => {
const { planHolidayRepair, getCanadaHolidays } = await load
const stored = [2026, 2027].flatMap(y => getCanadaHolidays(y).map(h => ({
id: 'holiday-' + h.date + '-' + h.title.replace(/\s+/g, '-').toLowerCase(),
date: h.date, title: h.title, creatorId: 'system',
})))
const plan = planHolidayRepair(stored, ['ca'], YEARS_26, 0)
assert.deepEqual(plan, { deletes: [], puts: [] })
})
test('the repair never resurrects a holiday the user deleted on purpose', async () => {
const { planHolidayRepair, getCanadaHolidays } = await load
// Everything correct except Canada Day, which the user removed by hand.
const stored = [2026, 2027].flatMap(y => getCanadaHolidays(y)
.filter(h => h.title !== 'Canada Day')
.map(h => ({
id: 'holiday-' + h.date + '-' + h.title.replace(/\s+/g, '-').toLowerCase(),
date: h.date, title: h.title, creatorId: 'system',
})))
const plan = planHolidayRepair(stored, ['ca'], YEARS_26, 0)
assert.deepEqual(plan, { deletes: [], puts: [] })
})
test('the repair drops the stray without duplicating an already-corrected date', async () => {
const { planHolidayRepair } = await load
const corrected = sysEvent('holiday-2026-12-28-boxing-day', '2026-12-28')
const { deletes, puts } = planHolidayRepair([WRONG_BOXING, corrected], ['ca'], YEARS_26, 0)
assert.deepEqual(deletes.map(e => e.id), ['holiday-2026-12-25-boxing-day'])
assert.deepEqual(puts, [])
})
test('a shared holiday is moved once when two calendars are both active', async () => {
const { planHolidayRepair } = await load
// Canada and the UK both call it Boxing Day and both now put it on the 28th.
const { deletes, puts } = planHolidayRepair([WRONG_BOXING], ['ca', 'uk'], YEARS_26, 0)
assert.equal(deletes.length, 1)
assert.equal(puts.length, 1)
})
test('the repair ignores calendars the user has not subscribed to', async () => {
const { planHolidayRepair } = await load
assert.deepEqual(planHolidayRepair([WRONG_BOXING], ['us'], YEARS_26, 0), { deletes: [], puts: [] })
assert.deepEqual(planHolidayRepair([WRONG_BOXING], [], YEARS_26, 0), { deletes: [], puts: [] })
})
test('the repair leaves user-created events alone', async () => {
const { planHolidayRepair } = await load
const mine = { id: 'evt-1', date: '2026-12-25', creatorId: 'me', title: 'Boxing Day' }
assert.deepEqual(planHolidayRepair([mine], ['ca'], YEARS_26, 0), { deletes: [], puts: [] })
})
test('the repair handles a US date corrected across the year boundary', async () => {
const { planHolidayRepair } = await load
// A pre-fix build wrote the impossible '2022-01-00'; the observed date is
// 31 Dec 2021, so the replacement is filed under the prior year.
const broken = { ...sysEvent("holiday-2022-01-00-new-year's-day", '2022-01-00'), title: "New Year's Day" }
const { deletes, puts } = planHolidayRepair([broken], ['us'], [2022, 2023], 0)
assert.deepEqual(deletes.map(e => e.id), ["holiday-2022-01-00-new-year's-day"])
assert.equal(puts[0].date, '2021-12-31')
})