Feedback inbox: read/unread + delete

Make the admin Feedback section a real inbox.

* DB: feedback.read_at column (schema + idempotent migration).
* API: feedback list returns read_at; POST /api/admin/feedback/{id}/read
  {read} toggles it; DELETE /api/admin/feedback/{id} removes a message
  (both admin-gated). admin_stats gains feedback_unread; the Attention strip
  and the tab badge now count UNREAD, not total.
* Frontend: unread messages are highlighted with an accent rail + dot; an
  Unread filter joins the category chips; each message has Mark read/unread
  and Delete (confirm), with optimistic updates that revert on failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-08 13:33:24 -04:00
parent 13722f04a8
commit ecaca35977
6 changed files with 141 additions and 28 deletions
+6 -4
View File
@@ -313,7 +313,7 @@ def source_health(conn: sqlite3.Connection) -> list[dict]:
return [dict(r) for r in rows]
def _attention(content: dict, sources: list[dict], feedback_7d: int) -> list[dict]:
def _attention(content: dict, sources: list[dict], feedback_unread: int) -> list[dict]:
"""The 'Attention Needed' strip: what an operator should look at, soft-toned
(warn = act soon, info = worth a glance). Derived from the same data shown
elsewhere, so it never disagrees with the detail sections."""
@@ -336,8 +336,8 @@ def _attention(content: dict, sources: list[dict], feedback_7d: int) -> list[dic
if brief_size < 7:
items.append({"level": "info", "text": f"Today's brief has only {brief_size} item{n(brief_size)}"})
if feedback_7d:
items.append({"level": "info", "text": f"{feedback_7d} feedback message{n(feedback_7d)} in the last 7 days"})
if feedback_unread:
items.append({"level": "info", "text": f"{feedback_unread} unread feedback message{n(feedback_unread)}"})
return items
@@ -441,13 +441,15 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
content = content_stats(conn)
sources = source_health(conn)
feedback_7d = scalar("SELECT COUNT(*) FROM feedback WHERE created_at >= datetime('now','-7 days')")
feedback_unread = scalar("SELECT COUNT(*) FROM feedback WHERE read_at IS NULL")
return {
"days": days,
"content": content,
"sources": sources,
"attention": _attention(content, sources, feedback_7d),
"attention": _attention(content, sources, feedback_unread),
"feedback_7d": feedback_7d,
"feedback_unread": feedback_unread,
"visitors": visitors,
"returning": loyalty.get("returning", 0),
"once": loyalty.get("once", 0),