From fd05c6501896d7ba6ebe5dab095ec0e269f48c0b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 25 Mar 2026 20:06:09 -0400 Subject: [PATCH 1/2] fix(calendar): correct event timezone handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Frontend sends user_timezone (IANA, from Intl.DateTimeFormat) with every message POST; threaded through route → generation_task → build_context - System prompt now tells the LLM the user's timezone so it creates events with the correct UTC offset (e.g. 15:00+01:00 not 15:00Z) - Calendar tool guidance updated to require UTC offset in all event datetimes - EventSlideOver: dateFromIso/timeFromIso now use JS Date to convert stored UTC times to local time for display; toIso includes local timezone offset when saving so the correct UTC time is stored Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/EventSlideOver.vue | 19 ++++++++++++++++--- frontend/src/stores/chat.ts | 1 + src/fabledassistant/routes/chat.py | 2 ++ .../services/generation_task.py | 2 ++ src/fabledassistant/services/llm.py | 15 ++++++++++++--- 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/EventSlideOver.vue b/frontend/src/components/EventSlideOver.vue index a04d918..5c5a776 100644 --- a/frontend/src/components/EventSlideOver.vue +++ b/frontend/src/components/EventSlideOver.vue @@ -38,17 +38,30 @@ const color = ref(""); const projectId = ref(null); function dateFromIso(iso: string): string { - return iso.slice(0, 10); + const d = new Date(iso); + if (isNaN(d.getTime())) return iso.slice(0, 10); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; } function timeFromIso(iso: string): string { if (!iso.includes("T")) return "09:00"; - return iso.slice(11, 16); + const d = new Date(iso); + if (isNaN(d.getTime())) return iso.slice(11, 16); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; } function toIso(date: string, time: string): string { if (!time) return `${date}T00:00:00`; - return `${date}T${time}:00`; + // Include local timezone offset so the server stores the correct UTC time + const local = new Date(`${date}T${time}:00`); + const off = -local.getTimezoneOffset(); + const sign = off >= 0 ? "+" : "-"; + const h = String(Math.floor(Math.abs(off) / 60)).padStart(2, "0"); + const min = String(Math.abs(off) % 60).padStart(2, "0"); + return `${date}T${time}:00${sign}${h}:${min}`; } function resetForm() { diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts index af19e2c..2b36aa2 100644 --- a/frontend/src/stores/chat.ts +++ b/frontend/src/stores/chat.ts @@ -324,6 +324,7 @@ export const useChatStore = defineStore("chat", () => { think, rag_project_id: ragProjectId ?? undefined, workspace_project_id: workspaceProjectId ?? undefined, + user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, }, ); assistantMessageId = resp.assistant_message_id; diff --git a/src/fabledassistant/routes/chat.py b/src/fabledassistant/routes/chat.py index 8ba5f89..63cd5e1 100644 --- a/src/fabledassistant/routes/chat.py +++ b/src/fabledassistant/routes/chat.py @@ -148,6 +148,7 @@ async def send_message_route(conv_id: int): think = bool(data.get("think", False)) rag_project_id = data.get("rag_project_id") or None workspace_project_id = data.get("workspace_project_id") or None + user_timezone = data.get("user_timezone") or None effective_rag_project_id = workspace_project_id or rag_project_id # Reject if generation already running for this conversation @@ -184,6 +185,7 @@ async def send_message_route(conv_id: int): think=think, rag_project_id=effective_rag_project_id, workspace_project_id=workspace_project_id, + user_timezone=user_timezone, )) return jsonify({ diff --git a/src/fabledassistant/services/generation_task.py b/src/fabledassistant/services/generation_task.py index a377b56..04fa395 100644 --- a/src/fabledassistant/services/generation_task.py +++ b/src/fabledassistant/services/generation_task.py @@ -151,6 +151,7 @@ async def run_generation( think: bool = False, rag_project_id: int | None = None, workspace_project_id: int | None = None, + user_timezone: str | None = None, ) -> None: """Stream LLM response into buffer with periodic DB flushes.""" MAX_TOOL_ROUNDS = 5 @@ -183,6 +184,7 @@ async def run_generation( excluded_note_ids=excluded_note_ids, rag_project_id=rag_project_id, workspace_project_id=workspace_project_id, + user_timezone=user_timezone, )) messages, context_meta = await context_task diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index 9f24050..3d55624 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -452,6 +452,7 @@ async def build_context( excluded_note_ids: list[int] | None = None, rag_project_id: int | None = None, workspace_project_id: int | None = None, + user_timezone: str | None = None, ) -> tuple[list[dict], dict]: """Build messages array for Ollama with system prompt and context. @@ -475,9 +476,16 @@ async def build_context( "Available actions: create_task, create_note, update_note, delete_note, delete_task, get_note, list_notes, list_tasks, search_notes, " "create_event, list_events, search_events, update_event, delete_event, list_calendars." ) - tool_lines.append("For calendar events, use ISO 8601 datetime format (e.g. 2026-09-30T00:00:00).") + tool_lines.append( + "For calendar events, use ISO 8601 datetime format with the user's timezone offset" + + (f" ({user_timezone})" if user_timezone else "") + + ". Always include the UTC offset in datetime strings (e.g. 2026-09-30T14:00:00+01:00)." + ) tool_lines.append("When the user says 'remind me' with a time before an event, use the reminder_minutes parameter.") - tool_lines.append("For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format.") + tool_lines.append( + "For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format. " + + (f"Always include the UTC offset when creating events (user's timezone: {user_timezone})." if user_timezone else "For event datetimes, include the UTC offset (e.g. 2026-09-30T14:00:00+01:00).") + ) tool_lines.append("When creating notes, use the `tags` parameter — do not embed #tag text in the note body.") tool_lines.append( "When search_images returns results, embed each image directly in your response by writing " @@ -498,11 +506,12 @@ async def build_context( ) tool_guidance = "\n".join(tool_lines) + tz_line = f" The user's timezone is {user_timezone}." if user_timezone else "" system_parts = [ f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Assistant. " "Help users with their notes, tasks, and general questions. " "When note context is provided, use it to give relevant answers. " - f"Today's date is {today}.\n\n" + f"Today's date is {today}.{tz_line}\n\n" f"{tool_guidance}" ] From 62dbb8d49638af86a4d8d8e99b7e5d8fdabde6a0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 25 Mar 2026 20:17:51 -0400 Subject: [PATCH 2/2] refactor: centralise user timezone as a standalone setting Browser timezone is now synced to user_settings["user_timezone"] on every login/page load (App.vue). The briefing scheduler and LLM context both read from this single source, falling back to the legacy briefing_config.timezone for existing users during migration. - App.vue: PUT /api/settings with browser IANA timezone on startAppServices - routes/chat.py: fall back to stored user_timezone when not sent in request - briefing_scheduler: read user_timezone setting; briefing_config.timezone kept as fallback only - routes/briefing.py: pass tz_override from user_timezone to live-patched scheduler - Remove timezone field from BriefingConfig interface and all briefing UI Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/App.vue | 4 +- frontend/src/api/client.ts | 2 - .../src/components/BriefingSetupWizard.vue | 1 - frontend/src/views/SettingsView.vue | 44 +------------------ src/fabledassistant/routes/briefing.py | 5 ++- src/fabledassistant/routes/chat.py | 2 + .../services/briefing_scheduler.py | 21 ++++++--- 7 files changed, 24 insertions(+), 55 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3412abe..3021118 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -8,7 +8,7 @@ import { useShortcuts } from "@/composables/useShortcuts"; import { useAuthStore } from "@/stores/auth"; import { useChatStore } from "@/stores/chat"; import { useSettingsStore } from "@/stores/settings"; -import { apiGet } from "@/api/client"; +import { apiGet, apiPut } from "@/api/client"; useTheme(); @@ -22,6 +22,8 @@ const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts(); function startAppServices() { chatStore.startStatusPolling(); settingsStore.fetchSettings(); + // Sync browser timezone to the server on every login/page load. + apiPut("/api/settings", { user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }).catch(() => {}); } function stopAppServices() { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 09724a7..5ae97d0 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -329,7 +329,6 @@ export interface BriefingConfig { slots: BriefingSlots; notifications: boolean; temp_unit: 'C' | 'F'; - timezone: string; } export interface BriefingFeed { @@ -364,7 +363,6 @@ 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/components/BriefingSetupWizard.vue b/frontend/src/components/BriefingSetupWizard.vue index f1b7c67..d7e5c54 100644 --- a/frontend/src/components/BriefingSetupWizard.vue +++ b/frontend/src/components/BriefingSetupWizard.vue @@ -20,7 +20,6 @@ const config = reactive({ 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 d230735..0b011e3 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -258,7 +258,6 @@ const briefingConfig = ref({ slots: { compilation: true, morning: true, midday: false, afternoon: false }, notifications: true, temp_unit: 'C', - timezone: '', }); const briefingFeeds = ref([]); const briefingSaving = ref(false); @@ -284,10 +283,6 @@ function _parseTopics(raw: unknown): string[] { 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; - } const allSettings = await apiGet>('/api/settings').catch(() => ({} as Record)); briefingIncludeTopics.value = _parseTopics(allSettings['briefing_include_topics'] ?? '[]'); briefingExcludeTopics.value = _parseTopics(allSettings['briefing_exclude_topics'] ?? '[]'); @@ -1694,35 +1689,6 @@ 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

@@ -1747,8 +1713,8 @@ function formatUserDate(iso: string): string { -

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

+ Firing in timezone: {{ Intl.DateTimeFormat().resolvedOptions().timeZone }}

@@ -3313,12 +3279,6 @@ FABLE_API_KEY=<your-api-key> 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 b950849..eba9411 100644 --- a/src/fabledassistant/routes/briefing.py +++ b/src/fabledassistant/routes/briefing.py @@ -44,9 +44,10 @@ 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. + # Live-patch the scheduler using the stored user_timezone. from fabledassistant.services.briefing_scheduler import update_user_schedule - update_user_schedule(g.user.id, data) + tz_override = await get_setting(g.user.id, "user_timezone") or None + update_user_schedule(g.user.id, data, tz_override=tz_override) return jsonify({"ok": True}) diff --git a/src/fabledassistant/routes/chat.py b/src/fabledassistant/routes/chat.py index 63cd5e1..e5b3328 100644 --- a/src/fabledassistant/routes/chat.py +++ b/src/fabledassistant/routes/chat.py @@ -149,6 +149,8 @@ async def send_message_route(conv_id: int): rag_project_id = data.get("rag_project_id") or None workspace_project_id = data.get("workspace_project_id") or None user_timezone = data.get("user_timezone") or None + if not user_timezone: + user_timezone = await get_setting(uid, "user_timezone") or None effective_rag_project_id = workspace_project_id or rag_project_id # Reject if generation already running for this conversation diff --git a/src/fabledassistant/services/briefing_scheduler.py b/src/fabledassistant/services/briefing_scheduler.py index fd5d5c6..44f1ed0 100644 --- a/src/fabledassistant/services/briefing_scheduler.py +++ b/src/fabledassistant/services/briefing_scheduler.py @@ -56,17 +56,22 @@ async def _get_briefing_enabled_users() -> list[tuple[int, str]]: import json async with async_session() as session: result = await session.execute( - select(Setting).where(Setting.key == "briefing_config") + select(Setting).where(Setting.key.in_(["briefing_config", "user_timezone"])) ) rows = list(result.scalars().all()) - enabled = [] + by_user: dict[int, dict[str, str]] = {} for row in rows: + by_user.setdefault(row.user_id, {})[row.key] = row.value or "" + + enabled = [] + for user_id, settings in by_user.items(): try: - config = json.loads(row.value) if row.value else {} + config = json.loads(settings.get("briefing_config", "{}") or "{}") if config.get("enabled"): - tz = _resolve_timezone(config.get("timezone", "UTC")) - enabled.append((row.user_id, tz)) + tz_str = settings.get("user_timezone") or config.get("timezone", "UTC") + tz = _resolve_timezone(tz_str) + enabled.append((user_id, tz)) except Exception: pass return enabled @@ -105,13 +110,15 @@ def _remove_user_jobs(user_id: int) -> None: # ── Public API ──────────────────────────────────────────────────────────────── -def update_user_schedule(user_id: int, config: dict) -> None: +def update_user_schedule(user_id: int, config: dict, tz_override: str | None = None) -> None: """ Called when a user saves their briefing config via the settings UI. Live-patches the scheduler — no restart required. + tz_override takes priority over any timezone in config. """ if config.get("enabled"): - tz = _resolve_timezone(config.get("timezone", "UTC")) + tz_str = tz_override or config.get("timezone", "UTC") + tz = _resolve_timezone(tz_str) _add_user_jobs(user_id, tz) else: _remove_user_jobs(user_id)