4192a64c0f
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>
492 lines
16 KiB
Vue
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>
|