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:
@@ -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(
|
||||
listens_query = (
|
||||
select(ListenEvent, Track)
|
||||
.join(Track, ListenEvent.track_id == Track.id)
|
||||
.order_by(ListenEvent.listened_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
).all()
|
||||
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(
|
||||
signal_query = (
|
||||
select(FeedbackEvent.signal, func.count(FeedbackEvent.id))
|
||||
.group_by(FeedbackEvent.signal)
|
||||
)
|
||||
).all()
|
||||
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(
|
||||
fb_recent_query = (
|
||||
select(FeedbackEvent, Track)
|
||||
.join(Track, FeedbackEvent.track_id == Track.id)
|
||||
.order_by(FeedbackEvent.created_at.desc())
|
||||
.limit(15)
|
||||
)
|
||||
).all()
|
||||
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(
|
||||
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)
|
||||
)
|
||||
).all()
|
||||
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(
|
||||
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())
|
||||
)
|
||||
).all()
|
||||
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)
|
||||
|
||||
@@ -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' %}▲{% elif fb.signal == 'down' %}▼{% else %}▸{% endif %}</span>
|
||||
<span class="feedback-track">{{ fb.title }} — {{ 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 }} · {{ fb.created_at | timeago }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
Reference in New Issue
Block a user