feat: kanban status buttons, task back-nav, RSS UI, weather search, briefing fixes

Project view:
- Add inline status advance buttons on kanban task cards (todo→in_progress,
  in_progress→done); buttons reveal on hover, stop link navigation

Task viewer:
- Back button navigates to task's project instead of /tasks when project_id set
- Esc key navigates to project (or /tasks); blurs focused element first

Quick capture:
- Use user's configured model instead of hardcoded Config.OLLAMA_MODEL
- Remove create_project from classifier prompt (tool not offered, caused
  task-shaped inputs to silently fall through to note fallback)

Briefing scheduler:
- Fix get_event_loop() → get_running_loop() so background thread uses the
  correct hypercorn event loop (jobs were scheduling but never executing)
- Suppress bare greeting when both LLM synthesis lanes return empty

RSS feed UI (SettingsView):
- Show last-fetched age, category badge, and feed URL per row
- Category input field when adding a feed
- Refresh all button: fetches latest items, reloads list, toasts with count
- Enter key submits add-feed form; better empty-state hint with example feeds

Weather tool:
- Accept any city/region name in addition to 'home'/'work'/'all'
- Geocodes via Nominatim + fetches live from Open-Meteo for arbitrary queries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 00:42:01 -04:00
parent a9414cf949
commit a2ba90160c
9 changed files with 238 additions and 34 deletions
+105 -11
View File
@@ -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<Record<string, boolean>>({});
const briefingGeoError = ref<Record<string, string>>({});
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 -->
<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-header">
<div>
<h2>RSS Feeds</h2>
<p class="section-desc">Add RSS or Atom feeds to be summarised in your morning briefing.</p>
</div>
<button
v-if="briefingFeeds.length"
class="btn-secondary briefing-refresh-btn"
@click="refreshFeeds"
:disabled="refreshingFeeds"
title="Fetch latest items from all feeds"
>{{ refreshingFeeds ? 'Refreshing' : 'Refresh all' }}</button>
</div>
<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 class="briefing-feed-title-row">
<span class="briefing-feed-title">{{ feed.title || feed.url }}</span>
<span v-if="feed.category" class="briefing-feed-cat">{{ feed.category }}</span>
</div>
<span v-if="feed.title" class="briefing-feed-url">{{ feed.url }}</span>
<span class="briefing-feed-age">{{ feedAge(feed.last_fetched_at) }}</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" />
<p v-else class="field-hint">No feeds yet. Try adding Reuters, BBC World, or Hacker News.</p>
<div class="briefing-add-feed-form">
<div class="briefing-add-feed-inputs">
<input v-model="newFeedUrl" class="input" placeholder="https://example.com/feed.xml" @keydown.enter="addFeed" />
<input v-model="newFeedCategory" class="input briefing-cat-input" placeholder="Category (optional)" @keydown.enter="addFeed" />
</div>
<button class="btn-secondary" @click="addFeed" :disabled="addingFeed || !newFeedUrl.trim()">
{{ addingFeed ? 'Adding' : 'Add Feed' }}
</button>
@@ -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;