This commit has no recorded session.
diff
commit 5393996e47a6750b78c1d392b46bd686cafd36eeAuthor: Colin Powell <colin@unbl.ink>Date: Tue Jun 16 16:42:14 2026 -0400 [trends] Add peak hours, weekly rhtyhms and activity dist trends@@ -88,7 +88,7 @@ fetching and simple saving. *** Metadata sources **** Scraper -* Backlog [2/22] :vrobbler:project:personal:+* Backlog [3/23] :vrobbler:project:personal: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles: :PROPERTIES: :ID: 702462cf-d54b-48c6-8a7c-78b8de751deb@@ -590,6 +590,11 @@ We should rename `email_scrobble_board_game` to reflect the fact that it's just a helper method to create board game scrobbles given a json blob. It's independent of the email flow it was originally creatdd for +** DONE [#B] Add peak hour, weekly rhythm and activity dist trends :trends:scrobbles:+:PROPERTIES:+:ID: 5fa52fac-d5f0-4369-bcaa-589c886b07d3+:END:+ ** DONE [#A] Implement YouTube channel info scraping :videos:youtube:stub: :PROPERTIES: :ID: 1d3beafd-62cb-4735-a465-edb37bf885db@@ -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:+ 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++ for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):+ trend_start = timezone.now()+ self.stdout.write(+ f" [{idx}/{total_trends}] {slug}... ", ending=""+ ) try:- compute_user_trends(user.id)- self.stdout.write(self.style.SUCCESS(f" OK {user}"))+ 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:- self.stderr.write(self.style.ERROR(f" FAILED {user}: {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 - self.stdout.write(self.style.SUCCESS("Done!"))+ 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))"+ )+ )@@ -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():+ 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:- data = fn(user)- TrendResult.objects.update_or_create(- user=user,- trend_slug=slug,- defaults={"data": data, "computed_at": timezone.now()},+ elapsed = _compute_and_save_trend(user, slug)+ logger.info(+ "[%s] Completed for user %d in %.1fs",+ slug, user_id, elapsed, ) except Exception:- logger.exception(- "Failed to compute trend '%s' for user %s", slug, user_id- )+ logger.exception("[%s] Failed for user %d", slug, user_id)@@ -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>@@ -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>@@ -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>@@ -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 %}@@ -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+)@@ -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),+ }@@ -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": "๐ ",+ }, }