feat: daily briefing frontend — view, setup wizard, settings tab, nav

- BriefingView: soft chat UI with conversation history dropdown, SSE
  streaming via chat store, manual refresh trigger, today/past conv toggle
- BriefingSetupWizard: 4-step first-time setup overlay (welcome →
  locations → office days → RSS feeds)
- SettingsView: Briefing tab with enable toggle, location geocoding,
  office day picker, slot toggles, RSS feed management, notifications
- AppHeader + router: /briefing route and nav link (desktop + mobile)
- client.ts: briefing types and API functions (config, feeds, convs,
  geocode, trigger)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 20:30:45 -04:00
parent d131621de6
commit 1686289af4
6 changed files with 1281 additions and 3 deletions
+116
View File
@@ -300,6 +300,122 @@ export function apiSSEStream(
return { close: () => controller.abort(), done };
}
// ---------------------------------------------------------------------------
// Briefing
// ---------------------------------------------------------------------------
export interface BriefingLocation {
label: string;
address: string;
lat?: number;
lon?: number;
}
export interface BriefingSlots {
compilation: boolean;
morning: boolean;
midday: boolean;
afternoon: boolean;
}
export interface BriefingConfig {
enabled: boolean;
locations: {
home?: BriefingLocation;
work?: BriefingLocation;
};
use_caldav_event_locations: boolean;
work_days: number[];
slots: BriefingSlots;
notifications: boolean;
}
export interface BriefingFeed {
id: number;
title: string;
url: string;
category: string | null;
last_fetched_at: string | null;
}
export interface BriefingConversation {
id: number;
title: string;
briefing_date: string | null;
message_count: number;
created_at: string;
}
export interface BriefingMessage {
id: number;
role: 'user' | 'assistant' | 'system';
content: string;
created_at: string;
}
const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
enabled: false,
locations: {},
use_caldav_event_locations: false,
work_days: [1, 2, 3, 4, 5],
slots: { compilation: true, morning: true, midday: false, afternoon: false },
notifications: true,
};
export async function getBriefingConfig(): Promise<BriefingConfig> {
try {
const data = await apiGet<BriefingConfig>('/api/briefing/config');
return { ...DEFAULT_BRIEFING_CONFIG, ...data };
} catch {
return { ...DEFAULT_BRIEFING_CONFIG };
}
}
export async function saveBriefingConfig(config: BriefingConfig): Promise<void> {
await apiPut('/api/briefing/config', config);
}
export async function getBriefingFeeds(): Promise<BriefingFeed[]> {
const data = await apiGet<BriefingFeed[]>('/api/briefing/feeds');
return data;
}
export async function createBriefingFeed(url: string): Promise<BriefingFeed> {
const data = await apiPost<{ id: number; url: string; title: string; category: string | null }>('/api/briefing/feeds', { url });
return { ...data, last_fetched_at: null };
}
export async function deleteBriefingFeed(id: number): Promise<void> {
await apiDelete(`/api/briefing/feeds/${id}`);
}
export async function getBriefingConversations(): Promise<BriefingConversation[]> {
const data = await apiGet<{ conversations: BriefingConversation[] }>('/api/briefing/conversations');
return data.conversations;
}
export async function getBriefingToday(): Promise<{ id: number; title: string; messages: BriefingMessage[] }> {
return apiGet('/api/briefing/conversations/today');
}
export async function getBriefingConvMessages(id: number): Promise<BriefingMessage[]> {
const data = await apiGet<{ messages: BriefingMessage[] }>(`/api/briefing/conversations/${id}/messages`);
return data.messages;
}
export async function triggerBriefingSlot(slot: string): Promise<void> {
await apiPost('/api/briefing/trigger', { slot });
}
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/briefing/weather/geocode', { query: address });
return { lat: r.lat, lon: r.lon, display_name: r.label };
} catch {
return null;
}
}
export async function apiStreamPost(
path: string,
body: unknown,