#6290·odysseus

All-day events are stored and pushed one day early for users east of UTC

Author: JulianFriedleCreated Sep 14, 2026Updated Sep 14, 2026
Labelsready for review

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 dev branch and the bug still reproduces there.

Odysseus Revision

Install Method

Docker (docker compose up)

Operating System

Linux

Steps to Reproduce

  1. Set the user timezone to any offset east of UTC (UTC+2 here).
  2. Create an all-day event through the agent tool or the REST route, e.g. manage_calendar with {"action": "create_event", "summary": "Reveal", "dtstart": "2026-10-01", "all_day": true}.
  3. 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.