Merge pull request 'refactor: centralise user timezone as standalone setting' (#10) from dev into main
This commit was merged in pull request #10.
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
|
||||||
|
|||||||
@@ -38,17 +38,30 @@ const color = ref("");
|
|||||||
const projectId = ref<number | null>(null);
|
const projectId = ref<number | null>(null);
|
||||||
|
|
||||||
function dateFromIso(iso: string): string {
|
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 {
|
function timeFromIso(iso: string): string {
|
||||||
if (!iso.includes("T")) return "09:00";
|
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 {
|
function toIso(date: string, time: string): string {
|
||||||
if (!time) return `${date}T00:00:00`;
|
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() {
|
function resetForm() {
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
think,
|
think,
|
||||||
rag_project_id: ragProjectId ?? undefined,
|
rag_project_id: ragProjectId ?? undefined,
|
||||||
workspace_project_id: workspaceProjectId ?? undefined,
|
workspace_project_id: workspaceProjectId ?? undefined,
|
||||||
|
user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assistantMessageId = resp.assistant_message_id;
|
assistantMessageId = resp.assistant_message_id;
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,9 @@ async def send_message_route(conv_id: int):
|
|||||||
think = bool(data.get("think", False))
|
think = bool(data.get("think", False))
|
||||||
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
|
||||||
|
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
|
||||||
@@ -184,6 +187,7 @@ async def send_message_route(conv_id: int):
|
|||||||
think=think,
|
think=think,
|
||||||
rag_project_id=effective_rag_project_id,
|
rag_project_id=effective_rag_project_id,
|
||||||
workspace_project_id=workspace_project_id,
|
workspace_project_id=workspace_project_id,
|
||||||
|
user_timezone=user_timezone,
|
||||||
))
|
))
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ async def run_generation(
|
|||||||
think: bool = False,
|
think: bool = False,
|
||||||
rag_project_id: int | None = None,
|
rag_project_id: int | None = None,
|
||||||
workspace_project_id: int | None = None,
|
workspace_project_id: int | None = None,
|
||||||
|
user_timezone: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Stream LLM response into buffer with periodic DB flushes."""
|
"""Stream LLM response into buffer with periodic DB flushes."""
|
||||||
MAX_TOOL_ROUNDS = 5
|
MAX_TOOL_ROUNDS = 5
|
||||||
@@ -183,6 +184,7 @@ async def run_generation(
|
|||||||
excluded_note_ids=excluded_note_ids,
|
excluded_note_ids=excluded_note_ids,
|
||||||
rag_project_id=rag_project_id,
|
rag_project_id=rag_project_id,
|
||||||
workspace_project_id=workspace_project_id,
|
workspace_project_id=workspace_project_id,
|
||||||
|
user_timezone=user_timezone,
|
||||||
))
|
))
|
||||||
|
|
||||||
messages, context_meta = await context_task
|
messages, context_meta = await context_task
|
||||||
|
|||||||
@@ -452,6 +452,7 @@ async def build_context(
|
|||||||
excluded_note_ids: list[int] | None = None,
|
excluded_note_ids: list[int] | None = None,
|
||||||
rag_project_id: int | None = None,
|
rag_project_id: int | None = None,
|
||||||
workspace_project_id: int | None = None,
|
workspace_project_id: int | None = None,
|
||||||
|
user_timezone: str | None = None,
|
||||||
) -> tuple[list[dict], dict]:
|
) -> tuple[list[dict], dict]:
|
||||||
"""Build messages array for Ollama with system prompt and context.
|
"""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, "
|
"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."
|
"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("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 creating notes, use the `tags` parameter — do not embed #tag text in the note body.")
|
||||||
tool_lines.append(
|
tool_lines.append(
|
||||||
"When search_images returns results, embed each image directly in your response by writing "
|
"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)
|
tool_guidance = "\n".join(tool_lines)
|
||||||
|
|
||||||
|
tz_line = f" The user's timezone is {user_timezone}." if user_timezone else ""
|
||||||
system_parts = [
|
system_parts = [
|
||||||
f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Assistant. "
|
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. "
|
"Help users with their notes, tasks, and general questions. "
|
||||||
"When note context is provided, use it to give relevant answers. "
|
"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}"
|
f"{tool_guidance}"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user