feat(journal): add JournalView UI with /journal route + nav link

Restores a working browser surface for the journal feature, which was
left UI-less when Stage F of the original plan was deferred. JournalView
is a fresh write (not a rename of BriefingView) — drops the briefing-
specific weather panel, news cards, RSS reactions, article-discuss
button, and setup wizard, since none of those have journal-backend
equivalents.

What it does:
- Fetches /api/journal/today on mount; shows today's daily-prep card
  (rendered from msg_metadata.kind === 'daily_prep' sections)
- Day picker via /api/journal/days lets you switch to past days
- Refresh-prep button hits /api/journal/trigger-prep
- Center is the existing ChatPanel pinned to today's journal conversation
  (so the chat input + SSE + tool-call cards inherit unchanged)
- Right sidebar keeps the upcoming-events list (uses the generic
  /api/events endpoint, not a briefing-specific one)

Also:
- Replace the dead Briefing API client functions with Journal equivalents
  (getJournalConfig, saveJournalConfig, getJournalToday, getJournalDay,
  getJournalDays, triggerJournalPrep, list/update/deleteJournalMoment).
- Remove NewsView.vue — it was orphaned (no route, no nav) and depended
  on the deleted /api/briefing/* endpoints. If a standalone news surface
  is wanted later it'll need to be rebuilt against new endpoints.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 11:54:06 -04:00
parent 0c91ab026d
commit 873e70c7ea
5 changed files with 576 additions and 495 deletions
+504
View File
@@ -0,0 +1,504 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
import ChatPanel from '@/components/ChatPanel.vue'
import {
getJournalToday,
getJournalDay,
getJournalDays,
triggerJournalPrep,
listEvents,
type EventEntry,
type JournalMessage,
} from '@/api/client'
const chatStore = useChatStore()
const days = ref<string[]>([])
const todayDate = ref<string | null>(null)
const selectedDay = ref<string | null>(null)
const dayConvId = ref<number | null>(null)
const dayMessages = ref<JournalMessage[]>([])
const triggering = ref(false)
const isToday = computed(() => selectedDay.value !== null && selectedDay.value === todayDate.value)
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' })
}
const prepMessage = computed<JournalMessage | null>(() => {
return dayMessages.value.find(
(m) => m.role === 'system' && (m.metadata as { kind?: string } | null)?.kind === 'daily_prep'
) ?? null
})
const prepSections = computed<Record<string, unknown>>(() => {
const meta = prepMessage.value?.metadata as { sections?: Record<string, unknown> } | null
return meta?.sections ?? {}
})
function asArray(v: unknown): unknown[] {
return Array.isArray(v) ? v : []
}
async function loadDay(iso: string) {
const payload = iso === todayDate.value
? await getJournalToday()
: await getJournalDay(iso)
dayMessages.value = payload.messages
if (payload.conversation) {
dayConvId.value = payload.conversation.id
await chatStore.fetchConversation(payload.conversation.id)
} else {
dayConvId.value = null
}
}
async function loadAll() {
// Today first — also creates the conversation + prep on first load.
try {
const today = await getJournalToday()
todayDate.value = today.day_date
selectedDay.value = today.day_date
dayMessages.value = today.messages
if (today.conversation) {
dayConvId.value = today.conversation.id
await chatStore.fetchConversation(today.conversation.id)
}
} catch { /* silent — backend may be unreachable */ }
// Day list
try {
days.value = await getJournalDays()
if (todayDate.value && !days.value.includes(todayDate.value)) {
days.value = [todayDate.value, ...days.value]
}
} catch { /* silent */ }
// Upcoming events
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 */ }
}
watch(selectedDay, async (iso) => {
if (!iso || iso === todayDate.value) return
try {
await loadDay(iso)
} catch { /* silent */ }
})
async function triggerPrep() {
if (triggering.value) return
triggering.value = true
try {
await triggerJournalPrep()
if (_mounted) await loadAll()
} finally {
if (_mounted) triggering.value = false
}
}
function dayLabel(iso: string): string {
if (iso === todayDate.value) return 'Today'
const d = new Date(iso + 'T00:00:00')
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
}
const todayBadge = computed(() => {
return new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })
})
let _mounted = true
onUnmounted(() => { _mounted = false })
useBackgroundRefresh(
async () => {
if (chatStore.streaming || !isToday.value || !dayConvId.value) return
await chatStore.fetchConversation(dayConvId.value)
},
60_000,
() => !chatStore.streaming && isToday.value && !!dayConvId.value,
)
onMounted(loadAll)
</script>
<template>
<div class="journal-root">
<div class="journal-shell">
<header class="journal-header">
<div class="journal-header-left">
<h1 class="journal-title">Journal</h1>
<span class="journal-today-badge">{{ todayBadge }}</span>
</div>
<div class="journal-header-right">
<select v-if="days.length" v-model="selectedDay" class="journal-day-select">
<option v-for="d in days" :key="d" :value="d">{{ dayLabel(d) }}</option>
</select>
<button
class="btn-trigger"
@click="triggerPrep"
:disabled="triggering || !isToday"
title="Regenerate today's daily prep"
>{{ triggering ? '…' : 'Refresh prep' }}</button>
</div>
</header>
<!-- Center: prep card + chat -->
<div class="journal-center">
<article v-if="prepMessage" class="daily-prep">
<header><h2>Today</h2></header>
<section v-if="asArray(prepSections.tasks).length" class="prep-section">
<h3>Tasks</h3>
<ul>
<li v-for="t in (asArray(prepSections.tasks) as { id: number; title: string; due_date: string | null }[])" :key="t.id">
{{ t.title }}<span v-if="t.due_date"> · due {{ t.due_date }}</span>
</li>
</ul>
</section>
<section v-if="asArray(prepSections.events).length" class="prep-section">
<h3>Calendar</h3>
<ul>
<li v-for="(e, i) in (asArray(prepSections.events) as { id: number; title: string; start_dt: string }[])" :key="i">
{{ e.title }} {{ e.start_dt }}
</li>
</ul>
</section>
<section v-if="asArray(prepSections.weather).length" class="prep-section">
<h3>Weather</h3>
<ul>
<li v-for="(w, i) in (asArray(prepSections.weather) as { location_label?: string; location_key?: string }[])" :key="i">
{{ w.location_label || w.location_key }}
</li>
</ul>
</section>
<section v-if="asArray(prepSections.projects).length" class="prep-section">
<h3>Active projects</h3>
<ul>
<li v-for="p in (asArray(prepSections.projects) as { id: number; title: string }[])" :key="p.id">{{ p.title }}</li>
</ul>
</section>
<section v-if="asArray(prepSections.recent_moments).length" class="prep-section">
<h3>Recent moments</h3>
<ul>
<li v-for="m in (asArray(prepSections.recent_moments) as { id: number; content: string; day_date: string }[])" :key="m.id">
<em>{{ m.day_date }}</em> {{ m.content }}
</li>
</ul>
</section>
<section v-if="asArray(prepSections.open_threads).length" class="prep-section">
<h3>Open threads</h3>
<ul>
<li v-for="m in (asArray(prepSections.open_threads) as { id: number; content: string; day_date: string }[])" :key="m.id">
<em>{{ m.day_date }}</em> {{ m.content }}
</li>
</ul>
</section>
<section v-if="asArray(prepSections.news).length" class="prep-section">
<h3>News</h3>
<ul>
<li v-for="(n, i) in (asArray(prepSections.news) as { title: string; source?: string }[])" :key="i">
{{ n.title }}<span v-if="n.source"> {{ n.source }}</span>
</li>
</ul>
</section>
</article>
<ChatPanel
variant="full"
briefingMode
:readOnly="!isToday"
placeholder="Tell your journal…"
class="journal-chat-panel"
/>
</div>
<!-- Right: upcoming events -->
<div class="journal-right">
<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>
</div>
</div>
</div>
</template>
<style scoped>
.journal-root {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.journal-shell {
display: grid;
grid-template-columns: 1fr minmax(280px, 30%);
grid-template-rows: auto 1fr;
height: 100%;
min-height: 0;
}
.journal-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;
}
.journal-header-left {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.journal-title {
font-family: 'Fraunces', Georgia, serif;
font-size: 1.3rem;
font-weight: 700;
margin: 0;
color: var(--color-text);
}
.journal-today-badge {
font-size: 0.82rem;
color: var(--color-text-muted);
}
.journal-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.journal-day-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; }
.journal-center {
grid-column: 1;
grid-row: 2;
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
}
.journal-chat-panel {
flex: 1;
min-height: 0;
}
.daily-prep {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1rem 1.25rem;
margin: 1rem;
background: var(--color-bg-card);
flex-shrink: 0;
}
.daily-prep > header h2 {
font-family: 'Fraunces', Georgia, serif;
font-size: 1.1rem;
margin: 0 0 0.5rem 0;
}
.prep-section {
margin-top: 0.75rem;
}
.prep-section h3 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
margin: 0 0 0.25rem 0;
font-weight: 600;
}
.prep-section ul {
margin: 0;
padding-left: 1.1rem;
font-size: 0.88rem;
color: var(--color-text);
}
.prep-section li {
margin-bottom: 0.15rem;
}
.journal-right {
grid-column: 2;
grid-row: 2;
border-left: 1px solid var(--color-border);
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
}
.events-section {
padding: 0.75rem 1rem;
}
.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;
}
.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; }
.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;
margin-bottom: 0.4rem;
}
@media (max-width: 900px) {
.journal-shell {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
}
.journal-center { grid-column: 1; grid-row: 2; }
.journal-right {
grid-column: 1;
grid-row: 3;
border-left: none;
border-top: 1px solid var(--color-border);
max-height: 240px;
}
}
</style>