Files
FabledScribe/docs/superpowers/plans/2026-03-28-news-briefing-redesign.md
T
2026-03-28 00:13:40 -04:00

46 KiB
Raw Blame History

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.tsNewsItem 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

# 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
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:

# Line 16 — was: ITEM_MAX_AGE_DAYS = 14
ITEM_MAX_AGE_DAYS = 90

In _prune_old_items (around line 135), change the SQL:

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
pytest tests/test_news_api.py::test_rss_item_max_age_is_90_days -v

Expected: PASS

  • Step 5: Commit
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:

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)
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:

@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
pytest tests/ -v --tb=short 2>&1 | tail -20

Expected: all existing tests pass

  • Step 5: Commit
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:

@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
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):

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):

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:

    # 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:

    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):

    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
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
pytest tests/ -v --tb=short 2>&1 | tail -20

Expected: all tests pass

  • Step 8: Commit
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

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:

import type { NewsItem } from '@/types/news'

Add the function after deleteRssReaction (around line 428):

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
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
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

<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
import { useChatStore } from '@/stores/chat'
import ChatMessage from '@/components/ChatMessage.vue'
import WeatherCard from '@/components/WeatherCard.vue'
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
import {
  apiGet,
  getBriefingConfig,
  getBriefingConversations,
  getBriefingToday,
  getBriefingConvMessages,
  triggerBriefingSlot,
  postRssReaction,
  deleteRssReaction,
  type BriefingConversation,
  type BriefingMessage,
} from '@/api/client'
import type { Message } from '@/types/chat'
import type { NewsItem } from '@/types/news'

// Matches WeatherCard.vue's internal WeatherData shape (structural typing)
interface WeatherData {
  location: string
  fetched_at: string
  current_temp: number
  condition: string
  today_high: number | null
  today_low: number | null
  yesterday_high: number | null
  yesterday_low: number | null
  forecast: { day: string; condition: string; high: number; low: number }[]
}

const chatStore = useChatStore()

// Setup wizard
const showWizard = ref(false)
const wizardChecked = ref(false)

async function checkSetup() {
  const config = await getBriefingConfig()
  if (!config.enabled) showWizard.value = true
  wizardChecked.value = true
}

async function onWizardDone() {
  showWizard.value = false
  await loadAll()
  loadWeather()
  loadNews()
}

// Conversations
const conversations = ref<BriefingConversation[]>([])
const selectedConvId = ref<number | null>(null)
const todayConvId = ref<number | null>(null)

const isToday = computed(() => selectedConvId.value === todayConvId.value)

// Messages
const messages = ref<BriefingMessage[]>([])
const loadingMessages = ref(false)

// Weather panel
const weatherData = ref<WeatherData[]>([])

// News panel
const newsItems = ref<NewsItem[]>([])
const reactions = ref<Record<number, 'up' | 'down' | null>>({})

// Scroll ref for messages center column
const messagesEl = ref<HTMLElement | null>(null)

function scrollToBottom() {
  nextTick(() => {
    if (messagesEl.value) {
      messagesEl.value.scrollTop = messagesEl.value.scrollHeight
    }
  })
}

async function loadWeather() {
  try {
    const data = await apiGet<{ locations: WeatherData[] }>('/api/briefing/weather')
    weatherData.value = data.locations ?? []
  } catch { /* silent */ }
}

async function loadNews() {
  try {
    const data = await apiGet<{ items: NewsItem[] }>('/api/briefing/news?days=2&limit=40')
    newsItems.value = data.items
    for (const item of data.items) {
      reactions.value[item.id] = item.reaction
    }
  } catch { /* silent */ }
}

async function loadAll() {
  const [convList, today] = await Promise.all([
    getBriefingConversations(),
    getBriefingToday().catch(() => null),
  ])
  conversations.value = convList
  if (today) {
    todayConvId.value = today.id
    if (!convList.find((c) => c.id === today.id)) {
      conversations.value = [
        { id: today.id, title: today.title ?? 'Today', briefing_date: null, message_count: 0, created_at: new Date().toISOString() },
        ...convList,
      ]
    }
    selectedConvId.value = today.id
    messages.value = today.messages
    await chatStore.fetchConversation(today.id)
  }
  scrollToBottom()
}

watch(selectedConvId, async (id) => {
  if (!id) return
  if (id === todayConvId.value) {
    await chatStore.fetchConversation(id)
    messages.value = (chatStore.currentConversation?.messages ?? []) as unknown as BriefingMessage[]
    scrollToBottom()
    return
  }
  loadingMessages.value = true
  try {
    messages.value = await getBriefingConvMessages(id)
    scrollToBottom()
  } finally {
    loadingMessages.value = false
  }
})

watch(() => chatStore.streaming, async (streaming) => {
  if (!streaming && selectedConvId.value === todayConvId.value && todayConvId.value) {
    const today = await getBriefingToday().catch(() => null)
    if (today) {
      messages.value = today.messages
      scrollToBottom()
    }
  }
})

// Input
const input = ref('')
const sending = ref(false)

async function send() {
  const text = input.value.trim()
  if (!text || !todayConvId.value || chatStore.streaming || sending.value) return
  if (chatStore.currentConversation?.id !== todayConvId.value) {
    await chatStore.fetchConversation(todayConvId.value)
  }
  input.value = ''
  sending.value = true
  try {
    await chatStore.sendMessage(text)
  } finally {
    sending.value = false
  }
}

function onKeydown(e: KeyboardEvent) {
  if (e.key === 'Enter' && !e.shiftKey) {
    e.preventDefault()
    send()
  }
}

async function handleReaction(itemId: number, reaction: 'up' | 'down') {
  const current = reactions.value[itemId]
  reactions.value[itemId] = current === reaction ? null : reaction
  try {
    if (current === reaction) {
      await deleteRssReaction(itemId)
    } else {
      await postRssReaction(itemId, reaction)
    }
  } catch {
    reactions.value[itemId] = current ?? null
  }
}

function formatRelativeDate(iso: string | null): string {
  if (!iso) return ''
  const d = new Date(iso)
  const now = new Date()
  const diffH = (now.getTime() - d.getTime()) / 3_600_000
  if (diffH < 24) return `${Math.round(diffH)}h ago`
  if (diffH < 48) return 'Yesterday'
  return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}

const triggering = ref(false)
async function triggerNow() {
  triggering.value = true
  try {
    await triggerBriefingSlot('compilation')
    await loadAll()
    loadNews()
  } finally {
    triggering.value = false
  }
}

function convLabel(c: BriefingConversation): string {
  if (c.id === todayConvId.value) return 'Today'
  if (c.briefing_date) {
    const d = new Date(c.briefing_date)
    return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
  }
  return c.title || 'Briefing'
}

function toMsg(m: BriefingMessage): Message {
  return {
    id: m.id,
    conversation_id: -1,
    role: m.role,
    content: m.content,
    context_note_id: null,
    context_note_title: null,
    created_at: m.created_at,
  }
}

async function _backgroundRefreshMessages() {
  try {
    const today = await getBriefingToday()
    if (!today) return
    const fresh = today.messages
    const last = fresh[fresh.length - 1]
    const cur = messages.value[messages.value.length - 1]
    if (fresh.length !== messages.value.length || last?.content !== cur?.content) {
      messages.value = fresh
    }
    await loadNews()
  } catch { /* silent */ }
}

useBackgroundRefresh(
  _backgroundRefreshMessages,
  60_000,
  () => !chatStore.streaming && isToday.value && !!todayConvId.value,
)

onMounted(async () => {
  await checkSetup()
  if (!showWizard.value) {
    await loadAll()
    loadWeather()
    loadNews()
  }
})
</script>

<template>
  <div class="briefing-root">
    <BriefingSetupWizard v-if="wizardChecked && showWizard" @done="onWizardDone" />

    <div class="briefing-shell" v-if="wizardChecked && !showWizard">

      <!-- Header spans all three columns -->
      <header class="briefing-header">
        <div class="briefing-header-left">
          <h1 class="briefing-title">Briefing</h1>
          <span class="briefing-today-badge">{{ new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) }}</span>
        </div>
        <div class="briefing-header-right">
          <select v-if="conversations.length" v-model="selectedConvId" class="briefing-conv-select">
            <option v-for="c in conversations" :key="c.id" :value="c.id">{{ convLabel(c) }}</option>
          </select>
          <button
            class="btn-trigger"
            @click="triggerNow"
            :disabled="triggering"
            title="Manually trigger morning briefing now"
          >{{ triggering ? '…' : 'Refresh' }}</button>
        </div>
      </header>

      <!-- Left: Weather panel -->
      <div class="briefing-left">
        <div class="panel-label">Weather</div>
        <template v-if="weatherData.length">
          <WeatherCard
            v-for="loc in weatherData"
            :key="loc.location"
            :weather="loc"
          />
        </template>
        <div v-else class="panel-empty">No weather configured</div>
      </div>

      <!-- Center: Messages + input -->
      <div class="briefing-center">
        <div class="briefing-messages-wrap" ref="messagesEl">
          <div v-if="loadingMessages" class="briefing-loading">Loading</div>
          <template v-else>
            <div v-if="!messages.length" class="briefing-empty">
              <p>No briefing yet for today.</p>
              <p class="briefing-empty-hint">Click "Refresh" to generate a briefing now, or wait for the scheduled slot.</p>
            </div>
            <div v-else class="briefing-messages">
              <template v-for="msg in messages" :key="msg.id">
                <ChatMessage :message="toMsg(msg)" :is-streaming="false" />
              </template>
              <!-- Live streaming bubble -->
              <ChatMessage
                v-if="isToday && chatStore.streaming && chatStore.streamingContent"
                :message="{
                  id: -1,
                  conversation_id: todayConvId ?? -1,
                  role: 'assistant',
                  content: chatStore.streamingContent,
                  context_note_id: null,
                  context_note_title: null,
                  created_at: new Date().toISOString(),
                }"
                :is-streaming="true"
              />
            </div>
          </template>
        </div>

        <div v-if="isToday" class="briefing-input-bar">
          <textarea
            v-model="input"
            class="briefing-input"
            placeholder="Reply to your briefing…"
            rows="1"
            @keydown="onKeydown"
          ></textarea>
          <button
            class="btn-send"
            @click="send"
            :disabled="!input.trim() || chatStore.streaming || sending"
            aria-label="Send"
          >
            <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
              <path d="M2 21l21-9L2 3v7l15 2-15 2v7z"/>
            </svg>
          </button>
        </div>
      </div>

      <!-- Right: News cards panel -->
      <div class="briefing-right">
        <div class="panel-label">Today's News</div>
        <div v-if="!newsItems.length" class="panel-empty">No news items</div>
        <div
          v-for="item in newsItems"
          :key="item.id"
          class="news-card"
        >
          <div class="news-card-meta">
            <span class="news-source">{{ item.source }}</span>
            <span v-if="item.published_at" class="news-date">{{ formatRelativeDate(item.published_at) }}</span>
          </div>
          <a
            v-if="item.url"
            :href="item.url"
            target="_blank"
            rel="noopener noreferrer"
            class="news-title"
          >{{ item.title }}</a>
          <p v-else class="news-title news-title--plain">{{ item.title }}</p>
          <p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
          <div class="news-reactions">
            <button
              class="reaction-btn"
              :class="{ active: reactions[item.id] === 'up' }"
              @click="handleReaction(item.id, 'up')"
              title="Interested"
            >👍</button>
            <button
              class="reaction-btn"
              :class="{ active: reactions[item.id] === 'down' }"
              @click="handleReaction(item.id, 'down')"
              title="Not interested"
            >👎</button>
          </div>
        </div>
      </div>

    </div>
  </div>
</template>

<style scoped>
.briefing-root {
  display: flex;
  flex-direction: column;
  height: 100%;
  min-height: 0;
}

.briefing-shell {
  display: grid;
  grid-template-columns: 1fr 2fr 1fr;
  grid-template-rows: auto 1fr;
  height: 100%;
  min-height: 0;
}

/* ── Header ────────────────────────────────────────────────── */
.briefing-header {
  grid-column: 1 / -1;
  grid-row: 1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1.25rem 1rem 1rem;
  border-bottom: 1px solid var(--color-border);
  flex-shrink: 0;
  gap: 1rem;
  flex-wrap: wrap;
}

.briefing-header-left {
  display: flex;
  align-items: baseline;
  gap: 0.75rem;
}

.briefing-title {
  font-family: 'Fraunces', Georgia, serif;
  font-size: 1.3rem;
  font-weight: 700;
  margin: 0;
  color: var(--color-text);
}

.briefing-today-badge {
  font-size: 0.82rem;
  color: var(--color-text-muted);
}

.briefing-header-right {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.briefing-conv-select {
  padding: 0.35rem 0.6rem;
  border: 1px solid var(--color-border);
  border-radius: 6px;
  background: var(--color-bg-card);
  color: var(--color-text);
  font-size: 0.82rem;
  cursor: pointer;
  font-family: inherit;
}

.btn-trigger {
  padding: 0.35rem 0.8rem;
  border: 1px solid var(--color-border);
  border-radius: 6px;
  background: var(--color-bg-card);
  color: var(--color-text-muted);
  font-size: 0.8rem;
  cursor: pointer;
  white-space: nowrap;
  transition: all 0.15s;
  font-family: inherit;
}
.btn-trigger:hover:not(:disabled) {
  border-color: var(--color-primary);
  color: var(--color-primary);
}
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }

/* ── Left: Weather ─────────────────────────────────────────── */
.briefing-left {
  grid-column: 1;
  grid-row: 2;
  border-right: 1px solid var(--color-border);
  overflow-y: auto;
  padding: 1rem;
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

/* Override WeatherCard's own margin-bottom so gap handles spacing */
.briefing-left :deep(.weather-card) {
  margin-bottom: 0;
}

/* ── Center: Chat ──────────────────────────────────────────── */
.briefing-center {
  grid-column: 2;
  grid-row: 2;
  display: flex;
  flex-direction: column;
  min-height: 0;
}

.briefing-messages-wrap {
  flex: 1;
  overflow-y: auto;
  padding: 1rem;
}

.briefing-loading,
.briefing-empty {
  text-align: center;
  padding: 3rem 1rem;
  color: var(--color-text-muted);
  font-size: 0.9rem;
}

.briefing-empty-hint {
  font-size: 0.8rem;
  opacity: 0.7;
  margin-top: 0.5rem;
}

.briefing-messages {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.briefing-input-bar {
  display: flex;
  gap: 0.5rem;
  align-items: flex-end;
  padding: 0.75rem 1rem 1rem;
  border-top: 1px solid var(--color-border);
  flex-shrink: 0;
}

.briefing-input {
  flex: 1;
  padding: 0.6rem 0.8rem;
  border: 1px solid var(--color-border);
  border-radius: 10px;
  background: var(--color-bg-card);
  color: var(--color-text);
  font-size: 0.9rem;
  resize: none;
  outline: none;
  font-family: inherit;
  line-height: 1.4;
  max-height: 120px;
  overflow-y: auto;
  transition: border-color 0.15s;
}
.briefing-input:focus { border-color: var(--color-primary); }

.btn-send {
  padding: 0.55rem 0.75rem;
  background: linear-gradient(135deg, #6366f1, #4f46e5);
  color: #fff;
  border: none;
  border-radius: 10px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: opacity 0.15s;
  flex-shrink: 0;
}
.btn-send:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-send:hover:not(:disabled) { opacity: 0.9; }

/* ── Right: News ───────────────────────────────────────────── */
.briefing-right {
  grid-column: 3;
  grid-row: 2;
  border-left: 1px solid var(--color-border);
  overflow-y: auto;
  padding: 1rem;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

/* ── Shared panel utilities ────────────────────────────────── */
.panel-label {
  font-size: 0.72rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--color-primary);
  flex-shrink: 0;
  margin-bottom: 0.25rem;
}

.panel-empty {
  font-size: 0.82rem;
  color: var(--color-text-muted);
}

/* ── News cards ────────────────────────────────────────────── */
.news-card {
  background: var(--color-bg-card);
  border: 1px solid var(--color-border);
  border-radius: 10px;
  padding: 0.65rem 0.85rem;
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  flex-shrink: 0;
}

.news-card-meta {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.news-source {
  font-size: 0.72rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--color-primary);
}

.news-date {
  font-size: 0.72rem;
  color: var(--color-text-muted);
}

.news-title {
  font-size: 0.88rem;
  font-weight: 600;
  color: var(--color-text);
  line-height: 1.35;
  text-decoration: none;
  margin: 0;
}
a.news-title:hover { text-decoration: underline; color: var(--color-primary); }

.news-snippet {
  font-size: 0.78rem;
  color: var(--color-text-muted);
  line-height: 1.45;
  margin: 0;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.news-reactions {
  display: flex;
  gap: 0.3rem;
  margin-top: 0.15rem;
}

.reaction-btn {
  background: none;
  border: 1px solid var(--color-border);
  border-radius: 6px;
  padding: 0.1rem 0.35rem;
  cursor: pointer;
  font-size: 0.82rem;
  line-height: 1.4;
  opacity: 0.55;
  transition: opacity 0.15s, border-color 0.15s;
}
.reaction-btn:hover {
  opacity: 1;
  border-color: var(--color-primary);
}
.reaction-btn.active {
  opacity: 1;
  border-color: var(--color-primary);
  background: color-mix(in srgb, var(--color-primary) 12%, transparent);
}

/* ── Responsive: collapse to single column on narrow screens ─ */
@media (max-width: 900px) {
  .briefing-shell {
    grid-template-columns: 1fr;
    grid-template-rows: auto auto 1fr auto;
    overflow-y: auto;
  }
  .briefing-header { grid-column: 1; grid-row: 1; }
  .briefing-left {
    grid-column: 1;
    grid-row: 2;
    border-right: none;
    border-bottom: 1px solid var(--color-border);
    max-height: 220px;
  }
  .briefing-center {
    grid-column: 1;
    grid-row: 3;
  }
  .briefing-right {
    grid-column: 1;
    grid-row: 4;
    border-left: none;
    border-top: 1px solid var(--color-border);
    max-height: 300px;
  }
}
</style>
  • Step 2: TypeScript check
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
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

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import {
  getNewsItems,
  getBriefingFeeds,
  postRssReaction,
  deleteRssReaction,
  type BriefingFeed,
} from '@/api/client'
import type { NewsItem } from '@/types/news'

const items = ref<NewsItem[]>([])
const feeds = ref<BriefingFeed[]>([])
const selectedFeedId = ref<number | null>(null)
const loading = ref(false)
const hasMore = ref(true)
const offset = ref(0)
const LIMIT = 40

// Reactions map for optimistic updates
const reactions = ref<Record<number, 'up' | 'down' | null>>({})

async function loadMore() {
  if (loading.value || !hasMore.value) return
  loading.value = true
  try {
    const data = await getNewsItems(90, LIMIT, offset.value, selectedFeedId.value ?? undefined)
    items.value = [...items.value, ...data.items]
    for (const item of data.items) {
      reactions.value[item.id] = item.reaction
    }
    offset.value += data.items.length
    hasMore.value = data.items.length === LIMIT
  } finally {
    loading.value = false
  }
}

function onFeedChange() {
  items.value = []
  offset.value = 0
  hasMore.value = true
  reactions.value = {}
  loadMore()
}

async function handleReaction(itemId: number, reaction: 'up' | 'down') {
  const current = reactions.value[itemId]
  reactions.value[itemId] = current === reaction ? null : reaction
  try {
    if (current === reaction) {
      await deleteRssReaction(itemId)
    } else {
      await postRssReaction(itemId, reaction)
    }
  } catch {
    reactions.value[itemId] = current ?? null
  }
}

function formatRelativeDate(iso: string | null): string {
  if (!iso) return ''
  const d = new Date(iso)
  const now = new Date()
  const diffH = (now.getTime() - d.getTime()) / 3_600_000
  if (diffH < 1) return 'Just now'
  if (diffH < 24) return `${Math.round(diffH)}h ago`
  if (diffH < 48) return 'Yesterday'
  if (diffH < 24 * 7) return `${Math.floor(diffH / 24)}d ago`
  return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}

onMounted(async () => {
  feeds.value = await getBriefingFeeds().catch(() => [])
  loadMore()
})
</script>

<template>
  <main class="news-page">
    <div class="news-header">
      <h1 class="news-title">News</h1>
      <select
        v-model="selectedFeedId"
        class="feed-filter"
        @change="onFeedChange"
      >
        <option :value="null">All feeds</option>
        <option v-for="feed in feeds" :key="feed.id" :value="feed.id">
          {{ feed.title || feed.url }}
        </option>
      </select>
    </div>

    <div v-if="items.length === 0 && !loading" class="news-empty">
      No articles found. Make sure RSS feeds are configured and have been refreshed.
    </div>

    <div class="news-grid">
      <div
        v-for="item in items"
        :key="item.id"
        class="news-card"
      >
        <div class="news-card-meta">
          <span class="news-source">{{ item.source }}</span>
          <span v-if="item.published_at" class="news-date">{{ formatRelativeDate(item.published_at) }}</span>
        </div>
        <a
          v-if="item.url"
          :href="item.url"
          target="_blank"
          rel="noopener noreferrer"
          class="news-card-title"
        >{{ item.title }}</a>
        <p v-else class="news-card-title news-card-title--plain">{{ item.title }}</p>
        <p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
        <div class="news-reactions">
          <button
            class="reaction-btn"
            :class="{ active: reactions[item.id] === 'up' }"
            @click="handleReaction(item.id, 'up')"
            title="Interested"
          >👍</button>
          <button
            class="reaction-btn"
            :class="{ active: reactions[item.id] === 'down' }"
            @click="handleReaction(item.id, 'down')"
            title="Not interested"
          >👎</button>
        </div>
      </div>
    </div>

    <div class="news-footer">
      <button
        v-if="hasMore"
        class="btn-load-more"
        @click="loadMore"
        :disabled="loading"
      >
        {{ loading ? 'Loading…' : 'Load more' }}
      </button>
      <p v-else-if="items.length > 0" class="news-end">
        All articles loaded ({{ items.length }} total)
      </p>
    </div>
  </main>
</template>

<style scoped>
.news-page {
  max-width: 1200px;
  margin: 0 auto;
  padding: 1.5rem 1.5rem 3rem;
  display: flex;
  flex-direction: column;
  gap: 1.25rem;
}

.news-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
}

.news-title {
  font-family: 'Fraunces', Georgia, serif;
  font-size: 1.4rem;
  font-weight: 700;
  margin: 0;
  color: var(--color-text);
}

.feed-filter {
  padding: 0.35rem 0.65rem;
  border: 1px solid var(--color-border);
  border-radius: 6px;
  background: var(--color-bg-card);
  color: var(--color-text);
  font-size: 0.85rem;
  font-family: inherit;
  cursor: pointer;
}

.news-empty {
  text-align: center;
  padding: 4rem 1rem;
  color: var(--color-text-muted);
  font-size: 0.9rem;
}

.news-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
  gap: 0.75rem;
}

.news-card {
  background: var(--color-bg-card);
  border: 1px solid var(--color-border);
  border-radius: 10px;
  padding: 0.85rem 1rem;
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
}

.news-card-meta {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.news-source {
  font-size: 0.72rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--color-primary);
}

.news-date {
  font-size: 0.72rem;
  color: var(--color-text-muted);
}

.news-card-title {
  font-size: 0.92rem;
  font-weight: 600;
  color: var(--color-text);
  line-height: 1.4;
  text-decoration: none;
  margin: 0;
}
a.news-card-title:hover {
  text-decoration: underline;
  color: var(--color-primary);
}

.news-snippet {
  font-size: 0.8rem;
  color: var(--color-text-muted);
  line-height: 1.45;
  margin: 0;
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.news-reactions {
  display: flex;
  gap: 0.3rem;
  margin-top: 0.25rem;
}

.reaction-btn {
  background: none;
  border: 1px solid var(--color-border);
  border-radius: 6px;
  padding: 0.1rem 0.35rem;
  cursor: pointer;
  font-size: 0.82rem;
  line-height: 1.4;
  opacity: 0.55;
  transition: opacity 0.15s, border-color 0.15s;
}
.reaction-btn:hover {
  opacity: 1;
  border-color: var(--color-primary);
}
.reaction-btn.active {
  opacity: 1;
  border-color: var(--color-primary);
  background: color-mix(in srgb, var(--color-primary) 12%, transparent);
}

.news-footer {
  display: flex;
  justify-content: center;
  padding-top: 0.5rem;
}

.btn-load-more {
  padding: 0.55rem 1.5rem;
  background: var(--color-bg-card);
  border: 1px solid var(--color-border);
  border-radius: 8px;
  color: var(--color-text-secondary);
  font-size: 0.85rem;
  font-family: inherit;
  cursor: pointer;
  transition: border-color 0.15s, color 0.15s;
}
.btn-load-more:hover:not(:disabled) {
  border-color: var(--color-primary);
  color: var(--color-primary);
}
.btn-load-more:disabled { opacity: 0.5; cursor: not-allowed; }

.news-end {
  font-size: 0.8rem;
  color: var(--color-text-muted);
}

@media (max-width: 600px) {
  .news-grid { grid-template-columns: 1fr; }
}
</style>
  • Step 2: Add /news route to frontend/src/router/index.ts

Add after the /briefing route (around line 122):

    {
      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:

<router-link to="/news" class="nav-link">News</router-link>

In the mobile menu div (around line 131), add after the Briefing link:

<router-link to="/news" class="nav-link">News</router-link>
  • Step 4: TypeScript check
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
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
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"