[trends] Add peak hours, weekly rhtyhms and activity dist trends
Some checks failed
build / test (push) Has been cancelled

This commit is contained in:
2026-06-16 16:42:14 -04:00
parent 1624f01e11
commit 5393996e47
10 changed files with 396 additions and 24 deletions

View File

@ -1,8 +1,13 @@
import logging
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from django.utils import timezone
from trends.tasks import compute_user_trends
from trends.tasks import _compute_and_save_trend
from trends.trends import TREND_REGISTRY
logger = logging.getLogger(__name__)
User = get_user_model()
@ -25,16 +30,57 @@ class Command(BaseCommand):
)
return
users = [user]
self.stdout.write(f"Computing trends for user: {user}")
else:
users = User.objects.filter(is_active=True)
self.stdout.write(f"Computing trends for {users.count()} users...")
total_users = len(users)
self.stdout.write(f"Computing trends for {total_users} user(s)...")
overall_start = timezone.now()
ok_count = 0
fail_count = 0
for user in users:
try:
compute_user_trends(user.id)
self.stdout.write(self.style.SUCCESS(f" OK {user}"))
except Exception as e:
self.stderr.write(self.style.ERROR(f" FAILED {user}: {e}"))
total_trends = len(TREND_REGISTRY)
self.stdout.write(f" {user} ({user.id}): {total_trends} trends...")
user_start = timezone.now()
user_ok = 0
user_fail = 0
self.stdout.write(self.style.SUCCESS("Done!"))
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
trend_start = timezone.now()
self.stdout.write(
f" [{idx}/{total_trends}] {slug}... ", ending=""
)
try:
elapsed = _compute_and_save_trend(user, slug)
self.stdout.write(
self.style.SUCCESS(f"OK ({elapsed:.1f}s)")
)
user_ok += 1
except Exception as e:
elapsed = (timezone.now() - trend_start).total_seconds()
self.stdout.write(
self.style.ERROR(
f"FAILED after {elapsed:.1f}s: {e}"
)
)
user_fail += 1
user_elapsed = (timezone.now() - user_start).total_seconds()
self.stdout.write(
self.style.SUCCESS(
f" {user}: {user_ok} OK, {user_fail} failed "
f"({user_elapsed:.1f}s total)"
)
)
ok_count += user_ok
fail_count += user_fail
overall_elapsed = (timezone.now() - overall_start).total_seconds()
self.stdout.write(
self.style.SUCCESS(
f"Done! {ok_count} OK, {fail_count} failed "
f"({overall_elapsed:.1f}s across {total_users} user(s))"
)
)

View File

@ -11,10 +11,30 @@ logger = logging.getLogger(__name__)
User = get_user_model()
def _compute_and_save_trend(user, slug):
"""Compute a single trend and persist the result.
Returns elapsed seconds on success, raises on failure.
"""
fn = TREND_REGISTRY[slug]
start = timezone.now()
data = fn(user)
TrendResult.objects.update_or_create(
user=user,
trend_slug=slug,
defaults={"data": data, "computed_at": timezone.now()},
)
return (timezone.now() - start).total_seconds()
@shared_task
def compute_all_trends():
for user in User.objects.filter(is_active=True):
compute_user_trends.delay(user.id)
user_ids = list(
User.objects.filter(is_active=True).values_list("id", flat=True)
)
logger.info("Dispatching trend computation for %d users", len(user_ids))
for uid in user_ids:
compute_user_trends.delay(uid)
@shared_task
@ -25,15 +45,38 @@ def compute_user_trends(user_id):
logger.warning("User %s not found, skipping trends", user_id)
return
for slug, fn in TREND_REGISTRY.items():
try:
data = fn(user)
TrendResult.objects.update_or_create(
user=user,
trend_slug=slug,
defaults={"data": data, "computed_at": timezone.now()},
)
except Exception:
logger.exception(
"Failed to compute trend '%s' for user %s", slug, user_id
)
total = len(TREND_REGISTRY)
logger.info(
"Computing %d trends for user %s (%d)",
total, user, user_id,
)
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
compute_single_trend.delay(user_id, slug)
logger.info("Dispatched all %d trends for user %s (%d)", total, user, user_id)
@shared_task
def compute_single_trend(user_id, slug):
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
logger.warning(
"User %d not found for trend '%s', skipping", user_id, slug
)
return
if slug not in TREND_REGISTRY:
logger.warning("Unknown trend slug '%s' for user %d", slug, user_id)
return
logger.info("[%s] Computing for user %d...", slug, user_id)
try:
elapsed = _compute_and_save_trend(user, slug)
logger.info(
"[%s] Completed for user %d in %.1fs",
slug, user_id, elapsed,
)
except Exception:
logger.exception("[%s] Failed for user %d", slug, user_id)

View File

@ -0,0 +1,47 @@
<div class="row">
<div class="col-12">
{% if data.distribution %}
<p class="text-muted mb-3">
Total scrobbles: <strong>{{ data.total_count }}</strong>
</p>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Media Type</th>
<th class="text-end">Total</th>
<th class="text-end">Completed</th>
<th class="text-end">%</th>
<th>Distribution</th>
</tr>
</thead>
<tbody>
{% with max=data.distribution.0.count %}
{% for entry in data.distribution %}
<tr>
<td>{{ entry.media_type }}</td>
<td class="text-end">{{ entry.count }}</td>
<td class="text-end">{{ entry.completed }}</td>
<td class="text-end">{{ entry.pct }}%</td>
<td style="width: 30%;">
{% if max > 0 %}
<div class="progress" style="height: 12px;">
<div class="progress-bar" role="progressbar"
style="width: {{ entry.pct }}%;"
aria-valuenow="{{ entry.count }}"
aria-valuemin="0" aria-valuemax="{{ max }}">
</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
{% endwith %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No activity data found.</p>
{% endif %}
</div>
</div>

View File

@ -0,0 +1,52 @@
<div class="row">
<div class="col-12">
{% if data.hours %}
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Hour</th>
<th class="text-end">Scrobbles</th>
<th>Distribution</th>
</tr>
</thead>
<tbody>
{% with total=data.hours|dictsortreversed:"count"|first %}
{% with max_count=total.count %}
{% for entry in data.hours %}
<tr>
<td>
{% if entry.hour == 0 %}
12 AM
{% elif entry.hour < 12 %}
{{ entry.hour }} AM
{% elif entry.hour == 12 %}
12 PM
{% else %}
{{ entry.hour|add:"-12" }} PM
{% endif %}
</td>
<td class="text-end">{{ entry.count }}</td>
<td style="width: 40%;">
{% if max_count > 0 %}
<div class="progress" style="height: 12px;">
<div class="progress-bar" role="progressbar"
style="width: {{ entry.count|floatformat:0 }}%;"
aria-valuenow="{{ entry.count }}"
aria-valuemin="0" aria-valuemax="{{ max_count }}">
</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
{% endwith %}
{% endwith %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No activity data found.</p>
{% endif %}
</div>
</div>

View File

@ -0,0 +1,42 @@
<div class="row">
<div class="col-12">
{% if data.days %}
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Day</th>
<th class="text-end">Scrobbles</th>
<th>Distribution</th>
</tr>
</thead>
<tbody>
{% with total=data.days|dictsortreversed:"count"|first %}
{% with max_count=total.count %}
{% for entry in data.days %}
<tr>
<td>{{ entry.day_name }}</td>
<td class="text-end">{{ entry.count }}</td>
<td style="width: 40%;">
{% if max_count > 0 %}
<div class="progress" style="height: 12px;">
<div class="progress-bar" role="progressbar"
style="width: {{ entry.count|floatformat:0 }}%;"
aria-valuenow="{{ entry.count }}"
aria-valuemin="0" aria-valuemax="{{ max_count }}">
</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
{% endwith %}
{% endwith %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No weekly data found.</p>
{% endif %}
</div>
</div>

View File

@ -34,5 +34,14 @@
{% elif trend.slug == "trending-up" %}
{% include "trends/_trending_up.html" %}
{% elif trend.slug == "peak-hours" %}
{% include "trends/_peak_hours.html" %}
{% elif trend.slug == "weekly-rhythm" %}
{% include "trends/_weekly_rhythm.html" %}
{% elif trend.slug == "activity-distribution" %}
{% include "trends/_activity_distribution.html" %}
{% endif %}
{% endblock %}

View File

@ -1,3 +1,8 @@
from trends.trends.activity import (
compute_activity_distribution,
compute_peak_hours,
compute_weekly_rhythm,
)
from trends.trends.concurrent import compute_concurrent_listening, compute_concurrent_reading
from trends.trends.reading import compute_reading_pace_vs_activity
from trends.trends.trending import compute_trending_up
@ -11,15 +16,24 @@ def register(slug):
return decorator
compute_activity_distribution = register("activity-distribution")(
compute_activity_distribution
)
compute_concurrent_listening = register("concurrent-listening")(
compute_concurrent_listening
)
compute_concurrent_reading = register("concurrent-reading")(
compute_concurrent_reading
)
compute_peak_hours = register("peak-hours")(
compute_peak_hours
)
compute_reading_pace_vs_activity = register("reading-pace-vs-activity")(
compute_reading_pace_vs_activity
)
compute_trending_up = register("trending-up")(
compute_trending_up
)
compute_weekly_rhythm = register("weekly-rhythm")(
compute_weekly_rhythm
)

View File

@ -0,0 +1,99 @@
from collections import OrderedDict, defaultdict
from django.db.models import Count, Q
from django.db.models.functions import Extract
from django.utils import timezone
from scrobbles.models import Scrobble
def compute_peak_hours(user):
"""Group scrobbles by hour of day (0-23) and count them.
Returns dict: {"hours": [{"hour": N, "count": N}, ...]} sorted by hour.
"""
hours_qs = (
Scrobble.objects.filter(user=user, timestamp__isnull=False)
.annotate(hour=Extract("timestamp", "hour"))
.values("hour")
.annotate(count=Count("id"))
.order_by("hour")
)
hours = []
raw = {row["hour"]: row["count"] for row in hours_qs}
for h in range(24):
hours.append({"hour": h, "count": raw.get(h, 0)})
return {"hours": hours}
def compute_weekly_rhythm(user):
"""Group scrobble counts by day of the week.
Uses iso_week_day (1=Monday, 7=Sunday). Returns dict sorted by day index
with human-readable day names.
"""
DAY_NAMES = OrderedDict([
(1, "Monday"),
(2, "Tuesday"),
(3, "Wednesday"),
(4, "Thursday"),
(5, "Friday"),
(6, "Saturday"),
(7, "Sunday"),
])
days_qs = (
Scrobble.objects.filter(user=user, timestamp__isnull=False)
.annotate(day=Extract("timestamp", "iso_week_day"))
.values("day")
.annotate(count=Count("id"))
.order_by("day")
)
raw = {row["day"]: row["count"] for row in days_qs}
days = []
for idx, name in DAY_NAMES.items():
days.append({
"day_index": idx,
"day_name": name,
"count": raw.get(idx, 0),
})
return {"days": days}
def compute_activity_distribution(user):
"""Proportion of total scrobbles per media type.
Returns dict: {"distribution": [{"media_type": "...", "count": N,
"completed": N, "pct": float}, ...]} sorted by count desc, plus
"total_count".
"""
dist_qs = (
Scrobble.objects.filter(user=user)
.values("media_type")
.annotate(
count=Count("id"),
completed=Count("id", filter=Q(played_to_completion=True)),
)
.order_by("-count")
)
rows = list(dist_qs)
total = sum(r["count"] for r in rows) or 1
distribution = []
for row in rows:
distribution.append({
"media_type": row["media_type"],
"count": row["count"],
"completed": row["completed"],
"pct": round((row["count"] / total) * 100, 1),
})
return {
"distribution": distribution,
"total_count": sum(r["count"] for r in rows),
}

View File

@ -5,6 +5,11 @@ from trends.models import TrendResult
from trends.trends import TREND_REGISTRY
TREND_METADATA = {
"activity-distribution": {
"title": "Activity Distribution",
"description": "How your scrobbles are divided across media types.",
"icon": "📊",
},
"concurrent-listening": {
"title": "Concurrent Listening",
"description": "What music were you listening to while on trails or at locations?",
@ -15,6 +20,11 @@ TREND_METADATA = {
"description": "What music did you listen to while reading books?",
"icon": "📖",
},
"peak-hours": {
"title": "Peak Activity Hours",
"description": "What time of day are you most active?",
"icon": "🕐",
},
"reading-pace-vs-activity": {
"title": "Reading Pace vs Music",
"description": "Compare how long you read per session with and without concurrent music.",
@ -25,6 +35,11 @@ TREND_METADATA = {
"description": "Which media types have you been consuming more or less of recently?",
"icon": "📈",
},
"weekly-rhythm": {
"title": "Weekly Rhythm",
"description": "Which days of the week see the most scrobble activity?",
"icon": "📅",
},
}