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 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 10:03:56 -06:00
parent 8101871877
commit 551b4c6ff9
2 changed files with 114 additions and 49 deletions

View File

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

View File

@@ -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 @@
</span>
</div>
{% if all_profile_names | length > 1 %}
<div class="profile-selector">
<a href="/" class="{{ 'active' if not selected_profile else '' }}">All</a>
{% for pname in all_profile_names %}
<a href="/?profile={{ pname }}" class="{{ 'active' if selected_profile == pname else '' }}">{{ pname }}</a>
{% endfor %}
</div>
{% endif %}
<!-- Recent Listens -->
<div class="section">
<h2>Recent Listens</h2>
@@ -182,6 +210,7 @@
<div class="feedback-item">
<span class="signal-icon {{ fb.signal }}">{% if fb.signal == 'up' %}&#9650;{% elif fb.signal == 'down' %}&#9660;{% else %}&#9656;{% endif %}</span>
<span class="feedback-track">{{ fb.title }} &mdash; {{ fb.artist }}</span>
{% if fb.profile_name and not selected_profile %}<span class="profile-badge">{{ fb.profile_name }}</span>{% endif %}
<div class="feedback-meta">{{ fb.vibe_text }} &middot; {{ fb.created_at | timeago }}</div>
</div>
{% endfor %}