f20e54a068
The right rail's loadEvents was using "now → now + 14 days", which filtered out events earlier today that had already passed. The prep uses "today 00:00 → today 23:59" so it includes those events and explicitly notes they're "in the past". The two surfaces talked about different sets of events. Widen the right rail to "today 00:00 → today + 14 days 23:59" so events from earlier today still surface and group under "Today". The prep and the right rail now reference the same set within today. Events further out (next 14 days) keep working as before. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
498 lines
16 KiB
Vue
498 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; }
|
|
|
|
/* Long-form reading line-height for journal assistant bubbles
|
|
(the daily prep is prose, not chat snippets — wants the doc's 1.7) */
|
|
.journal-chat-panel :deep(.role-assistant .message-content) {
|
|
line-height: 1.7;
|
|
}
|
|
|
|
/* ── 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>
|