diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index abb5c41..03efb8a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -384,11 +384,17 @@ export async function getBriefingFeeds(): Promise { 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 }); +export async function createBriefingFeed(url: string, category?: string): Promise { + const body: Record = { url }; + if (category?.trim()) body.category = category.trim(); + const data = await apiPost<{ id: number; url: string; title: string; category: string | null }>('/api/briefing/feeds', body); return { ...data, last_fetched_at: null }; } +export async function refreshBriefingFeeds(): Promise<{ feeds_refreshed: number; new_items: number }> { + return apiPost('/api/briefing/feeds/refresh', {}); +} + export async function deleteBriefingFeed(id: number): Promise { await apiDelete(`/api/briefing/feeds/${id}`); } diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index c1c2de5..1fefc0a 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -209,6 +209,30 @@ async function loadTasks() { } } +const advancingTaskId = ref(null); + +const taskStatusNext: Record = { + todo: "in_progress", + in_progress: "done", +}; + +async function advanceTaskStatus(task: NoteItem, e: Event) { + e.preventDefault(); + e.stopPropagation(); + const next = taskStatusNext[task.status ?? ""]; + if (!next || advancingTaskId.value === task.id) return; + advancingTaskId.value = task.id; + try { + await apiPatch(`/api/notes/${task.id}`, { status: next }); + const idx = tasks.value.findIndex((t) => t.id === task.id); + if (idx !== -1) tasks.value[idx] = { ...tasks.value[idx], status: next }; + } catch { + toast.show("Failed to update task", "error"); + } finally { + advancingTaskId.value = null; + } +} + async function loadNotes() { notesLoading.value = true; try { @@ -475,9 +499,17 @@ async function confirmDelete() { :class="['task-card', `pri-${task.priority || 'none'}`]" > {{ task.title || "Untitled" }} -
- - {{ task.due_date }} +

No tasks

@@ -498,9 +530,17 @@ async function confirmDelete() { :class="['task-card', `pri-${task.priority || 'none'}`]" > {{ task.title || "Untitled" }} -
- - {{ task.due_date }} +

No tasks

@@ -1088,7 +1128,26 @@ async function confirmDelete() { .task-card-done .task-title { text-decoration: line-through; } .task-title { display: block; font-weight: 500; margin-bottom: 0.2rem; line-height: 1.3; word-break: break-word; } +.task-card-footer { display: flex; align-items: center; justify-content: space-between; gap: 0.35rem; min-height: 1.2rem; } .task-meta { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; } +.task-advance-btn { + flex-shrink: 0; + display: inline-flex; align-items: center; justify-content: center; + width: 1.4rem; height: 1.4rem; + border: 1px solid var(--color-border); + border-radius: 4px; + background: transparent; + color: var(--color-text-muted); + font-size: 0.75rem; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s, background 0.15s, color 0.15s; + line-height: 1; +} +.task-card:hover .task-advance-btn { opacity: 1; } +.task-advance-btn:hover { background: var(--color-primary); border-color: var(--color-primary); color: #fff; } +.task-advance-btn--done:hover { background: var(--color-success, #22c55e); border-color: var(--color-success, #22c55e); color: #fff; } +.task-advance-btn:disabled { opacity: 0.4; cursor: default; } .priority-dot { width: 7px; diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 4d58272..bad9dad 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, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, geocodeAddress, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed } from "@/api/client"; +import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, refreshBriefingFeeds, 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"; @@ -231,7 +231,9 @@ const briefingSaved = ref(false); const briefingGeocoding = ref>({}); const briefingGeoError = ref>({}); const newFeedUrl = ref(''); +const newFeedCategory = ref(''); const addingFeed = ref(false); +const refreshingFeeds = ref(false); async function loadBriefingTab() { briefingConfig.value = await getBriefingConfig(); @@ -294,11 +296,12 @@ async function addFeed() { if (!newFeedUrl.value.trim() || addingFeed.value) return; addingFeed.value = true; try { - const feed = await createBriefingFeed(newFeedUrl.value.trim()); + const feed = await createBriefingFeed(newFeedUrl.value.trim(), newFeedCategory.value.trim() || undefined); briefingFeeds.value.push(feed); newFeedUrl.value = ''; - } catch { - toastStore.show('Failed to add feed', 'error'); + newFeedCategory.value = ''; + } catch (err: any) { + toastStore.show(err?.message === 'Feed already added' ? 'That feed is already in your list' : 'Failed to add feed', 'error'); } finally { addingFeed.value = false; } @@ -309,6 +312,32 @@ async function removeFeed(id: number) { briefingFeeds.value = briefingFeeds.value.filter((f) => f.id !== id); } +async function refreshFeeds() { + if (refreshingFeeds.value) return; + refreshingFeeds.value = true; + try { + const result = await refreshBriefingFeeds(); + // Reload feed list so last_fetched_at updates + briefingFeeds.value = await getBriefingFeeds(); + toastStore.show(`Refreshed ${result.feeds_refreshed} feed${result.feeds_refreshed !== 1 ? 's' : ''} — ${result.new_items} new item${result.new_items !== 1 ? 's' : ''}`); + } catch { + toastStore.show('Failed to refresh feeds', 'error'); + } finally { + refreshingFeeds.value = false; + } +} + +function feedAge(isoStr: string | null): string { + if (!isoStr) return 'never fetched'; + const diff = Date.now() - new Date(isoStr).getTime(); + const m = Math.floor(diff / 60000); + if (m < 1) return 'just now'; + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + // Chat retention const chatRetentionDays = ref(90); const savingRetention = ref(false); @@ -1513,20 +1542,40 @@ function formatUserDate(iso: string): string {
-

RSS Feeds

-

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

+
+
+

RSS Feeds

+

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

+
+ +
+
- {{ feed.title || feed.url }} - {{ feed.url }} +
+ {{ feed.title || feed.url }} + {{ feed.category }} +
+ {{ feed.url }} + {{ feedAge(feed.last_fetched_at) }}
-

No feeds yet.

-
- +

No feeds yet. Try adding Reuters, BBC World, or Hacker News.

+ +
+
+ + +
@@ -3030,6 +3079,51 @@ function formatUserDate(iso: string): string { transition: color 0.15s; } .briefing-btn-remove:hover { color: var(--color-danger, #ef4444); } +.briefing-feeds-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.75rem; +} +.briefing-feeds-header h2 { margin: 0; } +.briefing-feeds-header .section-desc { margin: 0.25rem 0 0; } +.briefing-refresh-btn { white-space: nowrap; flex-shrink: 0; } +.briefing-feed-title-row { + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} +.briefing-feed-cat { + font-size: 0.7rem; + padding: 0.1rem 0.4rem; + border-radius: 3px; + background: color-mix(in srgb, var(--color-primary) 15%, transparent); + color: var(--color-primary); + font-weight: 500; +} +.briefing-feed-age { + font-size: 0.72rem; + color: var(--color-text-muted); + display: block; + margin-top: 0.1rem; +} +.briefing-add-feed-form { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: flex-end; + margin-top: 0.75rem; +} +.briefing-add-feed-inputs { + display: flex; + gap: 0.5rem; + flex: 1; + flex-wrap: wrap; +} +.briefing-add-feed-inputs .input { flex: 1; min-width: 160px; } +.briefing-cat-input { max-width: 180px; } .briefing-add-feed { display: flex; gap: 0.5rem; diff --git a/frontend/src/views/TaskViewerView.vue b/frontend/src/views/TaskViewerView.vue index 3f7fc47..cbe391a 100644 --- a/frontend/src/views/TaskViewerView.vue +++ b/frontend/src/views/TaskViewerView.vue @@ -1,5 +1,5 @@