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:
2026-04-26 12:22:51 -04:00
parent 873e70c7ea
commit cacfcac86a
5 changed files with 405 additions and 149 deletions
+14 -1
View File
@@ -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;
+8 -2
View File
@@ -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
View File
@@ -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>
+102 -1
View File
@@ -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(
+1 -9
View File
@@ -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"] = [