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:
co-authored by
Claude Opus 5
parent
5da633d3c8
commit
9efc908622
@@ -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({
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
@@ -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() }
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user