diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4952daa..8083f77 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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 { + try { + const data = await apiGet('/api/briefing/config'); + return { ...DEFAULT_BRIEFING_CONFIG, ...data }; + } catch { + return { ...DEFAULT_BRIEFING_CONFIG }; + } +} + +export async function saveBriefingConfig(config: BriefingConfig): Promise { + await apiPut('/api/briefing/config', config); +} + +export async function getBriefingFeeds(): Promise { + const data = await apiGet('/api/briefing/feeds'); + return data; +} + +export async function createBriefingFeed(url: string): Promise { + 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 { + await apiDelete(`/api/briefing/feeds/${id}`); +} + +export async function getBriefingConversations(): Promise { + 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 { + const data = await apiGet<{ messages: BriefingMessage[] }>(`/api/briefing/conversations/${id}/messages`); + return data.messages; +} + +export async function triggerBriefingSlot(slot: string): Promise { + 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, diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 4a305ad..01e649c 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -77,6 +77,7 @@ router.afterEach(() => { Tasks Chat Graph + Briefing Shared @@ -125,6 +126,7 @@ router.afterEach(() => { Tasks Chat Graph + Briefing Shared
Settings diff --git a/frontend/src/components/BriefingSetupWizard.vue b/frontend/src/components/BriefingSetupWizard.vue new file mode 100644 index 0000000..b559014 --- /dev/null +++ b/frontend/src/components/BriefingSetupWizard.vue @@ -0,0 +1,412 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 2114269..3c99546 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -110,6 +110,11 @@ const router = createRouter({ name: "shared-with-me", component: () => import("@/views/SharedWithMeView.vue"), }, + { + path: "/briefing", + name: "briefing", + component: () => import("@/views/BriefingView.vue"), + }, { path: "/settings", name: "settings", diff --git a/frontend/src/views/BriefingView.vue b/frontend/src/views/BriefingView.vue new file mode 100644 index 0000000..4a5fa64 --- /dev/null +++ b/frontend/src/views/BriefingView.vue @@ -0,0 +1,395 @@ + + + + + diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 89d67d6..0c00e15 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -3,7 +3,7 @@ import { ref, watch, onMounted } from "vue"; import { useSettingsStore } from "@/stores/settings"; import { useAuthStore } from "@/stores/auth"; import { useToastStore } from "@/stores/toast"; -import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, type GroupEntry, type GroupMember, type UserSearchResult } from "@/api/client"; +import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, geocodeAddress, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed } from "@/api/client"; import { usePushStore } from "@/stores/push"; import type { User } from "@/types/auth"; import PaginationBar from "@/components/PaginationBar.vue"; @@ -32,7 +32,7 @@ const appVersion = ref('dev'); const restoreFileInput = ref(null); // Migrate stored "admin" → "config"; unknown tabs fall back to "general" -const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "config", "users", "logs", "groups"]); +const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "briefing", "config", "users", "logs", "groups"]); const _stored = localStorage.getItem("settings_tab") ?? "general"; const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general"); watch(activeTab, (v) => { @@ -40,6 +40,7 @@ watch(activeTab, (v) => { if (v === "users" && authStore.isAdmin) loadUsersPanel(); if (v === "logs" && authStore.isAdmin) loadLogsPanel(); if (v === "groups" && authStore.isAdmin) loadGroupsPanel(); + if (v === "briefing") loadBriefingTab(); }); // Groups management @@ -117,6 +118,94 @@ async function removeMemberFromGroup(groupId: number, userId: number) { await loadGroupsPanel(); } +// Briefing settings +const briefingConfig = ref({ + 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, +}); +const briefingFeeds = ref([]); +const briefingSaving = ref(false); +const briefingSaved = ref(false); +const briefingGeocoding = ref>({}); +const briefingGeoError = ref>({}); +const newFeedUrl = ref(''); +const addingFeed = ref(false); + +async function loadBriefingTab() { + briefingConfig.value = await getBriefingConfig(); + briefingFeeds.value = await getBriefingFeeds(); +} + +async function geocodeLocation(key: 'home' | 'work') { + const loc = briefingConfig.value.locations[key]; + if (!loc?.address?.trim()) return; + briefingGeocoding.value[key] = true; + briefingGeoError.value[key] = ''; + try { + const result = await geocodeAddress(loc.address.trim()); + if (result) { + briefingConfig.value.locations[key] = { + ...loc, + lat: result.lat, + lon: result.lon, + label: key.charAt(0).toUpperCase() + key.slice(1), + }; + briefingGeoError.value[key] = ''; + } else { + briefingGeoError.value[key] = 'Location not found — check the address'; + } + } catch { + briefingGeoError.value[key] = 'Geocoding failed'; + } finally { + briefingGeocoding.value[key] = false; + } +} + +function toggleWorkDay(day: number) { + const days = briefingConfig.value.work_days; + const idx = days.indexOf(day); + if (idx === -1) days.push(day); + else days.splice(idx, 1); + days.sort(); +} + +async function saveBriefingSettings() { + briefingSaving.value = true; + briefingSaved.value = false; + try { + await saveBriefingConfig(briefingConfig.value); + briefingSaved.value = true; + setTimeout(() => (briefingSaved.value = false), 2000); + } catch { + toastStore.show('Failed to save briefing settings', 'error'); + } finally { + briefingSaving.value = false; + } +} + +async function addFeed() { + if (!newFeedUrl.value.trim() || addingFeed.value) return; + addingFeed.value = true; + try { + const feed = await createBriefingFeed(newFeedUrl.value.trim()); + briefingFeeds.value.push(feed); + newFeedUrl.value = ''; + } catch { + toastStore.show('Failed to add feed', 'error'); + } finally { + addingFeed.value = false; + } +} + +async function removeFeed(id: number) { + await deleteBriefingFeed(id); + briefingFeeds.value = briefingFeeds.value.filter((f) => f.id !== id); +} + // Chat retention const chatRetentionDays = ref(90); const savingRetention = ref(false); @@ -774,7 +863,7 @@ function formatUserDate(iso: string): string { +
+ ✓ {{ briefingConfig.locations[key]!.lat?.toFixed(4) }}, {{ briefingConfig.locations[key]!.lon?.toFixed(4) }} +
+
{{ briefingGeoError[key] }}
+ + +
+ +

Look up weather for locations in today's calendar events.

+
+ + + +
+

Office Days

+

Which days do you go into the office? Used to decide whether to include the 8am check-in slot.

+
+ +
+
+ + +
+

Scheduled Slots

+

Each active slot will post an update into your briefing conversation.

+
+
+
+ {{ label }} + {{ time }} +
+ +
+
+
+ + +
+

RSS Feeds

+

Add RSS or Atom feeds to be summarised in your morning briefing.

+
+
+
+ {{ feed.title || feed.url }} + {{ feed.url }} +
+ +
+
+

No feeds yet.

+
+ + +
+
+ + +
+

Notifications

+
+ +
+
+ +
+
+ +
+
+ + +
@@ -2441,4 +2671,122 @@ function formatUserDate(iso: string): string { font-size: 0.82rem; padding: 0.25rem 0.5rem; } + +/* Briefing tab */ +.briefing-location-row { + margin-bottom: 1rem; +} +.briefing-input-group { + display: flex; + gap: 0.5rem; + margin-top: 0.25rem; +} +.briefing-geo-confirmed { + font-size: 0.78rem; + color: var(--color-success, #22c55e); + margin-top: 0.2rem; +} +.briefing-geo-error { + font-size: 0.78rem; + color: var(--color-danger, #ef4444); + margin-top: 0.2rem; +} +.briefing-day-toggles { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + margin-top: 0.5rem; +} +.briefing-day-btn { + padding: 0.35rem 0.65rem; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-bg-card); + color: var(--color-text-muted); + font-size: 0.82rem; + cursor: pointer; + transition: all 0.15s; + font-family: inherit; +} +.briefing-day-btn.active { + background: var(--color-primary); + border-color: var(--color-primary); + color: #fff; +} +.briefing-slot-list { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-top: 0.5rem; +} +.briefing-slot-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.45rem 0.75rem; + border-radius: 8px; + background: var(--color-bg-secondary); +} +.briefing-slot-info { + display: flex; + align-items: center; + gap: 0.75rem; +} +.briefing-slot-label { + font-size: 0.88rem; + font-weight: 500; +} +.briefing-slot-time { + font-size: 0.78rem; + color: var(--color-text-muted); +} +.briefing-feeds-list { + display: flex; + flex-direction: column; + gap: 0.3rem; + margin-bottom: 0.75rem; +} +.briefing-feed-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.6rem; + border-radius: 6px; + background: var(--color-bg-secondary); +} +.briefing-feed-info { + flex: 1; + min-width: 0; +} +.briefing-feed-title { + font-size: 0.85rem; + font-weight: 500; + display: block; +} +.briefing-feed-url { + font-size: 0.75rem; + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; +} +.briefing-btn-remove { + background: none; + border: none; + color: var(--color-text-muted); + cursor: pointer; + font-size: 0.85rem; + padding: 0.2rem 0.4rem; + border-radius: 4px; + flex-shrink: 0; + transition: color 0.15s; +} +.briefing-btn-remove:hover { color: var(--color-danger, #ef4444); } +.briefing-add-feed { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.5rem; +}