feat: redesign BriefingView into 3-column layout (weather · chat · news)

- Left column: weather loaded independently via /api/briefing/weather
- Center column: chat messages with input bar (unchanged behavior)
- Right column: news panel loaded from /api/briefing/news (last 2 days)
- Auto-scroll to bottom on mount and after streaming
- Background refresh also refreshes news panel
- Responsive: stacks to single column on narrow screens
- Fix TaskCard.vue to include 'cancelled' in status records

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-28 00:38:04 -04:00
parent 0a6e57e698
commit f81c38df84
2 changed files with 274 additions and 139 deletions
+6
View File
@@ -20,18 +20,21 @@ const statusCycle: Record<TaskStatus, TaskStatus> = {
todo: "in_progress", todo: "in_progress",
in_progress: "done", in_progress: "done",
done: "todo", done: "todo",
cancelled: "todo",
}; };
const statusDotClass: Record<TaskStatus, string> = { const statusDotClass: Record<TaskStatus, string> = {
todo: "dot-todo", todo: "dot-todo",
in_progress: "dot-in-progress", in_progress: "dot-in-progress",
done: "dot-done", done: "dot-done",
cancelled: "dot-cancelled",
}; };
const statusTitle: Record<TaskStatus, string> = { const statusTitle: Record<TaskStatus, string> = {
todo: "Todo — click to mark In Progress", todo: "Todo — click to mark In Progress",
in_progress: "In Progress — click to mark Done", in_progress: "In Progress — click to mark Done",
done: "Done — click to mark Todo", done: "Done — click to mark Todo",
cancelled: "Cancelled — click to mark Todo",
}; };
function cycleStatus() { function cycleStatus() {
@@ -152,6 +155,9 @@ function isOverdue(): boolean {
.dot-done { .dot-done {
background: var(--color-status-done, #22c55e); background: var(--color-status-done, #22c55e);
} }
.dot-cancelled {
background: var(--color-status-cancelled, #6b7280);
}
.task-title-compact { .task-title-compact {
font-size: 0.9rem; font-size: 0.9rem;
+268 -139
View File
@@ -1,11 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh' import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
import { useChatStore } from '@/stores/chat' import { useChatStore } from '@/stores/chat'
import ChatMessage from '@/components/ChatMessage.vue' import ChatMessage from '@/components/ChatMessage.vue'
import WeatherCard from '@/components/WeatherCard.vue' import WeatherCard from '@/components/WeatherCard.vue'
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue' import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
import { import {
apiGet,
getBriefingConfig, getBriefingConfig,
getBriefingConversations, getBriefingConversations,
getBriefingToday, getBriefingToday,
@@ -13,34 +14,23 @@ import {
triggerBriefingSlot, triggerBriefingSlot,
postRssReaction, postRssReaction,
deleteRssReaction, deleteRssReaction,
getNewsItems,
type BriefingConversation, type BriefingConversation,
type BriefingMessage, type BriefingMessage,
} from '@/api/client' } from '@/api/client'
import type { Message } from '@/types/chat' import type { Message } from '@/types/chat'
import type { NewsItem } from '@/types/news'
interface RssItemMeta { interface WeatherData {
id: number location: string
title: string fetched_at: string
url: string current_temp: number
source: string condition: string
snippet: string today_high: number | null
published_at: string | null today_low: number | null
} yesterday_high: number | null
yesterday_low: number | null
interface MessageMetadata { forecast: { day: string; condition: string; high: number; low: number }[]
weather?: {
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
forecast: { day: string; condition: string; high: number; low: number }[]
} | null
rss_item_ids?: number[]
rss_items?: RssItemMeta[]
} }
const chatStore = useChatStore() const chatStore = useChatStore()
@@ -71,15 +61,53 @@ const isToday = computed(() => selectedConvId.value === todayConvId.value)
const messages = ref<BriefingMessage[]>([]) const messages = ref<BriefingMessage[]>([])
const loadingMessages = ref(false) const loadingMessages = ref(false)
// Weather panel (left column)
const weatherData = ref<WeatherData[]>([])
async function loadWeather() {
try {
const data = await apiGet<{ locations: WeatherData[] }>('/api/briefing/weather')
weatherData.value = data.locations ?? []
} 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 */ }
}
// Scroll to bottom of messages
const messagesEl = ref<HTMLElement | null>(null)
function scrollToBottom() {
nextTick(() => {
if (messagesEl.value) {
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
}
})
}
async function loadAll() { async function loadAll() {
const [convList, today] = await Promise.all([ const [convList, today] = await Promise.all([
getBriefingConversations(), getBriefingConversations(),
getBriefingToday().catch(() => null), getBriefingToday().catch(() => null),
loadWeather(),
loadNews(),
]) ])
conversations.value = convList conversations.value = convList
if (today) { if (today) {
todayConvId.value = today.id todayConvId.value = today.id
// Ensure today is in the list
if (!convList.find((c) => c.id === today.id)) { if (!convList.find((c) => c.id === today.id)) {
conversations.value = [ conversations.value = [
{ id: today.id, title: today.title ?? 'Today', briefing_date: null, message_count: 0, created_at: new Date().toISOString() }, { id: today.id, title: today.title ?? 'Today', briefing_date: null, message_count: 0, created_at: new Date().toISOString() },
@@ -88,9 +116,9 @@ async function loadAll() {
} }
selectedConvId.value = today.id selectedConvId.value = today.id
messages.value = today.messages messages.value = today.messages
// Load into chatStore so we can stream
await chatStore.fetchConversation(today.id) await chatStore.fetchConversation(today.id)
} }
scrollToBottom()
} }
watch(selectedConvId, async (id) => { watch(selectedConvId, async (id) => {
@@ -98,11 +126,13 @@ watch(selectedConvId, async (id) => {
if (id === todayConvId.value) { if (id === todayConvId.value) {
await chatStore.fetchConversation(id) await chatStore.fetchConversation(id)
messages.value = (chatStore.currentConversation?.messages ?? []) as unknown as BriefingMessage[] messages.value = (chatStore.currentConversation?.messages ?? []) as unknown as BriefingMessage[]
scrollToBottom()
return return
} }
loadingMessages.value = true loadingMessages.value = true
try { try {
messages.value = await getBriefingConvMessages(id) messages.value = await getBriefingConvMessages(id)
scrollToBottom()
} finally { } finally {
loadingMessages.value = false loadingMessages.value = false
} }
@@ -113,6 +143,7 @@ watch(() => chatStore.streaming, async (streaming) => {
if (!streaming && selectedConvId.value === todayConvId.value && todayConvId.value) { if (!streaming && selectedConvId.value === todayConvId.value && todayConvId.value) {
const today = await getBriefingToday().catch(() => null) const today = await getBriefingToday().catch(() => null)
if (today) messages.value = today.messages if (today) messages.value = today.messages
scrollToBottom()
} }
}) })
@@ -123,7 +154,6 @@ const sending = ref(false)
async function send() { async function send() {
const text = input.value.trim() const text = input.value.trim()
if (!text || !todayConvId.value || chatStore.streaming || sending.value) return if (!text || !todayConvId.value || chatStore.streaming || sending.value) return
// Ensure today's conv is loaded in chatStore
if (chatStore.currentConversation?.id !== todayConvId.value) { if (chatStore.currentConversation?.id !== todayConvId.value) {
await chatStore.fetchConversation(todayConvId.value) await chatStore.fetchConversation(todayConvId.value)
} }
@@ -160,10 +190,6 @@ async function handleReaction(itemId: number, reaction: 'up' | 'down') {
} }
} }
function msgMetadata(msg: BriefingMessage): MessageMetadata | null {
return (msg.metadata as MessageMetadata | null | undefined) ?? null
}
function formatRelativeDate(iso: string | null): string { function formatRelativeDate(iso: string | null): string {
if (!iso) return '' if (!iso) return ''
const d = new Date(iso) const d = new Date(iso)
@@ -180,7 +206,6 @@ async function triggerNow() {
triggering.value = true triggering.value = true
try { try {
await triggerBriefingSlot('compilation') await triggerBriefingSlot('compilation')
// Reload
await loadAll() await loadAll()
} finally { } finally {
triggering.value = false triggering.value = false
@@ -221,6 +246,7 @@ async function _backgroundRefreshMessages() {
if (fresh.length !== messages.value.length || last?.content !== cur?.content) { if (fresh.length !== messages.value.length || last?.content !== cur?.content) {
messages.value = fresh messages.value = fresh
} }
await loadNews()
} catch { /* silent — don't disturb the UI on network hiccup */ } } catch { /* silent — don't disturb the UI on network hiccup */ }
} }
@@ -241,16 +267,15 @@ onMounted(async () => {
<!-- Setup wizard overlay --> <!-- Setup wizard overlay -->
<BriefingSetupWizard v-if="wizardChecked && showWizard" @done="onWizardDone" /> <BriefingSetupWizard v-if="wizardChecked && showWizard" @done="onWizardDone" />
<!-- Main view (shown after wizard check and only if not showing wizard) --> <!-- Main view -->
<div class="briefing-shell" v-if="wizardChecked && !showWizard"> <div class="briefing-shell" v-if="wizardChecked && !showWizard">
<!-- Header --> <!-- Header spans all columns -->
<header class="briefing-header"> <header class="briefing-header">
<div class="briefing-header-left"> <div class="briefing-header-left">
<h1 class="briefing-title">Briefing</h1> <h1 class="briefing-title">Briefing</h1>
<span class="briefing-today-badge">{{ new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) }}</span> <span class="briefing-today-badge">{{ new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) }}</span>
</div> </div>
<div class="briefing-header-right"> <div class="briefing-header-right">
<!-- Conversation history dropdown -->
<select v-if="conversations.length" v-model="selectedConvId" class="briefing-conv-select"> <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> <option v-for="c in conversations" :key="c.id" :value="c.id">{{ convLabel(c) }}</option>
</select> </select>
@@ -263,102 +288,115 @@ onMounted(async () => {
</div> </div>
</header> </header>
<!-- Messages --> <!-- Left column: Weather -->
<div class="briefing-messages-wrap"> <div class="briefing-left">
<div v-if="loadingMessages" class="briefing-loading">Loading</div> <div class="panel-label">Weather</div>
<template v-else> <template v-if="weatherData.length">
<div v-if="!messages.length" class="briefing-empty"> <WeatherCard
<p>No briefing yet for today.</p> v-for="loc in weatherData"
<p class="briefing-empty-hint">Click "Refresh" to generate a briefing now, or wait for the scheduled slot.</p> :key="(loc as WeatherData).location"
</div> :weather="loc"
<div v-else class="briefing-messages"> />
<template v-for="msg in messages" :key="msg.id">
<!-- Weather card above the first assistant message with weather metadata -->
<WeatherCard
v-if="msg.role === 'assistant' && msgMetadata(msg)?.weather !== undefined"
:weather="msgMetadata(msg)?.weather ?? null"
/>
<ChatMessage
:message="toMsg(msg)"
:is-streaming="false"
/>
<!-- News cards below assistant messages with rss_items -->
<div
v-if="msg.role === 'assistant' && (msgMetadata(msg)?.rss_items?.length ?? 0) > 0"
class="news-cards"
>
<div
v-for="item in msgMetadata(msg)!.rss_items"
: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>
</div>
</div>
</div>
</template>
<!-- Live streaming bubble for today -->
<ChatMessage
v-if="isToday && chatStore.streaming && chatStore.streamingContent"
:message="{
id: -1,
conversation_id: todayConvId ?? -1,
role: 'assistant',
content: chatStore.streamingContent,
context_note_id: null,
context_note_title: null,
created_at: new Date().toISOString(),
}"
:is-streaming="true"
/>
</div>
</template> </template>
<div v-else class="panel-empty">No weather configured</div>
</div> </div>
<!-- Input bar (today only) --> <!-- Center column: Chat -->
<div v-if="isToday" class="briefing-input-bar"> <div class="briefing-center">
<textarea <div class="briefing-messages-wrap" ref="messagesEl">
v-model="input" <div v-if="loadingMessages" class="briefing-loading">Loading</div>
class="briefing-input" <template v-else>
placeholder="Reply to your briefing…" <div v-if="!messages.length" class="briefing-empty">
rows="1" <p>No briefing yet for today.</p>
@keydown="onKeydown" <p class="briefing-empty-hint">Click "Refresh" to generate a briefing now, or wait for the scheduled slot.</p>
></textarea> </div>
<button <div v-else class="briefing-messages">
class="btn-send" <template v-for="msg in messages" :key="msg.id">
@click="send" <ChatMessage
:disabled="!input.trim() || chatStore.streaming || sending" :message="toMsg(msg)"
aria-label="Send" :is-streaming="false"
/>
</template>
<!-- Live streaming bubble for today -->
<ChatMessage
v-if="isToday && chatStore.streaming && chatStore.streamingContent"
:message="{
id: -1,
conversation_id: todayConvId ?? -1,
role: 'assistant',
content: chatStore.streamingContent,
context_note_id: null,
context_note_title: null,
created_at: new Date().toISOString(),
}"
:is-streaming="true"
/>
</div>
</template>
</div>
<!-- Input bar (today only) -->
<div v-if="isToday" class="briefing-input-bar">
<textarea
v-model="input"
class="briefing-input"
placeholder="Reply to your briefing…"
rows="1"
@keydown="onKeydown"
></textarea>
<button
class="btn-send"
@click="send"
:disabled="!input.trim() || chatStore.streaming || sending"
aria-label="Send"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 21l21-9L2 3v7l15 2-15 2v7z"/>
</svg>
</button>
</div>
</div>
<!-- Right column: News -->
<div class="briefing-right">
<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"
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"> <div class="news-card-meta">
<path d="M2 21l21-9L2 3v7l15 2-15 2v7z"/> <span class="news-source">{{ item.source }}</span>
</svg> <span v-if="item.published_at" class="news-date">{{ formatRelativeDate(item.published_at) }}</span>
</button> </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>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -373,21 +411,20 @@ onMounted(async () => {
} }
.briefing-shell { .briefing-shell {
display: flex; display: grid;
flex-direction: column; grid-template-columns: 1fr 2fr 1fr;
grid-template-rows: auto 1fr;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
max-width: 760px;
margin: 0 auto;
width: 100%;
padding: 0 1rem;
} }
.briefing-header { .briefing-header {
grid-column: 1 / -1;
grid-row: 1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 1.25rem 0 1rem; padding: 1.25rem 1rem 1rem;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
flex-shrink: 0; flex-shrink: 0;
gap: 1rem; gap: 1rem;
@@ -448,10 +485,38 @@ onMounted(async () => {
} }
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; } .btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
/* ─── Left column (Weather) ──────────────────────────────────────────────── */
.briefing-left {
grid-column: 1;
grid-row: 2;
border-right: 1px solid var(--color-border);
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.briefing-left :deep(.weather-card) {
margin-bottom: 0;
}
/* ─── Center column (Chat) ───────────────────────────────────────────────── */
.briefing-center {
grid-column: 2;
grid-row: 2;
display: flex;
flex-direction: column;
min-height: 0;
}
.briefing-messages-wrap { .briefing-messages-wrap {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 1rem 0; padding: 1rem;
min-height: 0;
} }
.briefing-loading, .briefing-loading,
@@ -478,7 +543,7 @@ onMounted(async () => {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: flex-end; align-items: flex-end;
padding: 0.75rem 0 1rem; padding: 0.75rem 1rem 1rem;
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -517,12 +582,44 @@ onMounted(async () => {
.btn-send:disabled { opacity: 0.4; cursor: not-allowed; } .btn-send:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-send:hover:not(:disabled) { opacity: 0.9; } .btn-send:hover:not(:disabled) { opacity: 0.9; }
.news-cards { /* ─── Right column (News) ────────────────────────────────────────────────── */
.briefing-right {
grid-column: 3;
grid-row: 2;
border-left: 1px solid var(--color-border);
overflow-y: auto;
padding: 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.25rem; }
margin-bottom: 0.25rem;
.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);
}
.panel-empty {
font-size: 0.82rem;
color: var(--color-text-muted);
padding: 0.5rem 0;
} }
.news-card { .news-card {
@@ -533,6 +630,7 @@ onMounted(async () => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
flex-shrink: 0;
} }
.news-card-meta { .news-card-meta {
@@ -604,4 +702,35 @@ a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
border-color: var(--color-primary); border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 12%, transparent); 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 auto 1fr auto;
}
.briefing-header {
grid-column: 1;
grid-row: 1;
}
.briefing-left {
grid-column: 1;
grid-row: 2;
border-right: none;
border-bottom: 1px solid var(--color-border);
max-height: 220px;
}
.briefing-center {
grid-column: 1;
grid-row: 3;
}
.briefing-right {
grid-column: 1;
grid-row: 4;
border-left: none;
border-top: 1px solid var(--color-border);
max-height: 260px;
}
}
</style> </style>