aa18f4f527
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1652 lines
46 KiB
Markdown
1652 lines
46 KiB
Markdown
# 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"
|
||
```
|