From 551b4c6ff9b874e4b372edb11acabac7be260fad Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Mon, 23 Feb 2026 10:03:56 -0600 Subject: [PATCH] Add profile selector and filtering to dashboard Profile pill selector at top of page filters Recent Listens, Feedback Activity, and Vibe Influence by selected profile. Feedback items show profile badge when viewing all profiles. Co-Authored-By: Claude Opus 4.6 --- src/haunt_fm/api/status_page.py | 134 ++++++++++++++++++----------- src/haunt_fm/templates/status.html | 29 +++++++ 2 files changed, 114 insertions(+), 49 deletions(-) diff --git a/src/haunt_fm/api/status_page.py b/src/haunt_fm/api/status_page.py index e1f1d5b..bff0e4f 100644 --- a/src/haunt_fm/api/status_page.py +++ b/src/haunt_fm/api/status_page.py @@ -1,10 +1,10 @@ from datetime import datetime, timezone from pathlib import Path -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import HTMLResponse from jinja2 import Environment, FileSystemLoader -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from haunt_fm.api.status import status as get_status_data @@ -57,18 +57,34 @@ _jinja_env.filters["timeago"] = _timeago @router.get("/", response_class=HTMLResponse) -async def status_page(request: Request, session: AsyncSession = Depends(get_session)): +async def status_page( + request: Request, + profile: str | None = Query(default=None), + session: AsyncSession = Depends(get_session), +): data = await get_status_data(session) + # Resolve selected profile + selected_profile: Profile | None = None + if profile: + result = await session.execute(select(Profile).where(Profile.name == profile)) + selected_profile = result.scalar_one_or_none() + # Recent listens (last 10) with track info - recent_rows = ( - await session.execute( - select(ListenEvent, Track) - .join(Track, ListenEvent.track_id == Track.id) - .order_by(ListenEvent.listened_at.desc()) - .limit(10) - ) - ).all() + listens_query = ( + select(ListenEvent, Track) + .join(Track, ListenEvent.track_id == Track.id) + .order_by(ListenEvent.listened_at.desc()) + .limit(10) + ) + if selected_profile: + if selected_profile.name == "default": + listens_query = listens_query.where( + or_(ListenEvent.profile_id == selected_profile.id, ListenEvent.profile_id.is_(None)) + ) + else: + listens_query = listens_query.where(ListenEvent.profile_id == selected_profile.id) + recent_rows = (await session.execute(listens_query)).all() recent_listens = [ { "title": track.title, @@ -121,28 +137,39 @@ async def status_page(request: Request, session: AsyncSession = Depends(get_sess "updated_at": tp.updated_at, } + # Feedback profile filter + def _feedback_profile_filter(): + if not selected_profile: + return None + if selected_profile.name == "default": + return or_(FeedbackEvent.profile_name.is_(None), FeedbackEvent.profile_name == "default") + return FeedbackEvent.profile_name == selected_profile.name + + fb_filter = _feedback_profile_filter() + # Feedback summary stats - feedback_total = ( - await session.execute(select(func.count(FeedbackEvent.id))) - ).scalar() or 0 + fb_count_query = select(func.count(FeedbackEvent.id)) + if fb_filter is not None: + fb_count_query = fb_count_query.where(fb_filter) + feedback_total = (await session.execute(fb_count_query)).scalar() or 0 feedback_by_signal: dict[str, int] = {} if feedback_total > 0: - signal_rows = ( - await session.execute( - select(FeedbackEvent.signal, func.count(FeedbackEvent.id)) - .group_by(FeedbackEvent.signal) - ) - ).all() + signal_query = ( + select(FeedbackEvent.signal, func.count(FeedbackEvent.id)) + .group_by(FeedbackEvent.signal) + ) + if fb_filter is not None: + signal_query = signal_query.where(fb_filter) + signal_rows = (await session.execute(signal_query)).all() feedback_by_signal = {signal: count for signal, count in signal_rows} feedback_distinct_tracks = 0 if feedback_total > 0: - feedback_distinct_tracks = ( - await session.execute( - select(func.count(func.distinct(FeedbackEvent.track_id))) - ) - ).scalar() or 0 + distinct_query = select(func.count(func.distinct(FeedbackEvent.track_id))) + if fb_filter is not None: + distinct_query = distinct_query.where(fb_filter) + feedback_distinct_tracks = (await session.execute(distinct_query)).scalar() or 0 feedback_summary = { "total": feedback_total, @@ -155,20 +182,22 @@ async def status_page(request: Request, session: AsyncSession = Depends(get_sess # Recent feedback events (last 15) recent_feedback: list[dict] = [] if feedback_total > 0: - feedback_rows = ( - await session.execute( - select(FeedbackEvent, Track) - .join(Track, FeedbackEvent.track_id == Track.id) - .order_by(FeedbackEvent.created_at.desc()) - .limit(15) - ) - ).all() + fb_recent_query = ( + select(FeedbackEvent, Track) + .join(Track, FeedbackEvent.track_id == Track.id) + .order_by(FeedbackEvent.created_at.desc()) + .limit(15) + ) + if fb_filter is not None: + fb_recent_query = fb_recent_query.where(fb_filter) + feedback_rows = (await session.execute(fb_recent_query)).all() recent_feedback = [ { "signal": event.signal, "signal_weight": event.signal_weight, "title": track.title, "artist": track.artist, + "profile_name": event.profile_name, "vibe_text": event.vibe_text or "no vibe", "created_at": event.created_at, } @@ -178,25 +207,27 @@ async def status_page(request: Request, session: AsyncSession = Depends(get_sess # Vibe influence data — top 10 tracks by feedback count vibe_influence: list[dict] = [] if feedback_total > 0: - top_track_ids_result = ( - await session.execute( - select(FeedbackEvent.track_id, func.count(FeedbackEvent.id).label("cnt")) - .group_by(FeedbackEvent.track_id) - .order_by(func.count(FeedbackEvent.id).desc()) - .limit(10) - ) - ).all() + top_tracks_query = ( + select(FeedbackEvent.track_id, func.count(FeedbackEvent.id).label("cnt")) + .group_by(FeedbackEvent.track_id) + .order_by(func.count(FeedbackEvent.id).desc()) + .limit(10) + ) + if fb_filter is not None: + top_tracks_query = top_tracks_query.where(fb_filter) + top_track_ids_result = (await session.execute(top_tracks_query)).all() top_track_ids = [row[0] for row in top_track_ids_result] if top_track_ids: - influence_rows = ( - await session.execute( - select(FeedbackEvent, Track) - .join(Track, FeedbackEvent.track_id == Track.id) - .where(FeedbackEvent.track_id.in_(top_track_ids)) - .order_by(FeedbackEvent.created_at.desc()) - ) - ).all() + influence_query = ( + select(FeedbackEvent, Track) + .join(Track, FeedbackEvent.track_id == Track.id) + .where(FeedbackEvent.track_id.in_(top_track_ids)) + .order_by(FeedbackEvent.created_at.desc()) + ) + if fb_filter is not None: + influence_query = influence_query.where(fb_filter) + influence_rows = (await session.execute(influence_query)).all() tracks_map: dict[int, dict] = {} for event, track in influence_rows: @@ -234,6 +265,9 @@ async def status_page(request: Request, session: AsyncSession = Depends(get_sess for p in playlist_rows ] + # Profile names for selector (use raw Profile.name, not display_name) + all_profile_names = [profile.name for profile, *_ in profile_rows] + template = _jinja_env.get_template("status.html") html = template.render( data=data, @@ -244,6 +278,8 @@ async def status_page(request: Request, session: AsyncSession = Depends(get_sess feedback_summary=feedback_summary, recent_feedback=recent_feedback, vibe_influence=vibe_influence, + selected_profile=selected_profile.name if selected_profile else None, + all_profile_names=all_profile_names, now=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), ) return HTMLResponse(html) diff --git a/src/haunt_fm/templates/status.html b/src/haunt_fm/templates/status.html index 6c770a5..e0dfa17 100644 --- a/src/haunt_fm/templates/status.html +++ b/src/haunt_fm/templates/status.html @@ -85,6 +85,25 @@ /* Dependencies row */ .deps-row { display: flex; flex-wrap: wrap; gap: 1rem; padding: 0.3rem 0; } + /* Profile selector */ + .profile-selector { + display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 1.25rem; + } + .profile-selector a { + display: inline-block; padding: 0.25rem 0.7rem; border-radius: 1rem; + font-size: 0.8rem; font-weight: 500; text-decoration: none; + border: 1px solid #30363d; color: #8b949e; background: #161b22; + transition: all 0.15s; + } + .profile-selector a:hover { border-color: #58a6ff; color: #c9d1d9; } + .profile-selector a.active { background: #58a6ff; color: #0d1117; border-color: #58a6ff; } + + .profile-badge { + display: inline-block; font-size: 0.7rem; padding: 0.1rem 0.4rem; + border-radius: 0.75rem; background: #21262d; color: #8b949e; + margin-left: 0.3rem; vertical-align: middle; + } + /* Footer */ .timestamp { color: #484f58; font-size: 0.8rem; margin-top: 1rem; text-align: center; } @@ -126,6 +145,15 @@ + {% if all_profile_names | length > 1 %} +
+ All + {% for pname in all_profile_names %} + {{ pname }} + {% endfor %} +
+ {% endif %} +

Recent Listens

@@ -182,6 +210,7 @@ {% endfor %}