feat: per-user timezone-aware briefing scheduler

Briefing slots (4am/8am/12pm/4pm) now fire in each user's local timezone
rather than UTC, so the schedule matches the user's actual day.

Backend:
- briefing_scheduler: replaced 4 global UTC cron jobs with per-user
  CronTrigger jobs keyed to the user's IANA timezone; update_user_schedule()
  live-patches the scheduler when config is saved (no restart needed)
- Catchup logic evaluates missed slots in the user's local timezone
- put_config route calls update_user_schedule() after saving

Frontend:
- New Timezone section in Briefing settings: text input pre-filled from
  browser (Intl.DateTimeFormat) with a Detect button for re-detection
- Slot times shown as fixed local times (4:00 am etc.) with the configured
  timezone displayed beneath, replacing the old UTC-conversion display
- timezone field added to BriefingConfig type and default

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 08:34:31 -04:00
parent 6e57ce4555
commit 5ea3bb5aff
4 changed files with 193 additions and 92 deletions
+2
View File
@@ -329,6 +329,7 @@ export interface BriefingConfig {
slots: BriefingSlots; slots: BriefingSlots;
notifications: boolean; notifications: boolean;
temp_unit: 'C' | 'F'; temp_unit: 'C' | 'F';
timezone: string;
} }
export interface BriefingFeed { export interface BriefingFeed {
@@ -362,6 +363,7 @@ const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
slots: { compilation: true, morning: true, midday: false, afternoon: false }, slots: { compilation: true, morning: true, midday: false, afternoon: false },
notifications: true, notifications: true,
temp_unit: 'C', temp_unit: 'C',
timezone: '',
}; };
export async function getBriefingConfig(): Promise<BriefingConfig> { export async function getBriefingConfig(): Promise<BriefingConfig> {
+48 -23
View File
@@ -138,6 +138,10 @@ const addingFeed = ref(false);
async function loadBriefingTab() { async function loadBriefingTab() {
briefingConfig.value = await getBriefingConfig(); briefingConfig.value = await getBriefingConfig();
briefingFeeds.value = await getBriefingFeeds(); 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') { async function geocodeLocation(key: 'home' | 'work') {
@@ -173,17 +177,6 @@ function toggleWorkDay(day: number) {
days.sort(); days.sort();
} }
/** Convert a UTC hour (023) 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() { async function saveBriefingSettings() {
briefingSaving.value = true; briefingSaving.value = true;
@@ -1361,35 +1354,61 @@ function formatUserDate(iso: string): string {
</div> </div>
</section> </section>
<!-- Timezone -->
<section class="settings-section full-width">
<h2>Timezone</h2>
<p class="section-desc">
Briefing slots fire at the times below in this timezone.
Auto-detected from your browser — override if the server should use a different zone.
</p>
<div class="briefing-timezone-row">
<input
type="text"
class="input"
v-model="briefingConfig.timezone"
placeholder="e.g. America/New_York"
style="flex: 1"
/>
<button
type="button"
class="btn-secondary"
@click="briefingConfig.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone"
>Detect</button>
</div>
<p class="field-hint">
Use an
<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones" target="_blank" rel="noopener">IANA timezone name</a>
(e.g. <code>Europe/London</code>, <code>America/Chicago</code>).
Your browser reports: <strong>{{ Intl.DateTimeFormat().resolvedOptions().timeZone }}</strong>
</p>
</section>
<!-- Slots --> <!-- Slots -->
<section class="settings-section full-width"> <section class="settings-section full-width">
<h2>Scheduled Slots</h2> <h2>Scheduled Slots</h2>
<p class="section-desc"> <p class="section-desc">Each active slot posts an update into your briefing conversation at the listed local time.</p>
Each active slot posts an update into your briefing conversation.
Slots run on the server in UTC — your local equivalent is shown below.
</p>
<div class="briefing-slot-list"> <div class="briefing-slot-list">
<div <div
v-for="[key, label, utcHour] in ([ v-for="[key, label, localTime] in ([
['compilation', 'Morning briefing', 4], ['compilation', 'Morning briefing', '4:00 am'],
['morning', 'Office check-in', 8], ['morning', 'Office check-in', '8:00 am'],
['midday', 'Midday update', 12], ['midday', 'Midday update', '12:00 pm'],
['afternoon', 'End of day', 16], ['afternoon', 'End of day', '4:00 pm'],
] as const)" ] as const)"
:key="key" :key="key"
class="briefing-slot-row" class="briefing-slot-row"
> >
<div class="briefing-slot-info"> <div class="briefing-slot-info">
<span class="briefing-slot-label">{{ label }}</span> <span class="briefing-slot-label">{{ label }}</span>
<span class="briefing-slot-time">{{ utcSlotToLocal(utcHour) }}</span> <span class="briefing-slot-time">{{ localTime }}</span>
</div> </div>
<label> <label>
<input type="checkbox" v-model="briefingConfig.slots[key]" /> <input type="checkbox" v-model="briefingConfig.slots[key]" />
</label> </label>
</div> </div>
</div> </div>
<p class="field-hint" style="margin-top: 0.5rem"> <p v-if="briefingConfig.timezone" class="field-hint" style="margin-top: 0.5rem">
Your browser's local timezone: <strong>{{ Intl.DateTimeFormat().resolvedOptions().timeZone }}</strong> Firing in timezone: <strong>{{ briefingConfig.timezone }}</strong>
</p> </p>
</section> </section>
@@ -2731,6 +2750,12 @@ function formatUserDate(iso: string): string {
flex-wrap: wrap; flex-wrap: wrap;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.briefing-timezone-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.5rem;
}
.briefing-unit-toggle { .briefing-unit-toggle {
display: flex; display: flex;
gap: 0; gap: 0;
+3
View File
@@ -44,6 +44,9 @@ async def get_config():
async def put_config(): async def put_config():
data = await request.get_json() data = await request.get_json()
await set_settings_batch(g.user.id, {"briefing_config": json.dumps(data)}) 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}) return jsonify({"ok": True})
@@ -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 Uses a background thread scheduler (not async) because APScheduler 3.x's
AsyncIOScheduler has known issues with Quart/hypercorn. Jobs are async functions AsyncIOScheduler has known issues with Quart/hypercorn. Jobs are async
wrapped with asyncio.run_coroutine_threadsafe(). functions wrapped with asyncio.run_coroutine_threadsafe().
""" """
import asyncio import asyncio
import logging 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.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
@@ -20,8 +26,9 @@ from fabledassistant.models.setting import Setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_scheduler: BackgroundScheduler | None = None _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 = [ SLOTS = [
("compilation", 4, 0), ("compilation", 4, 0),
("morning", 8, 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]]: 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 import json
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
@@ -44,12 +65,60 @@ async def _get_briefing_enabled_users() -> list[tuple[int, str]]:
try: try:
config = json.loads(row.value) if row.value else {} config = json.loads(row.value) if row.value else {}
if config.get("enabled"): 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: except Exception:
pass pass
return enabled 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: async def _run_slot_for_user(user_id: int, slot: str) -> None:
"""Execute one slot job for one user.""" """Execute one slot job for one user."""
from fabledassistant.services.briefing_conversations import ( 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": if slot == "compilation":
# Refresh external data first # Refresh external data first
try: try:
from fabledassistant.services.rss import refresh_all_feeds
import json import json
from fabledassistant.services.rss import refresh_all_feeds
config_raw = await get_setting(user_id, "briefing_config", "{}") config_raw = await get_setting(user_id, "briefing_config", "{}")
config = json.loads(config_raw) if isinstance(config_raw, str) else {} config = json.loads(config_raw) if isinstance(config_raw, str) else {}
await refresh_all_feeds(user_id) await refresh_all_feeds(user_id)
# Refresh weather for configured locations
from fabledassistant.services import weather as wx from fabledassistant.services import weather as wx
for key, loc in config.get("locations", {}).items(): for key, loc in config.get("locations", {}).items():
if loc.get("lat") and loc.get("lon"): 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: except Exception:
logger.warning("Pre-compilation refresh failed for user %d", user_id, exc_info=True) 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) 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) conv = await get_or_create_today_conversation(user_id, model)
text = await run_compilation(user_id, slot, model) text = await run_compilation(user_id, slot, model)
if text: if text:
await post_message(conv.id, "assistant", text) await post_message(conv.id, "assistant", text)
else: else:
# Inject slot update into today's conversation
conv = await get_or_create_today_conversation(user_id, model) conv = await get_or_create_today_conversation(user_id, model)
text = await run_slot_injection(user_id, slot, model) text = await run_slot_injection(user_id, slot, model)
if text: 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, "user", f"[{slot.title()} briefing update]")
await post_message(conv.id, "assistant", text) await post_message(conv.id, "assistant", text)
# Send push notification
try: try:
from fabledassistant.services.push import send_push_notification from fabledassistant.services.push import send_push_notification
slot_labels = { 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) 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: async def _run_profile_closeout(user_id: int, model: str) -> None:
""" """
Read yesterday's briefing conversation, ask the LLM to extract preference Read yesterday's briefing conversation, extract preference observations,
observations, and append them to the briefing profile note. and append them to the briefing profile note.
""" """
from fabledassistant.services.briefing_profile import append_observations from fabledassistant.services.briefing_profile import append_observations
from fabledassistant.services.briefing_pipeline import _llm_synthesise 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()) messages = list(msgs_result.scalars().all())
if len(messages) < 2: if len(messages) < 2:
return # Nothing interesting to learn from return
transcript = "\n".join( transcript = "\n".join(
f"{m.role.upper()}: {m.content[:500]}" for m in messages[-20:] 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) await append_observations(user_id, observations)
def _run_slot_sync(slot: str, loop: asyncio.AbstractEventLoop) -> None: # ── Startup / catchup ─────────────────────────────────────────────────────────
"""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)
async def _catchup_missed_slots(loop: asyncio.AbstractEventLoop) -> None: async def _catchup_missed_slots(loop: asyncio.AbstractEventLoop) -> None:
""" """
On startup, check if any slot was missed within the last 24 hours. On startup, fire any slot that was missed in the last 24 hours
Fire it once if so. Never backfill more than one slot per slot-name. (one catch-up per slot per user, evaluated in the user's local timezone).
""" """
now = datetime.now(timezone.utc) users = await _get_briefing_enabled_users()
today = now.date() 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: for slot_name, hour, minute in SLOTS:
slot_time = datetime.combine(today, time(hour, minute), tzinfo=timezone.utc) slot_local = datetime.combine(today_local, time(hour, minute), tzinfo=user_tz)
if slot_time > now: if slot_local > now_local:
continue # Hasn't happened yet today continue # Not yet due
age = (now - slot_time).total_seconds() age = (now_local - slot_local).total_seconds()
if age > 86400: if age > 86400:
continue # More than 24h ago — skip 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 # Check if today's conversation already has a message from after slot time
# after the slot time, consider it covered
users = await _get_briefing_enabled_users()
for user_id, _ in users:
async with async_session() as session: async with async_session() as session:
from fabledassistant.models.conversation import Conversation, Message from fabledassistant.models.conversation import Conversation, Message
result = await session.execute( result = await session.execute(
select(Conversation).where( select(Conversation).where(
Conversation.user_id == user_id, Conversation.user_id == user_id,
Conversation.conversation_type == "briefing", Conversation.conversation_type == "briefing",
Conversation.briefing_date == today, Conversation.briefing_date == today_local,
) )
) )
conv = result.scalars().first() conv = result.scalars().first()
if conv: if conv:
# Convert slot_local to UTC for DB comparison (stored as UTC)
slot_utc = slot_local.astimezone(ZoneInfo("UTC"))
msgs = await session.execute( msgs = await session.execute(
select(Message).where( select(Message).where(
Message.conversation_id == conv.id, Message.conversation_id == conv.id,
Message.created_at >= slot_time, Message.created_at >= slot_utc,
).limit(1) ).limit(1)
) )
if msgs.scalars().first(): if msgs.scalars().first():
continue # Already covered 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: try:
await _run_slot_for_user(user_id, slot_name) await _run_slot_for_user(user_id, slot_name)
except Exception: 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: 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. 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: if _scheduler is not None:
return return
_loop = loop
_scheduler = BackgroundScheduler(timezone="UTC") _scheduler = BackgroundScheduler(timezone="UTC")
for slot_name, hour, minute in SLOTS: # Schedule jobs synchronously: run the async query in the provided loop
_scheduler.add_job( future = asyncio.run_coroutine_threadsafe(_get_briefing_enabled_users(), loop)
_run_slot_sync, try:
CronTrigger(hour=hour, minute=minute), users = future.result(timeout=10)
args=[slot_name, loop], except Exception:
id=f"briefing_{slot_name}", logger.exception("Failed to load briefing users at startup")
replace_existing=True, users = []
misfire_grace_time=3600, # Fire up to 1h late rather than skip
) for user_id, tz in users:
_add_user_jobs(user_id, tz)
_scheduler.start() _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) asyncio.run_coroutine_threadsafe(_catchup_missed_slots(loop), loop)
def stop_briefing_scheduler() -> None: def stop_briefing_scheduler() -> None:
global _scheduler global _scheduler, _loop
if _scheduler: if _scheduler:
_scheduler.shutdown(wait=False) _scheduler.shutdown(wait=False)
_scheduler = None _scheduler = None
_loop = None