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:
@@ -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<HTMLInputElement | null>(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<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,
|
||||
});
|
||||
const briefingFeeds = ref<BriefingFeed[]>([]);
|
||||
const briefingSaving = ref(false);
|
||||
const briefingSaved = ref(false);
|
||||
const briefingGeocoding = ref<Record<string, boolean>>({});
|
||||
const briefingGeoError = ref<Record<string, string>>({});
|
||||
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 {
|
||||
<div class="sidebar-group">
|
||||
<div class="sidebar-group-label">User</div>
|
||||
<button
|
||||
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data']"
|
||||
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'briefing']"
|
||||
:key="tab"
|
||||
:class="['sidebar-item', { active: activeTab === tab }]"
|
||||
@click="activeTab = tab"
|
||||
@@ -1172,6 +1261,147 @@ function formatUserDate(iso: string): string {
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Briefing ── -->
|
||||
<div v-show="activeTab === 'briefing'" class="settings-grid">
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>Daily Briefing</h2>
|
||||
<p class="section-desc">
|
||||
Configure your daily briefing — a conversation that summarises your day,
|
||||
weather, and RSS feeds on a schedule.
|
||||
</p>
|
||||
|
||||
<!-- Enable -->
|
||||
<div class="checkbox-field">
|
||||
<label>
|
||||
<input type="checkbox" v-model="briefingConfig.enabled" />
|
||||
Enable Daily Briefing
|
||||
</label>
|
||||
<p class="field-hint">Start daily briefings at the configured slots.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Locations -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Locations</h2>
|
||||
<p class="section-desc">Enter addresses for weather lookup. Click "Look up" to geocode.</p>
|
||||
|
||||
<div v-for="key in (['home', 'work'] as const)" :key="key" class="briefing-location-row">
|
||||
<label class="field-label">{{ key.charAt(0).toUpperCase() + key.slice(1) }}</label>
|
||||
<div class="briefing-input-group">
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="key === 'home' ? 'e.g. 123 Main St, Springfield' : 'e.g. 456 Office Ave, Portland'"
|
||||
:value="briefingConfig.locations[key]?.address ?? ''"
|
||||
@input="(e) => {
|
||||
if (!briefingConfig.locations[key]) briefingConfig.locations[key] = { label: key, address: '' };
|
||||
briefingConfig.locations[key]!.address = (e.target as HTMLInputElement).value;
|
||||
}"
|
||||
/>
|
||||
<button class="btn-secondary" @click="geocodeLocation(key)" :disabled="briefingGeocoding[key]">
|
||||
{{ briefingGeocoding[key] ? 'Looking up…' : 'Look up' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="briefingConfig.locations[key]?.lat" class="briefing-geo-confirmed">
|
||||
✓ {{ briefingConfig.locations[key]!.lat?.toFixed(4) }}, {{ briefingConfig.locations[key]!.lon?.toFixed(4) }}
|
||||
</div>
|
||||
<div v-if="briefingGeoError[key]" class="briefing-geo-error">{{ briefingGeoError[key] }}</div>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-field" style="margin-top: 0.75rem">
|
||||
<label>
|
||||
<input type="checkbox" v-model="briefingConfig.use_caldav_event_locations" />
|
||||
Also check CalDAV event locations
|
||||
</label>
|
||||
<p class="field-hint">Look up weather for locations in today's calendar events.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Work schedule -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Office Days</h2>
|
||||
<p class="section-desc">Which days do you go into the office? Used to decide whether to include the 8am check-in slot.</p>
|
||||
<div class="briefing-day-toggles">
|
||||
<button
|
||||
v-for="(day, idx) in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
|
||||
:key="idx"
|
||||
:class="['briefing-day-btn', { active: briefingConfig.work_days.includes(idx) }]"
|
||||
@click="toggleWorkDay(idx)"
|
||||
type="button"
|
||||
>{{ day }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Slots -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Scheduled Slots</h2>
|
||||
<p class="section-desc">Each active slot will post an update into your briefing conversation.</p>
|
||||
<div class="briefing-slot-list">
|
||||
<div
|
||||
v-for="[key, label, time] in ([
|
||||
['compilation', 'Morning briefing', '4:00 am'],
|
||||
['morning', 'Office check-in', '8:00 am'],
|
||||
['midday', 'Midday update', '12:00 pm'],
|
||||
['afternoon', 'End of day', '4:00 pm'],
|
||||
] as const)"
|
||||
:key="key"
|
||||
class="briefing-slot-row"
|
||||
>
|
||||
<div class="briefing-slot-info">
|
||||
<span class="briefing-slot-label">{{ label }}</span>
|
||||
<span class="briefing-slot-time">{{ time }}</span>
|
||||
</div>
|
||||
<label>
|
||||
<input type="checkbox" v-model="briefingConfig.slots[key]" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- RSS Feeds -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>RSS Feeds</h2>
|
||||
<p class="section-desc">Add RSS or Atom feeds to be summarised in your morning briefing.</p>
|
||||
<div class="briefing-feeds-list" v-if="briefingFeeds.length">
|
||||
<div class="briefing-feed-row" v-for="feed in briefingFeeds" :key="feed.id">
|
||||
<div class="briefing-feed-info">
|
||||
<span class="briefing-feed-title">{{ feed.title || feed.url }}</span>
|
||||
<span class="briefing-feed-url">{{ feed.url }}</span>
|
||||
</div>
|
||||
<button class="briefing-btn-remove" @click="removeFeed(feed.id)" aria-label="Remove feed">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="field-hint">No feeds yet.</p>
|
||||
<div class="briefing-add-feed">
|
||||
<input v-model="newFeedUrl" class="input" placeholder="https://example.com/feed.xml" style="flex: 1" />
|
||||
<button class="btn-secondary" @click="addFeed" :disabled="addingFeed || !newFeedUrl.trim()">
|
||||
{{ addingFeed ? 'Adding…' : 'Add Feed' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Notifications -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Notifications</h2>
|
||||
<div class="checkbox-field">
|
||||
<label>
|
||||
<input type="checkbox" v-model="briefingConfig.notifications" />
|
||||
Push notification when each briefing slot is ready
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveBriefingSettings" :disabled="briefingSaving">
|
||||
{{ briefingSaved ? 'Saved ✓' : briefingSaving ? 'Saving…' : 'Save Briefing Settings' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Admin ── -->
|
||||
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user