From 02ff2e3cb04221c5b6ea8805c750c59c408c0c03 Mon Sep 17 00:00:00 2001 From: Afonso Coutinho Date: Wed, 3 Jun 2026 05:37:39 +0100 Subject: [PATCH] fix: updating a calendar event ignores user timezone and shifts the time (#1695) --- src/tool_implementations.py | 12 +++- tests/test_calendar_update_event_tz.py | 90 ++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 tests/test_calendar_update_event_tz.py diff --git a/src/tool_implementations.py b/src/tool_implementations.py index 3413075..c7b2649 100644 --- a/src/tool_implementations.py +++ b/src/tool_implementations.py @@ -2422,9 +2422,17 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict: if args.get("location") is not None: ev.location = args["location"] if args.get("dtstart") is not None: - ev.dtstart = _parse_dt(args["dtstart"]) + # Anchor naive/natural-language input to the USER's timezone and + # refresh is_utc, exactly like create_event. Parsing with the + # raw server-local _parse_dt here (and never touching is_utc) + # silently shifted an updated event by the user's UTC offset. + _eff_all_day = ( + args["all_day"] if args.get("all_day") is not None else ev.all_day + ) + ev.dtstart, _su = _parse_event_dt(args["dtstart"]) + ev.is_utc = bool(_su and not _eff_all_day) if args.get("dtend") is not None: - ev.dtend = _parse_dt(args["dtend"]) + ev.dtend, _eu = _parse_event_dt(args["dtend"]) if args.get("all_day") is not None: ev.all_day = args["all_day"] # Tag/category + importance updates (any of these aliases). diff --git a/tests/test_calendar_update_event_tz.py b/tests/test_calendar_update_event_tz.py new file mode 100644 index 0000000..e4c22aa --- /dev/null +++ b/tests/test_calendar_update_event_tz.py @@ -0,0 +1,90 @@ +"""update_event must anchor datetimes to the user tz, like create_event. + +create_event parses a naive/natural-language dtstart in the USER's +timezone (parse_due_for_user -> stored naive-UTC, is_utc=True), but +update_event parsed args["dtstart"] with the raw server-local _parse_dt +and never refreshed is_utc. So updating an event to the same naive value +it was created with silently shifted it by the user's UTC offset (9h for a +Tokyo user) and left is_utc inconsistent. The do_manage_notes update path +was already fixed for the analogous issue. +""" +import json +import tempfile +import uuid + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import NullPool + +import core.database as cdb +from core.database import CalendarEvent + +_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False) +_ENGINE = create_engine( + f"sqlite:///{_TMPDB.name}", + connect_args={"check_same_thread": False}, + poolclass=NullPool, +) +cdb.Base.metadata.create_all(_ENGINE) +_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False) + + +@pytest.fixture(autouse=True) +def _bind_temp_db(monkeypatch): + monkeypatch.setattr(cdb, "SessionLocal", _TS) + import routes.calendar_routes as cr + monkeypatch.setattr(cr, "SessionLocal", _TS, raising=False) + yield + + +@pytest.fixture +def tokyo_offset(): + from routes.calendar_routes import set_user_tz_offset + set_user_tz_offset(540) # Tokyo, UTC+9 + try: + yield + finally: + set_user_tz_offset(None) + + +async def test_update_event_dtstart_anchored_to_user_tz(tokyo_offset): + from src.tool_implementations import do_manage_calendar + + owner = "tz-" + uuid.uuid4().hex[:6] + naive = "2026-06-10T14:00:00" # 14:00 Tokyo == 05:00 UTC + + created = await do_manage_calendar(json.dumps({ + "action": "create_event", + "summary": "Standup", + "dtstart": naive, + }), owner=owner) + assert created.get("exit_code", 0) == 0, created + uid = created["uid"] + + db = _TS() + try: + ev = db.query(CalendarEvent).filter(CalendarEvent.uid == uid).first() + created_dtstart, created_is_utc = ev.dtstart, ev.is_utc + finally: + db.close() + + # Update the same event to the SAME naive wall-clock value. + updated = await do_manage_calendar(json.dumps({ + "action": "update_event", + "uid": uid, + "dtstart": naive, + }), owner=owner) + assert updated.get("exit_code", 0) == 0, updated + + db = _TS() + try: + ev = db.query(CalendarEvent).filter(CalendarEvent.uid == uid).first() + # Same input -> same stored moment and same is_utc flag as create. + assert ev.dtstart == created_dtstart + assert bool(ev.is_utc) == bool(created_is_utc) + # And concretely: 14:00 Tokyo is 05:00 UTC, stored naive-UTC. + assert ev.dtstart.hour == 5 + assert bool(ev.is_utc) is True + finally: + db.close()