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
+8 -2
View File
@@ -384,11 +384,17 @@ export async function getBriefingFeeds(): Promise<BriefingFeed[]> {
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 });
export async function createBriefingFeed(url: string, category?: string): Promise<BriefingFeed> {
const body: Record<string, string> = { 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<void> {
await apiDelete(`/api/briefing/feeds/${id}`);
}
+65 -6
View File
@@ -209,6 +209,30 @@ async function loadTasks() {
}
}
const advancingTaskId = ref<number | null>(null);
const taskStatusNext: Record<string, string> = {
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'}`]"
>
<span class="task-title">{{ task.title || "Untitled" }}</span>
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
<span v-if="task.due_date" class="due-date">{{ task.due_date }}</span>
<div class="task-card-footer">
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
<span v-if="task.due_date" class="due-date">{{ task.due_date }}</span>
</div>
<button
class="task-advance-btn"
title="Move to In Progress"
:disabled="advancingTaskId === task.id"
@click="advanceTaskStatus(task, $event)"
></button>
</div>
</router-link>
<p v-if="!group.tasks.filter(t => t.status === 'todo').length" class="col-empty">No tasks</p>
@@ -498,9 +530,17 @@ async function confirmDelete() {
:class="['task-card', `pri-${task.priority || 'none'}`]"
>
<span class="task-title">{{ task.title || "Untitled" }}</span>
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
<span v-if="task.due_date" class="due-date">{{ task.due_date }}</span>
<div class="task-card-footer">
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
<span v-if="task.due_date" class="due-date">{{ task.due_date }}</span>
</div>
<button
class="task-advance-btn task-advance-btn--done"
title="Mark as Done"
:disabled="advancingTaskId === task.id"
@click="advanceTaskStatus(task, $event)"
></button>
</div>
</router-link>
<p v-if="!group.tasks.filter(t => t.status === 'in_progress').length" class="col-empty">No tasks</p>
@@ -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;
+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;
+21 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, computed, ref, watch } from "vue";
import { onMounted, onUnmounted, computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import { useNotesStore } from "@/stores/notes";
@@ -94,7 +94,22 @@ async function loadTask(id: number) {
if (bl.status === "fulfilled") backlinks.value = bl.value;
}
onMounted(() => loadTask(taskId.value));
function handleKeydown(e: KeyboardEvent) {
if (e.key !== "Escape") return;
const active = document.activeElement as HTMLElement | null;
if (active && active !== document.body) { active.blur(); return; }
if (store.currentTask?.project_id) {
router.push(`/projects/${store.currentTask.project_id}`);
} else {
router.push("/tasks");
}
}
onMounted(() => {
loadTask(taskId.value);
window.addEventListener("keydown", handleKeydown);
});
onUnmounted(() => window.removeEventListener("keydown", handleKeydown));
watch(() => route.params.id, (newId) => {
if (newId) loadTask(Number(newId));
@@ -221,7 +236,10 @@ const subTaskProgress = computed(() => {
</div>
<template v-else-if="store.currentTask">
<div class="toolbar">
<router-link to="/tasks" class="btn-back"> Tasks</router-link>
<router-link
:to="store.currentTask.project_id ? `/projects/${store.currentTask.project_id}` : '/tasks'"
class="btn-back"
>{{ store.currentTask.project_id ? "← Project" : "← Tasks" }}</router-link>
<router-link
:to="`/tasks/${store.currentTask.id}/edit`"
class="btn-edit"
+1 -1
View File
@@ -246,7 +246,7 @@ def create_app() -> Quart:
# Start briefing scheduler
from fabledassistant.services.briefing_scheduler import start_briefing_scheduler
start_briefing_scheduler(asyncio.get_event_loop())
start_briefing_scheduler(asyncio.get_running_loop())
@app.after_serving
async def shutdown():
+2 -1
View File
@@ -78,7 +78,8 @@ async def quick_capture_route():
if not text:
return jsonify({"error": "text is required"}), 400
model = Config.OLLAMA_MODEL
from fabledassistant.services.settings import get_setting
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL)
# Build tool list for this user, then restrict to capture-only operations.
all_tools = await get_tools_for_user(uid)
@@ -274,6 +274,10 @@ async def run_compilation(user_id: int, slot: str, model: str | None = None) ->
),
)
if not internal_text and not external_text:
logger.warning("Briefing compilation produced no content for user %d slot %s", user_id, slot)
return ""
greeting = slot_greeting(slot)
today = internal_data["date"]
parts = [f"**{greeting}{today}**", ""]
-1
View File
@@ -132,7 +132,6 @@ Rules:
- create_event: appointments, meetings, scheduled occurrences with a date/time ("dentist Friday 2pm", "team meeting next Tuesday")
- update_note: updating, editing, appending to an existing note or task ("add to my shopping list: eggs", "mark buy milk done", "append to my meeting notes", "update my project note")
- research_topic: user wants a comprehensive research note from web sources ("research X", "look up X and make a note", "find everything about X", "compile a note on X")
- create_project: user wants to create a project, initiative, or campaign ("new project X", "start a project called Y", "create a project for Z")
- create_note: everything else — ideas, observations, links, excerpts, longer text
- For create_task / create_event: extract a concise title; put any extra detail in "body"
- For create_note: use a short descriptive title (≤60 chars); put the FULL original text as "body"
+32 -9
View File
@@ -530,16 +530,19 @@ _CORE_TOOLS = [
"function": {
"name": "get_weather",
"description": (
"Get the current 7-day weather forecast for the user's configured locations. "
"Returns temperature, precipitation, and conditions for each day. "
"Also reports any changes since the last forecast was fetched."
"Get the 7-day weather forecast. Pass a city/region name to look up any location, "
"or omit to get the user's configured home/work locations. "
"Returns temperature, precipitation, and conditions for each day."
),
"parameters": {
"type": "object",
"properties": {
"location_key": {
"location": {
"type": "string",
"description": "Which location to focus on: 'home', 'work', or 'all'. Defaults to 'all'.",
"description": (
"City, region, or country to look up — e.g. 'Portland, Oregon', 'Tokyo', 'home', 'work'. "
"Use 'home' or 'work' to get the user's saved locations. Omit for all saved locations."
),
}
},
"required": [],
@@ -1646,11 +1649,31 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
return {"success": True, "log": log.to_dict(), "task": note.title}
elif tool_name == "get_weather":
from fabledassistant.services.weather import get_cached_weather
location_key = arguments.get("location_key", "all")
from fabledassistant.services.weather import (
get_cached_weather, geocode, _fetch_open_meteo, parse_forecast
)
from datetime import datetime, timezone as _tz
location = (arguments.get("location") or "").strip()
_known = {"home", "work", "all", ""}
if location.lower() not in _known:
# Live geocode + fetch for arbitrary city
try:
lat, lon, label = await geocode(location)
raw = await _fetch_open_meteo(lat, lon)
days = parse_forecast(raw)
return {"data": {"locations": [{
"location_key": "query",
"location_label": label,
"fetched_at": datetime.now(_tz.utc).isoformat(),
"days": days,
"changes_since_last_fetch": [],
}]}}
except Exception as e:
return {"success": False, "error": f"Could not get weather for '{location}': {e}"}
# Fall back to cached configured locations
locations = await get_cached_weather(user_id)
if location_key != "all":
locations = [loc for loc in locations if loc["location_key"] == location_key]
if location.lower() not in ("all", ""):
locations = [loc for loc in locations if loc["location_key"] == location.lower()]
return {"data": {"locations": locations}}
elif tool_name == "get_rss_items":