fix(journal): restore weather + events panels; hide daily-prep system msg in chat
The journal UI was over-stripped earlier — weather panel, current-conditions poll, and the upcoming-events sidebar were all dropped. Restored those (calls the new /api/journal/weather, /api/journal/weather/current, /api/journal/weather/refresh, /api/journal/weather/geocode endpoints). Also: the daily-prep system message was rendering as flat text inside ChatPanel because there's no .role-system bubble styling. Added a hideMessage guard in ChatMessage so daily-prep system messages don't render in the chat stream — they're already shown above as a structured prep card. News / RSS reactions / article-discuss are intentionally NOT in the journal (scoped out per user direction). The broader RSS infrastructure cleanup is a separate, larger task. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -303,6 +303,13 @@ export function apiSSEStream(
|
||||
// Journal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface JournalLocation {
|
||||
label: string;
|
||||
address: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
}
|
||||
|
||||
export interface JournalConfig {
|
||||
prep_enabled: boolean;
|
||||
prep_hour: number;
|
||||
@@ -310,9 +317,15 @@ export interface JournalConfig {
|
||||
day_rollover_hour: number;
|
||||
morning_end_hour?: number;
|
||||
midday_end_hour?: number;
|
||||
// Ambient-context fields (carried forward from the briefing config schema)
|
||||
locations?: { home?: JournalLocation; work?: JournalLocation };
|
||||
temp_unit?: 'C' | 'F';
|
||||
use_caldav_event_locations?: boolean;
|
||||
enabled?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
export interface JournalConversation {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -410,7 +423,7 @@ export async function openArticleInChat(
|
||||
|
||||
export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number; display_name: string } | null> {
|
||||
try {
|
||||
const r = await apiPost<{ lat: number; lon: number; label: string }>('/api/geocode', { query: address });
|
||||
const r = await apiPost<{ lat: number; lon: number; label: string }>('/api/journal/weather/geocode', { query: address });
|
||||
return { lat: r.lat, lon: r.lon, display_name: r.label };
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -42,7 +42,13 @@ function formatMs(ms: number | null | undefined): string {
|
||||
}
|
||||
|
||||
const metadata = computed(() => (props.message.metadata ?? {}) as Record<string, unknown>);
|
||||
void metadata;
|
||||
|
||||
// Hide daily-prep system messages — they're rendered separately as a structured
|
||||
// card in JournalView. Without this filter they render as a flat unstyled block
|
||||
// inside the chat (no .role-system bubble styling exists).
|
||||
const hideMessage = computed(() =>
|
||||
props.message.role === "system" && metadata.value.kind === "daily_prep"
|
||||
);
|
||||
|
||||
const timingParts = computed((): string[] => {
|
||||
const t = props.message.timing;
|
||||
@@ -60,7 +66,7 @@ const timingParts = computed((): string[] => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-message" :class="`role-${message.role}`">
|
||||
<div v-if="!hideMessage" class="chat-message" :class="`role-${message.role}`">
|
||||
<div class="message-group">
|
||||
<div class="message-bubble">
|
||||
<div class="message-header">
|
||||
|
||||
+280
-136
@@ -1,9 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import ChatPanel from '@/components/ChatPanel.vue'
|
||||
import WeatherCard from '@/components/WeatherCard.vue'
|
||||
import JournalSetupWizard from '@/components/JournalSetupWizard.vue'
|
||||
import {
|
||||
apiGet,
|
||||
apiPost,
|
||||
getJournalConfig,
|
||||
getJournalToday,
|
||||
getJournalDay,
|
||||
getJournalDays,
|
||||
@@ -13,17 +18,111 @@ import {
|
||||
type JournalMessage,
|
||||
} 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()
|
||||
|
||||
// ── Setup wizard ─────────────────────────────────────────────────────────────
|
||||
const showWizard = ref(false)
|
||||
const wizardChecked = ref(false)
|
||||
|
||||
async function checkSetup() {
|
||||
const config = await getJournalConfig()
|
||||
// Wizard offers location/feed setup. Skip if already configured.
|
||||
const hasLocations = !!(config.locations?.home || config.locations?.work)
|
||||
if (!hasLocations) showWizard.value = true
|
||||
wizardChecked.value = true
|
||||
}
|
||||
|
||||
async function onWizardDone() {
|
||||
showWizard.value = false
|
||||
await loadAll()
|
||||
}
|
||||
|
||||
// ── 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 dayMessages = ref<JournalMessage[]>([])
|
||||
const triggering = ref(false)
|
||||
|
||||
const isToday = computed(() => selectedDay.value !== null && selectedDay.value === todayDate.value)
|
||||
|
||||
// ── Daily prep card ──────────────────────────────────────────────────────────
|
||||
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 : [] }
|
||||
|
||||
// ── 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 {
|
||||
@@ -61,26 +160,18 @@ function formatEventTime(ev: EventEntry): string {
|
||||
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 loadEvents() {
|
||||
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 */ }
|
||||
}
|
||||
|
||||
// ── Day load + switch ────────────────────────────────────────────────────────
|
||||
async function loadDay(iso: string) {
|
||||
const payload = iso === todayDate.value
|
||||
? await getJournalToday()
|
||||
: await getJournalDay(iso)
|
||||
|
||||
const payload = iso === todayDate.value ? await getJournalToday() : await getJournalDay(iso)
|
||||
dayMessages.value = payload.messages
|
||||
if (payload.conversation) {
|
||||
dayConvId.value = payload.conversation.id
|
||||
@@ -91,7 +182,6 @@ async function loadDay(iso: string) {
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
// Today first — also creates the conversation + prep on first load.
|
||||
try {
|
||||
const today = await getJournalToday()
|
||||
todayDate.value = today.day_date
|
||||
@@ -101,9 +191,8 @@ async function loadAll() {
|
||||
dayConvId.value = today.conversation.id
|
||||
await chatStore.fetchConversation(today.conversation.id)
|
||||
}
|
||||
} catch { /* silent — backend may be unreachable */ }
|
||||
} catch { /* silent */ }
|
||||
|
||||
// Day list
|
||||
try {
|
||||
days.value = await getJournalDays()
|
||||
if (todayDate.value && !days.value.includes(todayDate.value)) {
|
||||
@@ -111,22 +200,16 @@ async function loadAll() {
|
||||
}
|
||||
} 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 */ }
|
||||
await Promise.all([loadWeather(), loadCurrentConditions(), loadEvents()])
|
||||
}
|
||||
|
||||
watch(selectedDay, async (iso) => {
|
||||
if (!iso || iso === todayDate.value) return
|
||||
try {
|
||||
await loadDay(iso)
|
||||
} catch { /* silent */ }
|
||||
try { await loadDay(iso) } catch { /* silent */ }
|
||||
})
|
||||
|
||||
// ── Manual prep regeneration ─────────────────────────────────────────────────
|
||||
const triggering = ref(false)
|
||||
async function triggerPrep() {
|
||||
if (triggering.value) return
|
||||
triggering.value = true
|
||||
@@ -148,24 +231,41 @@ const todayBadge = computed(() => {
|
||||
return new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })
|
||||
})
|
||||
|
||||
let _mounted = true
|
||||
onUnmounted(() => { _mounted = false })
|
||||
// ── Background refresh ───────────────────────────────────────────────────────
|
||||
async function _backgroundRefreshMessages() {
|
||||
try {
|
||||
if (!_mounted || !isToday.value || !dayConvId.value) return
|
||||
await chatStore.fetchConversation(dayConvId.value)
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
useBackgroundRefresh(
|
||||
async () => {
|
||||
if (chatStore.streaming || !isToday.value || !dayConvId.value) return
|
||||
await chatStore.fetchConversation(dayConvId.value)
|
||||
},
|
||||
_backgroundRefreshMessages,
|
||||
60_000,
|
||||
() => !chatStore.streaming && isToday.value && !!dayConvId.value,
|
||||
)
|
||||
|
||||
onMounted(loadAll)
|
||||
let _mounted = true
|
||||
onUnmounted(() => {
|
||||
_mounted = false
|
||||
if (currentWeatherTimer) clearInterval(currentWeatherTimer)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await checkSetup()
|
||||
if (!showWizard.value) {
|
||||
await loadAll()
|
||||
currentWeatherTimer = setInterval(loadCurrentConditions, 30 * 60 * 1000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="journal-root">
|
||||
<div class="journal-shell">
|
||||
<!-- Setup wizard overlay -->
|
||||
<JournalSetupWizard v-if="wizardChecked && showWizard" @done="onWizardDone" />
|
||||
|
||||
<div class="journal-shell" v-if="wizardChecked && !showWizard">
|
||||
<header class="journal-header">
|
||||
<div class="journal-header-left">
|
||||
<h1 class="journal-title">Journal</h1>
|
||||
@@ -207,15 +307,6 @@ onMounted(loadAll)
|
||||
</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>
|
||||
@@ -240,15 +331,6 @@ onMounted(loadAll)
|
||||
</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
|
||||
@@ -260,8 +342,33 @@ onMounted(loadAll)
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right: upcoming events -->
|
||||
<!-- 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"
|
||||
>↻</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>
|
||||
@@ -281,6 +388,7 @@ onMounted(loadAll)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -296,7 +404,7 @@ onMounted(loadAll)
|
||||
|
||||
.journal-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(280px, 30%);
|
||||
grid-template-columns: 1fr minmax(320px, 35%);
|
||||
grid-template-rows: auto 1fr;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
@@ -314,13 +422,7 @@ onMounted(loadAll)
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.journal-header-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.journal-header-left { display: flex; align-items: baseline; gap: 0.75rem; }
|
||||
.journal-title {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 1.3rem;
|
||||
@@ -328,17 +430,8 @@ onMounted(loadAll)
|
||||
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-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;
|
||||
@@ -363,65 +456,46 @@ onMounted(loadAll)
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-trigger:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.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;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.journal-chat-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.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;
|
||||
margin: 1rem 1rem 0.5rem;
|
||||
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 { margin-top: 0.75rem; }
|
||||
.prep-section h3 {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.72rem;
|
||||
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; }
|
||||
|
||||
.prep-section ul {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.prep-section li {
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
/* ── Right column ─────────────────────────────────────────────────────────── */
|
||||
.journal-right {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
@@ -429,17 +503,55 @@ onMounted(loadAll)
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.events-section {
|
||||
padding: 0.75rem 1rem;
|
||||
.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;
|
||||
}
|
||||
.events-cal-link {
|
||||
font-size: 0.75rem;
|
||||
|
||||
.weather-refresh-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
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: 600;
|
||||
}
|
||||
|
||||
.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; }
|
||||
@@ -450,27 +562,27 @@ onMounted(loadAll)
|
||||
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 { 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%;
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 5px;
|
||||
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; }
|
||||
|
||||
.news-section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
@@ -479,26 +591,58 @@ onMounted(loadAll)
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.panel-label-row {
|
||||
.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 {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.news-card-meta { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.news-source { font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--color-primary); }
|
||||
.news-date { font-size: 0.72rem; color: var(--color-text-muted); }
|
||||
.news-title { font-size: 0.88rem; font-weight: 600; color: var(--color-text); line-height: 1.35; text-decoration: none; margin: 0; }
|
||||
a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
|
||||
.news-snippet {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.news-reactions { display: flex; gap: 0.3rem; margin-top: 0.15rem; }
|
||||
.reaction-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.1rem 0.35rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s, border-color 0.15s;
|
||||
}
|
||||
.reaction-btn:hover { opacity: 1; border-color: var(--color-primary); }
|
||||
.reaction-btn.active { opacity: 1; border-color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 12%, transparent); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.journal-shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
.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;
|
||||
grid-column: 1; grid-row: 3;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
max-height: 240px;
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
"""HTTP endpoints for the Journal feature."""
|
||||
"""HTTP endpoints for the Journal feature.
|
||||
|
||||
Includes the conversational journal endpoints (config / today / day / days /
|
||||
trigger-prep / moments) plus the ambient-context surface lifted from the
|
||||
old Briefing routes (RSS feeds, weather, news, RSS reactions, article-discuss).
|
||||
The ambient endpoints read locations + temp_unit + topic preferences from the
|
||||
``journal_config`` user setting.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
@@ -11,6 +18,7 @@ from sqlalchemy import select
|
||||
|
||||
from fabledassistant.auth import get_current_user_id, login_required
|
||||
from fabledassistant.models import Conversation, Message, async_session
|
||||
from fabledassistant.services import weather as weather_svc
|
||||
from fabledassistant.services.journal_prep import ensure_daily_prep_message
|
||||
from fabledassistant.services.journal_scheduler import (
|
||||
DEFAULT_CONFIG as DEFAULT_JOURNAL_CONFIG,
|
||||
@@ -200,6 +208,99 @@ async def remove_moment(moment_id: int):
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────────
|
||||
# Ambient endpoints (lifted from the old briefing surface).
|
||||
# ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _journal_temp_unit(user_id: int) -> str:
|
||||
cfg = await _resolve_config(user_id)
|
||||
unit = cfg.get("temp_unit", "C")
|
||||
return unit if unit in ("C", "F") else "C"
|
||||
|
||||
|
||||
# ── Weather ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@journal_bp.get("/weather")
|
||||
@login_required
|
||||
async def get_weather():
|
||||
user_id = get_current_user_id()
|
||||
rows = await weather_svc.get_cached_weather_rows(user_id)
|
||||
temp_unit = await _journal_temp_unit(user_id)
|
||||
cards = [
|
||||
card for row in rows
|
||||
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
|
||||
]
|
||||
return jsonify({"locations": cards, "temp_unit": temp_unit})
|
||||
|
||||
|
||||
@journal_bp.get("/weather/current")
|
||||
@login_required
|
||||
async def get_current_weather():
|
||||
"""Live current temperature + conditions for the user's primary location."""
|
||||
user_id = get_current_user_id()
|
||||
cfg = await _resolve_config(user_id)
|
||||
temp_unit = await _journal_temp_unit(user_id)
|
||||
locations = cfg.get("locations") or {}
|
||||
loc = locations.get("home") or locations.get("work")
|
||||
if not loc or not loc.get("lat") or not loc.get("lon"):
|
||||
return jsonify({"error": "No location configured"}), 404
|
||||
|
||||
current = await weather_svc.fetch_current_conditions(loc["lat"], loc["lon"])
|
||||
if current is None:
|
||||
return jsonify({"error": "Failed to fetch current conditions"}), 502
|
||||
|
||||
temp = current["temperature"]
|
||||
if temp is not None and temp_unit == "F":
|
||||
temp = temp * 9 / 5 + 32
|
||||
current["temperature"] = round(temp) if temp is not None else None
|
||||
current["temp_unit"] = temp_unit
|
||||
current["location"] = loc.get("label") or "Home"
|
||||
return jsonify(current)
|
||||
|
||||
|
||||
@journal_bp.post("/weather/refresh")
|
||||
@login_required
|
||||
async def refresh_weather():
|
||||
user_id = get_current_user_id()
|
||||
cfg = await _resolve_config(user_id)
|
||||
temp_unit = await _journal_temp_unit(user_id)
|
||||
for key, loc in (cfg.get("locations") or {}).items():
|
||||
if not loc.get("lat") or not loc.get("lon"):
|
||||
continue
|
||||
try:
|
||||
await weather_svc.refresh_location_cache(
|
||||
user_id=user_id,
|
||||
location_key=key,
|
||||
location_label=loc.get("label", key),
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to refresh weather for %s", key, exc_info=True)
|
||||
rows = await weather_svc.get_cached_weather_rows(user_id)
|
||||
cards = [
|
||||
card for row in rows
|
||||
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
|
||||
]
|
||||
return jsonify({"locations": cards, "temp_unit": temp_unit})
|
||||
|
||||
|
||||
@journal_bp.post("/weather/geocode")
|
||||
@login_required
|
||||
async def geocode_location():
|
||||
data = await request.get_json()
|
||||
query = (data.get("query") or "").strip()
|
||||
if not query:
|
||||
return jsonify({"error": "query required"}), 400
|
||||
try:
|
||||
lat, lon, label = await weather_svc.geocode(query)
|
||||
return jsonify({"lat": lat, "lon": lon, "label": label})
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
|
||||
|
||||
async def _day_payload(*, user_id: int, day_date: datetime.date):
|
||||
async with async_session() as session:
|
||||
conv_stmt = select(Conversation).where(
|
||||
|
||||
@@ -5,7 +5,7 @@ new day). Gathers raw data into a structured prep block that becomes the
|
||||
first message of the day's journal Conversation.
|
||||
|
||||
The output shape lives on Message.msg_metadata as:
|
||||
{ kind: 'daily_prep', sections: { tasks, events, weather, news,
|
||||
{ kind: 'daily_prep', sections: { tasks, events, weather,
|
||||
projects, recent_moments, open_threads } }
|
||||
|
||||
Deliberately deterministic — no LLM call here. The journal LLM weaves
|
||||
@@ -25,7 +25,6 @@ from fabledassistant.services.events import list_events
|
||||
from fabledassistant.services.journal_search import search_journal
|
||||
from fabledassistant.services.notes import list_notes
|
||||
from fabledassistant.services.projects import list_projects
|
||||
from fabledassistant.services.rss import get_recent_items
|
||||
from fabledassistant.services.weather import get_cached_weather_rows
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -84,13 +83,6 @@ async def generate_daily_prep(
|
||||
logger.exception("daily_prep weather section failed for user %d", user_id)
|
||||
sections["weather"] = []
|
||||
|
||||
try:
|
||||
news = await get_recent_items(user_id=user_id, limit=5)
|
||||
sections["news"] = news
|
||||
except Exception:
|
||||
logger.exception("daily_prep news section failed for user %d", user_id)
|
||||
sections["news"] = []
|
||||
|
||||
try:
|
||||
projects = await list_projects(user_id=user_id, status="active")
|
||||
sections["projects"] = [
|
||||
|
||||
Reference in New Issue
Block a user