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 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ import { useShortcuts } from "@/composables/useShortcuts";
|
|||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useChatStore } from "@/stores/chat";
|
import { useChatStore } from "@/stores/chat";
|
||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
import { apiGet } from "@/api/client";
|
import { apiGet, apiPut } from "@/api/client";
|
||||||
|
|
||||||
useTheme();
|
useTheme();
|
||||||
|
|
||||||
@@ -22,6 +22,8 @@ const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
|
|||||||
function startAppServices() {
|
function startAppServices() {
|
||||||
chatStore.startStatusPolling();
|
chatStore.startStatusPolling();
|
||||||
settingsStore.fetchSettings();
|
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() {
|
function stopAppServices() {
|
||||||
|
|||||||
@@ -329,7 +329,6 @@ 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 {
|
||||||
@@ -364,7 +363,6 @@ 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> {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ const config = reactive<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: '',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Step 2 — locations
|
// Step 2 — locations
|
||||||
|
|||||||
@@ -258,7 +258,6 @@ const briefingConfig = ref<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: '',
|
|
||||||
});
|
});
|
||||||
const briefingFeeds = ref<BriefingFeed[]>([]);
|
const briefingFeeds = ref<BriefingFeed[]>([]);
|
||||||
const briefingSaving = ref(false);
|
const briefingSaving = ref(false);
|
||||||
@@ -284,10 +283,6 @@ function _parseTopics(raw: unknown): string[] {
|
|||||||
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;
|
|
||||||
}
|
|
||||||
const allSettings = await apiGet<Record<string, string>>('/api/settings').catch(() => ({} as Record<string, string>));
|
const allSettings = await apiGet<Record<string, string>>('/api/settings').catch(() => ({} as Record<string, string>));
|
||||||
briefingIncludeTopics.value = _parseTopics(allSettings['briefing_include_topics'] ?? '[]');
|
briefingIncludeTopics.value = _parseTopics(allSettings['briefing_include_topics'] ?? '[]');
|
||||||
briefingExcludeTopics.value = _parseTopics(allSettings['briefing_exclude_topics'] ?? '[]');
|
briefingExcludeTopics.value = _parseTopics(allSettings['briefing_exclude_topics'] ?? '[]');
|
||||||
@@ -1694,35 +1689,6 @@ 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>
|
||||||
@@ -1747,8 +1713,8 @@ function formatUserDate(iso: string): string {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="briefingConfig.timezone" class="field-hint" style="margin-top: 0.5rem">
|
<p class="field-hint" style="margin-top: 0.5rem">
|
||||||
Firing in timezone: <strong>{{ briefingConfig.timezone }}</strong>
|
Firing in timezone: <strong>{{ Intl.DateTimeFormat().resolvedOptions().timeZone }}</strong>
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -3313,12 +3279,6 @@ FABLE_API_KEY=<your-api-key></pre>
|
|||||||
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;
|
||||||
|
|||||||
@@ -44,9 +44,10 @@ 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.
|
# Live-patch the scheduler using the stored user_timezone.
|
||||||
from fabledassistant.services.briefing_scheduler import update_user_schedule
|
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})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,8 @@ async def send_message_route(conv_id: int):
|
|||||||
rag_project_id = data.get("rag_project_id") or None
|
rag_project_id = data.get("rag_project_id") or None
|
||||||
workspace_project_id = data.get("workspace_project_id") or None
|
workspace_project_id = data.get("workspace_project_id") or None
|
||||||
user_timezone = data.get("user_timezone") 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
|
effective_rag_project_id = workspace_project_id or rag_project_id
|
||||||
|
|
||||||
# Reject if generation already running for this conversation
|
# Reject if generation already running for this conversation
|
||||||
|
|||||||
@@ -56,17 +56,22 @@ async def _get_briefing_enabled_users() -> list[tuple[int, str]]:
|
|||||||
import json
|
import json
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
result = await session.execute(
|
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())
|
rows = list(result.scalars().all())
|
||||||
|
|
||||||
enabled = []
|
by_user: dict[int, dict[str, str]] = {}
|
||||||
for row in rows:
|
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:
|
try:
|
||||||
config = json.loads(row.value) if row.value else {}
|
config = json.loads(settings.get("briefing_config", "{}") or "{}")
|
||||||
if config.get("enabled"):
|
if config.get("enabled"):
|
||||||
tz = _resolve_timezone(config.get("timezone", "UTC"))
|
tz_str = settings.get("user_timezone") or config.get("timezone", "UTC")
|
||||||
enabled.append((row.user_id, tz))
|
tz = _resolve_timezone(tz_str)
|
||||||
|
enabled.append((user_id, tz))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return enabled
|
return enabled
|
||||||
@@ -105,13 +110,15 @@ def _remove_user_jobs(user_id: int) -> None:
|
|||||||
|
|
||||||
# ── Public API ────────────────────────────────────────────────────────────────
|
# ── 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.
|
Called when a user saves their briefing config via the settings UI.
|
||||||
Live-patches the scheduler — no restart required.
|
Live-patches the scheduler — no restart required.
|
||||||
|
tz_override takes priority over any timezone in config.
|
||||||
"""
|
"""
|
||||||
if config.get("enabled"):
|
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)
|
_add_user_jobs(user_id, tz)
|
||||||
else:
|
else:
|
||||||
_remove_user_jobs(user_id)
|
_remove_user_jobs(user_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user