diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 39783c1..abb5c41 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -329,6 +329,7 @@ export interface BriefingConfig { slots: BriefingSlots; notifications: boolean; temp_unit: 'C' | 'F'; + timezone: string; } export interface BriefingFeed { @@ -362,6 +363,7 @@ const DEFAULT_BRIEFING_CONFIG: BriefingConfig = { slots: { compilation: true, morning: true, midday: false, afternoon: false }, notifications: true, temp_unit: 'C', + timezone: '', }; export async function getBriefingConfig(): Promise { diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index d64b40c..42d3d41 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -138,6 +138,10 @@ const addingFeed = ref(false); async function loadBriefingTab() { briefingConfig.value = await getBriefingConfig(); briefingFeeds.value = await getBriefingFeeds(); + // Auto-populate timezone from browser if not already stored. + if (!briefingConfig.value.timezone) { + briefingConfig.value.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + } } async function geocodeLocation(key: 'home' | 'work') { @@ -173,17 +177,6 @@ function toggleWorkDay(day: number) { days.sort(); } -/** Convert a UTC hour (0–23) to the browser's local time string, e.g. "6:00 am (UTC 4:00)" */ -function utcSlotToLocal(utcHour: number): string { - const now = new Date(); - const utcMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), utcHour, 0, 0); - const local = new Date(utcMs); - const localStr = local.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); - const utcStr = utcHour === 0 ? '12:00 am' : utcHour < 12 - ? `${utcHour}:00 am` - : utcHour === 12 ? '12:00 pm' : `${utcHour - 12}:00 pm`; - return `${localStr} (UTC ${utcStr})`; -} async function saveBriefingSettings() { briefingSaving.value = true; @@ -1361,35 +1354,61 @@ function formatUserDate(iso: string): string { + +
+

Timezone

+

+ Briefing slots fire at the times below in this timezone. + Auto-detected from your browser — override if the server should use a different zone. +

+
+ + +
+

+ Use an + IANA timezone name + (e.g. Europe/London, America/Chicago). + Your browser reports: {{ Intl.DateTimeFormat().resolvedOptions().timeZone }} +

+
+

Scheduled Slots

-

- Each active slot posts an update into your briefing conversation. - Slots run on the server in UTC — your local equivalent is shown below. -

+

Each active slot posts an update into your briefing conversation at the listed local time.

{{ label }} - {{ utcSlotToLocal(utcHour) }} + {{ localTime }}
-

- Your browser's local timezone: {{ Intl.DateTimeFormat().resolvedOptions().timeZone }} +

+ Firing in timezone: {{ briefingConfig.timezone }}

@@ -2731,6 +2750,12 @@ function formatUserDate(iso: string): string { flex-wrap: wrap; margin-top: 0.5rem; } +.briefing-timezone-row { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.5rem; +} .briefing-unit-toggle { display: flex; gap: 0; diff --git a/src/fabledassistant/routes/briefing.py b/src/fabledassistant/routes/briefing.py index eb6dad5..266f080 100644 --- a/src/fabledassistant/routes/briefing.py +++ b/src/fabledassistant/routes/briefing.py @@ -44,6 +44,9 @@ async def get_config(): async def put_config(): data = await request.get_json() await set_settings_batch(g.user.id, {"briefing_config": json.dumps(data)}) + # Live-patch the scheduler so the new timezone takes effect immediately. + from fabledassistant.services.briefing_scheduler import update_user_schedule + update_user_schedule(g.user.id, data) return jsonify({"ok": True}) diff --git a/src/fabledassistant/services/briefing_scheduler.py b/src/fabledassistant/services/briefing_scheduler.py index 28f371c..285f2a1 100644 --- a/src/fabledassistant/services/briefing_scheduler.py +++ b/src/fabledassistant/services/briefing_scheduler.py @@ -1,14 +1,20 @@ """ -APScheduler-based briefing scheduler. +APScheduler-based briefing scheduler — per-user, timezone-aware. + +Each enabled user gets 4 individual CronTrigger jobs keyed to their IANA +timezone (stored in briefing_config.timezone). Changing the config via the +settings UI calls update_user_schedule() which live-patches the scheduler +without a restart. Uses a background thread scheduler (not async) because APScheduler 3.x's -AsyncIOScheduler has known issues with Quart/hypercorn. Jobs are async functions -wrapped with asyncio.run_coroutine_threadsafe(). +AsyncIOScheduler has known issues with Quart/hypercorn. Jobs are async +functions wrapped with asyncio.run_coroutine_threadsafe(). """ import asyncio import logging -from datetime import date, datetime, time, timedelta, timezone +from datetime import date, datetime, time, timedelta +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger @@ -20,8 +26,9 @@ from fabledassistant.models.setting import Setting logger = logging.getLogger(__name__) _scheduler: BackgroundScheduler | None = None +_loop: asyncio.AbstractEventLoop | None = None -# Slot definitions: (name, hour, minute) +# Slot definitions: (name, hour, minute) — local time in the user's timezone SLOTS = [ ("compilation", 4, 0), ("morning", 8, 0), @@ -30,8 +37,22 @@ SLOTS = [ ] +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _resolve_timezone(tz_str: str) -> str: + """Validate and return an IANA timezone string, falling back to UTC.""" + if not tz_str: + return "UTC" + try: + ZoneInfo(tz_str) + return tz_str + except (ZoneInfoNotFoundError, KeyError): + logger.warning("Invalid timezone %r in briefing config, falling back to UTC", tz_str) + return "UTC" + + async def _get_briefing_enabled_users() -> list[tuple[int, str]]: - """Return [(user_id, model)] for users with briefing enabled.""" + """Return [(user_id, iana_timezone)] for all users with briefing enabled.""" import json async with async_session() as session: result = await session.execute( @@ -44,12 +65,60 @@ async def _get_briefing_enabled_users() -> list[tuple[int, str]]: try: config = json.loads(row.value) if row.value else {} if config.get("enabled"): - enabled.append((row.user_id, "")) # model resolved per-user at runtime + tz = _resolve_timezone(config.get("timezone", "UTC")) + enabled.append((row.user_id, tz)) except Exception: pass return enabled +def _job_id(user_id: int, slot: str) -> str: + return f"briefing_{slot}_user_{user_id}" + + +def _add_user_jobs(user_id: int, tz: str) -> None: + """Add (or replace) all 4 slot jobs for a user in their timezone.""" + if _scheduler is None or _loop is None: + return + for slot_name, hour, minute in SLOTS: + _scheduler.add_job( + _run_user_slot_sync, + CronTrigger(hour=hour, minute=minute, timezone=tz), + args=[user_id, slot_name], + id=_job_id(user_id, slot_name), + replace_existing=True, + misfire_grace_time=3600, + ) + logger.info("Scheduled briefing jobs for user %d in timezone %s", user_id, tz) + + +def _remove_user_jobs(user_id: int) -> None: + """Remove all slot jobs for a user.""" + if _scheduler is None: + return + for slot_name, _, _ in SLOTS: + jid = _job_id(user_id, slot_name) + if _scheduler.get_job(jid): + _scheduler.remove_job(jid) + logger.info("Removed briefing jobs for user %d", user_id) + + +# ── Public API ──────────────────────────────────────────────────────────────── + +def update_user_schedule(user_id: int, config: dict) -> None: + """ + Called when a user saves their briefing config via the settings UI. + Live-patches the scheduler — no restart required. + """ + if config.get("enabled"): + tz = _resolve_timezone(config.get("timezone", "UTC")) + _add_user_jobs(user_id, tz) + else: + _remove_user_jobs(user_id) + + +# ── Job execution ───────────────────────────────────────────────────────────── + async def _run_slot_for_user(user_id: int, slot: str) -> None: """Execute one slot job for one user.""" from fabledassistant.services.briefing_conversations import ( @@ -64,12 +133,11 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None: if slot == "compilation": # Refresh external data first try: - from fabledassistant.services.rss import refresh_all_feeds import json + from fabledassistant.services.rss import refresh_all_feeds config_raw = await get_setting(user_id, "briefing_config", "{}") config = json.loads(config_raw) if isinstance(config_raw, str) else {} await refresh_all_feeds(user_id) - # Refresh weather for configured locations from fabledassistant.services import weather as wx for key, loc in config.get("locations", {}).items(): if loc.get("lat") and loc.get("lon"): @@ -83,25 +151,19 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None: except Exception: logger.warning("Pre-compilation refresh failed for user %d", user_id, exc_info=True) - # Run previous day's profile close-out await _run_profile_closeout(user_id, model) - - # Create today's conversation and post opening message conv = await get_or_create_today_conversation(user_id, model) text = await run_compilation(user_id, slot, model) if text: await post_message(conv.id, "assistant", text) else: - # Inject slot update into today's conversation conv = await get_or_create_today_conversation(user_id, model) text = await run_slot_injection(user_id, slot, model) if text: - # Post as a system-injected user prompt + assistant response pair await post_message(conv.id, "user", f"[{slot.title()} briefing update]") await post_message(conv.id, "assistant", text) - # Send push notification try: from fabledassistant.services.push import send_push_notification slot_labels = { @@ -122,10 +184,22 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None: logger.info("Briefing slot '%s' completed for user %d", slot, user_id) +def _run_user_slot_sync(user_id: int, slot: str) -> None: + """Synchronous wrapper called by APScheduler's background thread.""" + if _loop is None: + logger.error("No event loop available for briefing slot %s user %d", slot, user_id) + return + future = asyncio.run_coroutine_threadsafe(_run_slot_for_user(user_id, slot), _loop) + try: + future.result(timeout=600) + except Exception: + logger.exception("Briefing slot '%s' failed for user %d", slot, user_id) + + async def _run_profile_closeout(user_id: int, model: str) -> None: """ - Read yesterday's briefing conversation, ask the LLM to extract preference - observations, and append them to the briefing profile note. + Read yesterday's briefing conversation, extract preference observations, + and append them to the briefing profile note. """ from fabledassistant.services.briefing_profile import append_observations from fabledassistant.services.briefing_pipeline import _llm_synthesise @@ -149,7 +223,7 @@ async def _run_profile_closeout(user_id: int, model: str) -> None: messages = list(msgs_result.scalars().all()) if len(messages) < 2: - return # Nothing interesting to learn from + return transcript = "\n".join( f"{m.role.upper()}: {m.content[:500]}" for m in messages[-20:] @@ -166,100 +240,97 @@ async def _run_profile_closeout(user_id: int, model: str) -> None: await append_observations(user_id, observations) -def _run_slot_sync(slot: str, loop: asyncio.AbstractEventLoop) -> None: - """Synchronous wrapper called by APScheduler's background thread.""" - async def _job(): - users = await _get_briefing_enabled_users() - for user_id, _ in users: - try: - await _run_slot_for_user(user_id, slot) - except Exception: - logger.exception("Briefing slot '%s' failed for user %d", slot, user_id) - - future = asyncio.run_coroutine_threadsafe(_job(), loop) - try: - future.result(timeout=600) # 10 min max per slot run - except Exception: - logger.exception("Briefing slot '%s' job failed", slot) - +# ── Startup / catchup ───────────────────────────────────────────────────────── async def _catchup_missed_slots(loop: asyncio.AbstractEventLoop) -> None: """ - On startup, check if any slot was missed within the last 24 hours. - Fire it once if so. Never backfill more than one slot per slot-name. + On startup, fire any slot that was missed in the last 24 hours + (one catch-up per slot per user, evaluated in the user's local timezone). """ - now = datetime.now(timezone.utc) - today = now.date() + users = await _get_briefing_enabled_users() + for user_id, tz in users: + user_tz = ZoneInfo(tz) + now_local = datetime.now(user_tz) + today_local = now_local.date() - for slot_name, hour, minute in SLOTS: - slot_time = datetime.combine(today, time(hour, minute), tzinfo=timezone.utc) - if slot_time > now: - continue # Hasn't happened yet today - age = (now - slot_time).total_seconds() - if age > 86400: - continue # More than 24h ago — skip - # Check if we already have a message for this slot today - # Simple heuristic: if today's briefing conversation has messages posted - # after the slot time, consider it covered - users = await _get_briefing_enabled_users() - for user_id, _ in users: + for slot_name, hour, minute in SLOTS: + slot_local = datetime.combine(today_local, time(hour, minute), tzinfo=user_tz) + if slot_local > now_local: + continue # Not yet due + age = (now_local - slot_local).total_seconds() + if age > 86400: + continue # More than 24h ago — skip + + # Check if today's conversation already has a message from after slot time async with async_session() as session: from fabledassistant.models.conversation import Conversation, Message result = await session.execute( select(Conversation).where( Conversation.user_id == user_id, Conversation.conversation_type == "briefing", - Conversation.briefing_date == today, + Conversation.briefing_date == today_local, ) ) conv = result.scalars().first() if conv: + # Convert slot_local to UTC for DB comparison (stored as UTC) + slot_utc = slot_local.astimezone(ZoneInfo("UTC")) msgs = await session.execute( select(Message).where( Message.conversation_id == conv.id, - Message.created_at >= slot_time, + Message.created_at >= slot_utc, ).limit(1) ) if msgs.scalars().first(): continue # Already covered - # Fire the missed slot - logger.info("Catching up missed briefing slot '%s' for user %d", slot_name, user_id) + + logger.info( + "Catching up missed briefing slot '%s' for user %d (tz: %s)", + slot_name, user_id, tz, + ) try: await _run_slot_for_user(user_id, slot_name) except Exception: - logger.exception("Catch-up for slot '%s' user %d failed", slot_name, user_id) + logger.exception( + "Catch-up for slot '%s' user %d failed", slot_name, user_id + ) def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None: """ - Start the APScheduler background scheduler. + Start the APScheduler background scheduler with per-user timezone-aware jobs. Must be called from the app's before_serving hook with the running event loop. """ - global _scheduler + global _scheduler, _loop if _scheduler is not None: return + _loop = loop _scheduler = BackgroundScheduler(timezone="UTC") - for slot_name, hour, minute in SLOTS: - _scheduler.add_job( - _run_slot_sync, - CronTrigger(hour=hour, minute=minute), - args=[slot_name, loop], - id=f"briefing_{slot_name}", - replace_existing=True, - misfire_grace_time=3600, # Fire up to 1h late rather than skip - ) + # Schedule jobs synchronously: run the async query in the provided loop + future = asyncio.run_coroutine_threadsafe(_get_briefing_enabled_users(), loop) + try: + users = future.result(timeout=10) + except Exception: + logger.exception("Failed to load briefing users at startup") + users = [] + + for user_id, tz in users: + _add_user_jobs(user_id, tz) _scheduler.start() - logger.info("Briefing scheduler started") + logger.info( + "Briefing scheduler started with %d user(s) across %d job(s)", + len(users), len(users) * len(SLOTS), + ) - # Catch up missed slots in the background asyncio.run_coroutine_threadsafe(_catchup_missed_slots(loop), loop) def stop_briefing_scheduler() -> None: - global _scheduler + global _scheduler, _loop if _scheduler: _scheduler.shutdown(wait=False) _scheduler = None + _loop = None