From 1125c8e107c45b0110702c22e503ea6f57564912 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 27 Mar 2026 22:37:55 -0400 Subject: [PATCH 01/18] feat: add started_at/completed_at columns to notes --- alembic/versions/0032_add_task_timestamps.py | 19 +++++++++++++++++++ src/fabledassistant/models/note.py | 8 ++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/0032_add_task_timestamps.py 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/src/fabledassistant/models/note.py b/src/fabledassistant/models/note.py index f1957dd..4fa6843 100644 --- a/src/fabledassistant/models/note.py +++ b/src/fabledassistant/models/note.py @@ -1,7 +1,7 @@ import enum -from datetime import date +from datetime import date, datetime -from sqlalchemy import Date, ForeignKey, Index, Integer, Text +from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Text from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.orm import Mapped, mapped_column @@ -45,6 +45,8 @@ 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) __table_args__ = ( Index("ix_notes_tags", "tags", postgresql_using="gin"), @@ -71,6 +73,8 @@ 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, "is_task": self.is_task, "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), From c92f4944ccc021ca87d49e83ef8915fd10fd3c20 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 27 Mar 2026 22:49:21 -0400 Subject: [PATCH 02/18] feat: auto-set started_at/completed_at on task status transitions --- src/fabledassistant/services/notes.py | 19 ++ tests/test_recurrence.py | 291 ++++++++++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 tests/test_recurrence.py diff --git a/src/fabledassistant/services/notes.py b/src/fabledassistant/services/notes.py index 818c393..111de1f 100644 --- a/src/fabledassistant/services/notes.py +++ b/src/fabledassistant/services/notes.py @@ -240,6 +240,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) diff --git a/tests/test_recurrence.py b/tests/test_recurrence.py new file mode 100644 index 0000000..0916085 --- /dev/null +++ b/tests/test_recurrence.py @@ -0,0 +1,291 @@ +"""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.recurrence.create_note", + new_callable=AsyncMock, + return_value=mock_child, + ) as mock_create, + patch("fabledassistant.services.recurrence.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 uses IN rather than equality.""" + from unittest.mock import AsyncMock, MagicMock, patch + + mock_session = AsyncMock() + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [] + mock_count_result = MagicMock() + mock_count_result.scalar.return_value = 0 + mock_session.execute = AsyncMock(side_effect=[mock_result, mock_count_result]) + 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 mock_session.execute.call_count == 2 From c271f1b41fdb939a0a1c5890ff47a128548c2add Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 27 Mar 2026 22:49:59 -0400 Subject: [PATCH 03/18] feat: add recurrence_rule and recurrence_next_spawn_at columns to notes --- alembic/versions/0033_add_task_recurrence.py | 30 ++++++++++++++++++++ src/fabledassistant/models/note.py | 12 +++++++- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/0033_add_task_recurrence.py 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/src/fabledassistant/models/note.py b/src/fabledassistant/models/note.py index 4fa6843..47635b2 100644 --- a/src/fabledassistant/models/note.py +++ b/src/fabledassistant/models/note.py @@ -2,7 +2,7 @@ import enum from datetime import date, datetime from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Text -from sqlalchemy.dialects.postgresql import ARRAY +from sqlalchemy.dialects.postgresql import ARRAY, JSONB from sqlalchemy.orm import Mapped, mapped_column from fabledassistant.models import Base @@ -47,6 +47,10 @@ class Note(Base, TimestampMixin): 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"), @@ -75,6 +79,12 @@ class Note(Base, TimestampMixin): "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(), From a24257aeed4e954f1f09ea747845bad55effaaad Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 27 Mar 2026 22:52:18 -0400 Subject: [PATCH 04/18] feat: add recurrence service (validate, calculate_next_due, spawn_recurring_tasks) --- src/fabledassistant/services/recurrence.py | 155 +++++++++++++++++++++ tests/test_recurrence.py | 4 +- 2 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 src/fabledassistant/services/recurrence.py diff --git a/src/fabledassistant/services/recurrence.py b/src/fabledassistant/services/recurrence.py new file mode 100644 index 0000000..b196df2 --- /dev/null +++ b/src/fabledassistant/services/recurrence.py @@ -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 diff --git a/tests/test_recurrence.py b/tests/test_recurrence.py index 0916085..41ca059 100644 --- a/tests/test_recurrence.py +++ b/tests/test_recurrence.py @@ -250,11 +250,11 @@ async def test_spawn_recurring_tasks_creates_child(): with ( patch("fabledassistant.services.recurrence.async_session", return_value=mock_session), patch( - "fabledassistant.services.recurrence.create_note", + "fabledassistant.services.notes.create_note", new_callable=AsyncMock, return_value=mock_child, ) as mock_create, - patch("fabledassistant.services.recurrence.upsert_note_embedding"), + patch("fabledassistant.services.embeddings.upsert_note_embedding"), ): from fabledassistant.services.recurrence import spawn_recurring_tasks count = await spawn_recurring_tasks() From 3179b60eacaa65ad8a5ac4135f2b46dcc83e4fdd Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 27 Mar 2026 22:53:20 -0400 Subject: [PATCH 05/18] feat: wire recurrence into create_note/update_note and add daily APScheduler job --- .../services/briefing_scheduler.py | 17 +++++++++++++++++ src/fabledassistant/services/notes.py | 2 ++ 2 files changed, 19 insertions(+) diff --git a/src/fabledassistant/services/briefing_scheduler.py b/src/fabledassistant/services/briefing_scheduler.py index 44f1ed0..2a77eac 100644 --- a/src/fabledassistant/services/briefing_scheduler.py +++ b/src/fabledassistant/services/briefing_scheduler.py @@ -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)", diff --git a/src/fabledassistant/services/notes.py b/src/fabledassistant/services/notes.py index 111de1f..be9e0ea 100644 --- a/src/fabledassistant/services/notes.py +++ b/src/fabledassistant/services/notes.py @@ -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() From 0dc3dfa5397ca7a6123a455bb4d0253fc61dc759 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 27 Mar 2026 22:54:11 -0400 Subject: [PATCH 06/18] feat: add recurrence_rule validation and recurrence-preview endpoint in tasks routes --- src/fabledassistant/routes/tasks.py | 60 ++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/src/fabledassistant/routes/tasks.py b/src/fabledassistant/routes/tasks.py index a483d5c..4392d6e 100644 --- a/src/fabledassistant/routes/tasks.py +++ b/src/fabledassistant/routes/tasks.py @@ -1,4 +1,5 @@ import asyncio +from datetime import date from quart import Blueprint, jsonify, request @@ -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,8 +48,8 @@ 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, offset = parse_pagination() @@ -80,6 +104,10 @@ async def create_task_route(): 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"): from fabledassistant.services.projects import get_project_by_title as _gpbt @@ -98,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: @@ -163,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") @@ -192,6 +227,27 @@ async def patch_task_status(task_id: int): return jsonify(task.to_dict()) +@tasks_bp.route("//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("/", methods=["DELETE"]) @login_required async def delete_task_route(task_id: int): From 6557fef6a2cabc3c5ebf2aa96a70896f5bcd4406 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 27 Mar 2026 23:01:24 -0400 Subject: [PATCH 07/18] feat: support multi-value status and priority filters in list_notes --- src/fabledassistant/routes/notes.py | 5 ++++- src/fabledassistant/services/notes.py | 14 ++++++++------ tests/test_recurrence.py | 11 ++++++----- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/fabledassistant/routes/notes.py b/src/fabledassistant/routes/notes.py index 0a237ea..320ee62 100644 --- a/src/fabledassistant/routes/notes.py +++ b/src/fabledassistant/routes/notes.py @@ -70,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}) diff --git a/src/fabledassistant/services/notes.py b/src/fabledassistant/services/notes.py index be9e0ea..0af7a30 100644 --- a/src/fabledassistant/services/notes.py +++ b/src/fabledassistant/services/notes.py @@ -106,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, @@ -153,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) diff --git a/tests/test_recurrence.py b/tests/test_recurrence.py index 41ca059..071e15e 100644 --- a/tests/test_recurrence.py +++ b/tests/test_recurrence.py @@ -271,15 +271,14 @@ async def test_spawn_recurring_tasks_creates_child(): # ── Multi-status filtering ──────────────────────────────────────────────────── async def test_list_notes_multi_status_builds_in_clause(): - """list_notes with a list of statuses uses IN rather than equality.""" + """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_count_result = MagicMock() - mock_count_result.scalar.return_value = 0 - mock_session.execute = AsyncMock(side_effect=[mock_result, mock_count_result]) + 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) @@ -288,4 +287,6 @@ async def test_list_notes_multi_status_builds_in_clause(): notes, total = await list_notes(1, status=["todo", "in_progress"], is_task=True) assert total == 0 - assert mock_session.execute.call_count == 2 + assert notes == [] + mock_session.scalar.assert_called_once() + mock_session.execute.assert_called_once() From 3d7c95362717bf870c9f08e5f25c3db734a0997a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 27 Mar 2026 23:02:30 -0400 Subject: [PATCH 08/18] feat: add recurrence_rule to LLM tools; list_tasks status accepts list + cancelled --- src/fabledassistant/services/tools.py | 37 +++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/src/fabledassistant/services/tools.py b/src/fabledassistant/services/tools.py index f68a866..8bebb60 100644 --- a/src/fabledassistant/services/tools.py +++ b/src/fabledassistant/services/tools.py @@ -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", From 3391825550cbbb6b966df9398ca4fc470fb7f507 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 27 Mar 2026 23:59:34 -0400 Subject: [PATCH 09/18] docs: add news feed & briefing redesign spec Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 +- .../2026-03-27-news-briefing-redesign.md | 398 ++++++++++++++++++ 2 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-03-27-news-briefing-redesign.md 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/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 From aa18f4f52744f2ca5932a6e7fa6e383b18a2fdc6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 28 Mar 2026 00:13:40 -0400 Subject: [PATCH 10/18] docs: add news briefing redesign spec and plan Co-Authored-By: Claude Sonnet 4.6 --- .../2026-03-28-news-briefing-redesign.md | 1651 +++++++++++++++++ 1 file changed, 1651 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-28-news-briefing-redesign.md 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" +``` From 57bf63e57645d95a195d9b54d1f987d2619d728a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 28 Mar 2026 00:29:32 -0400 Subject: [PATCH 11/18] feat: extend RSS item retention from 14 to 90 days Co-Authored-By: Claude Sonnet 4.6 --- src/fabledassistant/services/rss.py | 4 +- tests/test_news_api.py | 86 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 tests/test_news_api.py diff --git a/src/fabledassistant/services/rss.py b/src/fabledassistant/services/rss.py index 39457a4..d85b510 100644 --- a/src/fabledassistant/services/rss.py +++ b/src/fabledassistant/services/rss.py @@ -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() diff --git a/tests/test_news_api.py b/tests/test_news_api.py new file mode 100644 index 0000000..b7bec2e --- /dev/null +++ b/tests/test_news_api.py @@ -0,0 +1,86 @@ +"""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_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 From 35f57e0d3e8e653b6ba7fb8da6dc7b0af7a7cbeb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 28 Mar 2026 00:29:59 -0400 Subject: [PATCH 12/18] feat: add GET /api/briefing/news unified news endpoint Co-Authored-By: Claude Sonnet 4.6 --- src/fabledassistant/routes/briefing.py | 55 ++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/fabledassistant/routes/briefing.py b/src/fabledassistant/routes/briefing.py index eba9411..6089c08 100644 --- a/src/fabledassistant/routes/briefing.py +++ b/src/fabledassistant/routes/briefing.py @@ -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}) From 260103d533706f8246bc8c83eab7d6e4142ad940 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 28 Mar 2026 00:31:43 -0400 Subject: [PATCH 13/18] 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 --- .../services/generation_task.py | 1 + src/fabledassistant/services/llm.py | 82 +++++++++++++++++++ tests/test_news_api.py | 22 +++++ 3 files changed, 105 insertions(+) diff --git a/src/fabledassistant/services/generation_task.py b/src/fabledassistant/services/generation_task.py index 04fa395..c696cac 100644 --- a/src/fabledassistant/services/generation_task.py +++ b/src/fabledassistant/services/generation_task.py @@ -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 diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index 3d55624..9903f96 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -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) diff --git a/tests/test_news_api.py b/tests/test_news_api.py index b7bec2e..533fc64 100644 --- a/tests/test_news_api.py +++ b/tests/test_news_api.py @@ -56,6 +56,28 @@ def test_list_news_item_serialisation(): 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 = { From 0a6e57e698854444134ef081a6ec5a436cf5e375 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 28 Mar 2026 00:34:34 -0400 Subject: [PATCH 14/18] feat: add NewsItem type and getNewsItems() API client function Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/api/client.ts | 22 ++++++++++++++++++++++ frontend/src/types/news.ts | 10 ++++++++++ 2 files changed, 32 insertions(+) create mode 100644 frontend/src/types/news.ts 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/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 +} From f81c38df84b5e86cf8bb28c18ec0a6ba3317e964 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 28 Mar 2026 00:38:04 -0400 Subject: [PATCH 15/18] =?UTF-8?q?feat:=20redesign=20BriefingView=20into=20?= =?UTF-8?q?3-column=20layout=20(weather=20=C2=B7=20chat=20=C2=B7=20news)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/components/TaskCard.vue | 6 + frontend/src/views/BriefingView.vue | 407 ++++++++++++++++++--------- 2 files changed, 274 insertions(+), 139 deletions(-) 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/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 @@ + + + + From ac90548823d696def2edeeff8857ab03134de4ec Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 28 Mar 2026 00:51:36 -0400 Subject: [PATCH 17/18] 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 --- frontend/src/components/RecurrenceEditor.vue | 171 +++++++++++++++++++ frontend/src/components/StatusBadge.vue | 5 + frontend/src/stores/tasks.ts | 20 +-- frontend/src/types/note.ts | 6 +- frontend/src/views/TaskEditorView.vue | 48 ++++++ frontend/src/views/TaskViewerView.vue | 47 +++++ frontend/src/views/TasksListView.vue | 73 +++++--- 7 files changed, 337 insertions(+), 33 deletions(-) create mode 100644 frontend/src/components/RecurrenceEditor.vue 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/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/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/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) {
-