From 6e57ce4555ae6bf31c507b6ac8746a4261027b7a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 12 Mar 2026 08:29:40 -0400 Subject: [PATCH 1/6] feat: temperature unit preference and slot timezone display for briefing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add °C/°F toggle in briefing settings; persisted in briefing_config.temp_unit - briefing_pipeline reads temp_unit and converts Open-Meteo Celsius values before passing them to the LLM (both full compilation and slot injection) - Scheduled Slots section now shows each UTC slot time converted to the user's browser local time, plus a line confirming which timezone the browser is using Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/api/client.ts | 2 + frontend/src/views/SettingsView.vue | 74 +++++++++++++++++-- .../services/briefing_pipeline.py | 38 ++++++++-- 3 files changed, 101 insertions(+), 13 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8083f77..39783c1 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -328,6 +328,7 @@ export interface BriefingConfig { work_days: number[]; slots: BriefingSlots; notifications: boolean; + temp_unit: 'C' | 'F'; } export interface BriefingFeed { @@ -360,6 +361,7 @@ const DEFAULT_BRIEFING_CONFIG: BriefingConfig = { work_days: [1, 2, 3, 4, 5], slots: { compilation: true, morning: true, midday: false, afternoon: false }, notifications: true, + temp_unit: 'C', }; export async function getBriefingConfig(): Promise { diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 0c00e15..d64b40c 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -173,6 +173,18 @@ 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; briefingSaved.value = false; @@ -1316,6 +1328,22 @@ function formatUserDate(iso: string): string {

Look up weather for locations in today's calendar events.

+ +
+ +
+ + +
+
@@ -1336,27 +1364,33 @@ function formatUserDate(iso: string): string {

Scheduled Slots

-

Each active slot will post an update into your briefing conversation.

+

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

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

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

@@ -2697,6 +2731,32 @@ function formatUserDate(iso: string): string { flex-wrap: wrap; margin-top: 0.5rem; } +.briefing-unit-toggle { + display: flex; + gap: 0; + border: 1px solid var(--color-border); + border-radius: 8px; + overflow: hidden; + width: fit-content; + margin-top: 0.25rem; +} +.briefing-unit-btn { + padding: 0.35rem 1rem; + background: var(--color-bg-card); + color: var(--color-text-muted); + font-size: 0.85rem; + cursor: pointer; + border: none; + font-family: inherit; + transition: all 0.15s; +} +.briefing-unit-btn:first-child { + border-right: 1px solid var(--color-border); +} +.briefing-unit-btn.active { + background: var(--color-primary); + color: #fff; +} .briefing-day-btn { padding: 0.35rem 0.65rem; border: 1px solid var(--color-border); diff --git a/src/fabledassistant/services/briefing_pipeline.py b/src/fabledassistant/services/briefing_pipeline.py index 62b48e8..bac5ea5 100644 --- a/src/fabledassistant/services/briefing_pipeline.py +++ b/src/fabledassistant/services/briefing_pipeline.py @@ -192,16 +192,26 @@ def _internal_user_prompt(data: dict, slot: str) -> str: return "\n".join(lines) -def _external_user_prompt(data: dict, slot: str) -> str: +def _format_temp(value: float, unit: str) -> str: + """Convert Celsius to the requested unit and format as an integer string.""" + if unit == "F": + return f"{value * 9 / 5 + 32:.0f}" + return f"{value:.0f}" + + +def _external_user_prompt(data: dict, slot: str, temp_unit: str = "C") -> str: + unit_sym = f"°{temp_unit}" lines = [f"Briefing slot: {slot}", ""] if data["weather"]: lines.append("WEATHER:") for loc in data["weather"]: lines.append(f" {loc['location_label']}:") for day in loc["days"][:3]: + t_min = _format_temp(day["temp_min"], temp_unit) + t_max = _format_temp(day["temp_max"], temp_unit) lines.append( f" {day['date']}: {day['description']}, " - f"{day['temp_min']}–{day['temp_max']}°C, {day['precip_mm']}mm rain" + f"{t_min}–{t_max}{unit_sym}, {day['precip_mm']}mm rain" ) if loc["changes_since_last_fetch"]: lines.append(" FORECAST CHANGES:") @@ -218,6 +228,18 @@ def _external_user_prompt(data: dict, slot: str) -> str: # ── Main entry point ─────────────────────────────────────────────────────────── +async def _get_temp_unit(user_id: int) -> str: + """Read the user's preferred temperature unit from briefing_config ('C' or 'F').""" + import json + raw = await get_setting(user_id, "briefing_config", "{}") + try: + config = json.loads(raw) if isinstance(raw, str) else (raw or {}) + unit = config.get("temp_unit", "C") + return unit if unit in ("C", "F") else "C" + except Exception: + return "C" + + async def run_compilation(user_id: int, slot: str, model: str | None = None) -> str: """ Run the full two-lane briefing pipeline for a user and slot. @@ -227,7 +249,10 @@ async def run_compilation(user_id: int, slot: str, model: str | None = None) -> model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL) from fabledassistant.services.briefing_profile import get_profile_body - profile_body = await get_profile_body(user_id) + profile_body, temp_unit = await asyncio.gather( + get_profile_body(user_id), + _get_temp_unit(user_id), + ) # Parallel gather internal_data, external_data = await asyncio.gather( @@ -244,7 +269,7 @@ async def run_compilation(user_id: int, slot: str, model: str | None = None) -> ), _llm_synthesise( _external_system_prompt(), - _external_user_prompt(external_data, slot), + _external_user_prompt(external_data, slot, temp_unit), model, ), ) @@ -268,9 +293,10 @@ async def run_slot_injection(user_id: int, slot: str, model: str | None = None) if model is None: model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL) - internal_data, external_data = await asyncio.gather( + internal_data, external_data, temp_unit = await asyncio.gather( _gather_internal(user_id), _gather_external(user_id), + _get_temp_unit(user_id), ) system = ( @@ -281,6 +307,6 @@ async def run_slot_injection(user_id: int, slot: str, model: str | None = None) f"Slot: {slot}\n\n" + _internal_user_prompt(internal_data, slot) + "\n\n" - + _external_user_prompt(external_data, slot) + + _external_user_prompt(external_data, slot, temp_unit) ) return await _llm_synthesise(system, user_prompt, model) From 5ea3bb5afff0bfa3b8042e66b7ddfd81b9b5b36b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 12 Mar 2026 08:34:31 -0400 Subject: [PATCH 2/6] 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 --- frontend/src/api/client.ts | 2 + frontend/src/views/SettingsView.vue | 71 ++++-- src/fabledassistant/routes/briefing.py | 3 + .../services/briefing_scheduler.py | 209 ++++++++++++------ 4 files changed, 193 insertions(+), 92 deletions(-) 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 From beb57876fb66a7e4037cca13b0e8e7df6b00beeb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 12 Mar 2026 17:58:13 -0400 Subject: [PATCH 3/6] feat: update_milestone tool and workspace milestone refresh - Add update_milestone LLM tool: accepts project + milestone title to look up, then applies any of title/description/status changes - WorkspaceView SSE watcher now triggers taskPanelRef.reload() on create_milestone and update_milestone success, so milestone groups appear immediately without a manual refresh Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/views/WorkspaceView.vue | 2 +- src/fabledassistant/services/tools.py | 47 +++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/WorkspaceView.vue b/frontend/src/views/WorkspaceView.vue index bf05ec0..106ad41 100644 --- a/frontend/src/views/WorkspaceView.vue +++ b/frontend/src/views/WorkspaceView.vue @@ -80,7 +80,7 @@ watch( activeNoteId.value = tc.result.data.id as number; } if ( - ["create_task", "update_task"].includes(tc.function) && + ["create_task", "update_task", "create_milestone", "update_milestone"].includes(tc.function) && tc.status === "success" ) { taskPanelRef.value?.reload(); diff --git a/src/fabledassistant/services/tools.py b/src/fabledassistant/services/tools.py index 97efdd5..1013e0d 100644 --- a/src/fabledassistant/services/tools.py +++ b/src/fabledassistant/services/tools.py @@ -436,6 +436,24 @@ _CORE_TOOLS = [ }, }, }, + { + "type": "function", + "function": { + "name": "update_milestone", + "description": "Update the title, description, or status of an existing milestone.", + "parameters": { + "type": "object", + "properties": { + "project": {"type": "string", "description": "Project title the milestone belongs to"}, + "milestone": {"type": "string", "description": "Current milestone title to look up"}, + "title": {"type": "string", "description": "New title (omit to keep current)"}, + "description": {"type": "string", "description": "New description (omit to keep current)"}, + "status": {"type": "string", "enum": ["active", "completed", "cancelled"], "description": "New status (omit to keep current)"}, + }, + "required": ["project", "milestone"], + }, + }, + }, { "type": "function", "function": { @@ -1426,6 +1444,35 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict: ) return {"success": True, "type": "milestone", "data": ms.to_dict()} + elif tool_name == "update_milestone": + from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp + from fabledassistant.services.milestones import get_milestone_by_title as _gmbt, update_milestone as _um + project_name = arguments.get("project", "") + milestone_name = arguments.get("milestone", "") + if not project_name or not milestone_name: + return {"success": False, "error": "Both project and milestone are required"} + proj = await _gpbt(user_id, project_name) + if proj is None: + all_p = await _lp(user_id) + matches = [p for p in all_p if project_name.lower() in p.title.lower()] + proj = matches[0] if matches else None + if proj is None: + return {"success": False, "error": f"Project '{project_name}' not found"} + ms = await _gmbt(user_id, proj.id, milestone_name) + if ms is None: + return {"success": False, "error": f"Milestone '{milestone_name}' not found in project '{proj.title}'. Use list_milestones to see available milestones."} + fields: dict[str, object] = {} + if "title" in arguments: + fields["title"] = arguments["title"] + if "description" in arguments: + fields["description"] = arguments["description"] + if "status" in arguments: + fields["status"] = arguments["status"] + if not fields: + return {"success": False, "error": "No fields to update — provide at least one of: title, description, status"} + updated = await _um(user_id, ms.id, **fields) + return {"success": True, "type": "milestone", "data": updated.to_dict()} + elif tool_name == "list_milestones": from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp from fabledassistant.services.milestones import get_project_milestone_summary From 3eb61950c9de8c2d4a2e5667aff64234622520b2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 12 Mar 2026 18:02:28 -0400 Subject: [PATCH 4/6] fix: auto-scroll workspace chat when messages are added Watch currentConversation.messages.length so the chat panel scrolls to the bottom when user messages are appended or assistant messages land, not only while streaming content is updating. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/views/WorkspaceView.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/views/WorkspaceView.vue b/frontend/src/views/WorkspaceView.vue index 106ad41..139bc75 100644 --- a/frontend/src/views/WorkspaceView.vue +++ b/frontend/src/views/WorkspaceView.vue @@ -115,6 +115,7 @@ function scrollToBottom() { } watch(() => chatStore.streamingContent, scrollToBottom); +watch(() => chatStore.currentConversation?.messages.length, scrollToBottom); function togglePanel(panel: keyof typeof panelOpen.value) { const open = panelOpen.value; From b44d8496bc8230d443373b39501dd385af761890 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 12 Mar 2026 18:08:45 -0400 Subject: [PATCH 5/6] fix: queue drain survives navigation away from a streaming conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the queue drain guard (currentConversation.id === convId) meant that if the user navigated to ChatView while a workspace stream was running, the workspace's queued messages were silently abandoned — they sat in localStorage indefinitely with no code path to resume them. - Extract _tryDrainQueue(convId) with the same guard logic - Call it at stream-end (replaces the inline block) - Call it in fetchConversation after _loadQueue, so returning to a conversation with orphaned queue messages drains them automatically - Order is now preserved: messages drain in the order they were queued, even across navigation events Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/stores/chat.ts | 45 +++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts index 7d2e83f..76c1f23 100644 --- a/frontend/src/stores/chat.ts +++ b/frontend/src/stores/chat.ts @@ -109,6 +109,28 @@ export const useChatStore = defineStore("chat", () => { } } + // Drain the next queued message for a conversation, if conditions are met. + // Called both at stream-end and after fetchConversation, so orphaned queue + // messages (e.g. from a navigation away mid-stream) are picked up on return. + function _tryDrainQueue(convId: number) { + const queue = convQueues.value[convId]; + if (!queue?.length) return; + if (isStreamingConv(convId)) return; // stream-end will drain naturally + if (currentConversation.value?.id !== convId) return; // not our conversation + const next = queue.shift()!; + _saveQueue(convId); + setTimeout(() => sendMessage( + next.content, + next.contextNoteId, + next.includeNoteIds, + next.think, + next.contextNoteTitle, + next.excludeNoteIds, + next.ragProjectId, + next.workspaceProjectId, + ), 0); + } + function clearQueue() { const id = currentConversation.value?.id; if (id) { @@ -158,6 +180,9 @@ export const useChatStore = defineStore("chat", () => { `/api/chat/conversations/${id}` ); _loadQueue(id); + // Drain any messages that were queued but never sent because the user + // navigated away before the previous stream finished. + _tryDrainQueue(id); } catch (e) { useToastStore().show("Failed to load conversation", "error"); throw e; @@ -431,23 +456,9 @@ export const useChatStore = defineStore("chat", () => { s.pendingTool = null; } - // Process next queued message, if any. - // Use setTimeout so this frame resolves before the next send begins. - const queue = convQueues.value[convId]; - if (queue?.length && currentConversation.value?.id === convId) { - const next = queue.shift()!; - _saveQueue(convId); - setTimeout(() => sendMessage( - next.content, - next.contextNoteId, - next.includeNoteIds, - next.think, - next.contextNoteTitle, - next.excludeNoteIds, - next.ragProjectId, - next.workspaceProjectId, - ), 0); - } + // Process next queued message if this is still the active conversation. + // If the user has navigated away, _tryDrainQueue will fire on fetchConversation. + _tryDrainQueue(convId); } async function reconnectIfGenerating(convId: number): Promise { From 9cb3700a5ca86c55a287b1a41ec077d11cf58ba2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 12 Mar 2026 18:33:17 -0400 Subject: [PATCH 6/6] fix: add temp_unit and timezone to inline BriefingConfig literals BriefingSetupWizard and SettingsView had hardcoded initial objects that predated the new fields, causing TS2345 type errors. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/BriefingSetupWizard.vue | 2 ++ frontend/src/views/SettingsView.vue | 2 ++ 2 files changed, 4 insertions(+) diff --git a/frontend/src/components/BriefingSetupWizard.vue b/frontend/src/components/BriefingSetupWizard.vue index b559014..f1b7c67 100644 --- a/frontend/src/components/BriefingSetupWizard.vue +++ b/frontend/src/components/BriefingSetupWizard.vue @@ -19,6 +19,8 @@ const config = reactive({ work_days: [1, 2, 3, 4, 5], slots: { compilation: true, morning: true, midday: false, afternoon: false }, notifications: true, + temp_unit: 'C', + timezone: '', }) // Step 2 — locations diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 42d3d41..4de65a6 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -126,6 +126,8 @@ const briefingConfig = ref({ work_days: [1, 2, 3, 4, 5], slots: { compilation: true, morning: true, midday: false, afternoon: false }, notifications: true, + temp_unit: 'C', + timezone: '', }); const briefingFeeds = ref([]); const briefingSaving = ref(false);