Add location tabs when multiple weather sources configured. Weather section stays pinned at top of sidebar while news scrolls independently. Bump sidebar width from 420px to 620px max for better readability. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
635 lines
17 KiB
Vue
635 lines
17 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 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,
|
|
type BriefingConversation,
|
|
} 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()
|
|
|
|
// 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 */ }
|
|
}
|
|
|
|
// 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(),
|
|
])
|
|
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-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>
|
|
<WeatherCard
|
|
:weather="weatherData[selectedWeatherIdx]"
|
|
:temp-unit="tempUnit"
|
|
/>
|
|
</div>
|
|
|
|
<!-- News section (scrollable) -->
|
|
<div 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(420px, 620px);
|
|
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;
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
|
|
.weather-section :deep(.weather-card) {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.weather-tabs {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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>
|