From ef3467e8d47f3a7291acd264dec2d2f576f9295d Mon Sep 17 00:00:00 2001 From: red person Date: Sat, 31 Oct 2026 16:54:14 +0300 Subject: [PATCH] fix(tasks): render daily brief calendars in local time (#6926) --- src/builtin_actions.py | 42 +++++++-- tests/test_daily_brief_local_calendar.py | 103 +++++++++++++++++++++++ 2 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 tests/test_daily_brief_local_calendar.py diff --git a/src/builtin_actions.py b/src/builtin_actions.py index 4f77ccd74..4a25e01d4 100644 --- a/src/builtin_actions.py +++ b/src/builtin_actions.py @@ -10,7 +10,7 @@ import os import json import re import time -from datetime import datetime +from datetime import datetime, timezone from typing import Tuple from src.auth_helpers import owner_filter @@ -1957,6 +1957,23 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo return str(e), False +def _daily_brief_utc_boundary(local_dt, tz=None): + """Convert a local wall-clock boundary using the offset at that boundary.""" + if local_dt.tzinfo is None and tz is not None: + local_dt = local_dt.replace(tzinfo=tz) + return local_dt.astimezone(timezone.utc).replace(tzinfo=None) + + +def _daily_brief_local_start(event, tz=None): + """Keep date-only/floating rows local; convert only stored UTC instants.""" + start = event.dtstart + if event.is_utc and not event.all_day: + if start.tzinfo is None: + start = start.replace(tzinfo=timezone.utc) + return start.astimezone(tz).replace(tzinfo=None) + return start + + async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]: """Build a short morning digest: today's calendar events, unread email count + top-N senders/subjects, active todos.""" @@ -1966,10 +1983,13 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]: from core.database import SessionLocal, CalendarEvent, CalendarCal, Note from routes.email_helpers import _imap_connect, _decode_header + from sqlalchemy import and_, or_ # ----- Calendar: today's events ----- today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0) tomorrow = today + _td(days=1) + utc_today = _daily_brief_utc_boundary(today) + utc_tomorrow = _daily_brief_utc_boundary(tomorrow) # v2 review HIGH-12: gate the OR-null branch on single-user # (unconfigured) deploys only. In a multi-user deploy, one # user's daily brief must not include another user's notes or @@ -1982,13 +2002,25 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]: db = SessionLocal() try: ev_q = db.query(CalendarEvent).join(CalendarCal).filter( - CalendarEvent.dtstart < tomorrow, - CalendarEvent.dtend > today, + or_( + and_( + CalendarEvent.is_utc.is_(True), + CalendarEvent.all_day.is_not(True), + CalendarEvent.dtstart < utc_tomorrow, + CalendarEvent.dtend > utc_today, + ), + and_( + or_(CalendarEvent.is_utc.is_not(True), CalendarEvent.all_day.is_(True)), + CalendarEvent.dtstart < tomorrow, + CalendarEvent.dtend > today, + ), + ), CalendarEvent.status != "cancelled", ) if owner: ev_q = owner_filter(ev_q, CalendarCal, owner, include_shared=_allow_null) - events = ev_q.order_by(CalendarEvent.dtstart).all() + events = ev_q.all() + events.sort(key=_daily_brief_local_start) # ----- Notes: pinned + non-archived todos with at least one undone item ----- n_q = db.query(Note).filter(Note.archived == False) # noqa: E712 if owner: @@ -2058,7 +2090,7 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]: if events: plain.append("Calendar:") for e in events: - t = e.dtstart.strftime("%H:%M") if not e.all_day else "all day" + t = _daily_brief_local_start(e).strftime("%H:%M") if not e.all_day else "all day" loc = f" @ {e.location}" if e.location else "" plain.append(f" {t} {e.summary}{loc}") plain.append("") diff --git a/tests/test_daily_brief_local_calendar.py b/tests/test_daily_brief_local_calendar.py new file mode 100644 index 000000000..65790efc5 --- /dev/null +++ b/tests/test_daily_brief_local_calendar.py @@ -0,0 +1,103 @@ +"""Daily brief must query and display each calendar storage kind in local time.""" +import asyncio +import os +import time +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from core import database +from src import builtin_actions + + +@contextmanager +def system_timezone(name): + previous = os.environ.get('TZ') + os.environ['TZ'] = name + time.tzset() + try: + yield + finally: + if previous is None: + os.environ.pop('TZ', None) + else: + os.environ['TZ'] = previous + time.tzset() + + +@pytest.mark.skipif(not hasattr(time, 'tzset'), reason='requires changing the process local timezone') +@pytest.mark.parametrize('zone', ['Europe/Berlin', 'America/Los_Angeles']) +def test_daily_brief_selects_local_day_and_orders_mixed_storage(monkeypatch, zone): + engine = create_engine('sqlite://', poolclass=StaticPool) + for model in [database.CalendarCal, database.CalendarEvent, database.Note]: + model.__table__.create(engine) + sessions = sessionmaker(bind=engine) + monkeypatch.setattr(database, 'SessionLocal', sessions) + from core.auth import AuthManager + monkeypatch.setattr(AuthManager, 'is_configured', property(lambda self: True)) + from routes import email_helpers + def no_email(*args): + raise RuntimeError('No email account in this calendar-only test') + monkeypatch.setattr(email_helpers, '_imap_connect', no_email) + try: + with system_timezone(zone): + today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + def utc(value): + return value.astimezone(timezone.utc).replace(tzinfo=None) + with sessions() as db: + for owner in ['alice', 'bob', None]: + db.add(database.CalendarCal(id=owner or 'shared', owner=owner, name=owner or 'shared')) + db.flush() + def add(uid, start, end, *, utc_row=False, all_day=False, owner='alice', status='confirmed'): + db.add(database.CalendarEvent(uid=uid, calendar_id=owner or 'shared', summary=uid, + dtstart=utc(start) if utc_row and not all_day else start, + dtend=utc(end) if utc_row and not all_day else end, + is_utc=utc_row, all_day=all_day, status=status)) + add('early', today+timedelta(minutes=30), today+timedelta(hours=1), utc_row=True) + add('utc-ten', today+timedelta(hours=10), today+timedelta(hours=11), utc_row=True) + add('local-nine', today+timedelta(hours=9), today+timedelta(hours=10)) + add('all-day', today, today+timedelta(days=1), utc_row=True, all_day=True) + add('next-day', today+timedelta(days=1,minutes=30), today+timedelta(days=1,hours=1), utc_row=True) + add('ended', today-timedelta(hours=1), today, utc_row=True) + add('cancelled', today+timedelta(hours=12), today+timedelta(hours=13), utc_row=True, status='cancelled') + add('foreign', today+timedelta(hours=12), today+timedelta(hours=13), utc_row=True, owner='bob') + add('unowned', today+timedelta(hours=12), today+timedelta(hours=13), owner=None) + db.commit() + text, success = asyncio.run(builtin_actions.action_daily_brief('alice')) + assert success, text + lines = ['all day all-day', '00:30 early', '09:00 local-nine', '10:00 utc-ten'] + assert all(line in text for line in lines), text + assert [text.index(line) for line in lines] == sorted(text.index(line) for line in lines), text + for excluded in ['next-day', 'ended', 'cancelled', 'foreign', 'unowned']: + assert excluded not in text, text + finally: + engine.dispose() + + +@pytest.mark.parametrize('day, hours', [('2026-03-29', 23), ('2026-10-25', 25)]) +def test_utc_day_window_uses_offset_at_each_boundary(day, hours): + start = datetime.fromisoformat(day) + zone = ZoneInfo('Europe/Berlin') + begin = builtin_actions._daily_brief_utc_boundary(start, zone) + end = builtin_actions._daily_brief_utc_boundary(start+timedelta(days=1), zone) + assert end-begin == timedelta(hours=hours) + if hasattr(time, 'tzset'): + with system_timezone('Europe/Berlin'): + assert builtin_actions._daily_brief_utc_boundary(start) == begin + assert builtin_actions._daily_brief_utc_boundary(start+timedelta(days=1)) == end + + +@pytest.mark.parametrize('utc_row, all_day, expected', [ + (True, False, datetime(2026, 6, 1, 10)), + (False, False, datetime(2026, 6, 1, 8)), + (True, True, datetime(2026, 6, 1, 8)), +]) +def test_local_start_preserves_floating_and_date_only_values(utc_row, all_day, expected): + event = SimpleNamespace(dtstart=datetime(2026,6,1,8), is_utc=utc_row, all_day=all_day) + assert builtin_actions._daily_brief_local_start(event, ZoneInfo('Europe/Berlin')) == expected