Files
FabledGrimoire/frontend/src/views/BriefingView.vue
T
bvandeusenandClaude Opus 4.6 cac62c6caf feat(briefing): add 2-week upcoming events list below weather card
Fetch and display the next 14 days of events in the briefing right column,
grouped by day with color dots, time, and location. Fills the empty space
below the weather card.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 17:14:20 -04:00

829 lines
22 KiB
Vue

<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>