Files
FabledScribe/frontend/src/components/BriefingSetupWizard.vue
T
bvandeusen 62dbb8d496 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>
2026-03-25 20:17:51 -04:00

414 lines
12 KiB
Vue

<script setup lang="ts">
import { ref, reactive } from 'vue'
import {
saveBriefingConfig,
geocodeAddress,
createBriefingFeed,
type BriefingConfig,
} from '@/api/client'
const emit = defineEmits<{ done: [] }>()
const step = ref(1)
const TOTAL_STEPS = 4
const config = reactive<BriefingConfig>({
enabled: true,
locations: {},
use_caldav_event_locations: false,
work_days: [1, 2, 3, 4, 5],
slots: { compilation: true, morning: true, midday: false, afternoon: false },
notifications: true,
temp_unit: 'C',
})
// Step 2 — locations
const homeAddress = ref('')
const workAddress = ref('')
const geocodingHome = ref(false)
const geocodingWork = ref(false)
const homeConfirmed = ref<string | null>(null)
const workConfirmed = ref<string | null>(null)
const homeError = ref('')
const workError = ref('')
async function lookupHome() {
if (!homeAddress.value.trim()) return
geocodingHome.value = true
homeError.value = ''
try {
const r = await geocodeAddress(homeAddress.value.trim())
if (r) {
config.locations.home = { label: 'Home', address: homeAddress.value.trim(), lat: r.lat, lon: r.lon }
homeConfirmed.value = r.display_name
} else {
homeError.value = 'Location not found'
}
} finally {
geocodingHome.value = false
}
}
async function lookupWork() {
if (!workAddress.value.trim()) return
geocodingWork.value = true
workError.value = ''
try {
const r = await geocodeAddress(workAddress.value.trim())
if (r) {
config.locations.work = { label: 'Work', address: workAddress.value.trim(), lat: r.lat, lon: r.lon }
workConfirmed.value = r.display_name
} else {
workError.value = 'Location not found'
}
} finally {
geocodingWork.value = false
}
}
// Step 3 — work days
function toggleDay(d: number) {
const idx = config.work_days.indexOf(d)
if (idx === -1) config.work_days.push(d)
else config.work_days.splice(idx, 1)
config.work_days.sort()
}
// Step 4 — RSS feeds
const newFeedUrl = ref('')
const pendingFeeds = ref<Array<{ url: string }>>([])
function addPendingFeed() {
if (!newFeedUrl.value.trim()) return
pendingFeeds.value.push({ url: newFeedUrl.value.trim() })
newFeedUrl.value = ''
}
function removePendingFeed(i: number) { pendingFeeds.value.splice(i, 1) }
// Finish
const finishing = ref(false)
async function finish() {
finishing.value = true
try {
await saveBriefingConfig(config)
for (const f of pendingFeeds.value) {
try { await createBriefingFeed(f.url) } catch { /* best-effort */ }
}
emit('done')
} finally {
finishing.value = false
}
}
</script>
<template>
<Teleport to="body">
<div class="wizard-overlay">
<div class="wizard-card" role="dialog" aria-label="Briefing Setup">
<!-- Progress bar -->
<div class="wizard-progress">
<div class="wizard-progress-fill" :style="{ width: `${(step / TOTAL_STEPS) * 100}%` }"></div>
</div>
<div class="wizard-step-label">Step {{ step }} of {{ TOTAL_STEPS }}</div>
<!-- Step 1: Welcome -->
<div v-if="step === 1" class="wizard-body">
<h2 class="wizard-title">Good morning.</h2>
<p class="wizard-text">
The Briefing is a daily conversation that summarises your day tasks, calendar,
weather, and news and checks in a few times throughout the day.
</p>
<p class="wizard-text">
It learns from your responses over time and adapts to your schedule.
Let's take a minute to set it up.
</p>
<div class="wizard-actions">
<button class="btn-wizard-primary" @click="step = 2">Get started</button>
</div>
</div>
<!-- Step 2: Locations -->
<div v-else-if="step === 2" class="wizard-body">
<h2 class="wizard-title">Where are you?</h2>
<p class="wizard-text">
Enter your home and work addresses. The briefing uses these to fetch weather
for the right places. You can skip either one.
</p>
<div class="wizard-field">
<label class="wizard-label">Home address</label>
<div class="wizard-input-row">
<input v-model="homeAddress" class="wizard-input" placeholder="e.g. 123 Main St, Springfield" @keydown.enter="lookupHome" />
<button class="btn-wizard-secondary" @click="lookupHome" :disabled="geocodingHome">
{{ geocodingHome ? '' : 'Look up' }}
</button>
</div>
<div v-if="homeConfirmed" class="wizard-confirmed">✓ {{ homeConfirmed }}</div>
<div v-if="homeError" class="wizard-error">{{ homeError }}</div>
</div>
<div class="wizard-field">
<label class="wizard-label">Work address</label>
<div class="wizard-input-row">
<input v-model="workAddress" class="wizard-input" placeholder="e.g. 456 Office Ave, Portland" @keydown.enter="lookupWork" />
<button class="btn-wizard-secondary" @click="lookupWork" :disabled="geocodingWork">
{{ geocodingWork ? '' : 'Look up' }}
</button>
</div>
<div v-if="workConfirmed" class="wizard-confirmed">✓ {{ workConfirmed }}</div>
<div v-if="workError" class="wizard-error">{{ workError }}</div>
</div>
<div class="wizard-actions">
<button class="btn-wizard-ghost" @click="step = 1">Back</button>
<button class="btn-wizard-primary" @click="step = 3">Continue</button>
</div>
</div>
<!-- Step 3: Work schedule -->
<div v-else-if="step === 3" class="wizard-body">
<h2 class="wizard-title">When do you go to the office?</h2>
<p class="wizard-text">
The 8am slot is labelled "you're at the office" on days you have marked as office days.
Toggle any days you typically commute.
</p>
<div class="wizard-days">
<button
v-for="(day, idx) in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
:key="idx"
:class="['wizard-day-btn', { active: config.work_days.includes(idx) }]"
@click="toggleDay(idx)"
type="button"
>{{ day }}</button>
</div>
<div class="wizard-actions">
<button class="btn-wizard-ghost" @click="step = 2">Back</button>
<button class="btn-wizard-primary" @click="step = 4">Continue</button>
</div>
</div>
<!-- Step 4: RSS feeds -->
<div v-else-if="step === 4" class="wizard-body">
<h2 class="wizard-title">What do you follow?</h2>
<p class="wizard-text">
Add RSS or Atom feeds and the briefing will summarise recent items each morning.
You can add or remove feeds any time in Settings.
</p>
<div v-if="pendingFeeds.length" class="wizard-feeds-list">
<div v-for="(f, i) in pendingFeeds" :key="i" class="wizard-feed-row">
<span class="wizard-feed-url">{{ f.url }}</span>
<button class="btn-wizard-remove" @click="removePendingFeed(i)">✕</button>
</div>
</div>
<div class="wizard-add-feed">
<input v-model="newFeedUrl" class="wizard-input" placeholder="https://…/feed.xml" style="flex: 1" />
<button class="btn-wizard-secondary" @click="addPendingFeed" :disabled="!newFeedUrl.trim()">Add</button>
</div>
<div class="wizard-actions" style="margin-top: 1.5rem">
<button class="btn-wizard-ghost" @click="step = 3">Back</button>
<button class="btn-wizard-ghost" @click="finish" :disabled="finishing">Skip</button>
<button class="btn-wizard-primary" @click="finish" :disabled="finishing">
{{ finishing ? 'Setting up…' : 'Enable Briefing' }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.wizard-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.wizard-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg, 18px);
width: 520px;
max-width: 94vw;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.wizard-progress {
height: 3px;
background: var(--color-border);
}
.wizard-progress-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #4f46e5);
transition: width 0.3s ease;
}
.wizard-step-label {
font-size: 0.72rem;
color: var(--color-text-muted);
text-align: right;
padding: 0.5rem 1.5rem 0;
}
.wizard-body {
padding: 1.5rem 2rem 2rem;
}
.wizard-title {
font-family: 'Fraunces', Georgia, serif;
font-size: 1.4rem;
font-weight: 700;
margin: 0 0 0.75rem;
color: var(--color-text);
}
.wizard-text {
font-size: 0.9rem;
color: var(--color-text-muted);
line-height: 1.6;
margin: 0 0 0.75rem;
}
.wizard-field {
margin-bottom: 1rem;
}
.wizard-label {
font-size: 0.82rem;
font-weight: 600;
color: var(--color-text-muted);
display: block;
margin-bottom: 0.3rem;
}
.wizard-input-row {
display: flex;
gap: 0.5rem;
}
.wizard-input {
flex: 1;
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.9rem;
outline: none;
font-family: inherit;
}
.wizard-input:focus { border-color: var(--color-primary); }
.wizard-confirmed {
font-size: 0.78rem;
color: var(--color-success, #22c55e);
margin-top: 0.25rem;
}
.wizard-error {
font-size: 0.78rem;
color: var(--color-danger, #ef4444);
margin-top: 0.25rem;
}
.wizard-days {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin: 1rem 0 1.5rem;
}
.wizard-day-btn {
padding: 0.4rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.wizard-day-btn.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.wizard-feeds-list {
margin-bottom: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.wizard-feed-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.6rem;
background: var(--color-bg-secondary);
border-radius: 6px;
}
.wizard-feed-url {
flex: 1;
font-size: 0.82rem;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-wizard-remove {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.8rem;
padding: 0.15rem 0.3rem;
border-radius: 4px;
}
.btn-wizard-remove:hover { color: var(--color-danger, #ef4444); }
.wizard-add-feed {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.wizard-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
.btn-wizard-primary {
padding: 0.5rem 1.25rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
font-family: inherit;
}
.btn-wizard-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-wizard-secondary {
padding: 0.5rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.85rem;
cursor: pointer;
white-space: nowrap;
font-family: inherit;
}
.btn-wizard-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-wizard-ghost {
padding: 0.5rem 1rem;
border: none;
background: none;
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
border-radius: 6px;
font-family: inherit;
}
.btn-wizard-ghost:hover { background: var(--color-bg-secondary); }
.btn-wizard-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
</style>