feat: daily briefing frontend — view, setup wizard, settings tab, nav

- BriefingView: soft chat UI with conversation history dropdown, SSE
  streaming via chat store, manual refresh trigger, today/past conv toggle
- BriefingSetupWizard: 4-step first-time setup overlay (welcome →
  locations → office days → RSS feeds)
- SettingsView: Briefing tab with enable toggle, location geocoding,
  office day picker, slot toggles, RSS feed management, notifications
- AppHeader + router: /briefing route and nav link (desktop + mobile)
- client.ts: briefing types and API functions (config, feeds, convs,
  geocode, trigger)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 20:30:45 -04:00
parent d131621de6
commit 1686289af4
6 changed files with 1281 additions and 3 deletions
+116
View File
@@ -300,6 +300,122 @@ export function apiSSEStream(
return { close: () => controller.abort(), done };
}
// ---------------------------------------------------------------------------
// Briefing
// ---------------------------------------------------------------------------
export interface BriefingLocation {
label: string;
address: string;
lat?: number;
lon?: number;
}
export interface BriefingSlots {
compilation: boolean;
morning: boolean;
midday: boolean;
afternoon: boolean;
}
export interface BriefingConfig {
enabled: boolean;
locations: {
home?: BriefingLocation;
work?: BriefingLocation;
};
use_caldav_event_locations: boolean;
work_days: number[];
slots: BriefingSlots;
notifications: boolean;
}
export interface BriefingFeed {
id: number;
title: string;
url: string;
category: string | null;
last_fetched_at: string | null;
}
export interface BriefingConversation {
id: number;
title: string;
briefing_date: string | null;
message_count: number;
created_at: string;
}
export interface BriefingMessage {
id: number;
role: 'user' | 'assistant' | 'system';
content: string;
created_at: string;
}
const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
enabled: false,
locations: {},
use_caldav_event_locations: false,
work_days: [1, 2, 3, 4, 5],
slots: { compilation: true, morning: true, midday: false, afternoon: false },
notifications: true,
};
export async function getBriefingConfig(): Promise<BriefingConfig> {
try {
const data = await apiGet<BriefingConfig>('/api/briefing/config');
return { ...DEFAULT_BRIEFING_CONFIG, ...data };
} catch {
return { ...DEFAULT_BRIEFING_CONFIG };
}
}
export async function saveBriefingConfig(config: BriefingConfig): Promise<void> {
await apiPut('/api/briefing/config', config);
}
export async function getBriefingFeeds(): Promise<BriefingFeed[]> {
const data = await apiGet<BriefingFeed[]>('/api/briefing/feeds');
return data;
}
export async function createBriefingFeed(url: string): Promise<BriefingFeed> {
const data = await apiPost<{ id: number; url: string; title: string; category: string | null }>('/api/briefing/feeds', { url });
return { ...data, last_fetched_at: null };
}
export async function deleteBriefingFeed(id: number): Promise<void> {
await apiDelete(`/api/briefing/feeds/${id}`);
}
export async function getBriefingConversations(): Promise<BriefingConversation[]> {
const data = await apiGet<{ conversations: BriefingConversation[] }>('/api/briefing/conversations');
return data.conversations;
}
export async function getBriefingToday(): Promise<{ id: number; title: string; messages: BriefingMessage[] }> {
return apiGet('/api/briefing/conversations/today');
}
export async function getBriefingConvMessages(id: number): Promise<BriefingMessage[]> {
const data = await apiGet<{ messages: BriefingMessage[] }>(`/api/briefing/conversations/${id}/messages`);
return data.messages;
}
export async function triggerBriefingSlot(slot: string): Promise<void> {
await apiPost('/api/briefing/trigger', { slot });
}
export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number; display_name: string } | null> {
try {
const r = await apiPost<{ lat: number; lon: number; label: string }>('/api/briefing/weather/geocode', { query: address });
return { lat: r.lat, lon: r.lon, display_name: r.label };
} catch {
return null;
}
}
export async function apiStreamPost(
path: string,
body: unknown,
+2
View File
@@ -77,6 +77,7 @@ router.afterEach(() => {
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
<router-link to="/graph" class="nav-link">Graph</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
</div>
@@ -125,6 +126,7 @@ router.afterEach(() => {
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
<router-link to="/graph" class="nav-link">Graph</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
<div class="mobile-divider"></div>
<router-link to="/settings" class="nav-link">Settings</router-link>
@@ -0,0 +1,412 @@
<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,
})
// 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>
+5
View File
@@ -110,6 +110,11 @@ const router = createRouter({
name: "shared-with-me",
component: () => import("@/views/SharedWithMeView.vue"),
},
{
path: "/briefing",
name: "briefing",
component: () => import("@/views/BriefingView.vue"),
},
{
path: "/settings",
name: "settings",
+395
View File
@@ -0,0 +1,395 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useChatStore } from '@/stores/chat'
import ChatMessage from '@/components/ChatMessage.vue'
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
import {
getBriefingConfig,
getBriefingConversations,
getBriefingToday,
getBriefingConvMessages,
triggerBriefingSlot,
type BriefingConversation,
type BriefingMessage,
} from '@/api/client'
import type { Message } from '@/types/chat'
const chatStore = useChatStore()
// Setup wizard
const showWizard = ref(false)
const wizardChecked = ref(false)
async function checkSetup() {
const config = await getBriefingConfig()
if (!config.enabled) showWizard.value = true
wizardChecked.value = true
}
async function onWizardDone() {
showWizard.value = false
await loadAll()
}
// Conversations list for the dropdown
const conversations = ref<BriefingConversation[]>([])
const selectedConvId = ref<number | null>(null)
const todayConvId = ref<number | null>(null)
const isToday = computed(() => selectedConvId.value === todayConvId.value)
// Messages for selected conversation
const messages = ref<BriefingMessage[]>([])
const loadingMessages = ref(false)
async function loadAll() {
const [convList, today] = await Promise.all([
getBriefingConversations(),
getBriefingToday().catch(() => null),
])
conversations.value = convList
if (today) {
todayConvId.value = today.id
// Ensure today is in the list
if (!convList.find((c) => c.id === today.id)) {
conversations.value = [
{ id: today.id, title: today.title ?? 'Today', briefing_date: null, message_count: 0, created_at: new Date().toISOString() },
...convList,
]
}
selectedConvId.value = today.id
messages.value = today.messages
// Load into chatStore so we can stream
await chatStore.fetchConversation(today.id)
}
}
watch(selectedConvId, async (id) => {
if (!id) return
if (id === todayConvId.value) {
await chatStore.fetchConversation(id)
messages.value = (chatStore.currentConversation?.messages ?? []) as unknown as BriefingMessage[]
return
}
loadingMessages.value = true
try {
messages.value = await getBriefingConvMessages(id)
} finally {
loadingMessages.value = false
}
})
// Refresh messages after streaming ends
watch(() => chatStore.streaming, async (streaming) => {
if (!streaming && selectedConvId.value === todayConvId.value && todayConvId.value) {
const today = await getBriefingToday().catch(() => null)
if (today) messages.value = today.messages
}
})
// Input
const input = ref('')
const sending = ref(false)
async function send() {
const text = input.value.trim()
if (!text || !todayConvId.value || chatStore.streaming || sending.value) return
// Ensure today's conv is loaded in chatStore
if (chatStore.currentConversation?.id !== todayConvId.value) {
await chatStore.fetchConversation(todayConvId.value)
}
input.value = ''
sending.value = true
try {
await chatStore.sendMessage(text)
} finally {
sending.value = false
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}
// Manual trigger
const triggering = ref(false)
async function triggerNow() {
triggering.value = true
try {
await triggerBriefingSlot('compilation')
// Reload
await loadAll()
} finally {
triggering.value = false
}
}
// Dropdown label
function convLabel(c: BriefingConversation): string {
if (c.id === todayConvId.value) return 'Today'
if (c.briefing_date) {
const d = new Date(c.briefing_date)
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
}
return c.title || 'Briefing'
}
// Convert BriefingMessage to Message for ChatMessage component
function toMsg(m: BriefingMessage): Message {
return {
id: m.id,
conversation_id: -1,
role: m.role,
content: m.content,
context_note_id: null,
context_note_title: null,
created_at: m.created_at,
}
}
onMounted(async () => {
await checkSetup()
if (!showWizard.value) await loadAll()
})
</script>
<template>
<div class="briefing-root">
<!-- Setup wizard overlay -->
<BriefingSetupWizard v-if="wizardChecked && showWizard" @done="onWizardDone" />
<!-- Main view (shown after wizard check and only if not showing wizard) -->
<div class="briefing-shell" v-if="wizardChecked && !showWizard">
<!-- Header -->
<header class="briefing-header">
<div class="briefing-header-left">
<h1 class="briefing-title">Briefing</h1>
<span class="briefing-today-badge">{{ new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) }}</span>
</div>
<div class="briefing-header-right">
<!-- Conversation history dropdown -->
<select v-if="conversations.length" v-model="selectedConvId" class="briefing-conv-select">
<option v-for="c in conversations" :key="c.id" :value="c.id">{{ convLabel(c) }}</option>
</select>
<button
class="btn-trigger"
@click="triggerNow"
:disabled="triggering"
title="Manually trigger morning briefing now"
>{{ triggering ? '…' : 'Refresh' }}</button>
</div>
</header>
<!-- Messages -->
<div class="briefing-messages-wrap">
<div v-if="loadingMessages" class="briefing-loading">Loading</div>
<template v-else>
<div v-if="!messages.length" class="briefing-empty">
<p>No briefing yet for today.</p>
<p class="briefing-empty-hint">Click "Refresh" to generate a briefing now, or wait for the scheduled slot.</p>
</div>
<div v-else class="briefing-messages">
<ChatMessage
v-for="msg in messages"
:key="msg.id"
:message="toMsg(msg)"
:is-streaming="false"
/>
<!-- Live streaming bubble for today -->
<ChatMessage
v-if="isToday && chatStore.streaming && chatStore.streamingContent"
:message="{
id: -1,
conversation_id: todayConvId ?? -1,
role: 'assistant',
content: chatStore.streamingContent,
context_note_id: null,
context_note_title: null,
created_at: new Date().toISOString(),
}"
:is-streaming="true"
/>
</div>
</template>
</div>
<!-- Input bar (today only) -->
<div v-if="isToday" class="briefing-input-bar">
<textarea
v-model="input"
class="briefing-input"
placeholder="Reply to your briefing…"
rows="1"
@keydown="onKeydown"
></textarea>
<button
class="btn-send"
@click="send"
:disabled="!input.trim() || chatStore.streaming || sending"
aria-label="Send"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 21l21-9L2 3v7l15 2-15 2v7z"/>
</svg>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.briefing-root {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.briefing-shell {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
max-width: 760px;
margin: 0 auto;
width: 100%;
padding: 0 1rem;
}
.briefing-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 0 1rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
gap: 1rem;
flex-wrap: wrap;
}
.briefing-header-left {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.briefing-title {
font-family: 'Fraunces', Georgia, serif;
font-size: 1.3rem;
font-weight: 700;
margin: 0;
color: var(--color-text);
}
.briefing-today-badge {
font-size: 0.82rem;
color: var(--color-text-muted);
}
.briefing-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.briefing-conv-select {
padding: 0.35rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.82rem;
cursor: pointer;
font-family: inherit;
}
.btn-trigger {
padding: 0.35rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text-muted);
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
font-family: inherit;
}
.btn-trigger:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
.briefing-messages-wrap {
flex: 1;
overflow-y: auto;
padding: 1rem 0;
}
.briefing-loading,
.briefing-empty {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.briefing-empty-hint {
font-size: 0.8rem;
opacity: 0.7;
margin-top: 0.5rem;
}
.briefing-messages {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.briefing-input-bar {
display: flex;
gap: 0.5rem;
align-items: flex-end;
padding: 0.75rem 0 1rem;
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
.briefing-input {
flex: 1;
padding: 0.6rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.9rem;
resize: none;
outline: none;
font-family: inherit;
line-height: 1.4;
max-height: 120px;
overflow-y: auto;
transition: border-color 0.15s;
}
.briefing-input:focus { border-color: var(--color-primary); }
.btn-send {
padding: 0.55rem 0.75rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border: none;
border-radius: 10px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.15s;
flex-shrink: 0;
}
.btn-send:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-send:hover:not(:disabled) { opacity: 0.9; }
</style>
+351 -3
View File
@@ -3,7 +3,7 @@ import { ref, watch, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, type GroupEntry, type GroupMember, type UserSearchResult } from "@/api/client";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, geocodeAddress, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed } from "@/api/client";
import { usePushStore } from "@/stores/push";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
@@ -32,7 +32,7 @@ const appVersion = ref('dev');
const restoreFileInput = ref<HTMLInputElement | null>(null);
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "config", "users", "logs", "groups"]);
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "briefing", "config", "users", "logs", "groups"]);
const _stored = localStorage.getItem("settings_tab") ?? "general";
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
watch(activeTab, (v) => {
@@ -40,6 +40,7 @@ watch(activeTab, (v) => {
if (v === "users" && authStore.isAdmin) loadUsersPanel();
if (v === "logs" && authStore.isAdmin) loadLogsPanel();
if (v === "groups" && authStore.isAdmin) loadGroupsPanel();
if (v === "briefing") loadBriefingTab();
});
// Groups management
@@ -117,6 +118,94 @@ async function removeMemberFromGroup(groupId: number, userId: number) {
await loadGroupsPanel();
}
// Briefing settings
const briefingConfig = ref<BriefingConfig>({
enabled: false,
locations: {},
use_caldav_event_locations: false,
work_days: [1, 2, 3, 4, 5],
slots: { compilation: true, morning: true, midday: false, afternoon: false },
notifications: true,
});
const briefingFeeds = ref<BriefingFeed[]>([]);
const briefingSaving = ref(false);
const briefingSaved = ref(false);
const briefingGeocoding = ref<Record<string, boolean>>({});
const briefingGeoError = ref<Record<string, string>>({});
const newFeedUrl = ref('');
const addingFeed = ref(false);
async function loadBriefingTab() {
briefingConfig.value = await getBriefingConfig();
briefingFeeds.value = await getBriefingFeeds();
}
async function geocodeLocation(key: 'home' | 'work') {
const loc = briefingConfig.value.locations[key];
if (!loc?.address?.trim()) return;
briefingGeocoding.value[key] = true;
briefingGeoError.value[key] = '';
try {
const result = await geocodeAddress(loc.address.trim());
if (result) {
briefingConfig.value.locations[key] = {
...loc,
lat: result.lat,
lon: result.lon,
label: key.charAt(0).toUpperCase() + key.slice(1),
};
briefingGeoError.value[key] = '';
} else {
briefingGeoError.value[key] = 'Location not found — check the address';
}
} catch {
briefingGeoError.value[key] = 'Geocoding failed';
} finally {
briefingGeocoding.value[key] = false;
}
}
function toggleWorkDay(day: number) {
const days = briefingConfig.value.work_days;
const idx = days.indexOf(day);
if (idx === -1) days.push(day);
else days.splice(idx, 1);
days.sort();
}
async function saveBriefingSettings() {
briefingSaving.value = true;
briefingSaved.value = false;
try {
await saveBriefingConfig(briefingConfig.value);
briefingSaved.value = true;
setTimeout(() => (briefingSaved.value = false), 2000);
} catch {
toastStore.show('Failed to save briefing settings', 'error');
} finally {
briefingSaving.value = false;
}
}
async function addFeed() {
if (!newFeedUrl.value.trim() || addingFeed.value) return;
addingFeed.value = true;
try {
const feed = await createBriefingFeed(newFeedUrl.value.trim());
briefingFeeds.value.push(feed);
newFeedUrl.value = '';
} catch {
toastStore.show('Failed to add feed', 'error');
} finally {
addingFeed.value = false;
}
}
async function removeFeed(id: number) {
await deleteBriefingFeed(id);
briefingFeeds.value = briefingFeeds.value.filter((f) => f.id !== id);
}
// Chat retention
const chatRetentionDays = ref(90);
const savingRetention = ref(false);
@@ -774,7 +863,7 @@ function formatUserDate(iso: string): string {
<div class="sidebar-group">
<div class="sidebar-group-label">User</div>
<button
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data']"
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'briefing']"
:key="tab"
:class="['sidebar-item', { active: activeTab === tab }]"
@click="activeTab = tab"
@@ -1172,6 +1261,147 @@ function formatUserDate(iso: string): string {
</div>
<!-- Briefing -->
<div v-show="activeTab === 'briefing'" class="settings-grid">
<section class="settings-section full-width">
<h2>Daily Briefing</h2>
<p class="section-desc">
Configure your daily briefing a conversation that summarises your day,
weather, and RSS feeds on a schedule.
</p>
<!-- Enable -->
<div class="checkbox-field">
<label>
<input type="checkbox" v-model="briefingConfig.enabled" />
Enable Daily Briefing
</label>
<p class="field-hint">Start daily briefings at the configured slots.</p>
</div>
</section>
<!-- Locations -->
<section class="settings-section full-width">
<h2>Locations</h2>
<p class="section-desc">Enter addresses for weather lookup. Click "Look up" to geocode.</p>
<div v-for="key in (['home', 'work'] as const)" :key="key" class="briefing-location-row">
<label class="field-label">{{ key.charAt(0).toUpperCase() + key.slice(1) }}</label>
<div class="briefing-input-group">
<input
type="text"
class="input"
:placeholder="key === 'home' ? 'e.g. 123 Main St, Springfield' : 'e.g. 456 Office Ave, Portland'"
:value="briefingConfig.locations[key]?.address ?? ''"
@input="(e) => {
if (!briefingConfig.locations[key]) briefingConfig.locations[key] = { label: key, address: '' };
briefingConfig.locations[key]!.address = (e.target as HTMLInputElement).value;
}"
/>
<button class="btn-secondary" @click="geocodeLocation(key)" :disabled="briefingGeocoding[key]">
{{ briefingGeocoding[key] ? 'Looking up…' : 'Look up' }}
</button>
</div>
<div v-if="briefingConfig.locations[key]?.lat" class="briefing-geo-confirmed">
{{ briefingConfig.locations[key]!.lat?.toFixed(4) }}, {{ briefingConfig.locations[key]!.lon?.toFixed(4) }}
</div>
<div v-if="briefingGeoError[key]" class="briefing-geo-error">{{ briefingGeoError[key] }}</div>
</div>
<div class="checkbox-field" style="margin-top: 0.75rem">
<label>
<input type="checkbox" v-model="briefingConfig.use_caldav_event_locations" />
Also check CalDAV event locations
</label>
<p class="field-hint">Look up weather for locations in today's calendar events.</p>
</div>
</section>
<!-- Work schedule -->
<section class="settings-section full-width">
<h2>Office Days</h2>
<p class="section-desc">Which days do you go into the office? Used to decide whether to include the 8am check-in slot.</p>
<div class="briefing-day-toggles">
<button
v-for="(day, idx) in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
:key="idx"
:class="['briefing-day-btn', { active: briefingConfig.work_days.includes(idx) }]"
@click="toggleWorkDay(idx)"
type="button"
>{{ day }}</button>
</div>
</section>
<!-- Slots -->
<section class="settings-section full-width">
<h2>Scheduled Slots</h2>
<p class="section-desc">Each active slot will post an update into your briefing conversation.</p>
<div class="briefing-slot-list">
<div
v-for="[key, label, time] in ([
['compilation', 'Morning briefing', '4:00 am'],
['morning', 'Office check-in', '8:00 am'],
['midday', 'Midday update', '12:00 pm'],
['afternoon', 'End of day', '4:00 pm'],
] as const)"
:key="key"
class="briefing-slot-row"
>
<div class="briefing-slot-info">
<span class="briefing-slot-label">{{ label }}</span>
<span class="briefing-slot-time">{{ time }}</span>
</div>
<label>
<input type="checkbox" v-model="briefingConfig.slots[key]" />
</label>
</div>
</div>
</section>
<!-- RSS Feeds -->
<section class="settings-section full-width">
<h2>RSS Feeds</h2>
<p class="section-desc">Add RSS or Atom feeds to be summarised in your morning briefing.</p>
<div class="briefing-feeds-list" v-if="briefingFeeds.length">
<div class="briefing-feed-row" v-for="feed in briefingFeeds" :key="feed.id">
<div class="briefing-feed-info">
<span class="briefing-feed-title">{{ feed.title || feed.url }}</span>
<span class="briefing-feed-url">{{ feed.url }}</span>
</div>
<button class="briefing-btn-remove" @click="removeFeed(feed.id)" aria-label="Remove feed">✕</button>
</div>
</div>
<p v-else class="field-hint">No feeds yet.</p>
<div class="briefing-add-feed">
<input v-model="newFeedUrl" class="input" placeholder="https://example.com/feed.xml" style="flex: 1" />
<button class="btn-secondary" @click="addFeed" :disabled="addingFeed || !newFeedUrl.trim()">
{{ addingFeed ? 'Adding' : 'Add Feed' }}
</button>
</div>
</section>
<!-- Notifications -->
<section class="settings-section full-width">
<h2>Notifications</h2>
<div class="checkbox-field">
<label>
<input type="checkbox" v-model="briefingConfig.notifications" />
Push notification when each briefing slot is ready
</label>
</div>
</section>
<section class="settings-section full-width">
<div class="actions">
<button class="btn-save" @click="saveBriefingSettings" :disabled="briefingSaving">
{{ briefingSaved ? 'Saved ' : briefingSaving ? 'Saving' : 'Save Briefing Settings' }}
</button>
</div>
</section>
</div>
<!-- ── Admin ── -->
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">
@@ -2441,4 +2671,122 @@ function formatUserDate(iso: string): string {
font-size: 0.82rem;
padding: 0.25rem 0.5rem;
}
/* Briefing tab */
.briefing-location-row {
margin-bottom: 1rem;
}
.briefing-input-group {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
}
.briefing-geo-confirmed {
font-size: 0.78rem;
color: var(--color-success, #22c55e);
margin-top: 0.2rem;
}
.briefing-geo-error {
font-size: 0.78rem;
color: var(--color-danger, #ef4444);
margin-top: 0.2rem;
}
.briefing-day-toggles {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.briefing-day-btn {
padding: 0.35rem 0.65rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text-muted);
font-size: 0.82rem;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.briefing-day-btn.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.briefing-slot-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-top: 0.5rem;
}
.briefing-slot-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.45rem 0.75rem;
border-radius: 8px;
background: var(--color-bg-secondary);
}
.briefing-slot-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.briefing-slot-label {
font-size: 0.88rem;
font-weight: 500;
}
.briefing-slot-time {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.briefing-feeds-list {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-bottom: 0.75rem;
}
.briefing-feed-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
border-radius: 6px;
background: var(--color-bg-secondary);
}
.briefing-feed-info {
flex: 1;
min-width: 0;
}
.briefing-feed-title {
font-size: 0.85rem;
font-weight: 500;
display: block;
}
.briefing-feed-url {
font-size: 0.75rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
.briefing-btn-remove {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.85rem;
padding: 0.2rem 0.4rem;
border-radius: 4px;
flex-shrink: 0;
transition: color 0.15s;
}
.briefing-btn-remove:hover { color: var(--color-danger, #ef4444); }
.briefing-add-feed {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
</style>