All-day events are stored and pushed one day early for users east of UTC
Prerequisites
- I searched open issues and discussions and did not find an existing report of this bug.
- This is not a security vulnerability.
- I am running the latest code from the
devbranch and the bug still reproduces there.
Odysseus Revision
Install Method
Docker (docker compose up)
Operating System
Linux
Steps to Reproduce
- Set the user timezone to any offset east of UTC (UTC+2 here).
- Create an all-day event through the agent tool or the REST route, e.g.
manage_calendarwith{"action": "create_event", "summary": "Reveal", "dtstart": "2026-10-01", "all_day": true}. - Read the row back from
calendar_events, or look at the event in a CalDAV-backed calendar (Google).
Expected Behaviour
The event is stored on the date that was named: dtstart = 2026-10-01 00:00,
dtend = 2026-10-02 00:00, is_utc = 0, and CalDAV receives
DTSTART;VALUE=DATE:20261001. This is what all-day events synced down from
Google already look like.
Actual Behaviour
The event is stored shifted by the user's UTC offset but flagged as local
time: dtstart = 2026-09-30 22:00, is_utc = 0. It is pushed to CalDAV as
DTSTART;VALUE=DATE:20260930 and Google Calendar shows it on 30 September.
_parse_dt_pair() converts tz-aware input to naive UTC and reports that via
its second return value. Both create paths discard the flag for all-day
events while keeping the converted timestamp:
is_utc=_is_utc and not data.all_day, # routes/calendar_routes.py:1248
is_utc=dtstart_is_utc and not all_day, # src/tools/calendar.py:436
All-day events are date-only in RFC 5545 — they name a day, not an instant, so the UTC conversion must not happen for them at all.
The agent path makes this deterministic rather than incidental:
_parse_event_dt() routes every value through parse_due_for_user(), which
attaches the user's offset to naive input, so "2026-10-01" reaches
_parse_dt_pair() as "2026-10-01T00:00:00+02:00".
Both update paths have the same flaw plus an ordering problem: dtstart is
parsed before the effective all_day value is known
(routes/calendar_routes.py:1284 vs :1295, and src/tools/calendar.py:504
vs :516).
Minimal check without any app state:
from routes.calendar_routes import _parse_dt_pair
assert _parse_dt_pair("2026-10-01T00:00:00+02:00")[0].day == 1 # fails: 30
Logs / Screenshots
Two all-day events on the same date in the same Google calendar — one synced down, one created by the agent:
'Halde' 2026-10-01 00:00 -> 2026-10-05 00:00 is_utc=0 (from Google)
'Tesla Roadster Reveal' 2026-09-30 22:00 -> 2026-10-01 22:00 is_utc=0 (created here)
Model / Backend (if relevant)
Ollama + qwen3:14b — but the bug is in the calendar layer and reproduces through the REST route with no model involved.
Are you willing to submit a fix?
Yes — I can open a PR
Additional Information
A second, smaller symptom in the same code path: models routinely emit
dtstart == dtend for all-day events, which produces a zero-duration row.
The list_events overlap filter (dtstart < end AND dtend > start) drops
those silently, so the event never appears even though creation reported
success. _ensure_positive_duration() already handles exactly this case but
is only called on the ICS import path — both write paths should reuse it.
Source: odysseus-dev/odysseus