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 datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from haunt_fm.api.status import status as get_status_data
|
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)
|
@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)
|
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 listens (last 10) with track info
|
||||||
recent_rows = (
|
listens_query = (
|
||||||
await session.execute(
|
select(ListenEvent, Track)
|
||||||
select(ListenEvent, Track)
|
.join(Track, ListenEvent.track_id == Track.id)
|
||||||
.join(Track, ListenEvent.track_id == Track.id)
|
.order_by(ListenEvent.listened_at.desc())
|
||||||
.order_by(ListenEvent.listened_at.desc())
|
.limit(10)
|
||||||
.limit(10)
|
)
|
||||||
)
|
if selected_profile:
|
||||||
).all()
|
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 = [
|
recent_listens = [
|
||||||
{
|
{
|
||||||
"title": track.title,
|
"title": track.title,
|
||||||
@@ -121,28 +137,39 @@ async def status_page(request: Request, session: AsyncSession = Depends(get_sess
|
|||||||
"updated_at": tp.updated_at,
|
"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 summary stats
|
||||||
feedback_total = (
|
fb_count_query = select(func.count(FeedbackEvent.id))
|
||||||
await session.execute(select(func.count(FeedbackEvent.id)))
|
if fb_filter is not None:
|
||||||
).scalar() or 0
|
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] = {}
|
feedback_by_signal: dict[str, int] = {}
|
||||||
if feedback_total > 0:
|
if feedback_total > 0:
|
||||||
signal_rows = (
|
signal_query = (
|
||||||
await session.execute(
|
select(FeedbackEvent.signal, func.count(FeedbackEvent.id))
|
||||||
select(FeedbackEvent.signal, func.count(FeedbackEvent.id))
|
.group_by(FeedbackEvent.signal)
|
||||||
.group_by(FeedbackEvent.signal)
|
)
|
||||||
)
|
if fb_filter is not None:
|
||||||
).all()
|
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_by_signal = {signal: count for signal, count in signal_rows}
|
||||||
|
|
||||||
feedback_distinct_tracks = 0
|
feedback_distinct_tracks = 0
|
||||||
if feedback_total > 0:
|
if feedback_total > 0:
|
||||||
feedback_distinct_tracks = (
|
distinct_query = select(func.count(func.distinct(FeedbackEvent.track_id)))
|
||||||
await session.execute(
|
if fb_filter is not None:
|
||||||
select(func.count(func.distinct(FeedbackEvent.track_id)))
|
distinct_query = distinct_query.where(fb_filter)
|
||||||
)
|
feedback_distinct_tracks = (await session.execute(distinct_query)).scalar() or 0
|
||||||
).scalar() or 0
|
|
||||||
|
|
||||||
feedback_summary = {
|
feedback_summary = {
|
||||||
"total": feedback_total,
|
"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 events (last 15)
|
||||||
recent_feedback: list[dict] = []
|
recent_feedback: list[dict] = []
|
||||||
if feedback_total > 0:
|
if feedback_total > 0:
|
||||||
feedback_rows = (
|
fb_recent_query = (
|
||||||
await session.execute(
|
select(FeedbackEvent, Track)
|
||||||
select(FeedbackEvent, Track)
|
.join(Track, FeedbackEvent.track_id == Track.id)
|
||||||
.join(Track, FeedbackEvent.track_id == Track.id)
|
.order_by(FeedbackEvent.created_at.desc())
|
||||||
.order_by(FeedbackEvent.created_at.desc())
|
.limit(15)
|
||||||
.limit(15)
|
)
|
||||||
)
|
if fb_filter is not None:
|
||||||
).all()
|
fb_recent_query = fb_recent_query.where(fb_filter)
|
||||||
|
feedback_rows = (await session.execute(fb_recent_query)).all()
|
||||||
recent_feedback = [
|
recent_feedback = [
|
||||||
{
|
{
|
||||||
"signal": event.signal,
|
"signal": event.signal,
|
||||||
"signal_weight": event.signal_weight,
|
"signal_weight": event.signal_weight,
|
||||||
"title": track.title,
|
"title": track.title,
|
||||||
"artist": track.artist,
|
"artist": track.artist,
|
||||||
|
"profile_name": event.profile_name,
|
||||||
"vibe_text": event.vibe_text or "no vibe",
|
"vibe_text": event.vibe_text or "no vibe",
|
||||||
"created_at": event.created_at,
|
"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 data — top 10 tracks by feedback count
|
||||||
vibe_influence: list[dict] = []
|
vibe_influence: list[dict] = []
|
||||||
if feedback_total > 0:
|
if feedback_total > 0:
|
||||||
top_track_ids_result = (
|
top_tracks_query = (
|
||||||
await session.execute(
|
select(FeedbackEvent.track_id, func.count(FeedbackEvent.id).label("cnt"))
|
||||||
select(FeedbackEvent.track_id, func.count(FeedbackEvent.id).label("cnt"))
|
.group_by(FeedbackEvent.track_id)
|
||||||
.group_by(FeedbackEvent.track_id)
|
.order_by(func.count(FeedbackEvent.id).desc())
|
||||||
.order_by(func.count(FeedbackEvent.id).desc())
|
.limit(10)
|
||||||
.limit(10)
|
)
|
||||||
)
|
if fb_filter is not None:
|
||||||
).all()
|
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]
|
top_track_ids = [row[0] for row in top_track_ids_result]
|
||||||
|
|
||||||
if top_track_ids:
|
if top_track_ids:
|
||||||
influence_rows = (
|
influence_query = (
|
||||||
await session.execute(
|
select(FeedbackEvent, Track)
|
||||||
select(FeedbackEvent, Track)
|
.join(Track, FeedbackEvent.track_id == Track.id)
|
||||||
.join(Track, FeedbackEvent.track_id == Track.id)
|
.where(FeedbackEvent.track_id.in_(top_track_ids))
|
||||||
.where(FeedbackEvent.track_id.in_(top_track_ids))
|
.order_by(FeedbackEvent.created_at.desc())
|
||||||
.order_by(FeedbackEvent.created_at.desc())
|
)
|
||||||
)
|
if fb_filter is not None:
|
||||||
).all()
|
influence_query = influence_query.where(fb_filter)
|
||||||
|
influence_rows = (await session.execute(influence_query)).all()
|
||||||
|
|
||||||
tracks_map: dict[int, dict] = {}
|
tracks_map: dict[int, dict] = {}
|
||||||
for event, track in influence_rows:
|
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
|
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")
|
template = _jinja_env.get_template("status.html")
|
||||||
html = template.render(
|
html = template.render(
|
||||||
data=data,
|
data=data,
|
||||||
@@ -244,6 +278,8 @@ async def status_page(request: Request, session: AsyncSession = Depends(get_sess
|
|||||||
feedback_summary=feedback_summary,
|
feedback_summary=feedback_summary,
|
||||||
recent_feedback=recent_feedback,
|
recent_feedback=recent_feedback,
|
||||||
vibe_influence=vibe_influence,
|
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"),
|
now=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
|
||||||
)
|
)
|
||||||
return HTMLResponse(html)
|
return HTMLResponse(html)
|
||||||
|
|||||||
@@ -85,6 +85,25 @@
|
|||||||
/* Dependencies row */
|
/* Dependencies row */
|
||||||
.deps-row { display: flex; flex-wrap: wrap; gap: 1rem; padding: 0.3rem 0; }
|
.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 */
|
/* Footer */
|
||||||
.timestamp { color: #484f58; font-size: 0.8rem; margin-top: 1rem; text-align: center; }
|
.timestamp { color: #484f58; font-size: 0.8rem; margin-top: 1rem; text-align: center; }
|
||||||
|
|
||||||
@@ -126,6 +145,15 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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 -->
|
<!-- Recent Listens -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Recent Listens</h2>
|
<h2>Recent Listens</h2>
|
||||||
@@ -182,6 +210,7 @@
|
|||||||
<div class="feedback-item">
|
<div class="feedback-item">
|
||||||
<span class="signal-icon {{ fb.signal }}">{% if fb.signal == 'up' %}▲{% elif fb.signal == 'down' %}▼{% else %}▸{% endif %}</span>
|
<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>
|
<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 class="feedback-meta">{{ fb.vibe_text }} · {{ fb.created_at | timeago }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
Reference in New Issue
Block a user