Skip to content

fix(calendar): render all-day events on the date the calendar published - #159

Closed
mrramam wants to merge 1 commit into
jherforth:mainfrom
mrramam:fix/all-day-event-dates
Closed

fix(calendar): render all-day events on the date the calendar published#159
mrramam wants to merge 1 commit into
jherforth:mainfrom
mrramam:fix/all-day-event-dates

Conversation

@mrramam

@mrramam mrramam commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

All-day calendar events render one day early for any viewer west of UTC. On a display in America/Los_Angeles, a Friday "No School" shows on Thursday, and a Thursday bin-day event shows on Wednesday.

Why

An all-day event is a floating date. Google sends it with no timezone:

"start": { "date": "2026-09-11" }

server/services/calendarSync.js:241 parses that with new Date(start.date) — and a bare YYYY-MM-DD is parsed as UTC midnight — then :97 stores it via toISOString(). There is no all_day branch on the insert, so the Google, ICS and Apple CalDAV paths all funnel through it. The API then serves:

"start": "2026-09-11T00:00:00.000Z"

which asserts a timezone the source date never had.

Given that, the client is behaving correctly. eventSpansDay does moment(event.start), converting the asserted instant to local time — 2026-09-10 17:00 in PDT — which satisfies the Thursday bucket and fails the Friday one:

Thursday Sep 10:  start 17:00 <= end-of-Thu  OK,  end 17:00 >= start-of-Thu  OK  -> shown
Friday   Sep 11:  start      <= end-of-Fri  OK,  end Sep-10 17:00 >= start-of-Fri  NO -> hidden

Nothing mishandles UTC. Something claims UTC for a value that had no zone. East of UTC the same storage lands on the correct calendar day, which is why this is an Americas-only symptom.

What this changes

all_day is the only surviving record of the original intent, so this uses it: take the UTC date part — the real datum — and reinterpret it as a local date.

It is applied at CalendarWidget's single event-normalisation point in fetchCalendarEvents, so every downstream comparison, sort and format follows without touching the other ~15 moment(event.start) call sites. Timed events are untouched: they carry a genuine instant and must keep converting normally.

No server change, no data migration, no resync — existing cached rows are read differently, not rewritten.

This is a workaround, and I would rather say so

The underlying problem is that there is no timeless-day representation. calendar_events_cache.start_time is TEXT and would hold "2026-09-11" quite happily, and all_day INTEGER sits right beside it — but every writer goes through toISOString() and the reader rehydrates through new Date(), so the flag is carried end to end and never consulted when interpreting the date.

That means every other consumer of /api/calendar-events inherits the same trap with no signal that the Z is fictional. This PR fixes the one consumer in this repo.

A full fix branches on all_day at write and read across all three provider paths and needs a migration for cached rows. I kept that separate rather than folding it in, so a small and obviously-correct change does not turn into a risky one — happy to follow up with it if you would like it.

Verified

New calendarAllDay.test.js, 7 cases, run in four timezones — America/Los_Angeles, America/New_York, UTC, Australia/Sydney — since timezone is the whole failure mode. Full client suite passes (167), i18n parity unchanged, build clean.

Replayed against a live production cache of 1,462 events in America/Los_Angeles:

  • the Friday "No School" event moves from the Thursday bucket to Friday
  • all 124 all-day events shift forward exactly one day
  • all 1,338 timed events are byte-identical before and after

Running in production on a family install.

All-day events show one day early for any viewer west of UTC: a Friday
"NO SCHOOL" appears on Thursday in America/Los_Angeles.

An all-day event is a floating date. Google sends {"date": "2026-09-11"}
with no timezone. calendarSync parses that with new Date(), which reads a
date-only string as UTC midnight, and stores it via toISOString(), so the
API serves "2026-09-11T00:00:00.000Z" — asserting a timezone the source
date never had.

The client is then correct given what it is told: moment(event.start)
converts the asserted instant to local time, landing on 2026-09-10 17:00
in PDT, which satisfies the Thursday bucket and fails the Friday one.
Nothing mishandles UTC; something claims UTC for a value that had no zone.

The all_day flag is the only surviving record of the original intent, so
use it. eventDates() takes the UTC date part — the real datum — and
reinterprets it as a local date, applied at the widget's single event
normalisation point so every downstream comparison, sort and format
follows. Correct in any viewer timezone, no data migration.

This is a read-side correction. Storage still asserts a zone the source
date did not have, and every other consumer of /api/calendar-events
inherits the same trap; that is tracked separately.
@Szeraax

Szeraax commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

I thought that we are using the node-ical dateOnly property when we parse ical events as described in their project page. Can you tell me more about why none of that is mentioned in this PR and if there is a more targeted fix for this not getting used instead of a workaround extra property?

We definitely want to ensure that you can have events that start at midnight without being coerced into all-day activities, glad to see that's not being offered at all as a solution.

@mrramam

mrramam commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

<Updated: line numbers are wrong, Not correcting the>
You're right — dateOnly is used and works though the Google path does it differently. Having looked more carefully at the other paths I see that my patch would have actually introduced a bug for those using the other paths!

Current tip of main behavior:

Provider all-day detected start constructed result
Google googleCalendar.js:31 calendarSync.js:241new Date("2026-09-11") UTC midnight
ICS calendarSync.js:149, :165 :154, :170 — node-ical's Date local midnight
CalDAV calendarSync.js:200 :206toJSDate() local midnight
Apple appleCalDAV.js:282 :283toJSDate() local midnight

So a minimal fix is to make calendarSync.js:241 build local midnight for the allDay branch. parseDateOnlyToLocalDate at index.js:227 already has those semantics. This makes the paths all consistent, they would all store a date-time based on the calculation of 00:00 of the date in the server's timezone.

Making them consistent ties correctness to the server's time zone such that a viewer west of it still sees the previous day. I read HG from a laptop and travel, so that one's real for me.

The fundamental issue is that the date for an all-day event is most correctly stored as a date sans time while today ICS invents "midnight where the server is" while Google invents "midnight in UTC".

A remedy for travelers is a viewer-side follow-up: interpret an all-day instant in the server's time zone rather than the browser's ie getServerTimezoneSync() already exists and is cached.

So while I don't recommend accepting this PR, I do think there is a bug to be fixed here. It can be made TZ friendly by agreeing to use server local as the definitive time and teaching the clients to work with that or by changing the storage to accept dates without times.

@Szeraax

Szeraax commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

So if it fair to state that the issue is that when server and client timezone are different, all day events aren't working correctly? Does that apply only to google or to all 4 event sources? I just changed the TZ of the demo instance to NY and my pacific timezone (-7) is still working right for events and all day items (using the ICS/ical provider).

I'd rather make it so that the system displays things in the client local timezone and so that it handles all day events just fine instead of doing something like forcing everything to be in server timezone.

@mrramam

mrramam commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Not really, there are two different failure modes...

Google is broken even when server and client agree. It stores 00:00Z regardless of the server's TZ, so any viewer west of UTC sees the previous day. My instance has server and client both on Pacific and a Friday all-day event still lands on Thursday.

The other three are the mismatch case. They store midnight in the server's zone, so they are right when the viewer shares that offset and wrong when the viewer is behind it.

The demo has the bug, look at Thanksgiving this year which is Nov 26, 2026 - it shows as Wednesday the 25th.

I'd rather make it so that the system displays things in the client local timezone and so that it handles all day events just fine instead of doing something like forcing everything to be in server timezone.

Agreed, and I think that means not storing an all-day date as an instant at all. all_day already flags the row; what's missing is somewhere to keep the date. Store "2026-09-11", render it with no conversion, and timed events keep converting to the viewer's zone exactly as they do now. Nothing is coupled to the server's TZ, and DST can't shift anything.

Two notes on doing it that way:

  • No migration needed. calendar_events_cache is derived - sync does DELETE ... WHERE source_id = ? and reinserts (calendarSync.js), and widget-created events are written through to the provider rather than stored locally. Change the four write paths and the next sync rebuilds the cache.
  • One thing to decide on the fuller fix approach: if the date goes in start_time beside the instants, the range filter in getCachedEvents breaks - "2026-09-11" sorts below "2026-09-11T00:00:00.000Z", so an all-day event on the first day of the window drops out. Either that query learns both shapes, or the date gets its own column and the instant columns stay homogeneous.

The parseDateOnlyToLocalDate fix I suggested earlier would make all four consistent, but it keeps exactly the server-TZ coupling you don't want - it just moves everyone onto the ICS behaviour. If you'd rather go to the date-storage model, this PR should close and I'm happy to take a run at that instead.

@jherforth jherforth added the bug Something isn't working label Sep 8, 2026
@jherforth jherforth added this to the 1.8 milestone Sep 8, 2026
@Szeraax

Szeraax commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

When I look at Thanksgiving on my pacific time computer, I see it on thursday like you'd expect, so I'm not seeing the same that you are. Hmmmm

@mrramam

mrramam commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

When I look at Thanksgiving on my pacific time computer, I see it on thursday like you'd expect, so I'm not seeing the same that you are. Hmmmm

Now that is surprising. I'm attaching two screenshots just taken: demo looking at Thanksgiving 2026 and my device time and timezone, here is the terminal version:

~ % date
Tue Sep  8 11:14:03 PDT 2026

image Screenshot 2026-09-08 at 11 10 17

@mrramam

mrramam commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed Safari and Firefox show the same as Chrome (screenshot above is Chrome)

@mrramam

mrramam commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

When I look at Thanksgiving on my pacific time computer, I see it on thursday like you'd expect, so I'm not seeing the same that you are. Hmmmm

Is your device normally Pacific Time or did you change it for the test?

@Szeraax

Szeraax commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

WHOA. My UTC-7 is actually from Arizona time. If I swap to PDT directly, I see it on wed too.

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

Labels

bug Something isn't working

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

3 participants