Compare commits

...

36 Commits

Author SHA1 Message Date
bvandeusen 534e0a3a34 v26.03.28.1 — News/briefing redesign, task enhancements, recurrence 2026-03-28 05:38:26 +00:00
bvandeusen 51d2fd9d0a fix: migration 0031 no-op — status column is TEXT not a PG enum 2026-03-28 01:17:00 -04:00
bvandeusen ac90548823 feat: task management enhancements (cancelled status, recurrence, timestamps)
- Add 'cancelled' status to TaskStatus type, StatusBadge, TaskCard,
  TaskEditorView, TaskViewerView, TasksListView
- Add RecurrenceEditor component (none / interval / calendar rules)
- TaskEditorView: wire RecurrenceEditor, show started_at/completed_at
  timestamps read-only, include recurrence_rule in save payload
- TaskViewerView: show recurrence summary, timestamps in meta row
- tasks.ts: statusFilter/priorityFilter as arrays, add recurrence_rule
  to updateTask and createTask payloads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:51:36 -04:00
bvandeusen 37e2420192 feat: add /news archive view with feed filter and pagination
- NewsView.vue: 90-day article archive, feed filter dropdown,
  load-more pagination, topic pills, thumbs up/down reactions
- Router: add /news route
- AppHeader: add News nav link (desktop + mobile)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:51:24 -04:00
bvandeusen f81c38df84 feat: redesign BriefingView into 3-column layout (weather · chat · news)
- Left column: weather loaded independently via /api/briefing/weather
- Center column: chat messages with input bar (unchanged behavior)
- Right column: news panel loaded from /api/briefing/news (last 2 days)
- Auto-scroll to bottom on mount and after streaming
- Background refresh also refreshes news panel
- Responsive: stacks to single column on narrow screens
- Fix TaskCard.vue to include 'cancelled' in status records

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:38:04 -04:00
bvandeusen 0a6e57e698 feat: add NewsItem type and getNewsItems() API client function
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:34:34 -04:00
bvandeusen 260103d533 feat: inject briefing article content for deep article Q&A
Add _build_briefing_article_context() helper to llm.py that reads
rss_item_ids from briefing message metadata and injects article content
into the system prompt. Pass conv_id through build_context() and
generation_task.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:31:43 -04:00
bvandeusen 35f57e0d3e feat: add GET /api/briefing/news unified news endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:29:59 -04:00
bvandeusen 57bf63e576 feat: extend RSS item retention from 14 to 90 days
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:29:32 -04:00
bvandeusen aa18f4f527 docs: add news briefing redesign spec and plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:13:40 -04:00
bvandeusen 3391825550 docs: add news feed & briefing redesign spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 23:59:34 -04:00
bvandeusen 3d7c953627 feat: add recurrence_rule to LLM tools; list_tasks status accepts list + cancelled 2026-03-27 23:02:30 -04:00
bvandeusen 6557fef6a2 feat: support multi-value status and priority filters in list_notes 2026-03-27 23:01:24 -04:00
bvandeusen 0dc3dfa539 feat: add recurrence_rule validation and recurrence-preview endpoint in tasks routes 2026-03-27 22:54:11 -04:00
bvandeusen 3179b60eac feat: wire recurrence into create_note/update_note and add daily APScheduler job 2026-03-27 22:53:20 -04:00
bvandeusen a24257aeed feat: add recurrence service (validate, calculate_next_due, spawn_recurring_tasks) 2026-03-27 22:52:18 -04:00
bvandeusen c271f1b41f feat: add recurrence_rule and recurrence_next_spawn_at columns to notes 2026-03-27 22:49:59 -04:00
bvandeusen c92f4944cc feat: auto-set started_at/completed_at on task status transitions 2026-03-27 22:49:21 -04:00
bvandeusen 1125c8e107 feat: add started_at/completed_at columns to notes 2026-03-27 22:37:55 -04:00
bvandeusen 7888788d42 Merge pull request 'Release v26.03.27.2' (#15) from dev into main
Release v26.03.27.2
2026-03-27 21:23:53 +00:00
bvandeusen 7a12cba4d5 feat: add 'cancelled' task status; fix 500 on invalid status/priority
TaskStatus enum was missing 'cancelled' — the LLM tried to use it and
hit TaskStatus("cancelled") raising ValueError → 500. Added the value,
a migration to extend the task_status Postgres enum, and proper 400
validation guards on both create and update task routes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 09:18:16 -04:00
bvandeusen 78791175a1 Merge pull request 'Release v26.03.27.1' (#14) from dev into main
Release v26.03.27.1
2026-03-27 04:12:27 +00:00
bvandeusen 2a1644e571 feat: news story cards in briefing — backend embeds structured RSS items in metadata
Previously metadata only stored rss_item_ids (integers); the full item data
was discarded after LLM synthesis. Now rss_items (id, title, url, source,
snippet, published_at) is also stored so clients can render per-story cards
without additional API calls.

Web BriefingView: replaces bare reaction-row buttons with news cards showing
source, headline (linked), 2-line snippet, and 👍/👎 per card.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 00:08:21 -04:00
bvandeusen bc5f1679d5 fix: accept PATCH on /api/tasks/:id (MCP update_task compatibility)
The MCP fable_update_task tool calls PATCH /api/tasks/{id} but the route
only declared PUT. Adding PATCH to the same handler fixes the 405.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 23:44:20 -04:00
bvandeusen 22634aa0c9 refactor: DRY pass on backend — pagination helper and sharing utilities
- Add parse_pagination() to routes/utils.py; replace 6 duplicate limit/offset extractions in notes, tasks, chat, projects, milestones routes
- Extract _enrich_shares() in sharing.py; eliminates identical 12-line loop in list_project_shares and list_note_shares
- Extract _deduplicate_by_permission() in sharing.py; eliminates identical deduplication blocks in list_shared_with_me for projects and notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:52:39 -04:00
bvandeusen 699e525cb9 refactor: DRY pass on frontend — shared palette, composable, and tab loader
- Extract milestoneColor to utils/palette.ts; remove duplicate in HomeView + ProjectListView
- Create useBackgroundRefresh composable; wire into HomeView + BriefingView (removes manual setInterval/clearInterval boilerplate)
- Extract _loadTabContent() in SettingsView so watch and onMounted share one tab→loader mapping
- Move raw fetch() api-key calls to typed helpers in api/client.ts (listApiKeys, createApiKey, revokeApiKey)
- Drop unused onUnmounted import from BriefingView

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:43:59 -04:00
bvandeusen 2b2e5c666a fix: load all pre-selected settings tabs correctly on mount
The watch(activeTab) handler loads tab data on navigation, but not on
initial mount when localStorage restores a tab. Three more gaps:

- briefing: was inside the isAdmin guard — non-admin users who last
  visited the Briefing tab would see an empty form
- users: no onMounted equivalent — admin user list never loaded
- logs: no onMounted equivalent — admin log viewer never loaded

Moves briefing outside the admin guard and adds users/logs inside it.
2026-03-26 22:32:28 -04:00
bvandeusen 26a8fb5c51 fix: load API keys and MCP info on mount when tab is pre-selected
fetchApiKeys() and loadMcpInfo() were only wired to the activeTab watcher,
which fires on changes but not on initial mount. If localStorage had
'apikeys' as the last tab, both calls were skipped entirely — causing
an empty key list and no whl download button.
2026-03-26 22:24:37 -04:00
bvandeusen 3431719ff3 Merge pull request 'dev → main' (#13) from dev into main 2026-03-26 23:22:23 +00:00
bvandeusen 916cfa50df fix: add missing onUnmounted import in BriefingView 2026-03-26 18:50:25 -04:00
bvandeusen 190664366d Merge pull request 'dev → main' (#12) from dev into main
dev → main
2026-03-26 22:08:03 +00:00
bvandeusen 08d738ddfb feat: silent background polling for dashboard and briefing views
HomeView: setInterval every 90s refreshes events, orphan tasks/notes,
and hero next-up task. Never touches loading ref, so the page content
stays stable — only the data silently swaps when the fetch completes.
Skips when document.hidden or initial load is still in progress.

BriefingView: setInterval every 60s refetches today's messages, but
only when viewing today's conversation and not currently streaming.
Compares message count and last message content before updating to
avoid unnecessary re-renders.

Both timers are cleared on unmount.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:43:21 -04:00
bvandeusen a63e498067 fix: weather card spacing and sizing consistency
- Increase temp size to 2rem and add explicit font-size to condition
- Uniform 0.5rem spacing below current temp (was 0.35rem)
- Today row font-size explicit at 0.85rem
- Forecast gap 0.75rem (was 0.5rem), min-width 4.5rem (was 3.5rem)
- Padding-top on forecast strip 0.75rem to match overall rhythm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:37:27 -04:00
bvandeusen 48d1d9e64f fix: remove npm self-update in Docker build; use npm ci
npm install -g npm@latest corrupts npm's own module tree inside Alpine,
breaking subsequent installs. Use npm ci instead (faster, deterministic).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 08:19:01 -04:00
bvandeusen 538b67e57d docs(fable-mcp): add server instructions and detailed tool docstrings
Adds a comprehensive instructions block to the FastMCP server covering
the data model hierarchy, valid enum values, tag format, integer-or-none
conventions, when to use fable_send_message vs direct CRUD tools, and
admin key requirements.

All tool docstrings expanded with full argument descriptions, valid
values, and return shape notes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 08:03:31 -04:00
bvandeusen 383a4430f1 fix: convert calendar event times to user timezone in briefing
The briefing was formatting event start_dt directly in UTC instead of
converting to the user's local timezone. Also, the day_start/day_end
query window was naive (UTC), so events at the edges of the user's day
could be missed or included incorrectly.

Now reads user_timezone setting, uses it for today's date boundary and
for converting each event's start_dt before formatting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 06:34:22 -04:00
47 changed files with 4553 additions and 325 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ settings.local.json
# Claude Code
.claude/
docs/superpowers/
docs/superpowers/specs/.brainstorm/
# Environment
.env
+1 -1
View File
@@ -2,7 +2,7 @@
FROM node:22-alpine AS build-frontend
WORKDIR /build
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install -g npm@latest --quiet && npm install
RUN npm ci --quiet
COPY frontend/ .
RUN npm run build
@@ -0,0 +1,21 @@
"""Add 'cancelled' task status.
The status column is plain TEXT (not a PostgreSQL enum type), so no DDL
change is required — the application layer already accepts the new value.
"""
from alembic import op
revision = "0031"
down_revision = "0030"
branch_labels = None
depends_on = None
def upgrade() -> None:
# No-op: status is stored as TEXT; the new value is valid without DDL changes.
pass
def downgrade() -> None:
pass
@@ -0,0 +1,19 @@
"""Add started_at and completed_at to notes."""
from alembic import op
import sqlalchemy as sa
revision = "0032"
down_revision = "0031"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("notes", sa.Column("started_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("notes", sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("notes", "completed_at")
op.drop_column("notes", "started_at")
@@ -0,0 +1,30 @@
"""Add recurrence_rule and recurrence_next_spawn_at to notes."""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "0033"
down_revision = "0032"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("notes", sa.Column("recurrence_rule", JSONB(), nullable=True))
op.add_column(
"notes",
sa.Column("recurrence_next_spawn_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index(
"ix_notes_recurrence_next_spawn_at",
"notes",
["recurrence_next_spawn_at"],
postgresql_where=sa.text("recurrence_next_spawn_at IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("ix_notes_recurrence_next_spawn_at", table_name="notes")
op.drop_column("notes", "recurrence_next_spawn_at")
op.drop_column("notes", "recurrence_rule")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,398 @@
# News Feed & Briefing View Redesign
> **For agentic workers:** Use superpowers:executing-plans or superpowers:subagent-driven-development to implement this spec task-by-task.
**Goal:** Extend RSS article retention to 90 days, introduce a unified news API, redesign the briefing view into a 3-column layout (weather · chat · news), add deep article Q&A in briefing chat, and add a `/news` archive view.
---
## Architecture
### Data layer
`rss_items` and `rss_item_reactions` already exist and contain everything needed. No new tables or migrations required. The only DB change is extending the prune window from 14 to 90 days.
### Unified news API
A single `GET /api/briefing/news` endpoint serves both the briefing side panel (short window, no pagination) and the news archive view (full history, paginated). Both consumers share the same response shape and reaction logic.
### Briefing view
`BriefingView.vue` restructures from a single-column `max-width: 760px` layout to a full-width CSS grid with three independently scrolling columns. Weather and news are fetched directly — no longer read from message metadata.
### News archive view
A new `NewsView.vue` at `/news`, added to the sidebar nav, uses the same `/api/briefing/news` endpoint with `days=90` and offset-based pagination.
### Deep article Q&A
`build_context()` in `llm.py` detects briefing conversations and injects the source article content from today's briefing into the system prompt, giving follow-up chat messages access to the full article text.
---
## Section 1 — Data & API layer
### 1a. Article retention (90 days)
**File:** `src/fabledassistant/services/rss.py`
Change the module-level constant:
```python
ITEM_MAX_AGE_DAYS = 90
```
Update the SQL in `_prune_old_items`:
```python
AND published_at < NOW() - INTERVAL '90 days'
```
### 1b. Unified news endpoint
**File:** `src/fabledassistant/routes/briefing.py`
Add `GET /api/briefing/news` endpoint (registered on `briefing_bp` which has prefix `/api/briefing`):
```python
@briefing_bp.route("/news", methods=["GET"])
@_REQUIRE
async def list_news():
days = min(int(request.args.get("days", 2)), 90)
limit = min(int(request.args.get("limit", 40)), 100)
offset = max(int(request.args.get("offset", 0)), 0)
feed_id = request.args.get("feed_id", type=int)
from sqlalchemy import text as _text
async with async_session() as session:
result = await session.execute(
_text("""
SELECT
i.id, i.title, i.url, i.content, i.published_at,
i.topics, f.title AS feed_title,
r.reaction
FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id
LEFT JOIN rss_item_reactions r
ON r.rss_item_id = i.id AND r.user_id = :uid
WHERE f.user_id = :uid
AND (:feed_id IS NULL OR f.id = :feed_id)
AND i.published_at >= NOW() - make_interval(days => :days)
ORDER BY i.published_at DESC NULLS LAST
LIMIT :limit OFFSET :offset
""").bindparams(uid=g.user.id, days=days, limit=limit,
offset=offset, feed_id=feed_id)
)
rows = result.mappings().all()
items = [
{
"id": r["id"],
"title": r["title"],
"url": r["url"],
"snippet": (r["content"] or "")[:300],
"published_at": r["published_at"].isoformat() if r["published_at"] else None,
"topics": r["topics"] or [],
"source": r["feed_title"],
"reaction": r["reaction"],
}
for r in rows
]
return jsonify({"items": items, "offset": offset, "limit": limit})
```
---
## Section 2 — Briefing view redesign
### 2a. Layout restructure
**File:** `frontend/src/views/BriefingView.vue`
**Layout change:** Replace `.briefing-shell` (single column, max-width 760px) with a full-width 3-column grid:
```css
.briefing-shell {
display: grid;
grid-template-rows: auto 1fr;
grid-template-columns: 1fr 2fr 1fr;
height: 100%;
min-height: 0;
}
.briefing-header {
grid-column: 1 / -1; /* spans all three columns */
}
.briefing-left {
grid-column: 1;
border-right: 1px solid var(--color-border);
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.briefing-center {
grid-column: 2;
display: flex;
flex-direction: column;
min-height: 0;
}
.briefing-right {
grid-column: 3;
border-left: 1px solid var(--color-border);
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
@media (max-width: 900px) {
.briefing-shell {
grid-template-columns: 1fr;
grid-template-rows: auto auto 1fr auto;
}
.briefing-header { grid-column: 1; }
.briefing-left, .briefing-right {
border: none;
border-bottom: 1px solid var(--color-border);
max-height: 200px;
}
}
```
### 2b. Weather panel (left column)
Remove `WeatherCard` from the message flow. Load weather independently on mount:
Extract the existing weather shape from `MessageMetadata` into a standalone `WeatherData` interface at the top of `BriefingView.vue` (same fields: `location`, `current_temp`, `condition`, `today_high`, `today_low`, `yesterday_high`, `yesterday_low`, `forecast[]`). The `MessageMetadata` interface can then be removed since `rss_items`/weather are no longer read from message metadata.
```typescript
const weatherData = ref<WeatherData[]>([])
async function loadWeather() {
try {
const data = await apiGet<{ locations: WeatherData[] }>('/api/briefing/weather')
weatherData.value = data.locations ?? []
} catch { /* silent */ }
}
```
Render in left column:
```html
<div class="briefing-left">
<div class="panel-label">Weather</div>
<template v-if="weatherData.length">
<WeatherCard v-for="loc in weatherData" :key="(loc as WeatherData).location" :weather="loc" />
</template>
<div v-else class="panel-empty">No weather configured</div>
</div>
```
### 2c. News panel (right column)
Load on mount and on background refresh:
```typescript
const newsItems = ref<NewsItem[]>([])
async function loadNews() {
try {
const data = await apiGet<{ items: NewsItem[] }>('/api/briefing/news?days=2&limit=40')
newsItems.value = data.items
} catch { /* silent */ }
}
```
Render in right column using the existing `news-card` markup and `handleReaction` function. Remove the inline `news-cards` block from the message loop.
### 2d. Auto-scroll to bottom on mount
After messages load, scroll the center messages container to the bottom:
```typescript
const messagesEl = ref<HTMLElement | null>(null)
function scrollToBottom() {
nextTick(() => {
if (messagesEl.value) {
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
}
})
}
```
Call `scrollToBottom()` after `loadAll()` completes and after the streaming watcher fires.
### 2e. Remove inline weather/news from message flow
In the message loop template, remove:
- The `<WeatherCard>` block rendered above assistant messages
- The `<div class="news-cards">` block rendered below assistant messages
The `msgMetadata()` helper and `MessageMetadata` interface can be removed entirely since they're no longer used in the template.
---
## Section 3 — Deep article Q&A in briefing chat
**File:** `src/fabledassistant/services/llm.py` (the `build_context()` function)
After the existing RAG context is assembled, check if the conversation is a briefing and inject article content:
```python
# Inject briefing article content for follow-up Q&A
if conversation and getattr(conversation, "conversation_type", None) == "briefing":
article_context = await _build_briefing_article_context(conversation.id)
if article_context:
system_parts.append(article_context)
```
New helper function in `llm.py`:
```python
async def _build_briefing_article_context(conv_id: int) -> str:
"""
Fetch article content from today's briefing message and return
a formatted context block for injection into the system prompt.
Capped at 10 articles × 500 chars to keep token use reasonable.
"""
from sqlalchemy import select, text as _text
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Message
import json
async with async_session() as session:
# Get most recent assistant briefing message with rss_item_ids
result = await session.execute(
select(Message)
.where(
Message.conversation_id == conv_id,
Message.role == "assistant",
)
.order_by(Message.created_at.desc())
.limit(10)
)
messages = result.scalars().all()
rss_item_ids: list[int] = []
for msg in messages:
meta = msg.metadata or {}
if isinstance(meta, str):
try:
meta = json.loads(meta)
except Exception:
continue
ids = meta.get("rss_item_ids") or []
if ids:
rss_item_ids = ids
break
if not rss_item_ids:
return ""
async with async_session() as session:
result = await session.execute(
_text("""
SELECT i.title, i.url, i.content, f.title AS feed_title
FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id
WHERE i.id = ANY(:ids)
ORDER BY i.published_at DESC NULLS LAST
LIMIT 10
""").bindparams(ids=rss_item_ids[:10])
)
rows = result.mappings().all()
if not rows:
return ""
lines = ["ARTICLE CONTEXT (source articles from today's briefing):"]
for row in rows:
lines.append(f"\n[{row['feed_title']}] {row['title']}")
if row["url"]:
lines.append(f"URL: {row['url']}")
if row["content"]:
lines.append(row["content"][:500])
return "\n".join(lines)
```
---
## Section 4 — News archive view
### 4a. Frontend type
**File:** `frontend/src/types/news.ts` (new)
```typescript
export interface NewsItem {
id: number
title: string
url: string
snippet: string
published_at: string | null
topics: string[]
source: string
reaction: 'up' | 'down' | null
}
```
### 4b. NewsView component
**File:** `frontend/src/views/NewsView.vue` (new)
- Loads feeds list from `GET /api/briefing/feeds` for the filter dropdown
- Loads items from `GET /api/briefing/news?days=90&limit=40&offset=0&feed_id=X`
- "Load more" button appends next page (increments offset by 40)
- Each card: source label, title as `<a target="_blank">`, snippet, relative date, 👍/👎 buttons
- Reactions call existing `POST /api/briefing/rss-reactions` / `DELETE /api/briefing/rss-reactions/:id`
- Local `reactions` map tracks optimistic state (same pattern as `BriefingView.vue`)
```typescript
const items = ref<NewsItem[]>([])
const offset = ref(0)
const hasMore = ref(true)
const selectedFeedId = ref<number | null>(null)
const loading = ref(false)
async function loadMore() {
if (loading.value || !hasMore.value) return
loading.value = true
try {
const params = new URLSearchParams({
days: '90', limit: '40', offset: String(offset.value)
})
if (selectedFeedId.value) params.set('feed_id', String(selectedFeedId.value))
const data = await apiGet<{ items: NewsItem[] }>(`/api/briefing/news?${params}`)
items.value = [...items.value, ...data.items]
offset.value += data.items.length
hasMore.value = data.items.length === 40
} finally {
loading.value = false
}
}
function onFeedChange() {
items.value = []
offset.value = 0
hasMore.value = true
loadMore()
}
```
### 4c. Router and nav
**File:** `frontend/src/router/index.ts`
```typescript
{ path: '/news', component: () => import('@/views/NewsView.vue') }
```
**File:** `frontend/src/App.vue` (nav links section)
Add `<router-link to="/news">News</router-link>` alongside Notes/Tasks/Projects.
---
## What is NOT in scope
- Topic-based filtering in the news archive (reactions shape preferences; filtering can come later)
- Inline article display / full-text reader (links open to source)
- Changes to the RSS feed management UI in Settings
- Push notifications for new articles
+255 -34
View File
@@ -9,7 +9,76 @@ from fable_mcp.tools import notes, tasks, projects, milestones, search, chat, ad
load_dotenv()
mcp = FastMCP("fable")
_INSTRUCTIONS = """
Fable Assistant is a self-hosted second-brain and project management system with LLM integration.
## Data model
The hierarchy is: Project → Milestone → Task/Note.
- **Notes** and **Tasks** share the same underlying model. Tasks are notes with `is_task=True`.
The note tools (fable_*_note) operate on notes; the task tools (fable_*_task) operate on tasks.
Do not use note tools to manipulate tasks or vice versa.
- **Projects** group related work. A project has a title, description, goal, status, and an
auto-generated summary used for semantic search. Status values: `active`, `archived`.
- **Milestones** belong to a project and group tasks within it. Status values: `active`, `done`.
- **Tasks** belong to a project and optionally a milestone. They support sub-tasks via `parent_id`.
- Status values: `todo`, `in_progress`, `done`, `cancelled`
- Priority values: `low`, `normal`, `high`
- **Notes** are free-form markdown documents. They can belong to a project or be standalone
(orphan notes). Orphan notes are included in the default RAG scope for chat conversations.
## Tags
Tags are plain strings — do NOT include a `#` prefix. Example: `["python", "architecture"]`.
Tags are stored as an array on the note/task. Passing `tags=[]` clears all tags; omitting `tags`
leaves existing tags unchanged on updates.
## Integer-or-none fields
Due to MCP type constraints, optional integer fields (project_id, milestone_id, parent_id)
use `0` to mean "not set / no association". Pass `0` to leave the field unset.
## Search
`fable_search` performs semantic (embedding-based) search over notes and tasks. Use it to find
relevant content by meaning rather than exact keywords. Returns results ranked by cosine
similarity with id, title, a body snippet, and tags.
## Chat / LLM delegation
`fable_send_message` sends a natural-language message to Fable's built-in LLM (Ollama). Fable
handles its own tool use, RAG context injection, and conversation history internally.
Use `fable_send_message` when:
- The request is conversational or requires Fable's internal reasoning across many records
- You want Fable's RAG to surface relevant notes automatically
Use the direct CRUD tools when:
- You know exactly what to create/read/update/delete
- You need structured data back (IDs, field values) for further processing
- You are populating Fable programmatically from another system
## Task logs
Use `fable_add_task_log` to append time-stamped progress notes to a task without overwriting
its main body. Suitable for recording work sessions, decisions, or status updates over time.
## RSS / Briefing
Fable runs a daily briefing that summarises tasks, calendar events, and RSS feed items.
Use `fable_add_rss_feed` / `fable_remove_rss_feed` to manage the feeds included in that briefing.
## Admin logs
`fable_get_app_logs` requires an admin-scoped API key. Regular user keys will be rejected.
"""
mcp = FastMCP("fable", instructions=_INSTRUCTIONS)
# ---------------------------------------------------------------------------
@@ -24,7 +93,13 @@ async def fable_list_notes(
tag: str = "",
search_text: str = "",
) -> dict:
"""List notes stored in Fable. Optionally filter by tag or search text."""
"""List notes (non-task documents) stored in Fable.
Optionally filter by a single tag (plain string, no # prefix) or a keyword search
against title and body. Results are ordered by last-updated descending.
Use fable_search for semantic/meaning-based lookup instead of exact keyword search.
"""
async with FableClient() as client:
return await notes.list_notes(
client,
@@ -37,7 +112,10 @@ async def fable_list_notes(
@mcp.tool()
async def fable_get_note(note_id: int) -> dict:
"""Fetch the full content of a single Fable note by its ID."""
"""Fetch the full content of a single Fable note by its ID.
Returns id, title, body (markdown), tags, project_id, created_at, updated_at.
"""
async with FableClient() as client:
return await notes.get_note(client, note_id=note_id)
@@ -49,7 +127,16 @@ async def fable_create_note(
tags: list[str] | None = None,
project_id: int = 0,
) -> dict:
"""Create a new note in Fable."""
"""Create a new note in Fable.
Args:
title: Note title (required).
body: Markdown content. Supports [[wikilinks]] to other notes by title.
tags: List of plain-string tags without # prefix, e.g. ["python", "ideas"].
project_id: Associate with a project (use 0 for no project / orphan note).
Returns the created note object including its assigned id.
"""
async with FableClient() as client:
return await notes.create_note(
client,
@@ -68,7 +155,15 @@ async def fable_update_note(
tags: list[str] | None = None,
project_id: int = 0,
) -> dict:
"""Update an existing Fable note. Only provided fields are changed."""
"""Update an existing Fable note. Only explicitly provided fields are changed.
Args:
note_id: ID of the note to update.
title: New title, or omit to leave unchanged.
body: New markdown body, or omit to leave unchanged.
tags: Replaces the full tag list. Pass [] to clear all tags. Omit to leave unchanged.
project_id: New project association (0 = remove from project). Omit to leave unchanged.
"""
async with FableClient() as client:
return await notes.update_note(
client,
@@ -82,7 +177,7 @@ async def fable_update_note(
@mcp.tool()
async def fable_delete_note(note_id: int) -> str:
"""Delete a Fable note by ID."""
"""Permanently delete a Fable note by ID. This cannot be undone."""
async with FableClient() as client:
await notes.delete_note(client, note_id=note_id)
return f"Note {note_id} deleted."
@@ -100,7 +195,14 @@ async def fable_list_tasks(
status: str = "",
project_id: int = 0,
) -> dict:
"""List tasks in Fable. Filter by status (todo/in_progress/done) or project."""
"""List tasks in Fable.
Args:
status: Filter by status — one of: todo, in_progress, done, cancelled. Omit for all.
project_id: Filter to a specific project. Use 0 for no filter.
Results are ordered by last-updated descending.
"""
async with FableClient() as client:
return await tasks.list_tasks(
client,
@@ -113,7 +215,11 @@ async def fable_list_tasks(
@mcp.tool()
async def fable_get_task(task_id: int) -> dict:
"""Fetch a single Fable task by ID, including parent task title."""
"""Fetch a single Fable task by ID.
Returns id, title, body, status, priority, tags, project_id, milestone_id,
parent_id, parent_title, due_date, created_at, updated_at.
"""
async with FableClient() as client:
return await tasks.get_task(client, task_id=task_id)
@@ -129,7 +235,20 @@ async def fable_create_task(
parent_id: int = 0,
tags: list[str] | None = None,
) -> dict:
"""Create a new task in Fable."""
"""Create a new task in Fable.
Args:
title: Task title (required).
body: Markdown description / notes for the task.
status: Initial status — one of: todo (default), in_progress, done, cancelled.
priority: One of: low, normal, high. Omit for no priority.
project_id: Associate with a project (0 = no project).
milestone_id: Place within a project milestone (0 = no milestone).
parent_id: Make this a sub-task of another task (0 = top-level).
tags: List of plain-string tags without # prefix.
Returns the created task object including its assigned id.
"""
async with FableClient() as client:
return await tasks.create_task(
client,
@@ -154,7 +273,17 @@ async def fable_update_task(
project_id: int = 0,
milestone_id: int = 0,
) -> dict:
"""Update an existing Fable task. Only provided fields are changed."""
"""Update an existing Fable task. Only explicitly provided fields are changed.
Args:
task_id: ID of the task to update.
title: New title, or omit to leave unchanged.
body: New markdown body, or omit to leave unchanged.
status: New status — one of: todo, in_progress, done, cancelled.
priority: New priority — one of: low, normal, high.
project_id: New project (0 = remove from project). Omit to leave unchanged.
milestone_id: New milestone (0 = remove from milestone). Omit to leave unchanged.
"""
async with FableClient() as client:
return await tasks.update_task(
client,
@@ -170,7 +299,12 @@ async def fable_update_task(
@mcp.tool()
async def fable_add_task_log(task_id: int, body: str) -> dict:
"""Append a progress log entry to a Fable task."""
"""Append a timestamped progress log entry to a Fable task.
Use this to record work sessions, decisions, or status updates over time without
overwriting the task's main body. Each entry is stored separately and shown
chronologically in the task view.
"""
async with FableClient() as client:
return await tasks.add_task_log(client, task_id=task_id, body=body)
@@ -182,14 +316,22 @@ async def fable_add_task_log(task_id: int, body: str) -> dict:
@mcp.tool()
async def fable_list_projects() -> dict:
"""List all Fable projects for the current user."""
"""List all Fable projects for the current user.
Returns id, title, description, goal, status (active/archived), color,
and a short auto-generated summary for each project.
"""
async with FableClient() as client:
return await projects.list_projects(client)
@mcp.tool()
async def fable_get_project(project_id: int) -> dict:
"""Fetch a Fable project by ID, including milestone summary."""
"""Fetch a Fable project by ID, including its milestone summary.
Returns full project fields plus a milestone_summary list with each milestone's
id, title, status, and task counts.
"""
async with FableClient() as client:
return await projects.get_project(client, project_id=project_id)
@@ -202,7 +344,17 @@ async def fable_create_project(
status: str = "active",
color: str = "",
) -> dict:
"""Create a new project in Fable."""
"""Create a new project in Fable.
Args:
title: Project name (required).
description: Short summary of what the project is.
goal: The desired outcome or definition of done for the project.
status: active (default) or archived.
color: Optional hex colour for the project card (e.g. "#6366f1").
Returns the created project object including its assigned id.
"""
async with FableClient() as client:
return await projects.create_project(
client,
@@ -223,7 +375,16 @@ async def fable_update_project(
status: str = "",
color: str = "",
) -> dict:
"""Update an existing Fable project."""
"""Update an existing Fable project. Only explicitly provided fields are changed.
Args:
project_id: ID of the project to update.
title: New title, or omit to leave unchanged.
description: New description, or omit to leave unchanged.
goal: New goal/definition-of-done, or omit to leave unchanged.
status: New status — active or archived.
color: New hex colour, or omit to leave unchanged.
"""
async with FableClient() as client:
return await projects.update_project(
client,
@@ -243,7 +404,10 @@ async def fable_update_project(
@mcp.tool()
async def fable_list_milestones(project_id: int) -> dict:
"""List milestones for a Fable project."""
"""List milestones for a Fable project, ordered by order_index.
Returns id, title, description, status (active/done), order_index, and task counts.
"""
async with FableClient() as client:
return await milestones.list_milestones(client, project_id=project_id)
@@ -255,7 +419,16 @@ async def fable_create_milestone(
description: str = "",
status: str = "active",
) -> dict:
"""Create a milestone within a Fable project."""
"""Create a milestone within a Fable project.
Args:
project_id: The project this milestone belongs to (required).
title: Milestone name (required).
description: Optional description of what this milestone covers.
status: active (default) or done.
Returns the created milestone including its assigned id.
"""
async with FableClient() as client:
return await milestones.create_milestone(
client,
@@ -275,7 +448,16 @@ async def fable_update_milestone(
status: str = "",
order_index: int = -1,
) -> dict:
"""Update a Fable milestone."""
"""Update a Fable milestone. Only explicitly provided fields are changed.
Args:
project_id: Project the milestone belongs to.
milestone_id: ID of the milestone to update.
title: New title, or omit to leave unchanged.
description: New description, or omit to leave unchanged.
status: New status — active or done.
order_index: New display position (0-based). Use -1 to leave unchanged.
"""
async with FableClient() as client:
return await milestones.update_milestone(
client,
@@ -299,10 +481,17 @@ async def fable_search(
content_type: str = "all",
limit: int = 10,
) -> dict:
"""Semantic search over Fable notes and tasks.
"""Semantic search over Fable notes and tasks using embedding similarity.
content_type: "note", "task", or "all" (default).
Returns results ranked by similarity with id, title, body snippet, tags.
Finds content by meaning rather than exact keywords. Use this to discover
relevant records when you don't know the exact title or tags.
Args:
q: Natural-language query string.
content_type: "note", "task", or "all" (default).
limit: Maximum number of results (default 10).
Returns results ordered by cosine similarity, each with id, title, body snippet, and tags.
"""
async with FableClient() as client:
return await search.search(client, q=q, content_type=content_type, limit=limit)
@@ -315,7 +504,11 @@ async def fable_search(
@mcp.tool()
async def fable_list_conversations(limit: int = 20, offset: int = 0) -> dict:
"""List MCP chat conversations stored in Fable."""
"""List chat conversations stored in Fable, ordered by last activity.
Returns id, title, message_count, created_at, updated_at for each conversation.
Use the id with fable_send_message to continue a specific conversation.
"""
async with FableClient() as client:
return await chat.list_conversations(client, limit=limit, offset=offset)
@@ -326,11 +519,28 @@ async def fable_send_message(
conversation_id: str = "",
think: bool = False,
) -> dict:
"""Send a message to Fable's LLM and receive the full response.
"""Send a natural-language message to Fable's built-in LLM and receive the full response.
Fable handles tool use, RAG, and history internally.
Pass conversation_id to continue an existing conversation.
Returns conversation_id, response text, and any tool_call events.
Fable handles tool use, RAG context injection, and conversation history internally.
The LLM can create/update notes and tasks, search, manage projects, and more — all
driven by natural language without you needing to call individual tools.
Use this when:
- The request is conversational or exploratory
- You want Fable's RAG to automatically surface relevant notes as context
- The task is complex enough to benefit from Fable's internal reasoning
Use the direct CRUD tools (fable_create_note, etc.) instead when you need
structured data back or are performing bulk/programmatic operations.
Args:
message: The user message to send.
conversation_id: Continue an existing conversation by passing its id.
Omit to start a new conversation.
think: Enable extended reasoning mode for complex multi-step requests.
Returns conversation_id (for follow-up messages), the assistant response text,
and a list of any tool_call events that fired during generation.
"""
async with FableClient() as client:
return await chat.send_message(
@@ -352,10 +562,15 @@ async def fable_get_app_logs(
limit: int = 20,
search: str = "",
) -> dict:
"""Fetch Fable application logs. Requires an admin API key.
"""Fetch Fable application logs. Requires an admin-scoped API key.
category: "error" (default) | "audit" | "usage"
search: optional keyword filter applied to action, endpoint, username, and details
Args:
category: Log category — "error" (default), "audit", or "usage".
limit: Maximum number of log entries to return.
search: Optional keyword filter matched against action, endpoint, username, details.
Returns a list of log entries ordered by most recent first.
Regular user API keys will receive a 403 — only admin keys are accepted.
"""
async with FableClient() as client:
return await admin.get_app_logs(
@@ -373,7 +588,11 @@ async def fable_get_app_logs(
@mcp.tool()
async def fable_list_rss_feeds() -> dict:
"""List all RSS feeds configured in Fable for the current user."""
"""List all RSS/Atom feeds configured in Fable for the current user.
Returns id, title, url, category, and last_fetched_at for each feed.
These feeds are summarised in the user's daily briefing.
"""
async with FableClient() as client:
return await briefing.list_rss_feeds(client)
@@ -384,12 +603,14 @@ async def fable_add_rss_feed(
title: str = "",
category: str = "",
) -> dict:
"""Add a new RSS feed to Fable. Title is optional — auto-populated from feed metadata.
"""Add an RSS or Atom feed to Fable's daily briefing.
Args:
url: The RSS/Atom feed URL.
title: Optional display name override.
category: Optional category label (e.g. 'news', 'tech').
url: The RSS/Atom feed URL (required).
title: Optional display name. If omitted, auto-populated from feed metadata.
category: Optional category label to group feeds (e.g. "news", "tech", "finance").
Returns the created feed object including its assigned id.
"""
async with FableClient() as client:
return await briefing.add_rss_feed(
+40
View File
@@ -580,3 +580,43 @@ export async function updateEvent(id: number, payload: EventUpdatePayload): Prom
export async function deleteEvent(id: number): Promise<void> {
return apiDelete(`/api/events/${id}`);
}
// ─── API Keys ─────────────────────────────────────────────────────────────────
export interface ApiKeyEntry {
id: number
name: string
scope: string
key_prefix: string
last_used_at: string | null
}
export const listApiKeys = () =>
apiGet<{ api_keys: ApiKeyEntry[] }>('/api/api-keys').then(r => r.api_keys)
export const createApiKey = (name: string, scope: 'read' | 'write') =>
apiPost<{ key: string; api_key: ApiKeyEntry }>('/api/api-keys', { name, scope })
export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`)
// ─── News ─────────────────────────────────────────────────────────────────────
import type { NewsItem } from '@/types/news'
export interface GetNewsItemsParams {
days?: number
limit?: number
offset?: number
feed_id?: number | null
}
export function getNewsItems(params: GetNewsItemsParams = {}) {
const p = new URLSearchParams()
if (params.days != null) p.set('days', String(params.days))
if (params.limit != null) p.set('limit', String(params.limit))
if (params.offset != null) p.set('offset', String(params.offset))
if (params.feed_id != null) p.set('feed_id', String(params.feed_id))
return apiGet<{ items: NewsItem[]; offset: number; limit: number }>(
`/api/briefing/news?${p}`
)
}
+2
View File
@@ -79,6 +79,7 @@ router.afterEach(() => {
<router-link to="/graph" class="nav-link">Graph</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/news" class="nav-link">News</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
</div>
@@ -129,6 +130,7 @@ router.afterEach(() => {
<router-link to="/graph" class="nav-link">Graph</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/news" class="nav-link">News</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
<div class="mobile-divider"></div>
<router-link to="/settings" class="nav-link">Settings</router-link>
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { ref, watch, computed } from "vue";
const props = defineProps<{
modelValue: Record<string, unknown> | null;
}>();
const emit = defineEmits<{
(e: "update:modelValue", val: Record<string, unknown> | null): void;
}>();
type RecurrenceType = "none" | "interval" | "calendar";
type IntervalUnit = "day" | "week" | "month" | "year";
type CalendarUnit = "month" | "year";
const rType = ref<RecurrenceType>("none");
const intervalEvery = ref(1);
const intervalUnit = ref<IntervalUnit>("week");
const calendarUnit = ref<CalendarUnit>("month");
const calendarDay = ref(1);
const calendarMonth = ref(1);
function ruleFromState(): Record<string, unknown> | null {
if (rType.value === "none") return null;
if (rType.value === "interval") {
return { type: "interval", every: intervalEvery.value, unit: intervalUnit.value };
}
// calendar
const rule: Record<string, unknown> = {
type: "calendar",
unit: calendarUnit.value,
day_of_month: calendarDay.value,
};
if (calendarUnit.value === "year") {
rule.month = calendarMonth.value;
}
return rule;
}
function loadFromRule(rule: Record<string, unknown> | null) {
if (!rule) {
rType.value = "none";
return;
}
const t = rule.type as string;
if (t === "interval") {
rType.value = "interval";
intervalEvery.value = (rule.every as number) ?? 1;
intervalUnit.value = (rule.unit as IntervalUnit) ?? "week";
} else if (t === "calendar") {
rType.value = "calendar";
calendarUnit.value = (rule.unit as CalendarUnit) ?? "month";
calendarDay.value = (rule.day_of_month as number) ?? 1;
calendarMonth.value = (rule.month as number) ?? 1;
} else {
rType.value = "none";
}
}
// Load initial value
loadFromRule(props.modelValue);
// Watch for external changes (e.g., task loaded)
watch(() => props.modelValue, (val) => {
loadFromRule(val);
}, { deep: true });
// Emit on any state change
function onChange() {
emit("update:modelValue", ruleFromState());
}
const monthNames = [
"January","February","March","April","May","June",
"July","August","September","October","November","December",
];
const calendarDayMax = computed(() =>
calendarUnit.value === "month" ? 28 : 28
);
</script>
<template>
<div class="rec-editor">
<select v-model="rType" class="sb-select" @change="onChange">
<option value="none">No recurrence</option>
<option value="interval">Every N days/weeks/months</option>
<option value="calendar">On a calendar date</option>
</select>
<div v-if="rType === 'interval'" class="rec-row">
<span class="rec-label">Every</span>
<input
v-model.number="intervalEvery"
type="number"
min="1"
max="365"
class="rec-num-input"
@input="onChange"
/>
<select v-model="intervalUnit" class="sb-select rec-unit" @change="onChange">
<option value="day">day(s)</option>
<option value="week">week(s)</option>
<option value="month">month(s)</option>
<option value="year">year(s)</option>
</select>
</div>
<div v-if="rType === 'calendar'" class="rec-row rec-col">
<div class="rec-row">
<span class="rec-label">Unit</span>
<select v-model="calendarUnit" class="sb-select" @change="onChange">
<option value="month">Monthly</option>
<option value="year">Yearly</option>
</select>
</div>
<div class="rec-row">
<span class="rec-label">Day</span>
<input
v-model.number="calendarDay"
type="number"
min="1"
:max="calendarDayMax"
class="rec-num-input"
@input="onChange"
/>
</div>
<div v-if="calendarUnit === 'year'" class="rec-row">
<span class="rec-label">Month</span>
<select v-model.number="calendarMonth" class="sb-select" @change="onChange">
<option v-for="(name, i) in monthNames" :key="i + 1" :value="i + 1">{{ name }}</option>
</select>
</div>
</div>
</div>
</template>
<style scoped>
.rec-editor {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.rec-row {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.rec-col {
flex-direction: column;
align-items: flex-start;
}
.rec-label {
font-size: 0.78rem;
color: var(--color-text-muted);
min-width: 2.5rem;
}
.rec-num-input {
width: 4rem;
padding: 0.25rem 0.4rem;
border: 1px solid var(--color-input-border, var(--color-border));
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.85rem;
font-family: inherit;
}
.rec-num-input:focus { outline: none; border-color: var(--color-primary); }
.rec-unit { min-width: 6rem; }
</style>
+5
View File
@@ -12,6 +12,7 @@ const labels: Record<TaskStatus, string> = {
todo: "Todo",
in_progress: "In Progress",
done: "Done",
cancelled: "Cancelled",
};
</script>
@@ -48,6 +49,10 @@ const labels: Record<TaskStatus, string> = {
background: color-mix(in srgb, var(--color-status-done-bg) 78%, var(--color-status-done) 22%);
color: color-mix(in srgb, var(--color-status-done) 85%, #000 15%);
}
.status-cancelled {
background: color-mix(in srgb, var(--color-bg-secondary) 78%, var(--color-text-muted) 22%);
color: var(--color-text-muted);
}
.clickable {
cursor: pointer;
}
+6
View File
@@ -20,18 +20,21 @@ const statusCycle: Record<TaskStatus, TaskStatus> = {
todo: "in_progress",
in_progress: "done",
done: "todo",
cancelled: "todo",
};
const statusDotClass: Record<TaskStatus, string> = {
todo: "dot-todo",
in_progress: "dot-in-progress",
done: "dot-done",
cancelled: "dot-cancelled",
};
const statusTitle: Record<TaskStatus, string> = {
todo: "Todo — click to mark In Progress",
in_progress: "In Progress — click to mark Done",
done: "Done — click to mark Todo",
cancelled: "Cancelled — click to mark Todo",
};
function cycleStatus() {
@@ -152,6 +155,9 @@ function isOverdue(): boolean {
.dot-done {
background: var(--color-status-done, #22c55e);
}
.dot-cancelled {
background: var(--color-status-cancelled, #6b7280);
}
.task-title-compact {
font-size: 0.9rem;
+9 -7
View File
@@ -107,34 +107,36 @@ const fetchedAtLabel = computed(() => {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 0.35rem;
margin-bottom: 0.5rem;
}
.weather-temp {
font-size: 1.8rem;
font-size: 2rem;
font-weight: 700;
line-height: 1;
}
.weather-condition {
color: var(--color-text-muted);
font-size: 0.9rem;
}
.weather-today {
color: var(--color-text-secondary);
margin-bottom: 0.75rem;
font-size: 0.85rem;
}
.weather-delta {
color: var(--color-text-muted);
font-size: 0.85rem;
font-size: 0.82rem;
}
.weather-forecast {
display: flex;
gap: 0.5rem;
gap: 0.75rem;
overflow-x: auto;
padding-top: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border);
}
@@ -142,8 +144,8 @@ const fetchedAtLabel = computed(() => {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
min-width: 3.5rem;
gap: 0.25rem;
min-width: 4.5rem;
font-size: 0.8rem;
}
@@ -0,0 +1,29 @@
import { onMounted, onUnmounted } from 'vue'
/**
* Runs `refreshFn` on a recurring interval while the page is visible.
* Safe to use in any component — timer is cleared on unmount.
*
* @param refreshFn Called each tick. Should be silent (no loading state changes).
* @param intervalMs Polling interval in milliseconds.
* @param canRun Optional guard — refresh is skipped when this returns false.
*/
export function useBackgroundRefresh(
refreshFn: () => void,
intervalMs: number,
canRun?: () => boolean,
): void {
let timer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
timer = setInterval(() => {
if (document.hidden) return
if (canRun && !canRun()) return
refreshFn()
}, intervalMs)
})
onUnmounted(() => {
if (timer !== null) clearInterval(timer)
})
}
+5
View File
@@ -120,6 +120,11 @@ const router = createRouter({
name: "briefing",
component: () => import("@/views/BriefingView.vue"),
},
{
path: "/news",
name: "news",
component: () => import("@/views/NewsView.vue"),
},
{
path: "/settings",
name: "settings",
+10 -10
View File
@@ -12,8 +12,8 @@ export const useTasksStore = defineStore("tasks", () => {
// Filter / pagination / sort state
const activeTagFilters = ref<string[]>([]);
const statusFilter = ref<TaskStatus | "">("");
const priorityFilter = ref<TaskPriority | "">("");
const statusFilter = ref<TaskStatus[]>([]);
const priorityFilter = ref<TaskPriority[]>([]);
const limit = ref(20);
const offset = ref(0);
const sortField = ref("updated_at");
@@ -28,9 +28,8 @@ export const useTasksStore = defineStore("tasks", () => {
for (const t of activeTagFilters.value) {
searchParams.append("tag", t);
}
if (statusFilter.value) searchParams.set("status", statusFilter.value);
if (priorityFilter.value)
searchParams.set("priority", priorityFilter.value);
for (const s of statusFilter.value) searchParams.append("status", s);
for (const p of priorityFilter.value) searchParams.append("priority", p);
searchParams.set("sort", sortField.value);
searchParams.set("order", sortOrder.value);
searchParams.set("limit", String(limit.value));
@@ -72,6 +71,7 @@ export const useTasksStore = defineStore("tasks", () => {
project_id?: number | null;
milestone_id?: number | null;
parent_id?: number | null;
recurrence_rule?: Record<string, unknown> | null;
}): Promise<Task> {
try {
return await apiPost<Task>("/api/tasks", data);
@@ -84,7 +84,7 @@ export const useTasksStore = defineStore("tasks", () => {
async function updateTask(
id: number,
data: Partial<
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id">
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id" | "recurrence_rule">
>
): Promise<Task> {
try {
@@ -129,14 +129,14 @@ export const useTasksStore = defineStore("tasks", () => {
}
}
function setStatusFilter(status: TaskStatus | "") {
statusFilter.value = status;
function setStatusFilter(statuses: TaskStatus[]) {
statusFilter.value = statuses;
offset.value = 0;
refresh();
}
function setPriorityFilter(priority: TaskPriority | "") {
priorityFilter.value = priority;
function setPriorityFilter(priorities: TaskPriority[]) {
priorityFilter.value = priorities;
offset.value = 0;
refresh();
}
+10
View File
@@ -0,0 +1,10 @@
export interface NewsItem {
id: number
title: string
url: string
snippet: string
published_at: string | null
topics: string[]
source: string
reaction: 'up' | 'down' | null
}
+5 -1
View File
@@ -1,4 +1,4 @@
export type TaskStatus = "todo" | "in_progress" | "done";
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
export type TaskPriority = "none" | "low" | "medium" | "high";
export interface Note {
@@ -13,6 +13,10 @@ export interface Note {
status: TaskStatus | null;
priority: TaskPriority | null;
due_date: string | null;
started_at: string | null;
completed_at: string | null;
recurrence_rule: Record<string, unknown> | null;
recurrence_next_spawn_at: string | null;
is_task: boolean;
created_at: string;
updated_at: string;
+13
View File
@@ -0,0 +1,13 @@
/** Cyclic color palette for milestone progress bars. */
export const MILESTONE_PALETTE = [
'var(--color-primary)',
'var(--color-success, #22c55e)',
'#c98a00',
'#8b5cf6',
'var(--color-danger, #ef4444)',
'#06b6d4',
]
export function milestoneColor(index: number): string {
return MILESTONE_PALETTE[index % MILESTONE_PALETTE.length]
}
+349 -120
View File
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
import { useChatStore } from '@/stores/chat'
import ChatMessage from '@/components/ChatMessage.vue'
import WeatherCard from '@/components/WeatherCard.vue'
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
import {
apiGet,
getBriefingConfig,
getBriefingConversations,
getBriefingToday,
@@ -12,24 +14,23 @@ import {
triggerBriefingSlot,
postRssReaction,
deleteRssReaction,
getNewsItems,
type BriefingConversation,
type BriefingMessage,
} from '@/api/client'
import type { Message } from '@/types/chat'
import type { NewsItem } from '@/types/news'
interface MessageMetadata {
weather?: {
location: string
fetched_at: string
current_temp: number
condition: string
today_high: number | null
today_low: number | null
yesterday_high: number | null
yesterday_low: number | null
forecast: { day: string; condition: string; high: number; low: number }[]
} | null
rss_item_ids?: number[]
interface WeatherData {
location: string
fetched_at: string
current_temp: number
condition: string
today_high: number | null
today_low: number | null
yesterday_high: number | null
yesterday_low: number | null
forecast: { day: string; condition: string; high: number; low: number }[]
}
const chatStore = useChatStore()
@@ -60,15 +61,53 @@ const isToday = computed(() => selectedConvId.value === todayConvId.value)
const messages = ref<BriefingMessage[]>([])
const loadingMessages = ref(false)
// Weather panel (left column)
const weatherData = ref<WeatherData[]>([])
async function loadWeather() {
try {
const data = await apiGet<{ locations: WeatherData[] }>('/api/briefing/weather')
weatherData.value = data.locations ?? []
} catch { /* silent */ }
}
// News panel (right column)
const newsItems = ref<NewsItem[]>([])
async function loadNews() {
try {
const data = await getNewsItems({ days: 2, limit: 40 })
newsItems.value = data.items
// Seed reactions from API response
for (const item of data.items) {
if (reactions.value[item.id] === undefined) {
reactions.value[item.id] = item.reaction
}
}
} catch { /* silent */ }
}
// Scroll to bottom of messages
const messagesEl = ref<HTMLElement | null>(null)
function scrollToBottom() {
nextTick(() => {
if (messagesEl.value) {
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
}
})
}
async function loadAll() {
const [convList, today] = await Promise.all([
getBriefingConversations(),
getBriefingToday().catch(() => null),
loadWeather(),
loadNews(),
])
conversations.value = convList
if (today) {
todayConvId.value = today.id
// Ensure today is in the list
if (!convList.find((c) => c.id === today.id)) {
conversations.value = [
{ id: today.id, title: today.title ?? 'Today', briefing_date: null, message_count: 0, created_at: new Date().toISOString() },
@@ -77,9 +116,9 @@ async function loadAll() {
}
selectedConvId.value = today.id
messages.value = today.messages
// Load into chatStore so we can stream
await chatStore.fetchConversation(today.id)
}
scrollToBottom()
}
watch(selectedConvId, async (id) => {
@@ -87,11 +126,13 @@ watch(selectedConvId, async (id) => {
if (id === todayConvId.value) {
await chatStore.fetchConversation(id)
messages.value = (chatStore.currentConversation?.messages ?? []) as unknown as BriefingMessage[]
scrollToBottom()
return
}
loadingMessages.value = true
try {
messages.value = await getBriefingConvMessages(id)
scrollToBottom()
} finally {
loadingMessages.value = false
}
@@ -102,6 +143,7 @@ watch(() => chatStore.streaming, async (streaming) => {
if (!streaming && selectedConvId.value === todayConvId.value && todayConvId.value) {
const today = await getBriefingToday().catch(() => null)
if (today) messages.value = today.messages
scrollToBottom()
}
})
@@ -112,7 +154,6 @@ const sending = ref(false)
async function send() {
const text = input.value.trim()
if (!text || !todayConvId.value || chatStore.streaming || sending.value) return
// Ensure today's conv is loaded in chatStore
if (chatStore.currentConversation?.id !== todayConvId.value) {
await chatStore.fetchConversation(todayConvId.value)
}
@@ -149,8 +190,14 @@ async function handleReaction(itemId: number, reaction: 'up' | 'down') {
}
}
function msgMetadata(msg: BriefingMessage): MessageMetadata | null {
return (msg.metadata as MessageMetadata | null | undefined) ?? null
function formatRelativeDate(iso: string | null): string {
if (!iso) return ''
const d = new Date(iso)
const now = new Date()
const diffH = (now.getTime() - d.getTime()) / 3_600_000
if (diffH < 24) return `${Math.round(diffH)}h ago`
if (diffH < 48) return 'Yesterday'
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
// Manual trigger
@@ -159,7 +206,6 @@ async function triggerNow() {
triggering.value = true
try {
await triggerBriefingSlot('compilation')
// Reload
await loadAll()
} finally {
triggering.value = false
@@ -189,6 +235,27 @@ function toMsg(m: BriefingMessage): Message {
}
}
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
async function _backgroundRefreshMessages() {
try {
const today = await getBriefingToday()
if (!today) return
const fresh = today.messages
const last = fresh[fresh.length - 1]
const cur = messages.value[messages.value.length - 1]
if (fresh.length !== messages.value.length || last?.content !== cur?.content) {
messages.value = fresh
}
await loadNews()
} catch { /* silent — don't disturb the UI on network hiccup */ }
}
useBackgroundRefresh(
_backgroundRefreshMessages,
60_000,
() => !chatStore.streaming && isToday.value && !!todayConvId.value,
)
onMounted(async () => {
await checkSetup()
if (!showWizard.value) await loadAll()
@@ -200,16 +267,15 @@ onMounted(async () => {
<!-- Setup wizard overlay -->
<BriefingSetupWizard v-if="wizardChecked && showWizard" @done="onWizardDone" />
<!-- Main view (shown after wizard check and only if not showing wizard) -->
<!-- Main view -->
<div class="briefing-shell" v-if="wizardChecked && !showWizard">
<!-- Header -->
<!-- Header spans all columns -->
<header class="briefing-header">
<div class="briefing-header-left">
<h1 class="briefing-title">Briefing</h1>
<span class="briefing-today-badge">{{ new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) }}</span>
</div>
<div class="briefing-header-right">
<!-- Conversation history dropdown -->
<select v-if="conversations.length" v-model="selectedConvId" class="briefing-conv-select">
<option v-for="c in conversations" :key="c.id" :value="c.id">{{ convLabel(c) }}</option>
</select>
@@ -222,88 +288,115 @@ onMounted(async () => {
</div>
</header>
<!-- Messages -->
<div class="briefing-messages-wrap">
<div v-if="loadingMessages" class="briefing-loading">Loading</div>
<template v-else>
<div v-if="!messages.length" class="briefing-empty">
<p>No briefing yet for today.</p>
<p class="briefing-empty-hint">Click "Refresh" to generate a briefing now, or wait for the scheduled slot.</p>
</div>
<div v-else class="briefing-messages">
<template v-for="msg in messages" :key="msg.id">
<!-- Weather card above the first assistant message with weather metadata -->
<WeatherCard
v-if="msg.role === 'assistant' && msgMetadata(msg)?.weather !== undefined"
:weather="msgMetadata(msg)?.weather ?? null"
/>
<ChatMessage
:message="toMsg(msg)"
:is-streaming="false"
/>
<!-- Reaction buttons below assistant messages with rss_item_ids -->
<div
v-if="msg.role === 'assistant' && (msgMetadata(msg)?.rss_item_ids?.length ?? 0) > 0"
class="rss-reactions"
>
<div
v-for="(itemId, index) in msgMetadata(msg)!.rss_item_ids"
:key="itemId"
class="rss-reaction-row"
>
<span class="reaction-label">Story {{ index + 1 }}</span>
<button
class="reaction-btn"
:class="{ active: reactions[itemId] === 'up' }"
@click="handleReaction(itemId, 'up')"
title="Interested"
>👍</button>
<button
class="reaction-btn"
:class="{ active: reactions[itemId] === 'down' }"
@click="handleReaction(itemId, 'down')"
title="Not interested"
>👎</button>
</div>
</div>
</template>
<!-- Live streaming bubble for today -->
<ChatMessage
v-if="isToday && chatStore.streaming && chatStore.streamingContent"
:message="{
id: -1,
conversation_id: todayConvId ?? -1,
role: 'assistant',
content: chatStore.streamingContent,
context_note_id: null,
context_note_title: null,
created_at: new Date().toISOString(),
}"
:is-streaming="true"
/>
</div>
<!-- Left column: Weather -->
<div class="briefing-left">
<div class="panel-label">Weather</div>
<template v-if="weatherData.length">
<WeatherCard
v-for="loc in weatherData"
:key="(loc as WeatherData).location"
:weather="loc"
/>
</template>
<div v-else class="panel-empty">No weather configured</div>
</div>
<!-- Input bar (today only) -->
<div v-if="isToday" class="briefing-input-bar">
<textarea
v-model="input"
class="briefing-input"
placeholder="Reply to your briefing…"
rows="1"
@keydown="onKeydown"
></textarea>
<button
class="btn-send"
@click="send"
:disabled="!input.trim() || chatStore.streaming || sending"
aria-label="Send"
<!-- Center column: Chat -->
<div class="briefing-center">
<div class="briefing-messages-wrap" ref="messagesEl">
<div v-if="loadingMessages" class="briefing-loading">Loading</div>
<template v-else>
<div v-if="!messages.length" class="briefing-empty">
<p>No briefing yet for today.</p>
<p class="briefing-empty-hint">Click "Refresh" to generate a briefing now, or wait for the scheduled slot.</p>
</div>
<div v-else class="briefing-messages">
<template v-for="msg in messages" :key="msg.id">
<ChatMessage
:message="toMsg(msg)"
:is-streaming="false"
/>
</template>
<!-- Live streaming bubble for today -->
<ChatMessage
v-if="isToday && chatStore.streaming && chatStore.streamingContent"
:message="{
id: -1,
conversation_id: todayConvId ?? -1,
role: 'assistant',
content: chatStore.streamingContent,
context_note_id: null,
context_note_title: null,
created_at: new Date().toISOString(),
}"
:is-streaming="true"
/>
</div>
</template>
</div>
<!-- Input bar (today only) -->
<div v-if="isToday" class="briefing-input-bar">
<textarea
v-model="input"
class="briefing-input"
placeholder="Reply to your briefing…"
rows="1"
@keydown="onKeydown"
></textarea>
<button
class="btn-send"
@click="send"
:disabled="!input.trim() || chatStore.streaming || sending"
aria-label="Send"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 21l21-9L2 3v7l15 2-15 2v7z"/>
</svg>
</button>
</div>
</div>
<!-- Right column: News -->
<div class="briefing-right">
<div class="panel-label-row">
<div class="panel-label">Today's News</div>
<span v-if="newsItems.length" class="news-count">{{ newsItems.length }} items</span>
</div>
<div v-if="!newsItems.length" class="panel-empty">No articles in the last 2 days</div>
<div
v-for="item in newsItems"
:key="item.id"
class="news-card"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 21l21-9L2 3v7l15 2-15 2v7z"/>
</svg>
</button>
<div class="news-card-meta">
<span class="news-source">{{ item.source }}</span>
<span v-if="item.published_at" class="news-date">{{ formatRelativeDate(item.published_at) }}</span>
</div>
<a
v-if="item.url"
:href="item.url"
target="_blank"
rel="noopener noreferrer"
class="news-title"
>{{ item.title }}</a>
<p v-else class="news-title news-title--plain">{{ item.title }}</p>
<p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
<div class="news-reactions">
<button
class="reaction-btn"
:class="{ active: reactions[item.id] === 'up' }"
@click="handleReaction(item.id, 'up')"
title="Interested"
>👍</button>
<button
class="reaction-btn"
:class="{ active: reactions[item.id] === 'down' }"
@click="handleReaction(item.id, 'down')"
title="Not interested"
>👎</button>
</div>
</div>
</div>
</div>
</div>
@@ -318,21 +411,20 @@ onMounted(async () => {
}
.briefing-shell {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: 1fr 2fr 1fr;
grid-template-rows: auto 1fr;
height: 100%;
min-height: 0;
max-width: 760px;
margin: 0 auto;
width: 100%;
padding: 0 1rem;
}
.briefing-header {
grid-column: 1 / -1;
grid-row: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 0 1rem;
padding: 1.25rem 1rem 1rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
gap: 1rem;
@@ -393,10 +485,38 @@ onMounted(async () => {
}
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
/* ─── Left column (Weather) ──────────────────────────────────────────────── */
.briefing-left {
grid-column: 1;
grid-row: 2;
border-right: 1px solid var(--color-border);
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.briefing-left :deep(.weather-card) {
margin-bottom: 0;
}
/* ─── Center column (Chat) ───────────────────────────────────────────────── */
.briefing-center {
grid-column: 2;
grid-row: 2;
display: flex;
flex-direction: column;
min-height: 0;
}
.briefing-messages-wrap {
flex: 1;
overflow-y: auto;
padding: 1rem 0;
padding: 1rem;
min-height: 0;
}
.briefing-loading,
@@ -423,7 +543,7 @@ onMounted(async () => {
display: flex;
gap: 0.5rem;
align-items: flex-end;
padding: 0.75rem 0 1rem;
padding: 0.75rem 1rem 1rem;
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
@@ -462,33 +582,111 @@ onMounted(async () => {
.btn-send:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-send:hover:not(:disabled) { opacity: 0.9; }
.rss-reactions {
/* ─── Right column (News) ────────────────────────────────────────────────── */
.briefing-right {
grid-column: 3;
grid-row: 2;
border-left: 1px solid var(--color-border);
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.5rem 0.75rem;
margin-bottom: 0.25rem;
gap: 0.5rem;
}
.rss-reaction-row {
.panel-label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-primary);
flex-shrink: 0;
}
.panel-label-row {
display: flex;
align-items: center;
gap: 0.4rem;
justify-content: space-between;
flex-shrink: 0;
}
.reaction-label {
.news-count {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.panel-empty {
font-size: 0.82rem;
color: var(--color-text-muted);
padding: 0.5rem 0;
}
.news-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 0.65rem 0.85rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
flex-shrink: 0;
}
.news-card-meta {
display: flex;
align-items: center;
gap: 0.5rem;
}
.news-source {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-primary);
}
.news-date {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.news-title {
font-size: 0.88rem;
font-weight: 600;
color: var(--color-text);
line-height: 1.35;
text-decoration: none;
margin: 0;
}
a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
.news-snippet {
font-size: 0.78rem;
color: var(--color-text-muted);
min-width: 4rem;
line-height: 1.45;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.news-reactions {
display: flex;
gap: 0.3rem;
margin-top: 0.15rem;
}
.reaction-btn {
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.15rem 0.4rem;
padding: 0.1rem 0.35rem;
cursor: pointer;
font-size: 0.85rem;
font-size: 0.82rem;
line-height: 1.4;
opacity: 0.55;
transition: opacity 0.15s, border-color 0.15s;
@@ -504,4 +702,35 @@ onMounted(async () => {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
}
/* ─── Responsive ─────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.briefing-shell {
grid-template-columns: 1fr;
grid-template-rows: auto auto 1fr auto;
}
.briefing-header {
grid-column: 1;
grid-row: 1;
}
.briefing-left {
grid-column: 1;
grid-row: 2;
border-right: none;
border-bottom: 1px solid var(--color-border);
max-height: 220px;
}
.briefing-center {
grid-column: 1;
grid-row: 3;
}
.briefing-right {
grid-column: 1;
grid-row: 4;
border-left: none;
border-top: 1px solid var(--color-border);
max-height: 260px;
}
}
</style>
+36 -10
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { apiGet, listEvents } from "@/api/client";
import { useBackgroundRefresh } from "@/composables/useBackgroundRefresh";
import { milestoneColor } from "@/utils/palette";
import type { Note } from "@/types/note";
import type { Task, TaskListResponse, TaskStatus } from "@/types/task";
import type { ToolCallRecord, Message } from "@/types/chat";
@@ -69,17 +71,38 @@ const upcomingEvents = ref<EventEntry[]>([]);
const eventSlideOverOpen = ref(false);
const editingEvent = ref<EventEntry | null>(null);
// ─── Milestone color palette ──────────────────────────────────────────────────
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
// Never touches `loading` so existing content stays on screen while fetching.
function milestoneColor(index: number): string {
const palette = [
"var(--color-primary)",
"var(--color-success, #22c55e)",
"#c98a00",
"var(--color-danger, #e74c3c)",
"#8b5cf6",
];
return palette[index % palette.length];
function _dateRange() {
const today = new Date()
const nextWeek = new Date(today)
nextWeek.setDate(today.getDate() + 7)
return {
todayStr: today.toISOString().slice(0, 10) + 'T00:00:00',
nextWeekStr: nextWeek.toISOString().slice(0, 10) + 'T23:59:59',
}
}
function _backgroundRefresh() {
if (document.hidden || loading.value) return
const { todayStr, nextWeekStr } = _dateRange()
Promise.allSettled([
listEvents(todayStr, nextWeekStr),
apiGet<TaskListResponse>('/api/tasks?no_project=true&sort=updated_at&order=desc&limit=8'),
apiGet<{ notes: Note[] }>('/api/notes?type=note&no_project=true&sort=updated_at&order=desc&limit=6'),
]).then(([eventsRes, tasksRes, notesRes]) => {
if (eventsRes.status === 'fulfilled') upcomingEvents.value = eventsRes.value
if (tasksRes.status === 'fulfilled') orphanTasks.value = tasksRes.value.tasks
if (notesRes.status === 'fulfilled') orphanNotes.value = notesRes.value.notes
})
if (heroProject.value) {
apiGet<TaskListResponse>(
`/api/tasks?project_id=${heroProject.value.id}&status=todo&sort=updated_at&order=desc&limit=1`
)
.then((r) => { heroNextUp.value = r.tasks[0] ?? null })
.catch(() => {})
}
}
// ─── Data loading ─────────────────────────────────────────────────────────────
@@ -135,8 +158,11 @@ onMounted(async () => {
// Focus chat input after data loads
chatInputRef.value?.focus();
loadProjects();
});
useBackgroundRefresh(_backgroundRefresh, 90_000, () => !loading.value);
async function loadProjects() {
if (!heroProject.value) return;
const hid = heroProject.value.id;
+371
View File
@@ -0,0 +1,371 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import {
getBriefingFeeds,
postRssReaction,
deleteRssReaction,
getNewsItems,
type BriefingFeed,
} from '@/api/client'
import type { NewsItem } from '@/types/news'
const LIMIT = 40
const items = ref<NewsItem[]>([])
const offset = ref(0)
const hasMore = ref(true)
const loading = ref(false)
const feeds = ref<BriefingFeed[]>([])
const selectedFeedId = ref<number | null>(null)
// Reactions map: item id → current reaction
const reactions = ref<Record<number, 'up' | 'down' | null>>({})
async function loadMore() {
if (loading.value || !hasMore.value) return
loading.value = true
try {
const data = await getNewsItems({
days: 90,
limit: LIMIT,
offset: offset.value,
feed_id: selectedFeedId.value,
})
for (const item of data.items) {
if (reactions.value[item.id] === undefined) {
reactions.value[item.id] = item.reaction
}
}
items.value = [...items.value, ...data.items]
offset.value += data.items.length
hasMore.value = data.items.length === LIMIT
} finally {
loading.value = false
}
}
function onFeedChange() {
items.value = []
offset.value = 0
hasMore.value = true
reactions.value = {}
loadMore()
}
async function handleReaction(itemId: number, reaction: 'up' | 'down') {
const current = reactions.value[itemId]
reactions.value[itemId] = current === reaction ? null : reaction
try {
if (current === reaction) {
await deleteRssReaction(itemId)
} else {
await postRssReaction(itemId, reaction)
}
} catch {
reactions.value[itemId] = current ?? null
}
}
function formatRelativeDate(iso: string | null): string {
if (!iso) return ''
const d = new Date(iso)
const now = new Date()
const diffH = (now.getTime() - d.getTime()) / 3_600_000
if (diffH < 1) return 'Just now'
if (diffH < 24) return `${Math.round(diffH)}h ago`
if (diffH < 48) return 'Yesterday'
const days = Math.floor(diffH / 24)
if (days < 7) return `${days}d ago`
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
onMounted(async () => {
feeds.value = await getBriefingFeeds().catch(() => [])
await loadMore()
})
</script>
<template>
<div class="news-root">
<div class="news-header">
<div class="news-header-left">
<h1 class="news-title">News</h1>
<span class="news-subtitle">Last 90 days</span>
</div>
<div class="news-header-right">
<select
v-model="selectedFeedId"
class="feed-select"
@change="onFeedChange"
>
<option :value="null">All feeds</option>
<option v-for="feed in feeds" :key="feed.id" :value="feed.id">
{{ feed.title }}
</option>
</select>
</div>
</div>
<div class="news-list">
<div v-if="!items.length && !loading" class="news-empty">
No articles found for the selected feed.
</div>
<div
v-for="item in items"
:key="item.id"
class="news-card"
>
<div class="news-card-meta">
<span class="news-source">{{ item.source }}</span>
<span v-if="item.published_at" class="news-date">{{ formatRelativeDate(item.published_at) }}</span>
</div>
<a
v-if="item.url"
:href="item.url"
target="_blank"
rel="noopener noreferrer"
class="news-card-title"
>{{ item.title }}</a>
<p v-else class="news-card-title news-card-title--plain">{{ item.title }}</p>
<p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
<div v-if="item.topics?.length" class="news-topics">
<span v-for="topic in item.topics" :key="topic" class="news-topic">{{ topic }}</span>
</div>
<div class="news-reactions">
<button
class="reaction-btn"
:class="{ active: reactions[item.id] === 'up' }"
@click="handleReaction(item.id, 'up')"
title="Interested"
>👍</button>
<button
class="reaction-btn"
:class="{ active: reactions[item.id] === 'down' }"
@click="handleReaction(item.id, 'down')"
title="Not interested"
>👎</button>
</div>
</div>
<div class="news-footer">
<button
v-if="hasMore"
class="btn-load-more"
@click="loadMore"
:disabled="loading"
>{{ loading ? 'Loading…' : 'Load more' }}</button>
<div v-if="loading && !items.length" class="news-loading">Loading</div>
<p v-if="!hasMore && items.length" class="news-end">All articles loaded</p>
</div>
</div>
</div>
</template>
<style scoped>
.news-root {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow-y: auto;
}
.news-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem 1rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
flex-wrap: wrap;
gap: 0.75rem;
}
.news-header-left {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.news-title {
font-family: 'Fraunces', Georgia, serif;
font-size: 1.3rem;
font-weight: 700;
margin: 0;
color: var(--color-text);
}
.news-subtitle {
font-size: 0.82rem;
color: var(--color-text-muted);
}
.news-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.feed-select {
padding: 0.35rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.82rem;
cursor: pointer;
font-family: inherit;
}
.news-list {
padding: 1rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 860px;
width: 100%;
margin: 0 auto;
}
.news-empty,
.news-loading {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.news-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 0.85rem 1rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.news-card-meta {
display: flex;
align-items: center;
gap: 0.5rem;
}
.news-source {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-primary);
}
.news-date {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.news-card-title {
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text);
line-height: 1.4;
text-decoration: none;
margin: 0;
}
a.news-card-title:hover {
text-decoration: underline;
color: var(--color-primary);
}
.news-snippet {
font-size: 0.82rem;
color: var(--color-text-muted);
line-height: 1.5;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.news-topics {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.1rem;
}
.news-topic {
font-size: 0.68rem;
padding: 0.15rem 0.5rem;
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
color: var(--color-primary);
border-radius: 99px;
font-weight: 500;
}
.news-reactions {
display: flex;
gap: 0.3rem;
margin-top: 0.2rem;
}
.reaction-btn {
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.1rem 0.4rem;
cursor: pointer;
font-size: 0.82rem;
line-height: 1.4;
opacity: 0.55;
transition: opacity 0.15s, border-color 0.15s;
}
.reaction-btn:hover {
opacity: 1;
border-color: var(--color-primary);
}
.reaction-btn.active {
opacity: 1;
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
}
.news-footer {
display: flex;
justify-content: center;
padding: 1rem 0 0.5rem;
}
.btn-load-more {
padding: 0.5rem 1.5rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg-card);
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
}
.btn-load-more:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-load-more:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.news-end {
font-size: 0.8rem;
color: var(--color-text-muted);
margin: 0;
}
</style>
+1 -11
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { apiGet, apiPost } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import { milestoneColor } from "@/utils/palette";
interface MilestoneSummary {
id: number;
@@ -73,17 +74,6 @@ async function loadProjects() {
}
}
function milestoneColor(index: number): string {
const palette = [
"var(--color-primary)",
"var(--color-success)",
"#c98a00",
"#8b5cf6",
"#ef4444",
"#06b6d4",
];
return palette[index % palette.length];
}
onMounted(loadProjects);
+21 -27
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, refreshBriefingFeeds, geocodeAddress, getFableMcpInfo, 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, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, type ApiKeyEntry, 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";
@@ -43,17 +43,24 @@ const restoreFileInput = ref<HTMLInputElement | null>(null);
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "briefing", "apikeys", "config", "users", "logs", "groups"]);
const _stored = localStorage.getItem("settings_tab") ?? "general";
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
function _loadTabContent(tab: string) {
if (authStore.isAdmin) {
if (tab === "users") loadUsersPanel();
else if (tab === "logs") loadLogsPanel();
else if (tab === "groups") loadGroupsPanel();
}
if (tab === "briefing") loadBriefingTab();
if (tab === "apikeys") { fetchApiKeys(); loadMcpInfo(); }
}
watch(activeTab, (v) => {
localStorage.setItem("settings_tab", v === "admin" ? "config" : v);
if (v === "users" && authStore.isAdmin) loadUsersPanel();
if (v === "logs" && authStore.isAdmin) loadLogsPanel();
if (v === "groups" && authStore.isAdmin) loadGroupsPanel();
if (v === "briefing") loadBriefingTab();
if (v === "apikeys") { fetchApiKeys(); loadMcpInfo(); }
_loadTabContent(v);
});
// API Keys
const apiKeys = ref<Array<{id: number, name: string, scope: string, key_prefix: string, last_used_at: string | null}>>([]);
const apiKeys = ref<ApiKeyEntry[]>([]);
const newKeyName = ref('');
const newKeyScope = ref<'read' | 'write'>('write');
const newKeyValue = ref('');
@@ -89,36 +96,24 @@ async function loadMcpInfo() {
}
async function fetchApiKeys() {
const res = await fetch('/api/api-keys', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
apiKeys.value = data.api_keys;
}
apiKeys.value = await listApiKeys();
}
async function createApiKey() {
if (!newKeyName.value) return;
creatingApiKey.value = true;
try {
const res = await fetch('/api/api-keys', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newKeyName.value, scope: newKeyScope.value }),
});
if (res.ok) {
const data = await res.json();
newKeyValue.value = data.key;
newKeyName.value = '';
await fetchApiKeys();
}
const data = await apiCreateApiKey(newKeyName.value, newKeyScope.value);
newKeyValue.value = data.key;
newKeyName.value = '';
await fetchApiKeys();
} finally {
creatingApiKey.value = false;
}
}
async function revokeApiKey(id: number) {
await fetch(`/api/api-keys/${id}`, { method: 'DELETE', credentials: 'include' });
await apiRevokeApiKey(id);
revokeConfirmId.value = null;
await fetchApiKeys();
}
@@ -521,9 +516,8 @@ onMounted(async () => {
} catch {
// base URL not configured yet
}
if (activeTab.value === "groups") loadGroupsPanel();
if (activeTab.value === "briefing") loadBriefingTab();
}
_loadTabContent(activeTab.value);
});
async function changeEmail() {
+48
View File
@@ -12,6 +12,7 @@ import { useTagSuggestions } from "@/composables/useTagSuggestions";
import { useFloatingAssist } from "@/composables/useFloatingAssist";
import { apiPost, apiGet, apiPatch } from "@/api/client";
import type { TaskStatus, TaskPriority } from "@/types/task";
import type { Note } from "@/types/note";
import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import TiptapEditor from "@/components/TiptapEditor.vue";
@@ -23,6 +24,7 @@ import TaskLogSection from "@/components/TaskLogSection.vue";
import DiffView from "@/components/DiffView.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import VersionHistorySection from "@/components/VersionHistorySection.vue";
import RecurrenceEditor from "@/components/RecurrenceEditor.vue";
const route = useRoute();
const router = useRouter();
@@ -40,6 +42,9 @@ const projectId = ref<number | null>(null);
const milestoneId = ref<number | null>(null);
const parentId = ref<number | null>(null);
const parentTitle = ref("");
const startedAt = ref<string | null>(null);
const completedAt = ref<string | null>(null);
const recurrenceRule = ref<Record<string, unknown> | null>(null);
const parentSearchQuery = ref("");
const parentSearchResults = ref<{ id: number; title: string }[]>([]);
const parentSearchLoading = ref(false);
@@ -274,6 +279,10 @@ onMounted(async () => {
parentId.value = (taskRec.parent_id as number | null) ?? null;
parentTitle.value = (taskRec.parent_title as string | null) ?? "";
parentSearchQuery.value = parentTitle.value;
const noteTask = store.currentTask as unknown as Note;
startedAt.value = noteTask.started_at ?? null;
completedAt.value = noteTask.completed_at ?? null;
recurrenceRule.value = noteTask.recurrence_rule ?? null;
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
@@ -309,6 +318,7 @@ async function save() {
project_id: projectId.value,
milestone_id: milestoneId.value,
parent_id: parentId.value,
recurrence_rule: recurrenceRule.value,
};
if (isEditing.value) {
await store.updateTask(taskId.value!, data);
@@ -373,6 +383,7 @@ async function doAutoSave() {
project_id: projectId.value,
milestone_id: milestoneId.value,
parent_id: parentId.value,
recurrence_rule: recurrenceRule.value,
} as Record<string, unknown>);
savedTitle = title.value;
savedBody = body.value;
@@ -483,8 +494,19 @@ useEditorGuards(dirty, save);
<option value="todo">Todo</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div v-if="startedAt || completedAt" class="sb-timestamps">
<div v-if="startedAt" class="sb-timestamp">
<span class="sb-ts-label">Started</span>
<span class="sb-ts-value">{{ new Date(startedAt).toLocaleString() }}</span>
</div>
<div v-if="completedAt" class="sb-timestamp">
<span class="sb-ts-label">Completed</span>
<span class="sb-ts-value">{{ new Date(completedAt).toLocaleString() }}</span>
</div>
</div>
<div class="sb-field">
<label class="sb-label">Priority</label>
<select v-model="priority" @change="markDirty" class="sb-select">
@@ -498,6 +520,10 @@ useEditorGuards(dirty, save);
<label class="sb-label">Due Date</label>
<input v-model="dueDate" type="date" class="sb-input" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Recurrence</label>
<RecurrenceEditor v-model="recurrenceRule" @update:modelValue="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Project</label>
<ProjectSelector v-model="projectId" @update:modelValue="markDirty" />
@@ -903,6 +929,28 @@ useEditorGuards(dirty, save);
align-items: center;
}
/* Lifecycle timestamps */
.sb-timestamps {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-top: 0.25rem;
}
.sb-timestamp {
display: flex;
justify-content: space-between;
gap: 0.4rem;
font-size: 0.75rem;
}
.sb-ts-label {
color: var(--color-text-muted);
flex-shrink: 0;
}
.sb-ts-value {
color: var(--color-text-secondary);
text-align: right;
}
/* Narrow screen: sidebar collapses */
@media (max-width: 720px) {
.task-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
+47
View File
@@ -33,12 +33,14 @@ const statusCycle: Record<TaskStatus, TaskStatus> = {
todo: "in_progress",
in_progress: "done",
done: "todo",
cancelled: "todo",
};
const statusDotClass: Record<TaskStatus, string> = {
todo: "dot-todo",
in_progress: "dot-in-progress",
done: "dot-done",
cancelled: "dot-cancelled",
};
function cycleSubTaskStatus(subTask: Note) {
@@ -137,8 +139,25 @@ const forwardStatus: Record<TaskStatus, TaskStatus | null> = {
todo: "in_progress",
in_progress: "done",
done: null,
cancelled: null,
};
function recurrenceSummary(rule: Record<string, unknown> | null): string | null {
if (!rule) return null;
if (rule.type === "interval") {
return `Every ${rule.every} ${rule.unit}(s)`;
}
if (rule.type === "calendar") {
if (rule.unit === "month") return `Monthly on day ${rule.day_of_month}`;
if (rule.unit === "year") {
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const m = months[((rule.month as number) ?? 1) - 1];
return `Yearly on ${m} ${rule.day_of_month}`;
}
}
return null;
}
const advanceLabel = computed(() => {
const s = store.currentTask?.status as TaskStatus | undefined;
if (!s) return null;
@@ -318,6 +337,17 @@ const subTaskProgress = computed(() => {
Due: {{ store.currentTask.due_date }}
</span>
</div>
<div class="task-meta-row" v-if="store.currentTask.started_at || store.currentTask.completed_at || store.currentTask.recurrence_rule">
<span v-if="store.currentTask.started_at" class="task-meta-item">
Started: {{ new Date(store.currentTask.started_at).toLocaleString() }}
</span>
<span v-if="store.currentTask.completed_at" class="task-meta-item">
Completed: {{ new Date(store.currentTask.completed_at).toLocaleString() }}
</span>
<span v-if="recurrenceSummary(store.currentTask.recurrence_rule as Record<string, unknown> | null)" class="task-meta-item task-meta-recurrence">
{{ recurrenceSummary(store.currentTask.recurrence_rule as Record<string, unknown> | null) }}
</span>
</div>
<div class="tags" v-if="store.currentTask.tags.length">
<TagPill
v-for="tag in store.currentTask.tags"
@@ -551,6 +581,20 @@ const subTaskProgress = computed(() => {
color: var(--color-overdue);
font-weight: 600;
}
.task-meta-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.task-meta-item {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.task-meta-recurrence {
color: var(--color-primary);
font-weight: 500;
}
.tags {
display: flex;
gap: 0.5rem;
@@ -637,6 +681,9 @@ const subTaskProgress = computed(() => {
.dot-done {
background: var(--color-status-done, #22c55e);
}
.dot-cancelled {
background: var(--color-text-muted, #6b7280);
}
.sub-title {
flex: 1;
font-size: 0.9rem;
+51 -22
View File
@@ -2,7 +2,7 @@
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import type { Task, TaskStatus } from "@/types/task";
import type { Task, TaskStatus, TaskPriority } from "@/types/task";
import { apiGet } from "@/api/client";
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
import SearchBar from "@/components/SearchBar.vue";
@@ -122,7 +122,8 @@ onMounted(async () => {
store.activeTagFilters = tags;
}
if (route.query.status) {
store.statusFilter = route.query.status as TaskStatus;
const qs = route.query.status;
store.statusFilter = (Array.isArray(qs) ? qs : [qs]).filter(Boolean) as TaskStatus[];
}
store.limit = viewMode.value === "grouped" ? 100 : 200;
collapsedGroups.value.add("done");
@@ -150,14 +151,20 @@ function onSearch(q: string) {
store.setSearch(q);
}
function onStatusFilterChange(e: Event) {
const value = (e.target as HTMLSelectElement).value;
store.setStatusFilter(value as TaskStatus | "");
function toggleStatusChip(value: TaskStatus) {
const current = [...store.statusFilter];
const idx = current.indexOf(value);
if (idx === -1) current.push(value);
else current.splice(idx, 1);
store.setStatusFilter(current);
}
function onPriorityFilterChange(e: Event) {
const value = (e.target as HTMLSelectElement).value;
store.setPriorityFilter(value as any);
function togglePriorityChip(value: TaskPriority) {
const current = [...store.priorityFilter];
const idx = current.indexOf(value);
if (idx === -1) current.push(value);
else current.splice(idx, 1);
store.setPriorityFilter(current);
}
function onTagClick(tag: string) {
@@ -210,19 +217,20 @@ function toggleGroup(key: string) {
<div class="controls">
<div class="filter-controls">
<select :value="store.statusFilter" @change="onStatusFilterChange" class="filter-select">
<option value="">All Statuses</option>
<option value="todo">Todo</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
</select>
<select :value="store.priorityFilter" @change="onPriorityFilterChange" class="filter-select">
<option value="">All Priorities</option>
<option value="none">None</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<div class="filter-chip-group">
<button v-for="s in (['todo', 'in_progress', 'done', 'cancelled'] as TaskStatus[])" :key="s"
:class="['filter-chip', { active: store.statusFilter.includes(s) }]"
@click="toggleStatusChip(s)">
{{ { todo: 'Todo', in_progress: 'In Progress', done: 'Done', cancelled: 'Cancelled' }[s] }}
</button>
</div>
<div class="filter-chip-group">
<button v-for="p in (['low', 'medium', 'high'] as TaskPriority[])" :key="p"
:class="['filter-chip', { active: store.priorityFilter.includes(p) }]"
@click="togglePriorityChip(p)">
{{ p.charAt(0).toUpperCase() + p.slice(1) }}
</button>
</div>
</div>
<div class="right-controls">
<div class="sort-controls">
@@ -270,7 +278,7 @@ function toggleGroup(key: string) {
</div>
<div v-else-if="store.tasks.length === 0" class="empty-state">
<template v-if="store.searchQuery || store.activeTagFilters.length || store.statusFilter || store.priorityFilter">
<template v-if="store.searchQuery || store.activeTagFilters.length || store.statusFilter.length || store.priorityFilter.length">
<p class="empty-title">No tasks match your filters</p>
<p class="empty-subtitle">Try adjusting your search or removing filters.</p>
</template>
@@ -382,8 +390,29 @@ function toggleGroup(key: string) {
}
.filter-controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.filter-chip-group {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.filter-chip {
padding: 3px 10px;
border-radius: 999px;
border: 1px solid var(--color-input-border);
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.78rem;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.filter-chip.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.right-controls {
display: flex;
align-items: center;
+18 -3
View File
@@ -1,8 +1,8 @@
import enum
from datetime import date
from datetime import date, datetime
from sqlalchemy import Date, ForeignKey, Index, Integer, Text
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
@@ -13,6 +13,7 @@ class TaskStatus(str, enum.Enum):
todo = "todo"
in_progress = "in_progress"
done = "done"
cancelled = "cancelled"
class TaskPriority(str, enum.Enum):
@@ -44,6 +45,12 @@ class Note(Base, TimestampMixin):
status: Mapped[str | None] = mapped_column(Text, nullable=True)
priority: Mapped[str | None] = mapped_column(Text, nullable=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
recurrence_rule: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
recurrence_next_spawn_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
__table_args__ = (
Index("ix_notes_tags", "tags", postgresql_using="gin"),
@@ -70,6 +77,14 @@ class Note(Base, TimestampMixin):
"status": self.status,
"priority": self.priority,
"due_date": self.due_date.isoformat() if self.due_date else None,
"started_at": self.started_at.isoformat() if self.started_at else None,
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
"recurrence_rule": self.recurrence_rule,
"recurrence_next_spawn_at": (
self.recurrence_next_spawn_at.isoformat()
if self.recurrence_next_spawn_at
else None
),
"is_task": self.is_task,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
+55
View File
@@ -322,3 +322,58 @@ async def delete_rss_reaction(item_id: int):
await session.commit()
return jsonify({"ok": True})
@briefing_bp.route("/news", methods=["GET"])
@_REQUIRE
async def list_news():
"""Return recent RSS articles with optional feed filter and pagination.
Query params:
days — lookback window (default 2, max 90)
limit — items per page (default 40, max 100)
offset — pagination offset (default 0)
feed_id — optional integer filter by feed
"""
from sqlalchemy import text as _text
days = min(int(request.args.get("days", 2)), 90)
limit = min(int(request.args.get("limit", 40)), 100)
offset = max(int(request.args.get("offset", 0)), 0)
feed_id = request.args.get("feed_id", type=int)
async with async_session() as session:
result = await session.execute(
_text("""
SELECT
i.id, i.title, i.url, i.content, i.published_at,
i.topics, f.title AS feed_title,
r.reaction
FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id
LEFT JOIN rss_item_reactions r
ON r.rss_item_id = i.id AND r.user_id = :uid
WHERE f.user_id = :uid
AND (:feed_id IS NULL OR f.id = :feed_id)
AND i.published_at >= NOW() - make_interval(days => :days)
ORDER BY i.published_at DESC NULLS LAST
LIMIT :limit OFFSET :offset
""").bindparams(uid=g.user.id, days=days, limit=limit,
offset=offset, feed_id=feed_id)
)
rows = result.mappings().all()
items = [
{
"id": r["id"],
"title": r["title"],
"url": r["url"],
"snippet": (r["content"] or "")[:300],
"published_at": r["published_at"].isoformat() if r["published_at"] else None,
"topics": r["topics"] or [],
"source": r["feed_title"],
"reaction": r["reaction"],
}
for r in rows
]
return jsonify({"items": items, "offset": offset, "limit": limit})
+2 -3
View File
@@ -6,7 +6,7 @@ import httpx
from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found
from fabledassistant.routes.utils import not_found, parse_pagination
from fabledassistant.config import Config
from fabledassistant.services.chat import (
add_message,
@@ -38,8 +38,7 @@ chat_bp = Blueprint("chat", __name__, url_prefix="/api/chat")
@login_required
async def list_conversations_route():
uid = get_current_user_id()
limit = min(request.args.get("limit", 50, type=int), 500)
offset = request.args.get("offset", 0, type=int)
limit, offset = parse_pagination()
conv_type = request.args.get("type", "chat")
# Apply retention policy before returning list
retention_str = await get_setting(uid, "chat_retention_days", "90")
+2 -3
View File
@@ -4,7 +4,7 @@ import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found
from fabledassistant.routes.utils import not_found, parse_pagination
from fabledassistant.services.milestones import (
create_milestone,
delete_milestone,
@@ -107,8 +107,7 @@ async def get_milestone_tasks_route(project_id: int, milestone_id: int):
if milestone is None or milestone.project_id != project_id:
return not_found("Milestone")
status_filter = request.args.get("status")
limit = min(request.args.get("limit", 100, type=int), 500)
offset = request.args.get("offset", 0, type=int)
limit, offset = parse_pagination(default_limit=100)
notes, total = await list_notes(
uid,
is_task=True,
+6 -4
View File
@@ -7,7 +7,7 @@ from fabledassistant.services.embeddings import upsert_note_embedding
from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found, parse_iso_date
from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination
from fabledassistant.config import Config
from fabledassistant.services.assist import build_assist_messages
from fabledassistant.services.generation_buffer import (
@@ -49,8 +49,7 @@ async def list_notes_route():
tag = request.args.getlist("tag")
sort = request.args.get("sort", "updated_at")
order = request.args.get("order", "desc")
limit = min(request.args.get("limit", 50, type=int), 500)
offset = request.args.get("offset", 0, type=int)
limit, offset = parse_pagination()
# Default to non-task notes only; ?is_task=true for tasks, ?all=true for everything
is_task: bool | None = False
@@ -71,11 +70,14 @@ async def list_notes_route():
elif type_param == "note":
is_task = False
status = request.args.getlist("status") or None
priority = request.args.getlist("priority") or None
notes, total = await list_notes(
uid, q=q, tags=tag or None, is_task=is_task, sort=sort, order=order,
limit=limit, offset=offset,
project_id=project_id, milestone_id=milestone_id, parent_id=parent_id,
no_project=no_project,
no_project=no_project, status=status, priority=priority,
)
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
+2 -3
View File
@@ -4,7 +4,7 @@ import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found
from fabledassistant.routes.utils import not_found, parse_pagination
from fabledassistant.services.milestones import list_milestones
from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import (
@@ -100,8 +100,7 @@ async def get_project_notes_route(project_id: int):
# type filter: "note", "task", or None (both)
type_filter = request.args.get("type")
status_filter = request.args.get("status")
limit = min(request.args.get("limit", 100, type=int), 500)
offset = request.args.get("offset", 0, type=int)
limit, offset = parse_pagination(default_limit=100)
is_task: bool | None = None
if type_filter == "task":
+80 -11
View File
@@ -1,10 +1,11 @@
import asyncio
from datetime import date
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.models.note import TaskPriority, TaskStatus
from fabledassistant.routes.utils import not_found, parse_iso_date
from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination
from fabledassistant.services.embeddings import upsert_note_embedding
from fabledassistant.services.notes import (
create_note,
@@ -14,9 +15,32 @@ from fabledassistant.services.notes import (
list_notes,
update_note,
)
from fabledassistant.services.recurrence import calculate_next_due, validate_recurrence_rule
tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks")
_UNSET = object()
def _parse_recurrence_rule(data: dict) -> tuple[object, tuple | None]:
"""Extract and validate recurrence_rule from request data.
Returns (rule_or_UNSET, error_response_or_None):
_UNSET → key not present, do nothing
None → key present and null, clear the rule
dict → key present and valid, set the rule
"""
if "recurrence_rule" not in data:
return _UNSET, None
rule = data["recurrence_rule"]
if rule is None:
return None, None
try:
validate_recurrence_rule(rule)
except ValueError as exc:
return _UNSET, (jsonify({"error": str(exc)}), 400)
return rule, None
@tasks_bp.route("", methods=["GET"])
@login_required
@@ -24,12 +48,11 @@ async def list_tasks_route():
uid = get_current_user_id()
q = request.args.get("q")
tag = request.args.getlist("tag")
status = request.args.get("status")
priority = request.args.get("priority")
status = request.args.getlist("status") or None
priority = request.args.getlist("priority") or None
sort = request.args.get("sort", "updated_at")
order = request.args.get("order", "desc")
limit = min(request.args.get("limit", 50, type=int), 500)
offset = request.args.get("offset", 0, type=int)
limit, offset = parse_pagination()
project_id = request.args.get("project_id", type=int)
no_project = request.args.get("no_project", "").lower() == "true"
@@ -72,10 +95,18 @@ async def create_task_route():
if isinstance(due_date, tuple):
return due_date
status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value
priority = (
TaskPriority(data["priority"]).value if "priority" in data else TaskPriority.none.value
)
try:
status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value
except ValueError:
return jsonify({"error": f"Invalid status: {data['status']}"}), 400
try:
priority = TaskPriority(data["priority"]).value if "priority" in data else TaskPriority.none.value
except ValueError:
return jsonify({"error": f"Invalid priority: {data['priority']}"}), 400
recurrence_rule, recurrence_err = _parse_recurrence_rule(data)
if recurrence_err:
return recurrence_err
project_id = data.get("project_id")
if project_id is None and data.get("project"):
@@ -95,6 +126,7 @@ async def create_task_route():
project_id=project_id,
milestone_id=data.get("milestone_id"),
parent_id=data.get("parent_id"),
recurrence_rule=recurrence_rule if recurrence_rule is not _UNSET else None,
)
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
if text:
@@ -118,15 +150,25 @@ async def get_task_route(task_id: int):
return jsonify(data)
@tasks_bp.route("/<int:task_id>", methods=["PUT"])
@tasks_bp.route("/<int:task_id>", methods=["PUT", "PATCH"])
@login_required
async def update_task_route(task_id: int):
uid = get_current_user_id()
data = await request.get_json()
fields = {}
for key in ("title", "status", "priority"):
for key in ("title",):
if key in data:
fields[key] = data[key]
if "status" in data:
try:
fields["status"] = TaskStatus(data["status"]).value
except ValueError:
return jsonify({"error": f"Invalid status: {data['status']}"}), 400
if "priority" in data:
try:
fields["priority"] = TaskPriority(data["priority"]).value
except ValueError:
return jsonify({"error": f"Invalid priority: {data['priority']}"}), 400
# Accept both "body" and "description" (prefer body)
if "body" in data:
@@ -150,6 +192,12 @@ async def update_task_route(task_id: int):
if key in data:
fields[key] = data[key]
recurrence_rule, recurrence_err = _parse_recurrence_rule(data)
if recurrence_err:
return recurrence_err
if recurrence_rule is not _UNSET:
fields["recurrence_rule"] = recurrence_rule
task = await update_note(uid, task_id, **fields)
if task is None:
return not_found("Task")
@@ -179,6 +227,27 @@ async def patch_task_status(task_id: int):
return jsonify(task.to_dict())
@tasks_bp.route("/<int:task_id>/recurrence-preview", methods=["GET"])
@login_required
async def recurrence_preview_route(task_id: int):
uid = get_current_user_id()
result = await get_note_for_user(uid, task_id)
if result is None:
return not_found("Task")
task, _ = result
if not task.recurrence_rule:
return jsonify({"error": "Task has no recurrence rule"}), 400
count = min(request.args.get("count", 5, type=int), 10)
base = task.due_date or date.today()
dates = []
current = base
for _ in range(count):
current = calculate_next_due(task.recurrence_rule, current)
dates.append(current.isoformat())
return jsonify({"dates": dates})
@tasks_bp.route("/<int:task_id>", methods=["DELETE"])
@login_required
async def delete_task_route(task_id: int):
+8 -1
View File
@@ -1,6 +1,6 @@
from datetime import date
from quart import jsonify
from quart import jsonify, request
def not_found(resource: str = "Item"):
@@ -15,3 +15,10 @@ def parse_iso_date(value: str | None, field: str = "date"):
return date.fromisoformat(value)
except ValueError:
return jsonify({"error": f"Invalid {field} format. Use YYYY-MM-DD."}), 400
def parse_pagination(default_limit: int = 50, max_limit: int = 500) -> tuple[int, int]:
"""Extract and clamp ``limit`` / ``offset`` from the current request's query string."""
limit = min(request.args.get("limit", default_limit, type=int), max_limit)
offset = request.args.get("offset", 0, type=int)
return limit, offset
@@ -8,6 +8,7 @@ import asyncio
import hashlib
import logging
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import httpx
@@ -129,7 +130,13 @@ async def _gather_internal(user_id: int) -> dict:
from fabledassistant.services.projects import list_projects
from fabledassistant.services.caldav import is_caldav_configured, list_events
today = date.today().isoformat()
tz_name = await get_setting(user_id, "user_timezone") or "UTC"
try:
user_tz = ZoneInfo(tz_name)
except ZoneInfoNotFoundError:
user_tz = ZoneInfo("UTC")
today = datetime.now(user_tz).date().isoformat()
# Tasks: overdue, due today, high priority in-progress
all_tasks: list[dict] = []
@@ -166,9 +173,9 @@ async def _gather_internal(user_id: int) -> dict:
calendar_events: list[str] = []
try:
from fabledassistant.services.events import list_events as list_internal_events
today_date = date.today()
day_start = datetime(today_date.year, today_date.month, today_date.day, 0, 0, 0)
day_end = datetime(today_date.year, today_date.month, today_date.day, 23, 59, 59)
today_date = datetime.now(user_tz).date()
day_start = datetime(today_date.year, today_date.month, today_date.day, 0, 0, 0, tzinfo=user_tz)
day_end = datetime(today_date.year, today_date.month, today_date.day, 23, 59, 59, tzinfo=user_tz)
internal_events = await list_internal_events(
user_id=user_id, date_from=day_start, date_to=day_end
)
@@ -176,7 +183,8 @@ async def _gather_internal(user_id: int) -> dict:
if e.all_day:
time_str = "all day"
elif e.start_dt:
time_str = e.start_dt.strftime("%-I:%M %p")
local_dt = e.start_dt.astimezone(user_tz) if e.start_dt.tzinfo else e.start_dt.replace(tzinfo=timezone.utc).astimezone(user_tz)
time_str = local_dt.strftime("%-I:%M %p")
else:
time_str = "unknown time"
calendar_events.append(f"{e.title} at {time_str}")
@@ -408,6 +416,18 @@ async def run_compilation(
max_items=10,
)
rss_item_ids = [item["id"] for item in filtered_rss if item.get("id")]
rss_items_meta = [
{
"id": item["id"],
"title": item.get("title", ""),
"url": item.get("url", ""),
"source": item.get("feed_title", ""),
"snippet": (item.get("content") or "")[:300],
"published_at": item.get("published_at"),
}
for item in filtered_rss
if item.get("id")
]
# Weather staleness gate — returns None if data is >24h old
weather_card = parse_weather_card_data(weather_rows[0], temp_unit) if weather_rows else None
@@ -441,7 +461,7 @@ async def run_compilation(
# ── Post-processing ─────────────────────────────────────────────────────────
await upsert_task_snapshots(user_id, all_tasks)
metadata: dict = {"rss_item_ids": rss_item_ids, "weather": weather_card}
metadata: dict = {"rss_item_ids": rss_item_ids, "rss_items": rss_items_meta, "weather": weather_card}
if not internal_text and not external_text:
logger.warning("Briefing compilation produced no content for user %d slot %s", user_id, slot)
@@ -326,6 +326,23 @@ async def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None:
for user_id, tz in users:
_add_user_jobs(user_id, tz)
from fabledassistant.services.recurrence import spawn_recurring_tasks as _spawn_recurring
def _run_recurrence_spawn() -> None:
future = asyncio.run_coroutine_threadsafe(_spawn_recurring(), _loop)
try:
count = future.result(timeout=300)
logger.info("Recurrence spawn: %d task(s) created", count)
except Exception as exc:
logger.error("Recurrence spawn failed: %s", exc)
_scheduler.add_job(
_run_recurrence_spawn,
CronTrigger(hour=0, minute=0, timezone="UTC"),
id="recurrence_daily",
replace_existing=True,
)
_scheduler.start()
logger.info(
"Briefing scheduler started with %d user(s) across %d job(s)",
@@ -185,6 +185,7 @@ async def run_generation(
rag_project_id=rag_project_id,
workspace_project_id=workspace_project_id,
user_timezone=user_timezone,
conv_id=conv_id,
))
messages, context_meta = await context_task
+82
View File
@@ -453,6 +453,7 @@ async def build_context(
rag_project_id: int | None = None,
workspace_project_id: int | None = None,
user_timezone: str | None = None,
conv_id: int | None = None,
) -> tuple[list[dict], dict]:
"""Build messages array for Ollama with system prompt and context.
@@ -676,7 +677,88 @@ async def build_context(
f"\n\n--- Earlier Conversation ---\n{history_summary}\n--- End Earlier Conversation ---"
)
# Inject briefing article content for follow-up Q&A
if conv_id is not None:
from fabledassistant.models import async_session as _async_session
from fabledassistant.models.conversation import Conversation
async with _async_session() as _sess:
_conv = await _sess.get(Conversation, conv_id)
if _conv and getattr(_conv, "conversation_type", None) == "briefing":
article_context = await _build_briefing_article_context(conv_id)
if article_context:
system_parts.append(article_context)
messages = [{"role": "system", "content": "".join(system_parts)}]
messages.extend(history)
messages.append({"role": "user", "content": user_message})
return messages, context_meta
async def _build_briefing_article_context(conv_id: int) -> str:
"""Fetch article content from today's briefing message and return a
formatted context block for injection into the system prompt.
Looks at the most recent assistant briefing messages for rss_item_ids
in their metadata, then loads those items from the DB.
Capped at 10 articles × 500 chars to keep token use reasonable.
"""
import json as _json
from sqlalchemy import select, text as _text
from fabledassistant.models import async_session as _async_session
from fabledassistant.models.conversation import Message
async with _async_session() as session:
result = await session.execute(
select(Message)
.where(
Message.conversation_id == conv_id,
Message.role == "assistant",
)
.order_by(Message.created_at.desc())
.limit(10)
)
messages = result.scalars().all()
rss_item_ids: list[int] = []
for msg in messages:
meta = msg.msg_metadata or {}
if isinstance(meta, str):
try:
meta = _json.loads(meta)
except Exception:
continue
ids = meta.get("rss_item_ids") or []
if ids:
rss_item_ids = ids
break
if not rss_item_ids:
return ""
async with _async_session() as session:
result = await session.execute(
_text("""
SELECT i.title, i.url, i.content, f.title AS feed_title
FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id
WHERE i.id = ANY(:ids)
ORDER BY i.published_at DESC NULLS LAST
LIMIT 10
""").bindparams(ids=rss_item_ids[:10])
)
rows = result.mappings().all()
if not rows:
return ""
lines = ["\n\nARTICLE CONTEXT (source articles from today's briefing):"]
for row in rows:
lines.append(f"\n[{row['feed_title']}] {row['title']}")
if row["url"]:
lines.append(f"URL: {row['url']}")
if row["content"]:
lines.append(row["content"][:500])
return "\n".join(lines)
+29 -6
View File
@@ -56,6 +56,7 @@ async def create_note(
status: str | None = None,
priority: str | None = None,
due_date: date | None = None,
recurrence_rule: dict | None = None,
) -> Note:
# Auto-populate project_id from milestone when not explicitly provided
if milestone_id is not None and project_id is None:
@@ -80,6 +81,7 @@ async def create_note(
status=status,
priority=priority,
due_date=due_date,
recurrence_rule=recurrence_rule,
)
session.add(note)
await session.commit()
@@ -104,8 +106,8 @@ async def list_notes(
q: str | None = None,
tags: list[str] | None = None,
is_task: bool | None = None,
status: str | None = None,
priority: str | None = None,
status: str | list[str] | None = None,
priority: str | list[str] | None = None,
due_before: date | None = None,
due_after: date | None = None,
project_id: int | None = None,
@@ -151,12 +153,14 @@ async def list_notes(
count_query = count_query.where(tag_filter)
if status:
query = query.where(Note.status == status)
count_query = count_query.where(Note.status == status)
statuses = [status] if isinstance(status, str) else status
query = query.where(Note.status.in_(statuses))
count_query = count_query.where(Note.status.in_(statuses))
if priority:
query = query.where(Note.priority == priority)
count_query = count_query.where(Note.priority == priority)
priorities = [priority] if isinstance(priority, str) else priority
query = query.where(Note.priority.in_(priorities))
count_query = count_query.where(Note.priority.in_(priorities))
if due_before is not None:
query = query.where(Note.due_date < due_before)
@@ -240,6 +244,25 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
elif key == "tags" and isinstance(value, list):
value = _normalize_tags(value)
setattr(note, key, value)
# Auto-set lifecycle timestamps on status transitions
if "status" in fields:
_now = datetime.now(timezone.utc)
if note.status == TaskStatus.in_progress.value:
if note.started_at is None:
note.started_at = _now
elif note.status in (TaskStatus.done.value, TaskStatus.cancelled.value):
note.completed_at = _now
if note.recurrence_rule:
from fabledassistant.services.recurrence import calculate_next_due
base = note.due_date or _now.date()
next_due = calculate_next_due(note.recurrence_rule, base)
note.recurrence_next_spawn_at = datetime(
next_due.year, next_due.month, next_due.day, tzinfo=timezone.utc
)
elif note.status == TaskStatus.todo.value:
note.started_at = None
note.completed_at = None
note.recurrence_next_spawn_at = None
note.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(note)
+155
View File
@@ -0,0 +1,155 @@
"""Recurring task rule validation, date calculation, and scheduled spawning."""
import asyncio
import calendar
import logging
from datetime import date, datetime, timedelta, timezone
from sqlalchemy import and_, select
from fabledassistant.models import async_session
from fabledassistant.models.note import Note, TaskStatus
logger = logging.getLogger(__name__)
_VALID_UNITS = {"day", "week", "month", "year"}
def validate_recurrence_rule(rule: dict) -> None:
"""Raise ValueError if rule is structurally invalid."""
if not isinstance(rule, dict):
raise ValueError("recurrence_rule must be an object")
rule_type = rule.get("type")
if rule_type not in ("interval", "calendar"):
raise ValueError("recurrence_rule.type must be 'interval' or 'calendar'")
unit = rule.get("unit")
if unit not in _VALID_UNITS:
raise ValueError(f"recurrence_rule.unit must be one of {_VALID_UNITS}")
if rule_type == "interval":
every = rule.get("every")
if not isinstance(every, int) or every < 1:
raise ValueError("recurrence_rule.every must be a positive integer")
elif rule_type == "calendar":
day = rule.get("day_of_month")
if not isinstance(day, int) or not 1 <= day <= 31:
raise ValueError("recurrence_rule.day_of_month must be an integer between 1 and 31")
if unit == "year":
month = rule.get("month")
if not isinstance(month, int) or not 1 <= month <= 12:
raise ValueError("recurrence_rule.month must be an integer between 1 and 12")
elif unit != "month":
raise ValueError("calendar recurrence only supports unit 'month' or 'year'")
def _add_months(d: date, months: int) -> date:
month = d.month - 1 + months
year = d.year + month // 12
month = month % 12 + 1
day = min(d.day, calendar.monthrange(year, month)[1])
return d.replace(year=year, month=month, day=day)
def _next_day_of_month(from_date: date, day: int) -> date:
"""Return the next occurrence of day-of-month strictly after from_date."""
year, month = from_date.year, from_date.month
max_day = calendar.monthrange(year, month)[1]
clamped = min(day, max_day)
if clamped > from_date.day:
return from_date.replace(day=clamped)
# Advance one month
if month == 12:
year, month = year + 1, 1
else:
month += 1
return date(year, month, min(day, calendar.monthrange(year, month)[1]))
def _next_annual_date(from_date: date, month: int, day: int) -> date:
"""Return the next occurrence of month/day after from_date."""
max_day = calendar.monthrange(from_date.year, month)[1]
candidate = date(from_date.year, month, min(day, max_day))
if candidate > from_date:
return candidate
return date(
from_date.year + 1,
month,
min(day, calendar.monthrange(from_date.year + 1, month)[1]),
)
def calculate_next_due(rule: dict, from_date: date) -> date:
"""Return the next due date after from_date according to rule."""
rule_type = rule["type"]
unit = rule["unit"]
if rule_type == "interval":
every = rule["every"]
if unit == "day":
return from_date + timedelta(days=every)
if unit == "week":
return from_date + timedelta(weeks=every)
if unit == "month":
return _add_months(from_date, every)
if unit == "year":
return _add_months(from_date, every * 12)
elif rule_type == "calendar":
if unit == "month":
return _next_day_of_month(from_date, rule["day_of_month"])
if unit == "year":
return _next_annual_date(from_date, rule["month"], rule["day_of_month"])
raise ValueError(f"Unhandled recurrence rule: {rule!r}")
async def spawn_recurring_tasks() -> int:
"""Create child tasks for all recurring tasks whose spawn date has arrived.
Returns the number of tasks spawned.
"""
from fabledassistant.services.embeddings import upsert_note_embedding
from fabledassistant.services.notes import create_note
now = datetime.now(timezone.utc)
spawned = 0
async with async_session() as session:
result = await session.execute(
select(Note).where(
and_(
Note.recurrence_rule.isnot(None),
Note.recurrence_next_spawn_at <= now,
)
)
)
tasks = result.scalars().all()
for task in tasks:
base_date = task.due_date or now.date()
next_due = calculate_next_due(task.recurrence_rule, base_date)
try:
child = await create_note(
task.user_id,
title=task.title,
body=task.body or "",
status=TaskStatus.todo.value,
priority=task.priority or "none",
due_date=next_due,
tags=list(task.tags or []),
project_id=task.project_id,
milestone_id=task.milestone_id,
recurrence_rule=task.recurrence_rule,
)
text = f"{child.title}\n{child.body}".strip() if child.body else (child.title or "")
if text:
asyncio.create_task(upsert_note_embedding(child.id, task.user_id, text))
except Exception:
logger.exception("Failed to spawn recurring task %d", task.id)
continue
async with async_session() as session:
parent = await session.get(Note, task.id)
if parent:
parent.recurrence_next_spawn_at = None
await session.commit()
spawned += 1
logger.info("Spawned recurring task %d → child due %s", task.id, next_due)
return spawned
+2 -2
View File
@@ -14,7 +14,7 @@ logger = logging.getLogger(__name__)
CONTENT_MAX_CHARS = 2000
# Keep only items from the last N days to avoid unbounded growth
ITEM_MAX_AGE_DAYS = 14
ITEM_MAX_AGE_DAYS = 90
def extract_item(entry) -> dict:
@@ -135,7 +135,7 @@ async def _prune_old_items(feed_id: int) -> None:
text("""
DELETE FROM rss_items
WHERE feed_id = :feed_id
AND published_at < NOW() - INTERVAL '14 days'
AND published_at < NOW() - INTERVAL '90 days'
""").bindparams(feed_id=feed_id)
)
await session.commit()
+31 -34
View File
@@ -16,6 +16,33 @@ logger = logging.getLogger(__name__)
VALID_PERMISSIONS = {"viewer", "editor", "admin"}
async def _enrich_shares(session, shares) -> list[dict]:
"""Attach username / group_name to a list of share rows (ProjectShare or NoteShare)."""
result = []
for s in shares:
d = s.to_dict()
if s.shared_with_user_id:
user = await session.get(User, s.shared_with_user_id)
d["username"] = user.username if user else None
if s.shared_with_group_id:
group = await session.get(Group, s.shared_with_group_id)
d["group_name"] = group.name if group else None
result.append(d)
return result
def _deduplicate_by_permission(shares, id_attr: str) -> dict[int, str]:
"""Return {resource_id: best_permission} keeping the highest-ranked permission per resource."""
from fabledassistant.services.access import PERMISSION_RANK
seen: dict[int, str] = {}
for share in shares:
rid = getattr(share, id_attr)
prev = seen.get(rid)
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]:
seen[rid] = share.permission
return seen
# ---------------------------------------------------------------------------
# Project shares
# ---------------------------------------------------------------------------
@@ -83,17 +110,7 @@ async def list_project_shares(project_id: int) -> list[dict]:
shares = (await session.execute(
select(ProjectShare).where(ProjectShare.project_id == project_id)
)).scalars().all()
result = []
for s in shares:
d = s.to_dict()
if s.shared_with_user_id:
user = await session.get(User, s.shared_with_user_id)
d["username"] = user.username if user else None
if s.shared_with_group_id:
group = await session.get(Group, s.shared_with_group_id)
d["group_name"] = group.name if group else None
result.append(d)
return result
return await _enrich_shares(session, shares)
# ---------------------------------------------------------------------------
@@ -166,17 +183,7 @@ async def list_note_shares(note_id: int) -> list[dict]:
shares = (await session.execute(
select(NoteShare).where(NoteShare.note_id == note_id)
)).scalars().all()
result = []
for s in shares:
d = s.to_dict()
if s.shared_with_user_id:
user = await session.get(User, s.shared_with_user_id)
d["username"] = user.username if user else None
if s.shared_with_group_id:
group = await session.get(Group, s.shared_with_group_id)
d["group_name"] = group.name if group else None
result.append(d)
return result
return await _enrich_shares(session, shares)
# ---------------------------------------------------------------------------
@@ -203,12 +210,7 @@ async def list_shared_with_me(user_id: int) -> dict:
)
)).scalars().all()
seen_projects: dict[int, str] = {}
for share in list(proj_direct) + list(proj_group):
prev = seen_projects.get(share.project_id)
from fabledassistant.services.access import PERMISSION_RANK
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]:
seen_projects[share.project_id] = share.permission
seen_projects = _deduplicate_by_permission(list(proj_direct) + list(proj_group), "project_id")
projects = []
for pid, perm in seen_projects.items():
@@ -239,12 +241,7 @@ async def list_shared_with_me(user_id: int) -> dict:
)
)).scalars().all()
seen_notes: dict[int, str] = {}
for share in list(note_direct) + list(note_group):
prev = seen_notes.get(share.note_id)
from fabledassistant.services.access import PERMISSION_RANK
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]:
seen_notes[share.note_id] = share.permission
seen_notes = _deduplicate_by_permission(list(note_direct) + list(note_group), "note_id")
notes = []
for nid, perm in seen_notes.items():
+32 -5
View File
@@ -105,6 +105,19 @@ _CORE_TOOLS = [
"type": "boolean",
"description": "Set to true only when the user has explicitly confirmed creation after being warned about a similar existing task.",
},
"recurrence_rule": {
"type": "object",
"description": (
"Optional recurrence schedule so the task repeats automatically. Two forms:\n"
" Interval — repeat every N units from the due date:\n"
' {"type": "interval", "every": 3, "unit": "month"}\n'
" unit options: day | week | month | year\n"
" Calendar/monthly — specific day each month:\n"
' {"type": "calendar", "unit": "month", "day_of_month": 1}\n'
" Calendar/annual — specific day each year:\n"
' {"type": "calendar", "unit": "year", "day_of_month": 15, "month": 6}'
),
},
},
"required": ["title"],
},
@@ -179,8 +192,8 @@ _CORE_TOOLS = [
},
"status": {
"type": "string",
"enum": ["todo", "in_progress", "done"],
"description": "New task status. Use to mark a task done, start it, etc.",
"enum": ["todo", "in_progress", "done", "cancelled"],
"description": "New task status. Use to mark a task done, start it, cancel it, etc.",
},
"priority": {
"type": "string",
@@ -205,6 +218,13 @@ _CORE_TOOLS = [
"type": "string",
"description": "Optional project name to assign this note/task to (set to empty string to remove project)",
},
"recurrence_rule": {
"type": "object",
"description": (
"Set or update the recurrence schedule. Pass null to remove recurrence. "
"Same format as create_task recurrence_rule."
),
},
},
"required": ["query"],
},
@@ -353,9 +373,16 @@ _CORE_TOOLS = [
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["todo", "in_progress", "done"],
"description": "Filter by task status",
"type": "array",
"items": {
"type": "string",
"enum": ["todo", "in_progress", "done", "cancelled"],
},
"description": (
"Filter by one or more task statuses. "
'Example: ["todo", "in_progress"] to list all active tasks. '
"Omit to return tasks of any status."
),
},
"priority": {
"type": "string",
+108
View File
@@ -0,0 +1,108 @@
"""Tests for news API — retention constant and endpoint formatting."""
import pytest
from unittest.mock import AsyncMock, MagicMock
from datetime import datetime, timezone
def test_rss_item_max_age_is_90_days():
"""Retention window should be 90 days."""
from fabledassistant.services.rss import ITEM_MAX_AGE_DAYS
assert ITEM_MAX_AGE_DAYS == 90
def _make_mock_session(rows):
"""Return a mock async context manager whose session.execute returns rows."""
mock_result = MagicMock()
mock_result.mappings.return_value.all.return_value = rows
mock_session = AsyncMock()
mock_session.execute = AsyncMock(return_value=mock_result)
mock_cm = MagicMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_session)
mock_cm.__aexit__ = AsyncMock(return_value=False)
return mock_cm
def test_list_news_item_serialisation():
"""News item serialisation should truncate content to 300 chars and include reaction."""
pub = datetime(2026, 3, 28, 8, 0, tzinfo=timezone.utc)
item = {
"id": 1,
"title": "EU AI Act deadline",
"url": "https://example.com/ai",
"content": "x" * 500,
"published_at": pub,
"topics": ["tech"],
"feed_title": "TechCrunch",
"reaction": "up",
}
result = {
"id": item["id"],
"title": item["title"],
"url": item["url"],
"snippet": (item["content"] or "")[:300],
"published_at": item["published_at"].isoformat() if item["published_at"] else None,
"topics": item["topics"] or [],
"source": item["feed_title"],
"reaction": item["reaction"],
}
assert result["snippet"] == "x" * 300
assert result["source"] == "TechCrunch"
assert result["reaction"] == "up"
assert result["published_at"] == pub.isoformat()
def test_build_briefing_article_context_metadata_extraction():
"""Verify the rss_item_ids extraction logic from message metadata."""
import json
# Simulates the logic in _build_briefing_article_context
def extract_ids(msg_metadata):
meta = msg_metadata or {}
if isinstance(meta, str):
try:
meta = json.loads(meta)
except Exception:
return []
return meta.get("rss_item_ids") or []
assert extract_ids({}) == []
assert extract_ids(None) == []
assert extract_ids({"rss_item_ids": [1, 2, 3]}) == [1, 2, 3]
assert extract_ids(json.dumps({"rss_item_ids": [4, 5]})) == [4, 5]
assert extract_ids("not json") == []
def test_list_news_null_content_serialisation():
"""Null content should produce empty snippet without error."""
item = {
"id": 2,
"title": "No content article",
"url": "https://example.com/b",
"content": None,
"published_at": None,
"topics": None,
"feed_title": "Hacker News",
"reaction": None,
}
result = {
"id": item["id"],
"title": item["title"],
"url": item["url"],
"snippet": (item["content"] or "")[:300],
"published_at": item["published_at"].isoformat() if item["published_at"] else None,
"topics": item["topics"] or [],
"source": item["feed_title"],
"reaction": item["reaction"],
}
assert result["snippet"] == ""
assert result["topics"] == []
assert result["published_at"] is None
assert result["reaction"] is None
+292
View File
@@ -0,0 +1,292 @@
"""Tests for task lifecycle timestamps and recurrence logic."""
from datetime import date, datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
# ── Timestamp side-effect tests ──────────────────────────────────────────────
async def test_update_note_sets_started_at_on_in_progress():
"""started_at is set when status transitions to in_progress."""
mock_note = MagicMock()
mock_note.status = "in_progress"
mock_note.started_at = None
mock_note.recurrence_rule = None
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = mock_note
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch("fabledassistant.services.notes.async_session", return_value=mock_session):
from fabledassistant.services.notes import update_note
await update_note(1, 1, status="in_progress")
assert mock_note.started_at is not None
async def test_update_note_sets_completed_at_on_done():
"""completed_at is set when status transitions to done."""
mock_note = MagicMock()
mock_note.status = "done"
mock_note.started_at = datetime(2026, 3, 1, tzinfo=timezone.utc)
mock_note.completed_at = None
mock_note.recurrence_rule = None
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = mock_note
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch("fabledassistant.services.notes.async_session", return_value=mock_session):
from fabledassistant.services.notes import update_note
await update_note(1, 1, status="done")
assert mock_note.completed_at is not None
async def test_update_note_clears_timestamps_on_todo():
"""started_at and completed_at are cleared when status resets to todo."""
mock_note = MagicMock()
mock_note.status = "todo"
mock_note.started_at = datetime(2026, 3, 1, tzinfo=timezone.utc)
mock_note.completed_at = datetime(2026, 3, 15, tzinfo=timezone.utc)
mock_note.recurrence_rule = None
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = mock_note
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch("fabledassistant.services.notes.async_session", return_value=mock_session):
from fabledassistant.services.notes import update_note
await update_note(1, 1, status="todo")
assert mock_note.started_at is None
assert mock_note.completed_at is None
async def test_update_note_preserves_started_at_if_already_set():
"""started_at is not overwritten on a second transition to in_progress."""
original_start = datetime(2026, 3, 1, tzinfo=timezone.utc)
mock_note = MagicMock()
mock_note.status = "in_progress"
mock_note.started_at = original_start
mock_note.recurrence_rule = None
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = mock_note
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch("fabledassistant.services.notes.async_session", return_value=mock_session):
from fabledassistant.services.notes import update_note
await update_note(1, 1, status="in_progress")
assert mock_note.started_at == original_start
# ── Recurrence rule validation ────────────────────────────────────────────────
import pytest
def test_validate_interval_rule_valid():
from fabledassistant.services.recurrence import validate_recurrence_rule
validate_recurrence_rule({"type": "interval", "every": 3, "unit": "month"}) # no error
def test_validate_interval_rule_bad_unit():
from fabledassistant.services.recurrence import validate_recurrence_rule
with pytest.raises(ValueError, match="unit"):
validate_recurrence_rule({"type": "interval", "every": 3, "unit": "fortnight"})
def test_validate_interval_rule_bad_every():
from fabledassistant.services.recurrence import validate_recurrence_rule
with pytest.raises(ValueError, match="every"):
validate_recurrence_rule({"type": "interval", "every": 0, "unit": "week"})
def test_validate_calendar_monthly_valid():
from fabledassistant.services.recurrence import validate_recurrence_rule
validate_recurrence_rule({"type": "calendar", "unit": "month", "day_of_month": 1})
def test_validate_calendar_annual_valid():
from fabledassistant.services.recurrence import validate_recurrence_rule
validate_recurrence_rule(
{"type": "calendar", "unit": "year", "day_of_month": 15, "month": 6}
)
def test_validate_calendar_bad_day():
from fabledassistant.services.recurrence import validate_recurrence_rule
with pytest.raises(ValueError, match="day_of_month"):
validate_recurrence_rule({"type": "calendar", "unit": "month", "day_of_month": 32})
def test_validate_calendar_annual_missing_month():
from fabledassistant.services.recurrence import validate_recurrence_rule
with pytest.raises(ValueError, match="month"):
validate_recurrence_rule(
{"type": "calendar", "unit": "year", "day_of_month": 15}
)
def test_validate_unknown_type():
from fabledassistant.services.recurrence import validate_recurrence_rule
with pytest.raises(ValueError, match="type"):
validate_recurrence_rule({"type": "cron", "every": 1, "unit": "day"})
# ── calculate_next_due ────────────────────────────────────────────────────────
def test_calculate_next_due_interval_days():
from fabledassistant.services.recurrence import calculate_next_due
rule = {"type": "interval", "every": 7, "unit": "day"}
assert calculate_next_due(rule, date(2026, 3, 27)) == date(2026, 4, 3)
def test_calculate_next_due_interval_weeks():
from fabledassistant.services.recurrence import calculate_next_due
rule = {"type": "interval", "every": 2, "unit": "week"}
assert calculate_next_due(rule, date(2026, 3, 27)) == date(2026, 4, 10)
def test_calculate_next_due_interval_months():
from fabledassistant.services.recurrence import calculate_next_due
rule = {"type": "interval", "every": 3, "unit": "month"}
assert calculate_next_due(rule, date(2026, 3, 1)) == date(2026, 6, 1)
def test_calculate_next_due_interval_months_end_of_month():
"""Month addition clamps to last day when target month is shorter."""
from fabledassistant.services.recurrence import calculate_next_due
rule = {"type": "interval", "every": 1, "unit": "month"}
assert calculate_next_due(rule, date(2026, 1, 31)) == date(2026, 2, 28)
def test_calculate_next_due_interval_years():
from fabledassistant.services.recurrence import calculate_next_due
rule = {"type": "interval", "every": 1, "unit": "year"}
assert calculate_next_due(rule, date(2026, 3, 27)) == date(2027, 3, 27)
def test_calculate_next_due_calendar_monthly_future_day():
"""If day_of_month is later this month, return that date."""
from fabledassistant.services.recurrence import calculate_next_due
rule = {"type": "calendar", "unit": "month", "day_of_month": 28}
assert calculate_next_due(rule, date(2026, 3, 1)) == date(2026, 3, 28)
def test_calculate_next_due_calendar_monthly_same_or_past_day():
"""If day_of_month is today or earlier, advance to next month."""
from fabledassistant.services.recurrence import calculate_next_due
rule = {"type": "calendar", "unit": "month", "day_of_month": 1}
assert calculate_next_due(rule, date(2026, 3, 1)) == date(2026, 4, 1)
def test_calculate_next_due_calendar_annual_future():
"""Annual date later this year is returned."""
from fabledassistant.services.recurrence import calculate_next_due
rule = {"type": "calendar", "unit": "year", "day_of_month": 15, "month": 6}
assert calculate_next_due(rule, date(2026, 3, 27)) == date(2026, 6, 15)
def test_calculate_next_due_calendar_annual_past():
"""Annual date already passed this year → next year."""
from fabledassistant.services.recurrence import calculate_next_due
rule = {"type": "calendar", "unit": "year", "day_of_month": 15, "month": 1}
assert calculate_next_due(rule, date(2026, 3, 27)) == date(2027, 1, 15)
# ── spawn_recurring_tasks ─────────────────────────────────────────────────────
async def test_spawn_recurring_tasks_creates_child():
from unittest.mock import AsyncMock, MagicMock, patch
mock_task = MagicMock()
mock_task.id = 1
mock_task.user_id = 42
mock_task.title = "Change air filter"
mock_task.body = ""
mock_task.priority = "medium"
mock_task.tags = []
mock_task.project_id = None
mock_task.milestone_id = None
mock_task.due_date = date(2026, 3, 1)
mock_task.recurrence_rule = {"type": "interval", "every": 3, "unit": "month"}
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_task]
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.get = AsyncMock(return_value=mock_task)
mock_session.commit = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
mock_child = MagicMock()
mock_child.id = 2
mock_child.title = "Change air filter"
mock_child.body = ""
with (
patch("fabledassistant.services.recurrence.async_session", return_value=mock_session),
patch(
"fabledassistant.services.notes.create_note",
new_callable=AsyncMock,
return_value=mock_child,
) as mock_create,
patch("fabledassistant.services.embeddings.upsert_note_embedding"),
):
from fabledassistant.services.recurrence import spawn_recurring_tasks
count = await spawn_recurring_tasks()
assert count == 1
mock_create.assert_called_once()
kwargs = mock_create.call_args[1]
assert kwargs["title"] == "Change air filter"
assert kwargs["due_date"] == date(2026, 6, 1)
assert kwargs["recurrence_rule"] == {"type": "interval", "every": 3, "unit": "month"}
assert kwargs["status"] == "todo"
# ── Multi-status filtering ────────────────────────────────────────────────────
async def test_list_notes_multi_status_builds_in_clause():
"""list_notes with a list of statuses executes without error."""
from unittest.mock import AsyncMock, MagicMock, patch
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = []
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.scalar = AsyncMock(return_value=0)
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch("fabledassistant.services.notes.async_session", return_value=mock_session):
from fabledassistant.services.notes import list_notes
notes, total = await list_notes(1, status=["todo", "in_progress"], is_task=True)
assert total == 0
assert notes == []
mock_session.scalar.assert_called_once()
mock_session.execute.assert_called_once()