diff --git a/.gitignore b/.gitignore index c891752..54ef042 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,7 @@ settings.local.json # Claude Code .claude/ -docs/superpowers/ +docs/superpowers/specs/.brainstorm/ # Environment .env diff --git a/alembic/versions/0031_add_cancelled_task_status.py b/alembic/versions/0031_add_cancelled_task_status.py index 7194f02..d7fe198 100644 --- a/alembic/versions/0031_add_cancelled_task_status.py +++ b/alembic/versions/0031_add_cancelled_task_status.py @@ -1,4 +1,8 @@ -"""Add 'cancelled' value to task_status enum.""" +"""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 @@ -9,10 +13,9 @@ depends_on = None def upgrade() -> None: - op.execute("ALTER TYPE task_status ADD VALUE IF NOT EXISTS 'cancelled'") + # No-op: status is stored as TEXT; the new value is valid without DDL changes. + pass def downgrade() -> None: - # PostgreSQL does not support removing enum values without a full type rebuild. - # Downgrade is a no-op; the value simply becomes unused. pass diff --git a/alembic/versions/0032_add_task_timestamps.py b/alembic/versions/0032_add_task_timestamps.py new file mode 100644 index 0000000..fccd48f --- /dev/null +++ b/alembic/versions/0032_add_task_timestamps.py @@ -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") diff --git a/alembic/versions/0033_add_task_recurrence.py b/alembic/versions/0033_add_task_recurrence.py new file mode 100644 index 0000000..dcf533f --- /dev/null +++ b/alembic/versions/0033_add_task_recurrence.py @@ -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") diff --git a/docs/superpowers/plans/2026-03-28-news-briefing-redesign.md b/docs/superpowers/plans/2026-03-28-news-briefing-redesign.md new file mode 100644 index 0000000..725766e --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-news-briefing-redesign.md @@ -0,0 +1,1651 @@ +# News Feed & Briefing View Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend RSS article retention to 90 days, add a unified `/api/briefing/news` endpoint, 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:** The `rss_items` table already stores all article data — no new tables needed. A new endpoint queries it with a user-scoped join and optional feed filter. `build_context()` in `llm.py` gets a new `conv_id` param; for briefing conversations it injects source article content from the day's briefing message metadata. The Vue frontend restructures `BriefingView.vue` into a CSS grid and adds a new `NewsView.vue`. + +**Tech Stack:** Python/Quart, SQLAlchemy 2.0 async, raw SQL for the news query, Vue 3 + TypeScript, Pinia, existing `WeatherCard` and `ChatMessage` components. + +--- + +## File Map + +**Modified:** +- `src/fabledassistant/services/rss.py` — change `ITEM_MAX_AGE_DAYS` to 90 +- `src/fabledassistant/routes/briefing.py` — add `GET /api/briefing/news` +- `src/fabledassistant/services/llm.py` — add `conv_id` param + `_build_briefing_article_context()` helper +- `src/fabledassistant/services/generation_task.py` — pass `conv_id` to `build_context()` +- `frontend/src/api/client.ts` — add `getNewsItems()` function, import NewsItem +- `frontend/src/views/BriefingView.vue` — full 3-column redesign +- `frontend/src/router/index.ts` — add `/news` route +- `frontend/src/components/AppHeader.vue` — add News nav link + +**Created:** +- `frontend/src/types/news.ts` — `NewsItem` interface +- `frontend/src/views/NewsView.vue` — news archive view +- `tests/test_news_api.py` — backend unit tests + +--- + +### Task 1: RSS article retention (90 days) + +**Files:** +- Modify: `src/fabledassistant/services/rss.py:15-17` and `:135-141` +- Test: `tests/test_news_api.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_news_api.py +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 +``` + +- [ ] **Step 2: Run to verify it fails** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabledassistant +source .venv/bin/activate +pytest tests/test_news_api.py::test_rss_item_max_age_is_90_days -v +``` +Expected: FAIL — `AssertionError: assert 14 == 90` + +- [ ] **Step 3: Update `rss.py`** + +Change line 16 and the SQL interval in `_prune_old_items`: + +```python +# Line 16 — was: ITEM_MAX_AGE_DAYS = 14 +ITEM_MAX_AGE_DAYS = 90 +``` + +In `_prune_old_items` (around line 135), change the SQL: +```python +async def _prune_old_items(feed_id: int) -> None: + """Delete items older than ITEM_MAX_AGE_DAYS from a feed.""" + async with async_session() as session: + await session.execute( + text(""" + DELETE FROM rss_items + WHERE feed_id = :feed_id + AND published_at < NOW() - INTERVAL '90 days' + """).bindparams(feed_id=feed_id) + ) + await session.commit() +``` + +- [ ] **Step 4: Run to verify it passes** + +```bash +pytest tests/test_news_api.py::test_rss_item_max_age_is_90_days -v +``` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/fabledassistant/services/rss.py tests/test_news_api.py +git commit -m "feat: extend RSS item retention from 14 to 90 days" +``` + +--- + +### Task 2: `GET /api/briefing/news` endpoint + +**Files:** +- Modify: `src/fabledassistant/routes/briefing.py` +- Test: `tests/test_news_api.py` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_news_api.py`: + +```python +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from datetime import datetime, timezone + + +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 + + +@pytest.mark.asyncio +async def test_list_news_formats_items_correctly(): + """list_news service logic should truncate content to 300 chars and include reaction.""" + pub = datetime(2026, 3, 28, 8, 0, tzinfo=timezone.utc) + rows = [ + { + "id": 1, + "title": "EU AI Act deadline", + "url": "https://example.com/ai", + "content": "x" * 500, # should be truncated to 300 + "published_at": pub, + "topics": ["tech"], + "feed_title": "TechCrunch", + "reaction": "up", + } + ] + + # Import and call the formatting logic directly + # We test the item serialisation used in the route + item = rows[0] + 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() +``` + +- [ ] **Step 2: Run to verify it passes already (pure logic test)** + +```bash +pytest tests/test_news_api.py::test_list_news_formats_items_correctly -v +``` +Expected: PASS (no imports needed — pure dict manipulation) + +- [ ] **Step 3: Add the `list_news` route to `routes/briefing.py`** + +Add after the `recent_items` route (around line 124), before the weather section: + +```python +@briefing_bp.route("/news", methods=["GET"]) +@_REQUIRE +async def list_news(): + """Unified news endpoint for briefing panel (days=2) and archive view (days=90).""" + 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}) +``` + +- [ ] **Step 4: Run the existing test suite to check nothing broke** + +```bash +pytest tests/ -v --tb=short 2>&1 | tail -20 +``` +Expected: all existing tests pass + +- [ ] **Step 5: Commit** + +```bash +git add src/fabledassistant/routes/briefing.py tests/test_news_api.py +git commit -m "feat: add GET /api/briefing/news unified news endpoint" +``` + +--- + +### Task 3: Deep article Q&A in briefing chat + +**Files:** +- Modify: `src/fabledassistant/services/llm.py` +- Modify: `src/fabledassistant/services/generation_task.py:180-188` +- Test: `tests/test_news_api.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_news_api.py`: + +```python +@pytest.mark.asyncio +async def test_build_briefing_article_context_empty_when_no_metadata(): + """Returns empty string when no briefing message has rss_item_ids.""" + mock_msg = MagicMock() + mock_msg.msg_metadata = {} # no rss_item_ids key + mock_msg.role = "assistant" + + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [mock_msg] + + 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) + + with patch("fabledassistant.models.async_session", return_value=mock_cm): + from fabledassistant.services.llm import _build_briefing_article_context + result = await _build_briefing_article_context(conv_id=1) + + assert result == "" + + +@pytest.mark.asyncio +async def test_build_briefing_article_context_returns_formatted_string(): + """Returns ARTICLE CONTEXT block with titles and content from rss_item_ids.""" + mock_msg = MagicMock() + mock_msg.msg_metadata = {"rss_item_ids": [1, 2]} + mock_msg.role = "assistant" + + # First session: Message query + mock_result1 = MagicMock() + mock_result1.scalars.return_value.all.return_value = [mock_msg] + mock_session1 = AsyncMock() + mock_session1.execute = AsyncMock(return_value=mock_result1) + mock_cm1 = MagicMock() + mock_cm1.__aenter__ = AsyncMock(return_value=mock_session1) + mock_cm1.__aexit__ = AsyncMock(return_value=False) + + # Second session: rss_items query + rows = [ + {"title": "EU AI Act deadline", "url": "https://example.com/ai", + "content": "The EU has confirmed deadlines.", "feed_title": "TechCrunch"}, + ] + mock_result2 = MagicMock() + mock_result2.mappings.return_value.all.return_value = rows + mock_session2 = AsyncMock() + mock_session2.execute = AsyncMock(return_value=mock_result2) + mock_cm2 = MagicMock() + mock_cm2.__aenter__ = AsyncMock(return_value=mock_session2) + mock_cm2.__aexit__ = AsyncMock(return_value=False) + + with patch("fabledassistant.models.async_session", side_effect=[mock_cm1, mock_cm2]): + import importlib + import fabledassistant.services.llm as llm_mod + importlib.reload(llm_mod) + result = await llm_mod._build_briefing_article_context(conv_id=42) + + assert "ARTICLE CONTEXT" in result + assert "EU AI Act deadline" in result + assert "TechCrunch" in result + assert "The EU has confirmed" in result +``` + +- [ ] **Step 2: Run to verify they fail** + +```bash +pytest tests/test_news_api.py::test_build_briefing_article_context_empty_when_no_metadata tests/test_news_api.py::test_build_briefing_article_context_returns_formatted_string -v +``` +Expected: FAIL — `ImportError: cannot import name '_build_briefing_article_context'` + +- [ ] **Step 3: Add `_build_briefing_article_context` to `llm.py`** + +Add this function at the module level in `src/fabledassistant/services/llm.py`, after the `build_context` function (around line 685, after the `return messages, context_meta` line): + +```python +async def _build_briefing_article_context(conv_id: int) -> str: + """Inject source article content into system prompt for briefing follow-up Q&A. + + Finds the most recent assistant briefing message with rss_item_ids in its + metadata, loads those articles from DB, and returns a formatted context block. + Capped at 10 articles × 500 chars to keep token use reasonable. + """ + import json + from sqlalchemy import select, text as _text + from fabledassistant.models import async_session + from fabledassistant.models.conversation import Message + + rss_item_ids: list[int] = [] + + 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() + + 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[:10] + 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) + ) + 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) +``` + +- [ ] **Step 4: Add `conv_id` param to `build_context()` and call the helper** + +In `src/fabledassistant/services/llm.py`, change the `build_context` signature (line 444): + +```python +async def build_context( + user_id: int, + history: list[dict], + current_note_id: int | None, + user_message: str, + exclude_note_ids: list[int] | None = None, + history_summary: str | None = None, + include_note_ids: list[int] | None = None, + excluded_note_ids: list[int] | None = None, + 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]: +``` + +Then add the briefing context injection just before the `messages = [...]` line (around line 679). The full block to add: + +```python + # Inject briefing article context 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 as _Conversation + async with _async_session() as _sess: + _conv = await _sess.get(_Conversation, conv_id) + if _conv and _conv.conversation_type == "briefing": + article_context = await _build_briefing_article_context(conv_id) + if article_context: + system_parts.append(f"\n\n{article_context}") +``` + +After that block the existing code continues: +```python + messages = [{"role": "system", "content": "".join(system_parts)}] + messages.extend(history) + messages.append({"role": "user", "content": user_message}) + return messages, context_meta +``` + +- [ ] **Step 5: Pass `conv_id` in `generation_task.py`** + +In `src/fabledassistant/services/generation_task.py`, change the `build_context` call (around line 180): + +```python + context_task = asyncio.create_task(build_context( + user_id, history_to_use, context_note_id, user_content, + history_summary=history_summary, + include_note_ids=include_note_ids, + excluded_note_ids=excluded_note_ids, + rag_project_id=rag_project_id, + workspace_project_id=workspace_project_id, + user_timezone=user_timezone, + conv_id=conv_id, + )) +``` + +- [ ] **Step 6: Run the new tests** + +```bash +pytest tests/test_news_api.py::test_build_briefing_article_context_empty_when_no_metadata tests/test_news_api.py::test_build_briefing_article_context_returns_formatted_string -v +``` +Expected: PASS + +- [ ] **Step 7: Run full test suite** + +```bash +pytest tests/ -v --tb=short 2>&1 | tail -20 +``` +Expected: all tests pass + +- [ ] **Step 8: Commit** + +```bash +git add src/fabledassistant/services/llm.py src/fabledassistant/services/generation_task.py tests/test_news_api.py +git commit -m "feat: inject briefing article content into chat context for deep Q&A" +``` + +--- + +### Task 4: NewsItem type + API client function + +**Files:** +- Create: `frontend/src/types/news.ts` +- Modify: `frontend/src/api/client.ts` + +- [ ] **Step 1: Create `frontend/src/types/news.ts`** + +```typescript +export interface NewsItem { + id: number + title: string + url: string + snippet: string + published_at: string | null + topics: string[] + source: string + reaction: 'up' | 'down' | null +} +``` + +- [ ] **Step 2: Add `getNewsItems` to `frontend/src/api/client.ts`** + +Add the import at the top of `client.ts` alongside existing imports: + +```typescript +import type { NewsItem } from '@/types/news' +``` + +Add the function after `deleteRssReaction` (around line 428): + +```typescript +export async function getNewsItems( + days: number = 2, + limit: number = 40, + offset: number = 0, + feedId?: number +): Promise<{ items: NewsItem[]; offset: number; limit: number }> { + const params = new URLSearchParams({ + days: String(days), + limit: String(limit), + offset: String(offset), + }) + if (feedId != null) params.set('feed_id', String(feedId)) + return apiGet(`/api/briefing/news?${params}`) +} +``` + +- [ ] **Step 3: TypeScript check** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend +npx tsc --noEmit 2>&1 | grep -v "SlashCommands\|TagSuggestion\|WikilinkSuggestion\|suggestionRenderer" +``` +Expected: no new errors + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/types/news.ts frontend/src/api/client.ts +git commit -m "feat: add NewsItem type and getNewsItems API client function" +``` + +--- + +### Task 5: BriefingView.vue — 3-column redesign + +**Files:** +- Modify: `frontend/src/views/BriefingView.vue` (full rewrite) + +- [ ] **Step 1: Replace the entire content of `BriefingView.vue`** + +```vue + + + + + +``` + +- [ ] **Step 2: TypeScript check** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend +npx tsc --noEmit 2>&1 | grep -v "SlashCommands\|TagSuggestion\|WikilinkSuggestion\|suggestionRenderer" +``` +Expected: no new errors + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/views/BriefingView.vue +git commit -m "feat: redesign briefing view as 3-column layout with weather left, news right" +``` + +--- + +### Task 6: News archive view (`/news`) + +**Files:** +- Create: `frontend/src/views/NewsView.vue` +- Modify: `frontend/src/router/index.ts` +- Modify: `frontend/src/components/AppHeader.vue` + +- [ ] **Step 1: Create `frontend/src/views/NewsView.vue`** + +```vue + + + + + +``` + +- [ ] **Step 2: Add `/news` route to `frontend/src/router/index.ts`** + +Add after the `/briefing` route (around line 122): + +```typescript + { + path: '/news', + name: 'news', + component: () => import('@/views/NewsView.vue'), + }, +``` + +- [ ] **Step 3: Add News nav link to `frontend/src/components/AppHeader.vue`** + +In the `.nav-center` div (around line 74), add after the Briefing link: + +```html +News +``` + +In the mobile menu div (around line 131), add after the Briefing link: + +```html +News +``` + +- [ ] **Step 4: TypeScript check** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend +npx tsc --noEmit 2>&1 | grep -v "SlashCommands\|TagSuggestion\|WikilinkSuggestion\|suggestionRenderer" +``` +Expected: no new errors + +- [ ] **Step 5: Run full backend test suite** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabledassistant +source .venv/bin/activate +pytest tests/ -v --tb=short 2>&1 | tail -20 +``` +Expected: all tests pass + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/views/NewsView.vue frontend/src/router/index.ts frontend/src/components/AppHeader.vue +git commit -m "feat: add /news archive view with feed filter, pagination, and reactions" +``` diff --git a/docs/superpowers/specs/2026-03-27-news-briefing-redesign.md b/docs/superpowers/specs/2026-03-27-news-briefing-redesign.md new file mode 100644 index 0000000..be6efa5 --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-news-briefing-redesign.md @@ -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([]) + +async function loadWeather() { + try { + const data = await apiGet<{ locations: WeatherData[] }>('/api/briefing/weather') + weatherData.value = data.locations ?? [] + } catch { /* silent */ } +} +``` + +Render in left column: +```html +
+
Weather
+ +
No weather configured
+
+``` + +### 2c. News panel (right column) + +Load on mount and on background refresh: + +```typescript +const newsItems = ref([]) + +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(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 `` block rendered above assistant messages +- The `
` 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 ``, 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([]) +const offset = ref(0) +const hasMore = ref(true) +const selectedFeedId = ref(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 `News` 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 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 78e565a..112d63c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -598,3 +598,25 @@ 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}` + ) +} diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index a706590..7b0dd8f 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -79,6 +79,7 @@ router.afterEach(() => { Graph Calendar Briefing + News Shared
@@ -129,6 +130,7 @@ router.afterEach(() => { Graph Calendar Briefing + News Shared
Settings diff --git a/frontend/src/components/RecurrenceEditor.vue b/frontend/src/components/RecurrenceEditor.vue new file mode 100644 index 0000000..092426f --- /dev/null +++ b/frontend/src/components/RecurrenceEditor.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/frontend/src/components/StatusBadge.vue b/frontend/src/components/StatusBadge.vue index 1167cbd..4e3ca2e 100644 --- a/frontend/src/components/StatusBadge.vue +++ b/frontend/src/components/StatusBadge.vue @@ -12,6 +12,7 @@ const labels: Record = { todo: "Todo", in_progress: "In Progress", done: "Done", + cancelled: "Cancelled", }; @@ -48,6 +49,10 @@ const labels: Record = { 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; } diff --git a/frontend/src/components/TaskCard.vue b/frontend/src/components/TaskCard.vue index 3a272db..f109e7e 100644 --- a/frontend/src/components/TaskCard.vue +++ b/frontend/src/components/TaskCard.vue @@ -20,18 +20,21 @@ const statusCycle: Record = { todo: "in_progress", in_progress: "done", done: "todo", + cancelled: "todo", }; const statusDotClass: Record = { todo: "dot-todo", in_progress: "dot-in-progress", done: "dot-done", + cancelled: "dot-cancelled", }; const statusTitle: Record = { 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; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index ff162e7..7302c5f 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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", diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts index 93476da..231ee52 100644 --- a/frontend/src/stores/tasks.ts +++ b/frontend/src/stores/tasks.ts @@ -12,8 +12,8 @@ export const useTasksStore = defineStore("tasks", () => { // Filter / pagination / sort state const activeTagFilters = ref([]); - const statusFilter = ref(""); - const priorityFilter = ref(""); + const statusFilter = ref([]); + const priorityFilter = ref([]); 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 | null; }): Promise { try { return await apiPost("/api/tasks", data); @@ -84,7 +84,7 @@ export const useTasksStore = defineStore("tasks", () => { async function updateTask( id: number, data: Partial< - Pick + Pick > ): Promise { 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(); } diff --git a/frontend/src/types/news.ts b/frontend/src/types/news.ts new file mode 100644 index 0000000..93e371e --- /dev/null +++ b/frontend/src/types/news.ts @@ -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 +} diff --git a/frontend/src/types/note.ts b/frontend/src/types/note.ts index 6258b54..55f182a 100644 --- a/frontend/src/types/note.ts +++ b/frontend/src/types/note.ts @@ -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 | null; + recurrence_next_spawn_at: string | null; is_task: boolean; created_at: string; updated_at: string; diff --git a/frontend/src/views/BriefingView.vue b/frontend/src/views/BriefingView.vue index 894a868..397c8bf 100644 --- a/frontend/src/views/BriefingView.vue +++ b/frontend/src/views/BriefingView.vue @@ -1,11 +1,12 @@ + + + + diff --git a/frontend/src/views/TaskEditorView.vue b/frontend/src/views/TaskEditorView.vue index 871e291..133b0b3 100644 --- a/frontend/src/views/TaskEditorView.vue +++ b/frontend/src/views/TaskEditorView.vue @@ -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(null); const milestoneId = ref(null); const parentId = ref(null); const parentTitle = ref(""); +const startedAt = ref(null); +const completedAt = ref(null); +const recurrenceRule = ref | 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); savedTitle = title.value; savedBody = body.value; @@ -483,8 +494,19 @@ useEditorGuards(dirty, save); + +
+
+ Started + {{ new Date(startedAt).toLocaleString() }} +
+
+ Completed + {{ new Date(completedAt).toLocaleString() }} +
+
+
+ + +
@@ -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; } diff --git a/frontend/src/views/TaskViewerView.vue b/frontend/src/views/TaskViewerView.vue index 39adee4..7295be0 100644 --- a/frontend/src/views/TaskViewerView.vue +++ b/frontend/src/views/TaskViewerView.vue @@ -33,12 +33,14 @@ const statusCycle: Record = { todo: "in_progress", in_progress: "done", done: "todo", + cancelled: "todo", }; const statusDotClass: Record = { 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 = { todo: "in_progress", in_progress: "done", done: null, + cancelled: null, }; +function recurrenceSummary(rule: Record | 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 }}
+
+ + Started: {{ new Date(store.currentTask.started_at).toLocaleString() }} + + + Completed: {{ new Date(store.currentTask.completed_at).toLocaleString() }} + + + ↻ {{ recurrenceSummary(store.currentTask.recurrence_rule as Record | null) }} + +
{ 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; diff --git a/frontend/src/views/TasksListView.vue b/frontend/src/views/TasksListView.vue index 21cd4aa..a8de3fb 100644 --- a/frontend/src/views/TasksListView.vue +++ b/frontend/src/views/TasksListView.vue @@ -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) {
- - +
+ +
+
+ +
@@ -270,7 +278,7 @@ function toggleGroup(key: string) {
-