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/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/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 8ba5f89..e5b3328 100644 --- a/src/fabledassistant/routes/chat.py +++ b/src/fabledassistant/routes/chat.py @@ -148,6 +148,9 @@ 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 + 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 @@ -184,6 +187,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/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) 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}" ]