sessions / 5393996e47a6750b78c1d392b46bd686cafd36ee

/home/powellc/src/code.unbl.ink/secstate/vrobbler

This commit has no recorded session.

diff

ignore whitespace

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 trendsdiff --git a/PROJECT.org b/PROJECT.orgindex 6b9c021..4e0c3f0 100644--- a/PROJECT.org+++ b/PROJECT.org@@ -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-edb37bf885dbdiff --git a/vrobbler/apps/trends/management/commands/compute_trends.py b/vrobbler/apps/trends/management/commands/compute_trends.pyindex 76f605e..51f7f67 100644--- a/vrobbler/apps/trends/management/commands/compute_trends.py+++ b/vrobbler/apps/trends/management/commands/compute_trends.py@@ -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++            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 -        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))"+            )+        )diff --git a/vrobbler/apps/trends/tasks.py b/vrobbler/apps/trends/tasks.pyindex 85e6a7e..39e9500 100644--- a/vrobbler/apps/trends/tasks.py+++ b/vrobbler/apps/trends/tasks.py@@ -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)diff --git a/vrobbler/apps/trends/templates/trends/_activity_distribution.html b/vrobbler/apps/trends/templates/trends/_activity_distribution.htmlnew file mode 100644index 0000000..e48545c--- /dev/null+++ b/vrobbler/apps/trends/templates/trends/_activity_distribution.html@@ -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>diff --git a/vrobbler/apps/trends/templates/trends/_peak_hours.html b/vrobbler/apps/trends/templates/trends/_peak_hours.htmlnew file mode 100644index 0000000..5385e96--- /dev/null+++ b/vrobbler/apps/trends/templates/trends/_peak_hours.html@@ -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>diff --git a/vrobbler/apps/trends/templates/trends/_weekly_rhythm.html b/vrobbler/apps/trends/templates/trends/_weekly_rhythm.htmlnew file mode 100644index 0000000..2dc2594--- /dev/null+++ b/vrobbler/apps/trends/templates/trends/_weekly_rhythm.html@@ -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>diff --git a/vrobbler/apps/trends/templates/trends/trend_detail.html b/vrobbler/apps/trends/templates/trends/trend_detail.htmlindex cf6b569..fef417e 100644--- a/vrobbler/apps/trends/templates/trends/trend_detail.html+++ b/vrobbler/apps/trends/templates/trends/trend_detail.html@@ -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 %}diff --git a/vrobbler/apps/trends/trends/__init__.py b/vrobbler/apps/trends/trends/__init__.pyindex 369f46c..92807ea 100644--- a/vrobbler/apps/trends/trends/__init__.py+++ b/vrobbler/apps/trends/trends/__init__.py@@ -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+)diff --git a/vrobbler/apps/trends/trends/activity.py b/vrobbler/apps/trends/trends/activity.pynew file mode 100644index 0000000..9ffb2df--- /dev/null+++ b/vrobbler/apps/trends/trends/activity.py@@ -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),+    }diff --git a/vrobbler/apps/trends/views.py b/vrobbler/apps/trends/views.pyindex 2600554..587981a 100644--- a/vrobbler/apps/trends/views.py+++ b/vrobbler/apps/trends/views.py@@ -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": "๐Ÿ“…",+    }, }