Files
FabledScribe/frontend/src/views/JournalView.vue
T
bvandeusen 4192a64c0f feat(design): surface phase PR 3 — Chat surface polish
Per the surface-phase spec for the Chat cluster (ChatView, ChatPanel,
ChatMessage, ChatInputBar, ToolCallCard):

Long-form line-height
- ChatMessage: assistant-bubble .message-content jumps from 1.55 to
  1.7 — chat is a reading surface, not a snippet stream. User
  bubbles stay tighter.
- JournalView: dropped its :deep(.role-assistant .message-content)
  override; ChatMessage handles it now and Journal inherits.

ToolCallCard borders
- Removed the outer 1px border. ToolCallCard always renders inside
  an assistant bubble; the bubble already contains it. Background
  tint differentiates without re-bordering. Per the
  structural-not-decorative rule.
- Error state preserved as a 3px left-edge accent in --color-danger,
  mirroring the assistant bubble's own left-edge pattern.

Button reclassification per Hybrid rule
- ChatView .bulk-link "All"/"None": accent text → --color-text-secondary
  (these are tertiary list-control affordances, not brand moments)
- ChatView .bulk-delete-btn: --color-danger (Error terracotta) →
  --color-action-destructive (Oxblood) per Hybrid; paired with a
  Trash2 icon since destructive should always be reinforced by an
  icon, not just color
- ChatView .btn-delete-conv hover: same Error → Oxblood swap
- .btn-new-conv stays accent (brand moment, correct already)
- .btn-send stays accent gradient (primary brand moment, foundation)

Two-weights-only
- Snapped every font-weight: 600/700 to 500 across ChatView,
  ChatPanel, ChatMessage, ToolCallCard per the doc rule.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 22:42:44 -04:00

492 lines
16 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } 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 { RotateCcw } from 'lucide-vue-next'
import {
apiGet,
apiPost,
getJournalToday,
getJournalDay,
getJournalDays,
triggerJournalPrep,
listEvents,
type EventEntry,
} from '@/api/client'
interface WeatherDay {
day: string
condition: string
high: number
low: number
precip_probability: number | null
precip_mm: number | null
windspeed_max: number
}
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: WeatherDay[]
}
interface CurrentConditions {
temperature: number | null
windspeed: number | null
description: string
precip_next_3h: number[]
temp_unit: string
location: string
}
const chatStore = useChatStore()
// ── Day picker + conversation state ──────────────────────────────────────────
const days = ref<string[]>([])
const todayDate = ref<string | null>(null)
const selectedDay = ref<string | null>(null)
const dayConvId = ref<number | null>(null)
const isToday = computed(() => selectedDay.value !== null && selectedDay.value === todayDate.value)
// ── Weather panel ────────────────────────────────────────────────────────────
const weatherData = ref<WeatherData[]>([])
const selectedWeatherIdx = ref(0)
const tempUnit = ref<string>('C')
const currentConditions = ref<CurrentConditions | null>(null)
let currentWeatherTimer: ReturnType<typeof setInterval> | null = null
async function loadCurrentConditions() {
try {
currentConditions.value = await apiGet<CurrentConditions>('/api/journal/weather/current')
if (currentConditions.value?.temperature != null && weatherData.value.length > 0) {
weatherData.value[0] = { ...weatherData.value[0], current_temp: currentConditions.value.temperature }
}
} catch { /* silent */ }
}
async function loadWeather() {
try {
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/journal/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/journal/weather/refresh', {})
weatherData.value = data.locations ?? []
tempUnit.value = data.temp_unit ?? 'C'
} catch { /* silent */ }
finally { refreshingWeather.value = false }
}
// ── Upcoming events ──────────────────────────────────────────────────────────
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 {
// Window: today 00:00 → 14 days out. Matches the prep's framing — events
// earlier today (already past) still surface here, grouped under "Today",
// so the right rail aligns with what the prep references.
const start = new Date()
start.setHours(0, 0, 0, 0)
const end = new Date(start)
end.setDate(end.getDate() + 14)
end.setHours(23, 59, 59, 999)
upcomingEvents.value = await listEvents(start.toISOString(), end.toISOString())
} catch { /* silent */ }
}
// ── Day load + switch ────────────────────────────────────────────────────────
async function loadDay(iso: string) {
const payload = iso === todayDate.value ? await getJournalToday() : await getJournalDay(iso)
if (payload.conversation) {
dayConvId.value = payload.conversation.id
await chatStore.fetchConversation(payload.conversation.id)
} else {
dayConvId.value = null
}
}
async function loadAll() {
try {
const today = await getJournalToday()
todayDate.value = today.day_date
selectedDay.value = today.day_date
if (today.conversation) {
dayConvId.value = today.conversation.id
await chatStore.fetchConversation(today.conversation.id)
}
} catch { /* silent */ }
try {
days.value = await getJournalDays()
if (todayDate.value && !days.value.includes(todayDate.value)) {
days.value = [todayDate.value, ...days.value]
}
} catch { /* silent */ }
await Promise.all([loadWeather(), loadCurrentConditions(), loadEvents()])
}
watch(selectedDay, async (iso) => {
if (!iso || iso === todayDate.value) return
try { await loadDay(iso) } catch { /* silent */ }
})
// ── Manual prep regeneration ─────────────────────────────────────────────────
const triggering = ref(false)
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' })
})
// ── Background refresh ───────────────────────────────────────────────────────
async function _backgroundRefreshMessages() {
try {
if (!_mounted || !isToday.value || !dayConvId.value) return
await chatStore.fetchConversation(dayConvId.value)
} catch { /* silent */ }
}
useBackgroundRefresh(
_backgroundRefreshMessages,
60_000,
() => !chatStore.streaming && isToday.value && !!dayConvId.value,
)
let _mounted = true
onUnmounted(() => {
_mounted = false
if (currentWeatherTimer) clearInterval(currentWeatherTimer)
})
onMounted(async () => {
await loadAll()
currentWeatherTimer = setInterval(loadCurrentConditions, 30 * 60 * 1000)
})
</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: chat (prep is the first assistant message inside) -->
<div class="journal-center">
<ChatPanel
variant="full"
briefingMode
:readOnly="!isToday"
placeholder="Tell your journal…"
class="journal-chat-panel"
/>
</div>
<!-- Right: Weather + Events + News -->
<div class="journal-right">
<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"
>
<RotateCcw :size="16" />
</button>
</div>
<WeatherCard
:weather="weatherData[selectedWeatherIdx]"
:temp-unit="tempUnit"
/>
</div>
<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(320px, 35%);
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 {
/* h1 inherits Fraunces from theme.css; weight 500 follows the doc's "two weights only" rule */
font-size: 1.3rem;
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; }
/* ── Center column: prep card + chat ──────────────────────────────────────── */
.journal-center {
grid-column: 1;
grid-row: 2;
display: flex;
flex-direction: column;
min-height: 0;
}
.journal-chat-panel { flex: 1; min-height: 0; }
/* ── Right column ─────────────────────────────────────────────────────────── */
.journal-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: 500;
}
.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: 500;
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: 500; 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: 500;
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; }
@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: 300px;
}
}
</style>