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

1652 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<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**
```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
<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):
```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
<router-link to="/news" class="nav-link">News</router-link>
```
In the mobile menu div (around line 131), add after the Briefing link:
```html
<router-link to="/news" class="nav-link">News</router-link>
```
- [ ] **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"
```