From 5393996e47a6750b78c1d392b46bd686cafd36ee Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Tue, 16 Jun 2026 16:42:14 -0400 Subject: [PATCH] [trends] Add peak hours, weekly rhtyhms and activity dist trends --- PROJECT.org | 7 +- .../management/commands/compute_trends.py | 64 ++++++++++-- vrobbler/apps/trends/tasks.py | 71 ++++++++++--- .../trends/_activity_distribution.html | 47 +++++++++ .../trends/templates/trends/_peak_hours.html | 52 ++++++++++ .../templates/trends/_weekly_rhythm.html | 42 ++++++++ .../trends/templates/trends/trend_detail.html | 9 ++ vrobbler/apps/trends/trends/__init__.py | 14 +++ vrobbler/apps/trends/trends/activity.py | 99 +++++++++++++++++++ vrobbler/apps/trends/views.py | 15 +++ 10 files changed, 396 insertions(+), 24 deletions(-) create mode 100644 vrobbler/apps/trends/templates/trends/_activity_distribution.html create mode 100644 vrobbler/apps/trends/templates/trends/_peak_hours.html create mode 100644 vrobbler/apps/trends/templates/trends/_weekly_rhythm.html create mode 100644 vrobbler/apps/trends/trends/activity.py diff --git a/PROJECT.org b/PROJECT.org index 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-edb37bf885db diff --git a/vrobbler/apps/trends/management/commands/compute_trends.py b/vrobbler/apps/trends/management/commands/compute_trends.py index 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 - 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))" + ) + ) diff --git a/vrobbler/apps/trends/tasks.py b/vrobbler/apps/trends/tasks.py index 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.html new file mode 100644 index 0000000..e48545c --- /dev/null +++ b/vrobbler/apps/trends/templates/trends/_activity_distribution.html @@ -0,0 +1,47 @@ +
+
+ {% if data.distribution %} +

+ Total scrobbles: {{ data.total_count }} +

+
+ + + + + + + + + + + + {% with max=data.distribution.0.count %} + {% for entry in data.distribution %} + + + + + + + + {% endfor %} + {% endwith %} + +
Media TypeTotalCompleted%Distribution
{{ entry.media_type }}{{ entry.count }}{{ entry.completed }}{{ entry.pct }}% + {% if max > 0 %} +
+
+
+
+ {% endif %} +
+
+ {% else %} +

No activity data found.

+ {% endif %} +
+
diff --git a/vrobbler/apps/trends/templates/trends/_peak_hours.html b/vrobbler/apps/trends/templates/trends/_peak_hours.html new file mode 100644 index 0000000..5385e96 --- /dev/null +++ b/vrobbler/apps/trends/templates/trends/_peak_hours.html @@ -0,0 +1,52 @@ +
+
+ {% if data.hours %} +
+ + + + + + + + + + {% with total=data.hours|dictsortreversed:"count"|first %} + {% with max_count=total.count %} + {% for entry in data.hours %} + + + + + + {% endfor %} + {% endwith %} + {% endwith %} + +
HourScrobblesDistribution
+ {% 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 %} + {{ entry.count }} + {% if max_count > 0 %} +
+
+
+
+ {% endif %} +
+
+ {% else %} +

No activity data found.

+ {% endif %} +
+
diff --git a/vrobbler/apps/trends/templates/trends/_weekly_rhythm.html b/vrobbler/apps/trends/templates/trends/_weekly_rhythm.html new file mode 100644 index 0000000..2dc2594 --- /dev/null +++ b/vrobbler/apps/trends/templates/trends/_weekly_rhythm.html @@ -0,0 +1,42 @@ +
+
+ {% if data.days %} +
+ + + + + + + + + + {% with total=data.days|dictsortreversed:"count"|first %} + {% with max_count=total.count %} + {% for entry in data.days %} + + + + + + {% endfor %} + {% endwith %} + {% endwith %} + +
DayScrobblesDistribution
{{ entry.day_name }}{{ entry.count }} + {% if max_count > 0 %} +
+
+
+
+ {% endif %} +
+
+ {% else %} +

No weekly data found.

+ {% endif %} +
+
diff --git a/vrobbler/apps/trends/templates/trends/trend_detail.html b/vrobbler/apps/trends/templates/trends/trend_detail.html index 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__.py index 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.py new file mode 100644 index 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.py index 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": "📅", + }, }