Skip to content

Fix all-day events showing a day early, and expand recurring events - #152

Closed
cosmosified wants to merge 2 commits into
jherforth:mainfrom
cosmosified:fix/calendar-all-day-dates-and-recurrence
Closed

cosmosified wants to merge 2 commits into
jherforth:mainfrom
cosmosified:fix/calendar-all-day-dates-and-recurrence

Conversation

@cosmosified

Copy link
Copy Markdown
Contributor

The problem

An all-day event is a date, not an instant. Both sync paths resolved date-only
values against the server's local midnight and cached the resulting instant,
which baked the backend's timezone into the row. A backend on America/New_York
therefore handed a display on America/Chicago an all-day marker that rendered
at 11:00 PM on the previous day:

NO PRACTICE   cached: 2026-09-05T04:00:00Z   all_day: false
              shown:  Fri Sep 4, 11:00 PM

Two things compounded it:

  • School-sports feeds (SportsEngine and friends) write their day markers as a
    timed midnight-to-midnight span rather than VALUE=DATE, so all_day was
    false and the widget drew a clock time instead of an all-day banner.
  • icsToEvents read DTSTART off each VEVENT without expanding RRULE, so a
    recurring series appeared once, at the date it began — a yearly birthday
    stranded in 2022, a weekly practice only in the week it started, and a shared
    iCloud calendar syncing 17 events across two years.

The change

  • All-day events are anchored to UTC midnight of the calendar date the source
    named
    , on every sync path (server/utils/calendarDates.js). Google's sync
    already did this; Apple and ICS now match. The widget rebuilds them as local
    midnight from that date, so they hold their day on any display.
  • A midnight-to-midnight span is recognised as all-day. An overnight 00:00
    to 00:00 shift is indistinguishable from a one-day all-day event in
    iCalendar, and is treated as all-day.
  • Recurring series are expanded over the same ±13-month window the rest of the
    sync uses, honouring EXDATE and applying RECURRENCE-ID overrides to their
    own occurrence only, with an iteration cap so an open-ended daily RRULE
    cannot spin.
  • The ICS reader moved to server/utils/icsEvents.js: an iCloud calendar, a
    subscription's upstream feed and a generic CalDAV URL all need the same one,
    and it was only in appleCalDAV.js because that is where it was written.
    appleCalDAV re-exports icsToEvents so callers keep a single import.
  • The generic CalDAV source type now uses that reader instead of its own
    hand-rolled parse, so it loses the same two bugs, and reports a non-200 or a
    non-iCalendar body (a login page, or the XML a collection URL returns when it
    wanted a REPORT) instead of surfacing an ical.js parse error.

Also fixes, incidentally: the event edit dialog showed the previous day for
Google all-day events, and cross-calendar dedup can now match an all-day event
between a Google and an Apple calendar, since both anchor it identically.

Testing

Server 234/234, client 165/165, npm run build clean.

The key test runs the same ICS payload in three subprocesses under
America/New_York, Asia/Tokyo and UTC and requires byte-identical output —
which the old code could never do. Alongside it: date-only and
midnight-to-midnight anchoring, multi-day spans, EXDATE, RECURRENCE-ID
overrides (including an orphan whose master is absent), window bounding of an
open-ended series, and a malformed VEVENT not sinking the rest of the payload.

The CalDAV path is covered end-to-end against a local ICS host: the basic-auth
header is asserted from what the server actually received, a series is expanded,
an all-day date is anchored, and both failure shapes report clearly.

Existing appleCalDAV fixtures were pinned to March 2026; they are now relative
to today, since the ±13-month window would otherwise age them out and start
failing the suite.

Notes

  • Verified against a live install whose backend runs America/New_York with a
    Central display — the reported symptom. Expect noticeably more (correct)
    events after the first sync, since recurring series were previously collapsed
    to one occurrence.
  • Setting TZ correctly on the backend is still worth doing: all-day events no
    longer care, but a feed that sends a timed value with no timezone at all is
    still resolved against the server's clock.
  • The generic CalDAV path remains an authenticated GET of an ICS document, not
    a real REPORT. That works for export URLs (Nextcloud, Baikal) but not for a
    collection URL that requires a REPORT — now a clear error rather than a
    parse failure.

Chris Morton added 2 commits September 3, 2026 11:41
…ents

An all-day event is a date, not an instant. Both the Apple and ICS sync paths
resolved date-only values against the *server's* local midnight and cached the
resulting instant, which baked the backend's timezone into the row. A backend on
America/New_York therefore handed a display on America/Chicago an all-day marker
that rendered at 11:00 PM on the previous day: "NO PRACTICE" on Sep 5 showed up
under Friday Sep 4, on nearly every day of a school-sports feed.

Two things made it worse. Team feeds (SportsEngine and friends) write their day
markers as a timed midnight-to-midnight span rather than VALUE=DATE, so all_day
was false and the widget drew a clock time instead of an all-day banner. And
icsToEvents read DTSTART off each VEVENT without expanding RRULE, so a recurring
series appeared once, at the date it began — a yearly birthday stranded in 2022,
a weekly practice only in the week it started, and a shared iCloud calendar
syncing 17 events across two years.

All-day events are now anchored to UTC midnight of the calendar date the source
named, on every sync path (Google already did this), and the widget rebuilds
them as local midnight from that date, so they hold their day on any display.
Midnight-to-midnight spans are recognised as all-day. Recurring series are
expanded over the same +/-13-month window the rest of the sync uses, honouring
EXDATE and applying RECURRENCE-ID overrides to their own occurrence only.

Verified: server and client suites pass, and a subprocess test runs the same ICS
payload under America/New_York, Asia/Tokyo, and UTC and requires byte-identical
output.
The CalDAV source type (a Nextcloud/Baikal/Radicale export URL, or any private
ICS behind basic auth) hand-rolled its own ical.js parse, so it carried both of
the bugs the Apple path just lost: a recurring event was cached once, at the date
its series began, and an all-day event was anchored to the server's local
midnight, which lands it on the previous day on a display in another timezone.

Moved the reader to utils/icsEvents.js — an iCloud calendar, a subscription's
upstream feed and a plain authenticated ICS URL all want the same one, and it
was only living in appleCalDAV.js because that is where it was written.
appleCalDAV re-exports it so existing callers and tests keep one import.

Also stop guessing at the response: a non-200, or a 200 whose body is a login
page or the XML a collection URL returns when it wanted a REPORT, now reports
what happened instead of surfacing an ical.js parse error.

Tests cover the path end to end against a local ICS host: the basic-auth header
is built from the decrypted password, a series is expanded, an all-day date is
anchored to UTC midnight, and both failure shapes report clearly.
@cosmosified

Copy link
Copy Markdown
Contributor Author

Closing — opened against upstream by mistake; this belongs on my fork for now. Will re-open here if it's wanted upstream.

@cosmosified cosmosified closed this Sep 3, 2026
@github-project-automation github-project-automation Bot moved this from Backlog to Done in HomeGlow Kanban Sep 3, 2026
@mrramam

mrramam commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

@cosmosified did you implement "store the date in the DB" and handle the work around it? Lengthy discussion here if you are interested #159 Sounds like maybe you did much of the work for this - if not or you don't like the path you took and don't want to ... lmk and I'll pick it up correctly which I think means store the date in the DB without a time or timezone.

@cosmosified

cosmosified commented Sep 8, 2026 via email

Copy link
Copy Markdown
Contributor Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

2 participants