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
|
// Journal
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface JournalLocation {
|
||||||
|
label: string;
|
||||||
|
address: string;
|
||||||
|
lat?: number;
|
||||||
|
lon?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface JournalConfig {
|
export interface JournalConfig {
|
||||||
prep_enabled: boolean;
|
prep_enabled: boolean;
|
||||||
prep_hour: number;
|
prep_hour: number;
|
||||||
@@ -310,9 +317,15 @@ export interface JournalConfig {
|
|||||||
day_rollover_hour: number;
|
day_rollover_hour: number;
|
||||||
morning_end_hour?: number;
|
morning_end_hour?: number;
|
||||||
midday_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;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface JournalConversation {
|
export interface JournalConversation {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
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> {
|
export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number; display_name: string } | null> {
|
||||||
try {
|
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 };
|
return { lat: r.lat, lon: r.lon, display_name: r.label };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -42,7 +42,13 @@ function formatMs(ms: number | null | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const metadata = computed(() => (props.message.metadata ?? {}) as Record<string, unknown>);
|
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 timingParts = computed((): string[] => {
|
||||||
const t = props.message.timing;
|
const t = props.message.timing;
|
||||||
@@ -60,7 +66,7 @@ const timingParts = computed((): string[] => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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-group">
|
||||||
<div class="message-bubble">
|
<div class="message-bubble">
|
||||||
<div class="message-header">
|
<div class="message-header">
|
||||||
|
|||||||
+280
-136
@@ -1,9 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useChatStore } from '@/stores/chat'
|
|
||||||
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
||||||
|
import { useChatStore } from '@/stores/chat'
|
||||||
import ChatPanel from '@/components/ChatPanel.vue'
|
import ChatPanel from '@/components/ChatPanel.vue'
|
||||||
|
import WeatherCard from '@/components/WeatherCard.vue'
|
||||||
|
import JournalSetupWizard from '@/components/JournalSetupWizard.vue'
|
||||||
import {
|
import {
|
||||||
|
apiGet,
|
||||||
|
apiPost,
|
||||||
|
getJournalConfig,
|
||||||
getJournalToday,
|
getJournalToday,
|
||||||
getJournalDay,
|
getJournalDay,
|
||||||
getJournalDays,
|
getJournalDays,
|
||||||
@@ -13,17 +18,111 @@ import {
|
|||||||
type JournalMessage,
|
type JournalMessage,
|
||||||
} from '@/api/client'
|
} 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()
|
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 days = ref<string[]>([])
|
||||||
const todayDate = ref<string | null>(null)
|
const todayDate = ref<string | null>(null)
|
||||||
const selectedDay = ref<string | null>(null)
|
const selectedDay = ref<string | null>(null)
|
||||||
const dayConvId = ref<number | null>(null)
|
const dayConvId = ref<number | null>(null)
|
||||||
const dayMessages = ref<JournalMessage[]>([])
|
const dayMessages = ref<JournalMessage[]>([])
|
||||||
const triggering = ref(false)
|
|
||||||
|
|
||||||
const isToday = computed(() => selectedDay.value !== null && selectedDay.value === todayDate.value)
|
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[]>([])
|
const upcomingEvents = ref<EventEntry[]>([])
|
||||||
|
|
||||||
interface GroupedDay {
|
interface GroupedDay {
|
||||||
@@ -61,26 +160,18 @@ function formatEventTime(ev: EventEntry): string {
|
|||||||
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const prepMessage = computed<JournalMessage | null>(() => {
|
async function loadEvents() {
|
||||||
return dayMessages.value.find(
|
try {
|
||||||
(m) => m.role === 'system' && (m.metadata as { kind?: string } | null)?.kind === 'daily_prep'
|
const now = new Date()
|
||||||
) ?? null
|
const end = new Date(now)
|
||||||
})
|
end.setDate(end.getDate() + 14)
|
||||||
|
upcomingEvents.value = await listEvents(now.toISOString(), end.toISOString())
|
||||||
const prepSections = computed<Record<string, unknown>>(() => {
|
} catch { /* silent */ }
|
||||||
const meta = prepMessage.value?.metadata as { sections?: Record<string, unknown> } | null
|
|
||||||
return meta?.sections ?? {}
|
|
||||||
})
|
|
||||||
|
|
||||||
function asArray(v: unknown): unknown[] {
|
|
||||||
return Array.isArray(v) ? v : []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Day load + switch ────────────────────────────────────────────────────────
|
||||||
async function loadDay(iso: string) {
|
async function loadDay(iso: string) {
|
||||||
const payload = iso === todayDate.value
|
const payload = iso === todayDate.value ? await getJournalToday() : await getJournalDay(iso)
|
||||||
? await getJournalToday()
|
|
||||||
: await getJournalDay(iso)
|
|
||||||
|
|
||||||
dayMessages.value = payload.messages
|
dayMessages.value = payload.messages
|
||||||
if (payload.conversation) {
|
if (payload.conversation) {
|
||||||
dayConvId.value = payload.conversation.id
|
dayConvId.value = payload.conversation.id
|
||||||
@@ -91,7 +182,6 @@ async function loadDay(iso: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
// Today first — also creates the conversation + prep on first load.
|
|
||||||
try {
|
try {
|
||||||
const today = await getJournalToday()
|
const today = await getJournalToday()
|
||||||
todayDate.value = today.day_date
|
todayDate.value = today.day_date
|
||||||
@@ -101,9 +191,8 @@ async function loadAll() {
|
|||||||
dayConvId.value = today.conversation.id
|
dayConvId.value = today.conversation.id
|
||||||
await chatStore.fetchConversation(today.conversation.id)
|
await chatStore.fetchConversation(today.conversation.id)
|
||||||
}
|
}
|
||||||
} catch { /* silent — backend may be unreachable */ }
|
} catch { /* silent */ }
|
||||||
|
|
||||||
// Day list
|
|
||||||
try {
|
try {
|
||||||
days.value = await getJournalDays()
|
days.value = await getJournalDays()
|
||||||
if (todayDate.value && !days.value.includes(todayDate.value)) {
|
if (todayDate.value && !days.value.includes(todayDate.value)) {
|
||||||
@@ -111,22 +200,16 @@ async function loadAll() {
|
|||||||
}
|
}
|
||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
|
|
||||||
// Upcoming events
|
await Promise.all([loadWeather(), loadCurrentConditions(), 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 */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(selectedDay, async (iso) => {
|
watch(selectedDay, async (iso) => {
|
||||||
if (!iso || iso === todayDate.value) return
|
if (!iso || iso === todayDate.value) return
|
||||||
try {
|
try { await loadDay(iso) } catch { /* silent */ }
|
||||||
await loadDay(iso)
|
|
||||||
} catch { /* silent */ }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Manual prep regeneration ─────────────────────────────────────────────────
|
||||||
|
const triggering = ref(false)
|
||||||
async function triggerPrep() {
|
async function triggerPrep() {
|
||||||
if (triggering.value) return
|
if (triggering.value) return
|
||||||
triggering.value = true
|
triggering.value = true
|
||||||
@@ -148,24 +231,41 @@ const todayBadge = computed(() => {
|
|||||||
return new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })
|
return new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })
|
||||||
})
|
})
|
||||||
|
|
||||||
let _mounted = true
|
// ── Background refresh ───────────────────────────────────────────────────────
|
||||||
onUnmounted(() => { _mounted = false })
|
async function _backgroundRefreshMessages() {
|
||||||
|
try {
|
||||||
|
if (!_mounted || !isToday.value || !dayConvId.value) return
|
||||||
|
await chatStore.fetchConversation(dayConvId.value)
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
useBackgroundRefresh(
|
useBackgroundRefresh(
|
||||||
async () => {
|
_backgroundRefreshMessages,
|
||||||
if (chatStore.streaming || !isToday.value || !dayConvId.value) return
|
|
||||||
await chatStore.fetchConversation(dayConvId.value)
|
|
||||||
},
|
|
||||||
60_000,
|
60_000,
|
||||||
() => !chatStore.streaming && isToday.value && !!dayConvId.value,
|
() => !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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="journal-root">
|
<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">
|
<header class="journal-header">
|
||||||
<div class="journal-header-left">
|
<div class="journal-header-left">
|
||||||
<h1 class="journal-title">Journal</h1>
|
<h1 class="journal-title">Journal</h1>
|
||||||
@@ -207,15 +307,6 @@ onMounted(loadAll)
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</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">
|
<section v-if="asArray(prepSections.projects).length" class="prep-section">
|
||||||
<h3>Active projects</h3>
|
<h3>Active projects</h3>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -240,15 +331,6 @@ onMounted(loadAll)
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</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>
|
</article>
|
||||||
|
|
||||||
<ChatPanel
|
<ChatPanel
|
||||||
@@ -260,8 +342,33 @@ onMounted(loadAll)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: upcoming events -->
|
<!-- Right: Weather + Events + News -->
|
||||||
<div class="journal-right">
|
<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="events-section" v-if="groupedEvents.length">
|
||||||
<div class="panel-label-row">
|
<div class="panel-label-row">
|
||||||
<div class="panel-label">Upcoming</div>
|
<div class="panel-label">Upcoming</div>
|
||||||
@@ -281,6 +388,7 @@ onMounted(loadAll)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -296,7 +404,7 @@ onMounted(loadAll)
|
|||||||
|
|
||||||
.journal-shell {
|
.journal-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr minmax(280px, 30%);
|
grid-template-columns: 1fr minmax(320px, 35%);
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto 1fr;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -314,13 +422,7 @@ onMounted(loadAll)
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
flex-wrap: wrap;
|
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 {
|
.journal-title {
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
@@ -328,17 +430,8 @@ onMounted(loadAll)
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
.journal-today-badge { font-size: 0.82rem; color: var(--color-text-muted); }
|
||||||
.journal-today-badge {
|
.journal-header-right { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.journal-header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journal-day-select {
|
.journal-day-select {
|
||||||
padding: 0.35rem 0.6rem;
|
padding: 0.35rem 0.6rem;
|
||||||
@@ -363,65 +456,46 @@ onMounted(loadAll)
|
|||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.btn-trigger:hover:not(:disabled) {
|
.btn-trigger:hover:not(:disabled) { border-color: var(--color-primary); color: var(--color-primary); }
|
||||||
border-color: var(--color-primary);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
|
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Center column: prep card + chat ──────────────────────────────────────── */
|
||||||
.journal-center {
|
.journal-center {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.journal-chat-panel {
|
.journal-chat-panel { flex: 1; min-height: 0; }
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.daily-prep {
|
.daily-prep {
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
margin: 1rem;
|
margin: 1rem 1rem 0.5rem;
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.daily-prep > header h2 {
|
.daily-prep > header h2 {
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
.prep-section { margin-top: 0.75rem; }
|
||||||
.prep-section {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prep-section h3 {
|
.prep-section h3 {
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
margin: 0 0 0.25rem 0;
|
margin: 0 0 0.25rem 0;
|
||||||
font-weight: 600;
|
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 {
|
/* ── Right column ─────────────────────────────────────────────────────────── */
|
||||||
margin: 0;
|
|
||||||
padding-left: 1.1rem;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prep-section li {
|
|
||||||
margin-bottom: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.journal-right {
|
.journal-right {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
@@ -429,17 +503,55 @@ onMounted(loadAll)
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.events-section {
|
.weather-section { flex-shrink: 0; padding: 1rem 1rem 0.5rem; }
|
||||||
padding: 0.75rem 1rem;
|
.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);
|
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-cal-link:hover { color: var(--color-primary); }
|
||||||
.events-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
.events-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||||
.events-day-group { display: flex; flex-direction: column; gap: 0.2rem; }
|
.events-day-group { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||||
@@ -450,27 +562,27 @@ onMounted(loadAll)
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
.event-row {
|
.event-row { display: flex; align-items: flex-start; gap: 0.4rem; padding: 0.25rem 0.4rem; border-radius: 6px; }
|
||||||
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-row:hover { background: color-mix(in srgb, var(--color-primary) 6%, transparent); }
|
||||||
.event-dot {
|
.event-dot {
|
||||||
width: 7px;
|
width: 7px; height: 7px; border-radius: 50%;
|
||||||
height: 7px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0; margin-top: 5px;
|
||||||
margin-top: 5px;
|
|
||||||
}
|
}
|
||||||
.event-body { display: flex; flex-direction: column; gap: 0.05rem; min-width: 0; }
|
.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-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-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; }
|
.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 {
|
.panel-label {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -479,26 +591,58 @@ onMounted(loadAll)
|
|||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
flex-shrink: 0;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
gap: 0.25rem;
|
||||||
flex-shrink: 0;
|
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) {
|
@media (max-width: 900px) {
|
||||||
.journal-shell {
|
.journal-shell { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; }
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-template-rows: auto 1fr auto;
|
|
||||||
}
|
|
||||||
.journal-center { grid-column: 1; grid-row: 2; }
|
.journal-center { grid-column: 1; grid-row: 2; }
|
||||||
.journal-right {
|
.journal-right {
|
||||||
grid-column: 1;
|
grid-column: 1; grid-row: 3;
|
||||||
grid-row: 3;
|
|
||||||
border-left: none;
|
border-left: none;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
max-height: 240px;
|
max-height: 300px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
@@ -11,6 +18,7 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
from fabledassistant.auth import get_current_user_id, login_required
|
from fabledassistant.auth import get_current_user_id, login_required
|
||||||
from fabledassistant.models import Conversation, Message, async_session
|
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_prep import ensure_daily_prep_message
|
||||||
from fabledassistant.services.journal_scheduler import (
|
from fabledassistant.services.journal_scheduler import (
|
||||||
DEFAULT_CONFIG as DEFAULT_JOURNAL_CONFIG,
|
DEFAULT_CONFIG as DEFAULT_JOURNAL_CONFIG,
|
||||||
@@ -200,6 +208,99 @@ async def remove_moment(moment_id: int):
|
|||||||
return jsonify({"ok": True})
|
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 def _day_payload(*, user_id: int, day_date: datetime.date):
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
conv_stmt = select(Conversation).where(
|
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.
|
first message of the day's journal Conversation.
|
||||||
|
|
||||||
The output shape lives on Message.msg_metadata as:
|
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 } }
|
projects, recent_moments, open_threads } }
|
||||||
|
|
||||||
Deliberately deterministic — no LLM call here. The journal LLM weaves
|
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.journal_search import search_journal
|
||||||
from fabledassistant.services.notes import list_notes
|
from fabledassistant.services.notes import list_notes
|
||||||
from fabledassistant.services.projects import list_projects
|
from fabledassistant.services.projects import list_projects
|
||||||
from fabledassistant.services.rss import get_recent_items
|
|
||||||
from fabledassistant.services.weather import get_cached_weather_rows
|
from fabledassistant.services.weather import get_cached_weather_rows
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
logger.exception("daily_prep weather section failed for user %d", user_id)
|
||||||
sections["weather"] = []
|
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:
|
try:
|
||||||
projects = await list_projects(user_id=user_id, status="active")
|
projects = await list_projects(user_id=user_id, status="active")
|
||||||
sections["projects"] = [
|
sections["projects"] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user