feat(briefing): hard-cut tear-down
Backend: - Delete briefing services (pipeline, scheduler, conversations, profile, tools) - Delete routes/briefing.py + remove blueprint registration - Move _get_temp_unit into services/weather.get_temp_unit (reads top-level temp_unit setting) - Rename briefing_preferences.py → rss_filtering.py (functions are RSS-specific) - Strip briefing scheduler hooks from app.py - Strip briefing scheduler call from routes/settings.py - Update test imports (test_rss_service, test_tz_helpers) Frontend: - Delete BriefingView, BriefingSetupWizard, BriefingToolStatusRow - Strip /briefing route + nav links (AppHeader, KnowledgeView) - Strip Settings → Briefing tab + state + functions + imports - Strip briefing-intermediate handling from ChatMessage - Hide /news route + nav links (NewsView depended on briefing endpoints; orphaned in tree) - Drop unused useSettingsStore from AppHeader The Android BriefingScreen lives in a separate repo and is not touched here. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,6 @@ import { useTheme } from "@/composables/useTheme";
|
||||
import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
import NotificationBell from "@/components/NotificationBell.vue";
|
||||
|
||||
@@ -13,7 +12,6 @@ const { theme, toggleTheme } = useTheme();
|
||||
const { toggleShortcuts } = useShortcuts();
|
||||
const authStore = useAuthStore();
|
||||
const chatStore = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
@@ -78,9 +76,7 @@ router.afterEach(() => {
|
||||
<div class="nav-pill-bar">
|
||||
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link v-if="settingsStore.rssEnabled" to="/news" class="nav-link">News</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,10 +123,8 @@ router.afterEach(() => {
|
||||
<div v-if="mobileMenuOpen" class="mobile-menu">
|
||||
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link v-if="settingsStore.rssEnabled" to="/news" class="nav-link">News</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>
|
||||
|
||||
@@ -1,413 +0,0 @@
|
||||
<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: var(--gradient-cta);
|
||||
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: var(--gradient-cta);
|
||||
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>
|
||||
@@ -1,168 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import ToolCallCard from "@/components/ToolCallCard.vue";
|
||||
import type { ToolCallRecord } from "@/types/chat";
|
||||
|
||||
const props = defineProps<{
|
||||
toolCalls: ToolCallRecord[];
|
||||
}>();
|
||||
|
||||
const expanded = ref(false);
|
||||
|
||||
function shortName(fn: string): string {
|
||||
return fn
|
||||
.replace(/^(list|get|search|fetch)_/, "")
|
||||
.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
interface Pill {
|
||||
key: string;
|
||||
name: string;
|
||||
count: string | null;
|
||||
state: "ok" | "empty" | "error";
|
||||
}
|
||||
|
||||
const pills = computed<Pill[]>(() =>
|
||||
props.toolCalls.map((tc, i) => {
|
||||
const ok = tc.result?.success !== false && tc.status !== "error";
|
||||
const data = tc.result?.data as Record<string, unknown> | undefined;
|
||||
let count: string | null = null;
|
||||
let state: Pill["state"] = ok ? "ok" : "error";
|
||||
if (ok && data) {
|
||||
const raw =
|
||||
(typeof data.count === "number" && data.count) ||
|
||||
(typeof data.total === "number" && data.total);
|
||||
if (typeof raw === "number") {
|
||||
count = String(raw);
|
||||
if (raw === 0) state = "empty";
|
||||
}
|
||||
}
|
||||
return {
|
||||
key: `${tc.function}-${i}`,
|
||||
name: shortName(tc.function),
|
||||
count,
|
||||
state,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const errorCount = computed(() => pills.value.filter((p) => p.state === "error").length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="briefing-status" :class="{ expanded }">
|
||||
<button class="briefing-status-toggle" @click="expanded = !expanded">
|
||||
<span class="briefing-status-label">Gathered</span>
|
||||
<span
|
||||
v-for="p in pills"
|
||||
:key="p.key"
|
||||
class="briefing-status-pill"
|
||||
:class="`state-${p.state}`"
|
||||
>
|
||||
{{ p.name }}<span v-if="p.count != null" class="pill-count">{{ p.count }}</span>
|
||||
</span>
|
||||
<span v-if="errorCount" class="briefing-status-errcount">{{ errorCount }} failed</span>
|
||||
<span class="briefing-status-chevron" :class="{ open: expanded }">▶</span>
|
||||
</button>
|
||||
<div v-if="expanded" class="briefing-status-detail">
|
||||
<ToolCallCard
|
||||
v-for="(tc, i) in toolCalls"
|
||||
:key="i"
|
||||
:tool-call="tc"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.briefing-status {
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.briefing-status-toggle {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.55rem;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--color-text-muted);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.briefing-status-toggle:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 4%, transparent);
|
||||
}
|
||||
|
||||
.briefing-status-label {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-primary);
|
||||
opacity: 0.85;
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
|
||||
.briefing-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.08rem 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-card);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.briefing-status-pill.state-empty {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.briefing-status-pill.state-error {
|
||||
border-color: color-mix(in srgb, var(--color-danger, #ef4444) 60%, var(--color-border));
|
||||
color: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.pill-count {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.state-error .pill-count {
|
||||
background: color-mix(in srgb, var(--color-danger, #ef4444) 18%, transparent);
|
||||
}
|
||||
|
||||
.briefing-status-errcount {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-danger, #ef4444);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.briefing-status-chevron {
|
||||
margin-left: auto;
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.5;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.briefing-status-chevron.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.briefing-status-detail {
|
||||
margin-top: 0.4rem;
|
||||
padding-left: 0.55rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
</style>
|
||||
@@ -3,16 +3,8 @@ import { computed } from "vue";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import ToolCallCard from "@/components/ToolCallCard.vue";
|
||||
import BriefingToolStatusRow from "@/components/BriefingToolStatusRow.vue";
|
||||
import type { Message } from "@/types/chat";
|
||||
|
||||
const SLOT_LABELS: Record<string, string> = {
|
||||
compilation: "Full Briefing",
|
||||
morning: "Morning Update",
|
||||
midday: "Midday Update",
|
||||
afternoon: "Afternoon Update",
|
||||
};
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -50,28 +42,7 @@ function formatMs(ms: number | null | undefined): string {
|
||||
}
|
||||
|
||||
const metadata = computed(() => (props.message.metadata ?? {}) as Record<string, unknown>);
|
||||
|
||||
const isBriefingIntermediate = computed(
|
||||
() => props.message.role === "assistant" && metadata.value.briefing_intermediate === true,
|
||||
);
|
||||
|
||||
const briefingSlot = computed(() => {
|
||||
const slot = metadata.value.briefing_slot;
|
||||
return typeof slot === "string" ? slot : null;
|
||||
});
|
||||
|
||||
const slotLabel = computed(() => {
|
||||
const slot = briefingSlot.value;
|
||||
if (!slot) return null;
|
||||
return SLOT_LABELS[slot] ?? slot;
|
||||
});
|
||||
|
||||
const isSlotUpdate = computed(
|
||||
() =>
|
||||
!isBriefingIntermediate.value &&
|
||||
briefingSlot.value != null &&
|
||||
briefingSlot.value !== "compilation",
|
||||
);
|
||||
void metadata;
|
||||
|
||||
const timingParts = computed((): string[] => {
|
||||
const t = props.message.timing;
|
||||
@@ -89,16 +60,11 @@ const timingParts = computed((): string[] => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Briefing intermediate: compact status row, no bubble -->
|
||||
<div v-if="isBriefingIntermediate" class="chat-message role-assistant briefing-intermediate-row">
|
||||
<BriefingToolStatusRow :tool-calls="message.tool_calls ?? []" />
|
||||
</div>
|
||||
<div v-else class="chat-message" :class="[`role-${message.role}`, { 'slot-update': isSlotUpdate }]">
|
||||
<div class="chat-message" :class="`role-${message.role}`">
|
||||
<div class="message-group">
|
||||
<div class="message-bubble" :class="{ 'bubble-slot': isSlotUpdate }">
|
||||
<div class="message-bubble">
|
||||
<div class="message-header">
|
||||
<span class="role-label">{{ roleLabel }}</span>
|
||||
<span v-if="slotLabel" class="slot-badge" :class="{ 'slot-badge-update': isSlotUpdate }">{{ slotLabel }}</span>
|
||||
<div class="message-actions" v-if="message.role === 'assistant' && !isStreaming">
|
||||
<button class="btn-save" @click="emit('saveAsNote', message.id)" title="Save as note">
|
||||
Save as Note
|
||||
@@ -319,38 +285,4 @@ details[open] .thinking-summary::before {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ── Briefing intermediate row (no bubble) ──────────────── */
|
||||
.briefing-intermediate-row {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Slot badge (morning/midday/afternoon/full) ─────────── */
|
||||
.slot-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.slot-badge-update {
|
||||
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
/* ── Slot update bubbles: softer treatment than compilation ── */
|
||||
.slot-update .message-bubble.bubble-slot {
|
||||
background: transparent;
|
||||
border-left: 2px solid var(--color-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
.slot-update .message-bubble.bubble-slot .message-content {
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -116,16 +116,6 @@ const router = createRouter({
|
||||
name: "calendar",
|
||||
component: () => import("@/views/CalendarView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/briefing",
|
||||
name: "briefing",
|
||||
component: () => import("@/views/BriefingView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/news",
|
||||
name: "news",
|
||||
component: () => import("@/views/NewsView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
name: "settings",
|
||||
|
||||
@@ -1,828 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import ChatPanel from '@/components/ChatPanel.vue'
|
||||
import WeatherCard from '@/components/WeatherCard.vue'
|
||||
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
|
||||
import {
|
||||
apiGet,
|
||||
apiPost,
|
||||
getBriefingConfig,
|
||||
getBriefingConversations,
|
||||
getBriefingToday,
|
||||
triggerBriefingSlot,
|
||||
postRssReaction,
|
||||
deleteRssReaction,
|
||||
getNewsItems,
|
||||
listEvents,
|
||||
type BriefingConversation,
|
||||
type EventEntry,
|
||||
} from '@/api/client'
|
||||
import type { NewsItem } from '@/types/news'
|
||||
|
||||
interface WeatherData {
|
||||
location: string
|
||||
fetched_at: string
|
||||
current_temp: number
|
||||
condition: string
|
||||
today_high: number | null
|
||||
today_low: number | null
|
||||
yesterday_high: number | null
|
||||
yesterday_low: number | null
|
||||
wind_unit?: string
|
||||
forecast: { day: string; condition: string; high: number; low: number; precip_probability: number | null; precip_mm: number | null; windspeed_max: number }[]
|
||||
}
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
// 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)
|
||||
|
||||
// Weather panel
|
||||
const weatherData = ref<WeatherData[]>([])
|
||||
const selectedWeatherIdx = ref(0)
|
||||
const tempUnit = ref<string>('C')
|
||||
|
||||
interface CurrentConditions {
|
||||
temperature: number | null;
|
||||
windspeed: number | null;
|
||||
description: string;
|
||||
precip_next_3h: number[];
|
||||
temp_unit: string;
|
||||
location: string;
|
||||
}
|
||||
const currentConditions = ref<CurrentConditions | null>(null)
|
||||
let currentWeatherTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function loadCurrentConditions() {
|
||||
try {
|
||||
currentConditions.value = await apiGet<CurrentConditions>('/api/briefing/weather/current')
|
||||
// Patch the live temperature into the WeatherCard so it stays fresh
|
||||
if (currentConditions.value?.temperature != null && weatherData.value.length > 0) {
|
||||
weatherData.value[0] = { ...weatherData.value[0], current_temp: currentConditions.value.temperature }
|
||||
}
|
||||
} catch { /* silent — endpoint may not have locations configured */ }
|
||||
}
|
||||
|
||||
async function loadWeather() {
|
||||
try {
|
||||
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather')
|
||||
weatherData.value = data.locations ?? []
|
||||
tempUnit.value = data.temp_unit ?? 'C'
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
const refreshingWeather = ref(false)
|
||||
async function refreshWeather() {
|
||||
refreshingWeather.value = true
|
||||
try {
|
||||
const data = await apiPost<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather/refresh', {})
|
||||
weatherData.value = data.locations ?? []
|
||||
tempUnit.value = data.temp_unit ?? 'C'
|
||||
} catch { /* silent */ }
|
||||
finally { refreshingWeather.value = false }
|
||||
}
|
||||
|
||||
// Upcoming events (right column, below weather)
|
||||
const upcomingEvents = ref<EventEntry[]>([])
|
||||
|
||||
interface GroupedDay {
|
||||
label: string
|
||||
dateKey: string
|
||||
events: EventEntry[]
|
||||
}
|
||||
|
||||
const groupedEvents = computed<GroupedDay[]>(() => {
|
||||
const groups = new Map<string, EventEntry[]>()
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
for (const ev of upcomingEvents.value) {
|
||||
const d = new Date(ev.start_dt)
|
||||
const key = d.toISOString().slice(0, 10)
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key)!.push(ev)
|
||||
}
|
||||
|
||||
const result: GroupedDay[] = []
|
||||
for (const [key, events] of groups) {
|
||||
const d = new Date(key + 'T00:00:00')
|
||||
const diff = Math.round((d.getTime() - today.getTime()) / 86_400_000)
|
||||
let label: string
|
||||
if (diff === 0) label = 'Today'
|
||||
else if (diff === 1) label = 'Tomorrow'
|
||||
else label = d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
result.push({ label, dateKey: key, events })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function formatEventTime(ev: EventEntry): string {
|
||||
if (ev.all_day) return 'All day'
|
||||
const d = new Date(ev.start_dt)
|
||||
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
try {
|
||||
const now = new Date()
|
||||
const end = new Date(now)
|
||||
end.setDate(end.getDate() + 14)
|
||||
upcomingEvents.value = await listEvents(now.toISOString(), end.toISOString())
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
// News panel (right column)
|
||||
const newsItems = ref<NewsItem[]>([])
|
||||
|
||||
async function loadNews() {
|
||||
try {
|
||||
const data = await getNewsItems({ days: 2, limit: 40 })
|
||||
newsItems.value = data.items
|
||||
// Seed reactions from API response
|
||||
for (const item of data.items) {
|
||||
if (reactions.value[item.id] === undefined) {
|
||||
reactions.value[item.id] = item.reaction
|
||||
}
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const [convList, today] = await Promise.all([
|
||||
getBriefingConversations(),
|
||||
getBriefingToday().catch(() => null),
|
||||
loadWeather(),
|
||||
loadNews(),
|
||||
loadCurrentConditions(),
|
||||
loadEvents(),
|
||||
])
|
||||
conversations.value = convList
|
||||
if (today) {
|
||||
todayConvId.value = today.id
|
||||
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
|
||||
await chatStore.fetchConversation(today.id)
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedConvId, async (id) => {
|
||||
if (!id) return
|
||||
try {
|
||||
await chatStore.fetchConversation(id)
|
||||
} catch {
|
||||
// Historical conversation unavailable — do nothing
|
||||
}
|
||||
})
|
||||
|
||||
async function discussArticle(item: NewsItem) {
|
||||
if (!todayConvId.value || chatStore.streaming) return
|
||||
if (!isToday.value) selectedConvId.value = todayConvId.value
|
||||
await nextTick(() => {
|
||||
document.querySelector('.briefing-center')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
})
|
||||
try {
|
||||
await apiPost<{ assistant_message_id: number }>(`/api/briefing/articles/${item.id}/discuss`, { conv_id: todayConvId.value })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await chatStore.fetchConversation(todayConvId.value)
|
||||
await chatStore.reconnectIfGenerating(todayConvId.value)
|
||||
}
|
||||
|
||||
// RSS reactions: map of rss_item_id -> 'up' | 'down' | null
|
||||
const reactions = ref<Record<number, 'up' | 'down' | null>>({})
|
||||
|
||||
async function handleReaction(itemId: number, reaction: 'up' | 'down') {
|
||||
const current = reactions.value[itemId]
|
||||
reactions.value[itemId] = current === reaction ? null : reaction
|
||||
try {
|
||||
if (current === reaction) {
|
||||
await deleteRssReaction(itemId)
|
||||
} else {
|
||||
await postRssReaction(itemId, reaction)
|
||||
}
|
||||
} catch {
|
||||
reactions.value[itemId] = current ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeDate(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const diffH = (now.getTime() - d.getTime()) / 3_600_000
|
||||
if (diffH < 24) return `${Math.round(diffH)}h ago`
|
||||
if (diffH < 48) return 'Yesterday'
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
// Manual trigger
|
||||
const triggering = ref(false)
|
||||
async function triggerNow() {
|
||||
triggering.value = true
|
||||
try {
|
||||
await triggerBriefingSlot('compilation')
|
||||
// Guard: user may have navigated away during the long compilation
|
||||
if (_mounted) await loadAll()
|
||||
} finally {
|
||||
if (_mounted) 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'
|
||||
}
|
||||
|
||||
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
|
||||
async function _backgroundRefreshMessages() {
|
||||
try {
|
||||
const today = await getBriefingToday()
|
||||
if (!today) return
|
||||
if (_mounted && isToday.value && chatStore.currentConversation?.id === todayConvId.value) {
|
||||
await chatStore.fetchConversation(today.id)
|
||||
}
|
||||
await loadNews()
|
||||
} catch { /* silent — don't disturb the UI on network hiccup */ }
|
||||
}
|
||||
|
||||
useBackgroundRefresh(
|
||||
_backgroundRefreshMessages,
|
||||
60_000,
|
||||
() => !chatStore.streaming && isToday.value && !!todayConvId.value,
|
||||
)
|
||||
|
||||
let _mounted = true
|
||||
onUnmounted(() => {
|
||||
_mounted = false
|
||||
if (currentWeatherTimer) clearInterval(currentWeatherTimer)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await checkSetup()
|
||||
if (!showWizard.value) {
|
||||
await loadAll()
|
||||
// Poll current conditions every 30 minutes
|
||||
currentWeatherTimer = setInterval(loadCurrentConditions, 30 * 60 * 1000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="briefing-root">
|
||||
<!-- Setup wizard overlay -->
|
||||
<BriefingSetupWizard v-if="wizardChecked && showWizard" @done="onWizardDone" />
|
||||
|
||||
<!-- Main view -->
|
||||
<div class="briefing-shell" v-if="wizardChecked && !showWizard">
|
||||
<!-- Header spans all columns -->
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<!-- Left column: Chat -->
|
||||
<div class="briefing-center">
|
||||
<ChatPanel
|
||||
variant="full"
|
||||
briefingMode
|
||||
:readOnly="!isToday"
|
||||
placeholder="Reply to your briefing…"
|
||||
class="briefing-chat-panel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right column: Weather + News -->
|
||||
<div class="briefing-right">
|
||||
<!-- Weather section (sticky) -->
|
||||
<div class="weather-section" v-if="weatherData.length">
|
||||
<div class="weather-section-header">
|
||||
<div class="weather-tabs" v-if="weatherData.length > 1">
|
||||
<button
|
||||
v-for="(loc, i) in weatherData"
|
||||
:key="(loc as WeatherData).location"
|
||||
class="weather-tab"
|
||||
:class="{ active: selectedWeatherIdx === i }"
|
||||
@click="selectedWeatherIdx = i"
|
||||
>{{ (loc as WeatherData).location }}</button>
|
||||
</div>
|
||||
<button
|
||||
class="weather-refresh-btn"
|
||||
:class="{ spinning: refreshingWeather }"
|
||||
:disabled="refreshingWeather"
|
||||
@click="refreshWeather"
|
||||
title="Refresh weather"
|
||||
>↻</button>
|
||||
</div>
|
||||
<WeatherCard
|
||||
:weather="weatherData[selectedWeatherIdx]"
|
||||
:temp-unit="tempUnit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Upcoming events -->
|
||||
<div class="events-section" v-if="groupedEvents.length">
|
||||
<div class="panel-label-row">
|
||||
<div class="panel-label">Upcoming</div>
|
||||
<router-link to="/calendar" class="events-cal-link">Calendar →</router-link>
|
||||
</div>
|
||||
<div class="events-list">
|
||||
<div v-for="group in groupedEvents" :key="group.dateKey" class="events-day-group">
|
||||
<div class="events-day-label">{{ group.label }}</div>
|
||||
<div v-for="ev in group.events" :key="ev.id" class="event-row">
|
||||
<span class="event-dot" :style="ev.color ? { background: ev.color } : {}"></span>
|
||||
<span class="event-body">
|
||||
<span class="event-title">{{ ev.title }}</span>
|
||||
<span class="event-time">{{ formatEventTime(ev) }}</span>
|
||||
<span v-if="ev.location" class="event-loc">{{ ev.location }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- News section (scrollable) -->
|
||||
<div v-if="settingsStore.rssEnabled" class="news-section">
|
||||
<div class="panel-label-row">
|
||||
<div class="panel-label">Today's News</div>
|
||||
<span v-if="newsItems.length" class="news-count">{{ newsItems.length }} items</span>
|
||||
</div>
|
||||
<div v-if="!newsItems.length" class="panel-empty">No articles in the last 2 days</div>
|
||||
<div
|
||||
v-for="item in newsItems"
|
||||
:key="item.id"
|
||||
class="news-card"
|
||||
>
|
||||
<div class="news-card-meta">
|
||||
<span class="news-source">{{ item.source }}</span>
|
||||
<span v-if="item.published_at" class="news-date">{{ formatRelativeDate(item.published_at) }}</span>
|
||||
</div>
|
||||
<a
|
||||
v-if="item.url"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="news-title"
|
||||
>{{ item.title }}</a>
|
||||
<p v-else class="news-title news-title--plain">{{ item.title }}</p>
|
||||
<p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
|
||||
<div class="news-reactions">
|
||||
<button
|
||||
class="reaction-btn"
|
||||
:class="{ active: reactions[item.id] === 'up' }"
|
||||
@click="handleReaction(item.id, 'up')"
|
||||
title="Interested"
|
||||
>👍</button>
|
||||
<button
|
||||
class="reaction-btn"
|
||||
:class="{ active: reactions[item.id] === 'down' }"
|
||||
@click="handleReaction(item.id, 'down')"
|
||||
title="Not interested"
|
||||
>👎</button>
|
||||
<button
|
||||
v-if="isToday && todayConvId"
|
||||
class="reaction-btn discuss-btn"
|
||||
@click="discussArticle(item)"
|
||||
title="Discuss in briefing chat"
|
||||
>💬</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.briefing-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.briefing-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(320px, 35%);
|
||||
grid-template-rows: auto 1fr;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.briefing-header {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1rem 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; }
|
||||
|
||||
/* ─── Left column (Chat) ─────────────────────────────────────────────────── */
|
||||
|
||||
.briefing-center {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.briefing-chat-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ─── Right column (Weather + News) ──────────────────────────────────────── */
|
||||
|
||||
.briefing-right {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
border-left: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.weather-section {
|
||||
flex-shrink: 0;
|
||||
padding: 1rem 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.weather-section :deep(.weather-card) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.weather-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.weather-refresh-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.45rem;
|
||||
line-height: 1;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.weather-refresh-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.weather-refresh-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.weather-refresh-btn.spinning {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.weather-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.weather-tab {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.weather-tab:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.weather-tab.active {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ─── Upcoming events ─────────────────────────────────────── */
|
||||
.events-section {
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.events-cal-link {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
.events-cal-link:hover { color: var(--color-primary); }
|
||||
.events-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.events-day-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.events-day-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
padding-bottom: 0.15rem;
|
||||
}
|
||||
.event-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.event-row:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 6%, transparent);
|
||||
}
|
||||
.event-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.event-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.05rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.event-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.event-time {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.event-loc {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.news-section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.news-count {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ── Current conditions (live) ─────────────────────────── */
|
||||
|
||||
.panel-empty {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.news-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.news-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.news-source {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.news-date {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.news-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
line-height: 1.35;
|
||||
text-decoration: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
|
||||
|
||||
.news-snippet {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-reactions {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.reaction-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.1rem 0.35rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.reaction-btn:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.reaction-btn.active {
|
||||
opacity: 1;
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Responsive ─────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.briefing-shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
.briefing-center {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
.briefing-right {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -410,7 +410,6 @@ onUnmounted(() => {
|
||||
<router-link v-if="overdueCount > 0" to="/tasks" class="overdue-badge">
|
||||
{{ overdueCount }} overdue
|
||||
</router-link>
|
||||
<router-link to="/briefing" class="today-link">Briefing</router-link>
|
||||
<router-link to="/chat" class="today-link">Chat</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref, computed, 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, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, refreshBriefingFeeds, geocodeAddress, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed, type VoiceStatusResult, type VoiceEntry, type VoiceBlendEntry, type UserProfile } from "@/api/client";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type VoiceStatusResult, type VoiceEntry, type VoiceBlendEntry, type UserProfile } from "@/api/client";
|
||||
import { usePushStore } from "@/stores/push";
|
||||
import type { User } from "@/types/auth";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
@@ -62,7 +62,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", "profile", "notifications", "integrations", "data", "briefing", "voice", "apikeys", "config", "users", "logs", "groups"]);
|
||||
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "voice", "apikeys", "config", "users", "logs", "groups"]);
|
||||
const _stored = localStorage.getItem("settings_tab") ?? "general";
|
||||
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
||||
|
||||
@@ -72,7 +72,6 @@ function _loadTabContent(tab: string) {
|
||||
else if (tab === "logs") loadLogsPanel();
|
||||
else if (tab === "groups") loadGroupsPanel();
|
||||
}
|
||||
if (tab === "briefing") loadBriefingTab();
|
||||
if (tab === "voice") loadVoiceTab();
|
||||
if (tab === "apikeys") { fetchApiKeys(); loadMcpInfo(); }
|
||||
}
|
||||
@@ -291,155 +290,6 @@ 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,
|
||||
temp_unit: 'C',
|
||||
});
|
||||
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 newFeedCategory = ref('');
|
||||
const addingFeed = ref(false);
|
||||
const refreshingFeeds = ref(false);
|
||||
const briefingIncludeTopics = ref<string[]>([]);
|
||||
const briefingExcludeTopics = ref<string[]>([]);
|
||||
|
||||
function _parseTopics(raw: unknown): string[] {
|
||||
try {
|
||||
const val = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
return Array.isArray(val) ? val.map(String) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBriefingTab() {
|
||||
briefingConfig.value = await getBriefingConfig();
|
||||
briefingFeeds.value = await getBriefingFeeds();
|
||||
const allSettings = await apiGet<Record<string, string>>('/api/settings').catch(() => ({} as Record<string, string>));
|
||||
briefingIncludeTopics.value = _parseTopics(allSettings['briefing_include_topics'] ?? '[]');
|
||||
briefingExcludeTopics.value = _parseTopics(allSettings['briefing_exclude_topics'] ?? '[]');
|
||||
}
|
||||
|
||||
async function saveIncludeTopics(topics: string[]) {
|
||||
briefingIncludeTopics.value = topics;
|
||||
await apiPut('/api/settings', { briefing_include_topics: JSON.stringify(topics) });
|
||||
}
|
||||
|
||||
async function saveExcludeTopics(topics: string[]) {
|
||||
briefingExcludeTopics.value = topics;
|
||||
await apiPut('/api/settings', { briefing_exclude_topics: JSON.stringify(topics) });
|
||||
}
|
||||
|
||||
const _STANDARD_TOPICS = ['technology', 'science', 'politics', 'business', 'health', 'environment', 'local', 'entertainment', 'sports', 'other'];
|
||||
async function fetchTopicSuggestions(q: string): Promise<string[]> {
|
||||
if (!q) return _STANDARD_TOPICS;
|
||||
return _STANDARD_TOPICS.filter((t) => t.startsWith(q.toLowerCase()));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
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 toggleRss() {
|
||||
try {
|
||||
await store.updateSettings({ rss_enabled: store.rssEnabled ? "false" : "true" });
|
||||
} catch {
|
||||
toastStore.show("Failed to update RSS setting", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function addFeed() {
|
||||
if (!newFeedUrl.value.trim() || addingFeed.value) return;
|
||||
addingFeed.value = true;
|
||||
try {
|
||||
const feed = await createBriefingFeed(newFeedUrl.value.trim(), newFeedCategory.value.trim() || undefined);
|
||||
briefingFeeds.value.push(feed);
|
||||
newFeedUrl.value = '';
|
||||
newFeedCategory.value = '';
|
||||
} catch (err: any) {
|
||||
toastStore.show(err?.message === 'Feed already added' ? 'That feed is already in your list' : '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);
|
||||
}
|
||||
|
||||
async function refreshFeeds() {
|
||||
if (refreshingFeeds.value) return;
|
||||
refreshingFeeds.value = true;
|
||||
try {
|
||||
const result = await refreshBriefingFeeds();
|
||||
// Reload feed list so last_fetched_at updates
|
||||
briefingFeeds.value = await getBriefingFeeds();
|
||||
toastStore.show(`Refreshed ${result.feeds_refreshed} feed${result.feeds_refreshed !== 1 ? 's' : ''} — ${result.new_items} new item${result.new_items !== 1 ? 's' : ''}`);
|
||||
} catch {
|
||||
toastStore.show('Failed to refresh feeds', 'error');
|
||||
} finally {
|
||||
refreshingFeeds.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function feedAge(isoStr: string | null): string {
|
||||
if (!isoStr) return 'never fetched';
|
||||
const diff = Date.now() - new Date(isoStr).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return 'just now';
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
// Chat retention
|
||||
const chatRetentionDays = ref(90);
|
||||
@@ -1465,7 +1315,7 @@ function formatUserDate(iso: string): string {
|
||||
<div class="sidebar-group">
|
||||
<div class="sidebar-group-label">User</div>
|
||||
<button
|
||||
v-for="tab in ['general', 'account', 'profile', 'notifications', 'integrations', 'data', 'briefing', 'voice', 'apikeys']"
|
||||
v-for="tab in ['general', 'account', 'profile', 'notifications', 'integrations', 'data', 'voice', 'apikeys']"
|
||||
:key="tab"
|
||||
:class="['sidebar-item', { active: activeTab === tab }]"
|
||||
@click="activeTab = tab"
|
||||
@@ -2115,219 +1965,6 @@ 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>
|
||||
|
||||
<div class="field" style="margin-top: 1rem">
|
||||
<label class="field-label">Temperature unit</label>
|
||||
<div class="briefing-unit-toggle">
|
||||
<button
|
||||
type="button"
|
||||
:class="['briefing-unit-btn', { active: briefingConfig.temp_unit === 'C' }]"
|
||||
@click="briefingConfig.temp_unit = 'C'"
|
||||
>°C</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="['briefing-unit-btn', { active: briefingConfig.temp_unit === 'F' }]"
|
||||
@click="briefingConfig.temp_unit = 'F'"
|
||||
>°F</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Slots -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Scheduled Slots</h2>
|
||||
<p class="section-desc">Each active slot posts an update into your briefing conversation at the listed local time.</p>
|
||||
<div class="briefing-slot-list">
|
||||
<div
|
||||
v-for="[key, label, localTime] 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">{{ localTime }}</span>
|
||||
</div>
|
||||
<label>
|
||||
<input type="checkbox" v-model="briefingConfig.slots[key]" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-hint" style="margin-top: 0.5rem">
|
||||
Firing in timezone: <strong>{{ userTimezone || 'UTC (not configured — set in General settings)' }}</strong>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- RSS toggle -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>RSS / News</h2>
|
||||
<div class="checkbox-field">
|
||||
<label>
|
||||
<input type="checkbox" :checked="store.rssEnabled" @change="toggleRss" />
|
||||
Enable RSS feeds
|
||||
</label>
|
||||
<p class="field-hint">Subscribe to RSS/Atom feeds and include news in your briefings.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- RSS Feeds -->
|
||||
<section v-if="store.rssEnabled" class="settings-section full-width">
|
||||
<div class="briefing-feeds-header">
|
||||
<div>
|
||||
<h2>RSS Feeds</h2>
|
||||
<p class="section-desc">Add RSS or Atom feeds to be summarised in your morning briefing.</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="briefingFeeds.length"
|
||||
class="btn-secondary briefing-refresh-btn"
|
||||
@click="refreshFeeds"
|
||||
:disabled="refreshingFeeds"
|
||||
title="Fetch latest items from all feeds"
|
||||
>{{ refreshingFeeds ? 'Refreshing…' : 'Refresh all' }}</button>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div class="briefing-feed-title-row">
|
||||
<span class="briefing-feed-title">{{ feed.title || feed.url }}</span>
|
||||
<span v-if="feed.category" class="briefing-feed-cat">{{ feed.category }}</span>
|
||||
</div>
|
||||
<span v-if="feed.title" class="briefing-feed-url">{{ feed.url }}</span>
|
||||
<span class="briefing-feed-age">{{ feedAge(feed.last_fetched_at) }}</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. Try adding Reuters, BBC World, or Hacker News.</p>
|
||||
|
||||
<div class="briefing-add-feed-form">
|
||||
<div class="briefing-add-feed-inputs">
|
||||
<input v-model="newFeedUrl" class="input" placeholder="https://example.com/feed.xml" @keydown.enter="addFeed" />
|
||||
<input v-model="newFeedCategory" class="input briefing-cat-input" placeholder="Category (optional)" @keydown.enter="addFeed" />
|
||||
</div>
|
||||
<button class="btn-secondary" @click="addFeed" :disabled="addingFeed || !newFeedUrl.trim()">
|
||||
{{ addingFeed ? 'Adding…' : 'Add Feed' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- News Preferences -->
|
||||
<section v-if="store.rssEnabled" class="settings-section full-width">
|
||||
<h2>News Preferences</h2>
|
||||
<p class="section-desc">
|
||||
Tell the briefing what topics you care about. Topics are matched against
|
||||
classified RSS items before each briefing runs.
|
||||
</p>
|
||||
<div class="settings-field">
|
||||
<label class="field-label">Interested in</label>
|
||||
<TagInput
|
||||
:model-value="briefingIncludeTopics"
|
||||
placeholder="e.g. technology, science, local"
|
||||
:fetch-tags="fetchTopicSuggestions"
|
||||
@update:model-value="saveIncludeTopics"
|
||||
/>
|
||||
</div>
|
||||
<div class="settings-field" style="margin-top: 0.75rem">
|
||||
<label class="field-label">Not interested in</label>
|
||||
<TagInput
|
||||
:model-value="briefingExcludeTopics"
|
||||
placeholder="e.g. sports, celebrity"
|
||||
:fetch-tags="fetchTopicSuggestions"
|
||||
@update:model-value="saveExcludeTopics"
|
||||
/>
|
||||
</div>
|
||||
<details class="topic-vocab-hint" style="margin-top: 0.75rem">
|
||||
<summary style="cursor: pointer; color: var(--color-text-muted); font-size: 0.82rem">Standard topic vocabulary</summary>
|
||||
<p class="field-hint" style="margin-top: 0.35rem">
|
||||
technology · science · politics · business · health · environment ·
|
||||
local · entertainment · sports · other
|
||||
</p>
|
||||
<p class="field-hint">Custom terms are also accepted.</p>
|
||||
</details>
|
||||
</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>
|
||||
|
||||
<!-- ── Voice ── -->
|
||||
<div v-show="activeTab === 'voice'" class="settings-grid">
|
||||
|
||||
<section class="settings-section full-width">
|
||||
|
||||
Reference in New Issue
Block a user