205 lines
8.8 KiB
Markdown
205 lines
8.8 KiB
Markdown
# Settings Consistency Pass — Design
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Fix five interrelated gaps in the settings UI — missing timezone field, SSO-unaware account tab, duplicated work schedule, ignored slot toggles, and timezone changes not propagating to the briefing scheduler.
|
||
|
||
**Architecture:** Primarily frontend cleanup with two focused backend hooks: settings PUT route gains a timezone→scheduler bridge; briefing scheduler gains slot-gating and work-day awareness.
|
||
|
||
**Tech Stack:** Vue 3 + TypeScript frontend; Python/Quart backend; APScheduler; `zoneinfo`.
|
||
|
||
---
|
||
|
||
## Problem summary
|
||
|
||
1. **No timezone field** — `user_timezone` is read by the scheduler and the chat pipeline but is never exposed in the UI. The briefing tab displays the browser's detected timezone but never persists it. Scheduler falls back to UTC.
|
||
|
||
2. **Account tab ignores SSO** — "Email Address" and "Change Password" sections are shown to SSO users (`has_password = false`) even though they cannot change credentials here.
|
||
|
||
3. **Work schedule duplicated** — Profile tab has the canonical work schedule (days + start/end time, stored in `profile.work_schedule`). Briefing tab has a redundant "Office Days" section (`briefing_config.work_days`) that the backend never reads.
|
||
|
||
4. **Slot toggles are decorative** — The briefing tab's four slot checkboxes are saved to `briefing_config.slots` but `_add_user_jobs` schedules all four slots unconditionally.
|
||
|
||
5. **Timezone setting not propagated** — `PUT /api/settings` saves `user_timezone` to the DB but does not call `update_user_schedule`, so the in-memory scheduler keeps the stale timezone until restart or briefing config re-save.
|
||
|
||
---
|
||
|
||
## Components
|
||
|
||
### 1. General tab — Timezone field
|
||
|
||
**File:** `frontend/src/views/SettingsView.vue`
|
||
|
||
New section in the General tab (after the Assistant section, before Model Management):
|
||
|
||
```html
|
||
<section class="settings-section full-width">
|
||
<h2>Timezone</h2>
|
||
<p class="section-desc">Used to schedule briefings and format times in chat.</p>
|
||
<div class="field">
|
||
<label for="user-timezone">Your timezone</label>
|
||
<div style="display:flex; gap:0.5rem; align-items:center">
|
||
<input id="user-timezone" v-model="userTimezone" type="text"
|
||
class="input" placeholder="e.g. America/New_York" />
|
||
<button class="btn-secondary" type="button" @click="detectTimezone">Detect</button>
|
||
</div>
|
||
<p class="field-hint">IANA timezone name (e.g. America/Chicago, Europe/London).</p>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-save" @click="saveTimezone" :disabled="savingTimezone">
|
||
{{ savingTimezone ? 'Saving…' : 'Save' }}
|
||
</button>
|
||
<span v-if="timezoneSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
```
|
||
|
||
- `detectTimezone()` sets `userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone`
|
||
- `saveTimezone()` calls `PUT /api/settings` with `{ user_timezone: userTimezone }`
|
||
- Loaded in `onMounted` / general settings load alongside `assistantName`, `defaultModel`
|
||
|
||
The briefing tab's "Firing in timezone" hint changes from the live Intl API to reading the stored `user_timezone` value:
|
||
```
|
||
Firing in timezone: <strong>{{ userTimezone || 'UTC (not set)' }}</strong>
|
||
```
|
||
|
||
### 2. Account tab — SSO guard
|
||
|
||
**File:** `frontend/src/views/SettingsView.vue`
|
||
|
||
Wrap the Email and Password sections:
|
||
|
||
```html
|
||
<!-- SSO info banner (shown when no local password) -->
|
||
<section v-if="!authStore.user?.has_password" class="settings-section">
|
||
<h2>Account</h2>
|
||
<p class="section-desc">
|
||
Your account is managed by an external identity provider.
|
||
Email and password changes are made through your provider, not here.
|
||
</p>
|
||
</section>
|
||
|
||
<!-- Local-auth sections (hidden for SSO) -->
|
||
<template v-if="authStore.user?.has_password">
|
||
<section class="settings-section"> <!-- Email Address --> </section>
|
||
<section class="settings-section"> <!-- Change Password --> </section>
|
||
</template>
|
||
|
||
<!-- Active Sessions — always shown -->
|
||
<section class="settings-section"> ... </section>
|
||
```
|
||
|
||
No backend change needed — the API already rejects email/password changes for SSO accounts.
|
||
|
||
### 3. Briefing tab — Remove Office Days
|
||
|
||
**File:** `frontend/src/views/SettingsView.vue`
|
||
|
||
Delete the "Office Days" `<section>` (lines ~2068–2082). The `briefing_config.work_days` field can remain in the config object for backwards compatibility but the UI stops writing it.
|
||
|
||
The slot toggles section stays — it now actually drives scheduling (see §5).
|
||
|
||
### 4. Backend — settings PUT propagates timezone to scheduler
|
||
|
||
**File:** `src/fabledassistant/routes/settings.py`
|
||
|
||
After `set_settings_batch`, add:
|
||
|
||
```python
|
||
if "user_timezone" in to_save:
|
||
import json
|
||
from fabledassistant.services.briefing_scheduler import update_user_schedule
|
||
config_raw = await get_setting(uid, "briefing_config", "{}")
|
||
try:
|
||
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
|
||
except Exception:
|
||
config = {}
|
||
if config.get("enabled"):
|
||
update_user_schedule(uid, config, tz_override=to_save["user_timezone"] or None)
|
||
```
|
||
|
||
### 5. Backend — scheduler respects slot toggles and work days
|
||
|
||
**File:** `src/fabledassistant/services/briefing_scheduler.py`
|
||
|
||
**5a. `_add_user_jobs` — only schedule enabled slots**
|
||
|
||
Change signature to accept `config: dict`:
|
||
|
||
```python
|
||
def _add_user_jobs(user_id: int, tz: str, config: dict | None = None) -> None:
|
||
enabled_slots = (config or {}).get("slots", {})
|
||
for slot_name, hour, minute in SLOTS:
|
||
# Default True for compilation (always run); others respect toggle
|
||
if slot_name != "compilation" and not enabled_slots.get(slot_name, True):
|
||
jid = _job_id(user_id, slot_name)
|
||
if _scheduler and _scheduler.get_job(jid):
|
||
_scheduler.remove_job(jid)
|
||
continue
|
||
_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,
|
||
)
|
||
```
|
||
|
||
Update callers:
|
||
- `update_user_schedule(user_id, config, tz_override)` → pass `config` to `_add_user_jobs`
|
||
- `start_briefing_scheduler` startup loop → fetch full config to pass through
|
||
|
||
**5b. `_run_slot_for_user` — skip morning on non-work days**
|
||
|
||
For the `morning` slot, check today against `profile.work_schedule.days`:
|
||
|
||
```python
|
||
if slot == "morning":
|
||
from fabledassistant.services.user_profile import get_profile
|
||
from datetime import datetime
|
||
tz_str = await get_setting(user_id, "user_timezone") or "UTC"
|
||
try:
|
||
user_tz = ZoneInfo(tz_str)
|
||
except Exception:
|
||
user_tz = ZoneInfo("UTC")
|
||
today_abbr = datetime.now(user_tz).strftime("%a") # 'Mon', 'Tue', …
|
||
profile = await get_profile(user_id)
|
||
work_days = (profile.work_schedule or {}).get("days", ["Mon","Tue","Wed","Thu","Fri"])
|
||
if today_abbr not in work_days:
|
||
logger.info("Skipping morning slot for user %d — %s not a work day", user_id, today_abbr)
|
||
return
|
||
```
|
||
|
||
Note: `get_profile` must be importable from `user_profile.py` — confirm signature during implementation.
|
||
|
||
---
|
||
|
||
## Data flow
|
||
|
||
1. User opens Settings → General tab loads, reads `user_timezone` from `GET /api/settings`, populates the field
|
||
2. User clicks Detect → browser timezone fills the field
|
||
3. User clicks Save → `PUT /api/settings {user_timezone: "America/New_York"}` → backend saves and immediately calls `update_user_schedule` if briefing enabled
|
||
4. Briefing tab "Firing in timezone" now shows stored value instead of live browser API
|
||
5. Next 8am job: scheduler checks if `morning` is enabled in `briefing_config.slots`, then checks if today is in `profile.work_schedule.days` before running
|
||
|
||
---
|
||
|
||
## Error handling
|
||
|
||
| Scenario | Behaviour |
|
||
|---|---|
|
||
| `user_timezone` saved as empty string | `update_user_schedule` called with `tz_override=None` → falls back to `briefing_config.timezone` or UTC |
|
||
| Invalid IANA string saved | `_resolve_timezone` already falls back to UTC with a warning log |
|
||
| `profile.work_schedule` is None | `morning` slot defaults to Mon–Fri |
|
||
| Slot toggles key missing from config | All non-compilation slots default to enabled (`True`) — no regression for existing users |
|
||
| SSO user visits Account tab | Sees info banner; email/password forms hidden; no API calls attempted |
|
||
|
||
---
|
||
|
||
## What is NOT changing
|
||
|
||
- Profile "Interests" and Briefing "News Preferences" remain separate — they serve different purposes (system-prompt personalisation vs RSS topic filtering)
|
||
- `briefing_config.work_days` field is not deleted from existing configs — just stops being written by the UI
|
||
- No migration needed — `profile.work_schedule.days` already exists; scheduler change is additive
|