This commit has no recorded session.
diff
commit 19f2b5e80164366d2f4ccb5f0895befef43c2a9fAuthor: Colin Powell <colin@unbl.ink>Date: Wed Jun 17 10:50:16 2026 -0400 [trends] Add time periods@@ -88,7 +88,7 @@ fetching and simple saving. *** Metadata sources **** Scraper -* Backlog [0/20] :vrobbler:project:personal:+* Backlog [1/21] :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,18 @@ 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] Trends dont seem to look very far back :trends:+:PROPERTIES:+:ID: ffcfba3f-5a93-9ee0-9680-666e6eccd684+:END:++*** Description++Specificially, looking at reading-pace when run on prod, it claims that I've+only had one reading session without music. Which may be true, but perhaps we+need to indicate what the time frame we're looking at is (month, week, year)+and provide a way to jump back and forward through time, same as charts.+ * Version 54.1 [1/1] ** DONE [#A] Concurrent listening trend is inefficient and should be disabled :trends:scrobbles: :PROPERTIES:@@ -1,5 +1,4 @@ from django.contrib import admin- from trends.models import TrendResult @@ -3,9 +3,8 @@ 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_and_save_trend from trends.trends import TREND_REGISTRY+from trends.utils import compute_and_save_trend, get_supported_periods logger = logging.getLogger(__name__) User = get_user_model()@@ -48,22 +47,19 @@ class Command(BaseCommand): user_fail = 0 for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):+ periods = get_supported_periods(slug)+ self.stdout.write(f" [{idx}/{total_trends}] {slug}...\n")+ for period in periods: trend_start = timezone.now()- self.stdout.write(- f" [{idx}/{total_trends}] {slug}... ", ending=""- )+ self.stdout.write(f" {period}... ", ending="") try:- elapsed = _compute_and_save_trend(user, slug)- self.stdout.write(- self.style.SUCCESS(f"OK ({elapsed:.1f}s)")- )+ elapsed = compute_and_save_trend(user, slug, period)+ 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}"- )+ self.style.ERROR(f"FAILED after {elapsed:.1f}s: {e}") ) user_fail += 1 @@ -1,9 +1,9 @@ # Generated by Django 4.2.29 on 2026-06-16 14:52 -from django.conf import settings-from django.db import migrations, models import django.db.models.deletion import django_extensions.db.fields+from django.conf import settings+from django.db import migrations, models class Migration(migrations.Migration):@@ -0,0 +1,37 @@+# Generated by Django 4.2.29 on 2026-06-17 14:32++from django.conf import settings+from django.db import migrations, models+++class Migration(migrations.Migration):++ dependencies = [+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),+ ("trends", "0001_initial"),+ ]++ operations = [+ migrations.AlterUniqueTogether(+ name="trendresult",+ unique_together=set(),+ ),+ migrations.AddField(+ model_name="trendresult",+ name="period",+ field=models.CharField(+ choices=[+ ("last_30", "Last 30 days"),+ ("last_90", "Last 90 days"),+ ("last_year", "Last year"),+ ("all_time", "All time"),+ ],+ default="all_time",+ max_length=20,+ ),+ ),+ migrations.AlterUniqueTogether(+ name="trendresult",+ unique_together={("user", "trend_slug", "period")},+ ),+ ]@@ -4,15 +4,27 @@ from django_extensions.db.models import TimeStampedModel User = get_user_model() +PERIOD_CHOICES = [+ ("last_30", "Last 30 days"),+ ("last_90", "Last 90 days"),+ ("last_year", "Last year"),+ ("all_time", "All time"),+]+ class TrendResult(TimeStampedModel): user = models.ForeignKey(User, on_delete=models.CASCADE) trend_slug = models.CharField(max_length=100, db_index=True)+ period = models.CharField(+ max_length=20,+ choices=PERIOD_CHOICES,+ default="all_time",+ ) computed_at = models.DateTimeField(auto_now_add=True) data = models.JSONField(default=dict) class Meta:- unique_together = ["user", "trend_slug"]+ unique_together = ["user", "trend_slug", "period"] def __str__(self):- return f"{self.user} - {self.trend_slug} ({self.computed_at})"+ return f"{self.user} - {self.trend_slug} ({self.period})"@@ -3,35 +3,16 @@ import logging from celery import shared_task from django.contrib.auth import get_user_model from django.utils import timezone--from trends.models import TrendResult from trends.trends import TREND_REGISTRY+from trends.utils import compute_and_save_trend, get_supported_periods 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():- user_ids = list(- User.objects.filter(is_active=True).values_list("id", flat=True)- )+ 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)@@ -48,7 +29,9 @@ def compute_user_trends(user_id): total = len(TREND_REGISTRY) logger.info( "Computing %d trends for user %s (%d)",- total, user, user_id,+ total,+ user,+ user_id, ) for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):@@ -62,21 +45,25 @@ 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- )+ 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)+ periods = get_supported_periods(slug)++ for period in periods:+ logger.info("[%s/%s] Computing for user %d...", slug, period, user_id) try:- elapsed = _compute_and_save_trend(user, slug)+ elapsed = compute_and_save_trend(user, slug, period) logger.info(- "[%s] Completed for user %d in %.1fs",- slug, user_id, elapsed,+ "[%s/%s] Completed for user %d in %.1fs",+ slug,+ period,+ user_id,+ elapsed, ) except Exception:- logger.exception("[%s] Failed for user %d", slug, user_id)+ logger.exception("[%s/%s] Failed for user %d", slug, period, user_id)@@ -2,7 +2,7 @@ <div class="col-12"> {% if data.distribution %} <p class="text-muted mb-3">- Total scrobbles: <strong>{{ data.total_count }}</strong>+ Total scrobbles{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total_count }}</strong> </p> <div class="table-responsive"> <table class="table table-striped table-sm">@@ -1,4 +1,9 @@ <div class="row">+ {% if current_period_label %}+ <div class="col-12 mb-2">+ <small class="text-muted">Period: {{ current_period_label }}</small>+ </div>+ {% endif %} <div class="col-md-6 mb-3"> <div class="card"> <div class="card-body">@@ -6,8 +6,8 @@ <thead> <tr> <th>Media Type</th>- <th class="text-end">Recent (30 days)</th>- <th class="text-end">Previous (30 days)</th>+ <th class="text-end">Recent ({{ current_period_label }})</th>+ <th class="text-end">Previous ({{ current_period_label }})</th> <th class="text-end">Change</th> </tr> </thead>@@ -8,6 +8,30 @@ <a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary mb-2">← All Trends</a> <h2>{{ trend.icon }} {{ trend.title }}</h2> <p class="text-muted">{{ trend.description }}</p>++ {% if supported_periods|length > 1 %}+ <div class="d-flex align-items-center gap-2 mb-2 flex-wrap">+ <nav class="btn-group btn-group-sm" role="group">+ {% for period_slug, period_label in supported_periods.items %}+ <a href="?period={{ period_slug }}"+ class="btn btn-sm {% if period_slug == current_period %}btn-primary{% else %}btn-outline-secondary{% endif %}">+ {{ period_label }}+ </a>+ {% endfor %}+ </nav>+ {% if prev_period or next_period %}+ <div class="btn-group btn-group-sm">+ {% if prev_period %}+ <a href="?period={{ prev_period }}" class="btn btn-outline-secondary">« Prev</a>+ {% endif %}+ {% if next_period %}+ <a href="?period={{ next_period }}" class="btn btn-outline-secondary">Next »</a>+ {% endif %}+ </div>+ {% endif %}+ </div>+ {% endif %}+ {% if computed_at %} <small class="text-muted">Last computed: {{ computed_at|date:"F j, Y H:i" }}</small> {% endif %}@@ -19,7 +43,7 @@ {% elif data is None %} <div class="alert alert-info">- No data computed yet. Trends are updated once daily, check back later.+ No data computed yet for this period. Trends are updated once daily, check back later. </div> {% elif trend.slug == "concurrent-listening" %}@@ -3,11 +3,10 @@ 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):+def compute_peak_hours(user, period="all_time"): """Group scrobbles by hour of day (0-23) and count them. Returns dict: {"hours": [{"hour": N, "count": N}, ...]} sorted by hour.@@ -28,13 +27,14 @@ def compute_peak_hours(user): return {"hours": hours} -def compute_weekly_rhythm(user):+def compute_weekly_rhythm(user, period="all_time"): """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([+ DAY_NAMES = OrderedDict(+ [ (1, "Monday"), (2, "Tuesday"), (3, "Wednesday"),@@ -42,7 +42,8 @@ def compute_weekly_rhythm(user): (5, "Friday"), (6, "Saturday"), (7, "Sunday"),- ])+ ]+ ) days_qs = ( Scrobble.objects.filter(user=user, timestamp__isnull=False)@@ -55,24 +56,35 @@ def compute_weekly_rhythm(user): raw = {row["day"]: row["count"] for row in days_qs} days = [] for idx, name in DAY_NAMES.items():- days.append({+ days.append(+ { "day_index": idx, "day_name": name, "count": raw.get(idx, 0),- })+ }+ ) return {"days": days} -def compute_activity_distribution(user):+def compute_activity_distribution(user, period="all_time"): """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". """+ from trends.utils import get_date_range++ start, end = get_date_range(period)+ filters = Q(user=user)+ if start:+ filters &= Q(timestamp__gte=start)+ if end:+ filters &= Q(timestamp__lte=end)+ dist_qs = (- Scrobble.objects.filter(user=user)+ Scrobble.objects.filter(filters) .values("media_type") .annotate( count=Count("id"),@@ -86,12 +98,14 @@ def compute_activity_distribution(user): distribution = [] for row in rows:- distribution.append({+ distribution.append(+ { "media_type": row["media_type"], "count": row["count"], "completed": row["completed"], "pct": round((row["count"] / total) * 100, 1),- })+ }+ ) return { "distribution": distribution,@@ -1,6 +1,7 @@ import datetime from collections import defaultdict +from django.db.models import Q from scrobbles.models import Scrobble @@ -21,12 +22,8 @@ def _find_concurrent(anchor_scrobbles, paired_scrobbles): Returns a dict mapping each anchor scrobble PK to a list of paired scrobble PKs that overlap with it. """- anchor_ranges = {- s.pk: _range_for(s) for s in anchor_scrobbles- }- paired_ranges = {- s.pk: _range_for(s) for s in paired_scrobbles- }+ anchor_ranges = {s.pk: _range_for(s) for s in anchor_scrobbles}+ paired_ranges = {s.pk: _range_for(s) for s in paired_scrobbles} anchor_to_paired = defaultdict(list) @@ -41,7 +38,10 @@ def _find_concurrent(anchor_scrobbles, paired_scrobbles): def _get_media_name(scrobble): """Return the name of the media object associated with a scrobble.""" for attr in [- "trail", "geo_location", "book", "track",+ "trail",+ "geo_location",+ "book",+ "track", ]: obj = getattr(scrobble, attr, None) if obj is not None:@@ -49,22 +49,45 @@ def _get_media_name(scrobble): return "Unknown" -def compute_concurrent_listening(user):+def compute_concurrent_listening(user, period="all_time"): """Find what music was listened to while on trails or at locations. Returns a dict with two keys: 'trails' and 'locations', each containing a list of entries with the trail/location name and the tracks listened to. """- media_types_to_exclude_from_anchor = ("Track", "Book", "Video", "PodcastEpisode",- "VideoGame", "BoardGame", "Puzzle", "Food",- "Beer", "Task", "WebPage", "LifeEvent",- "Mood", "BrickSet", "Channel", "BirdingLocation",- "Paper", "SportEvent")+ from trends.utils import get_date_range++ start, end = get_date_range(period)+ base_filters = Q(user=user, timestamp__isnull=False)+ if start:+ base_filters &= Q(timestamp__gte=start)+ if end:+ base_filters &= Q(timestamp__lte=end)++ media_types_to_exclude_from_anchor = (+ "Track",+ "Book",+ "Video",+ "PodcastEpisode",+ "VideoGame",+ "BoardGame",+ "Puzzle",+ "Food",+ "Beer",+ "Task",+ "WebPage",+ "LifeEvent",+ "Mood",+ "BrickSet",+ "Channel",+ "BirdingLocation",+ "Paper",+ "SportEvent",+ ) anchor_scrobbles = list( Scrobble.objects.filter(- user=user,- timestamp__isnull=False,+ base_filters, played_to_completion=True, ) .exclude(media_type__in=media_types_to_exclude_from_anchor)@@ -74,9 +97,8 @@ def compute_concurrent_listening(user): paired_scrobbles = list( Scrobble.objects.filter(- user=user,+ base_filters, media_type="Track",- timestamp__isnull=False, stop_timestamp__isnull=False, played_to_completion=True, )@@ -131,29 +153,45 @@ def compute_concurrent_listening(user): } if anchor.media_type == "Trail":- entry["uuid"] = str(anchor.trail.uuid) if anchor.trail and anchor.trail.uuid else ""+ entry["uuid"] = (+ str(anchor.trail.uuid) if anchor.trail and anchor.trail.uuid else ""+ ) trails.append(entry) else:- entry["uuid"] = str(anchor.geo_location.uuid) if anchor.geo_location and anchor.geo_location.uuid else ""+ entry["uuid"] = (+ str(anchor.geo_location.uuid)+ if anchor.geo_location and anchor.geo_location.uuid+ else ""+ ) locations.append(entry) return { "trails": sorted(trails, key=lambda x: x["total_sessions"], reverse=True)[:20],- "locations": sorted(locations, key=lambda x: x["total_sessions"], reverse=True)[:20],+ "locations": sorted(locations, key=lambda x: x["total_sessions"], reverse=True)[+ :20+ ], } -def compute_concurrent_reading(user):+def compute_concurrent_reading(user, period="all_time"): """Find what music was listened to while reading books. Returns a dict with key 'books' containing a list of entries with the book title and the tracks listened to while reading. """+ from trends.utils import get_date_range++ start, end = get_date_range(period)+ base_filters = Q(user=user, timestamp__isnull=False)+ if start:+ base_filters &= Q(timestamp__gte=start)+ if end:+ base_filters &= Q(timestamp__lte=end)+ anchor_scrobbles = list( Scrobble.objects.filter(- user=user,+ base_filters, media_type="Book",- timestamp__isnull=False, stop_timestamp__isnull=False, played_to_completion=True, )@@ -163,9 +201,8 @@ def compute_concurrent_reading(user): paired_scrobbles = list( Scrobble.objects.filter(- user=user,+ base_filters, media_type="Track",- timestamp__isnull=False, stop_timestamp__isnull=False, played_to_completion=True, )@@ -203,7 +240,8 @@ def compute_concurrent_reading(user): } book = anchor.book- books.append({+ books.append(+ { "book_title": str(book) if book else "Unknown", "book_uuid": str(book.uuid) if book and book.uuid else "", "total_sessions": len(paired_pks),@@ -215,7 +253,8 @@ def compute_concurrent_reading(user): key=lambda x: x["count"], reverse=True, )[:20],- })+ }+ ) return { "books": sorted(books, key=lambda x: x["total_sessions"], reverse=True)[:20],@@ -1,21 +1,30 @@ import datetime from collections import defaultdict +from django.db.models import Q from scrobbles.models import Scrobble -def compute_reading_pace_vs_activity(user):+def compute_reading_pace_vs_activity(user, period="all_time"): """Compare reading pace (seconds per session) when music is playing vs. not. For each Book scrobble with a playback_position_seconds value, checks whether there is an overlapping Track scrobble and groups the data. Returns average session duration for both groups. """+ from trends.utils import get_date_range++ start, end = get_date_range(period)+ base_filters = Q(user=user, timestamp__isnull=False)+ if start:+ base_filters &= Q(timestamp__gte=start)+ if end:+ base_filters &= Q(timestamp__lte=end)+ book_scrobbles = list( Scrobble.objects.filter(- user=user,+ base_filters, media_type="Book",- timestamp__isnull=False, playback_position_seconds__isnull=False, played_to_completion=True, )@@ -28,12 +37,10 @@ def compute_reading_pace_vs_activity(user): track_scrobbles = list( Scrobble.objects.filter(- user=user,+ base_filters, media_type="Track",- timestamp__isnull=False, played_to_completion=True,- )- .order_by("-timestamp")+ ).order_by("-timestamp") ) track_ranges = []@@ -2,18 +2,21 @@ from collections import defaultdict from django.db.models import Count from django.utils import timezone- from scrobbles.models import Scrobble -def compute_trending_up(user, days=30):+def compute_trending_up(user, period="last_30"): """Compare scrobble counts per media type between two periods. Compares the most recent N days against the N days before that, returning the count for each period and the percentage change.+ The period controls the window size (e.g. 30, 90, 365 days). Returns a dict keyed by media_type with count and change info. """+ from trends.utils import get_period_days++ days = get_period_days(period) or 30 now = timezone.now() recent_start = now - timezone.timedelta(days=days) previous_start = recent_start - timezone.timedelta(days=days)@@ -1,5 +1,4 @@ from django.urls import path- from trends.views import TrendDetailView, TrendListView app_name = "trends"@@ -0,0 +1,80 @@+import logging+from datetime import timedelta++from django.utils import timezone+from trends.models import PERIOD_CHOICES, TrendResult++logger = logging.getLogger(__name__)++PERIOD_DAYS = {+ "last_30": 30,+ "last_90": 90,+ "last_year": 365,+ "all_time": None,+}++PERIOD_LABELS = dict(PERIOD_CHOICES)++TIME_BOUND_TRENDS = {+ "activity-distribution",+ "concurrent-reading",+ "concurrent-listening",+ "reading-pace-vs-activity",+ "trending-up",+}++TREND_PERIOD_OVERRIDES = {+ "trending-up": ["last_30", "last_90", "last_year"],+}+++def get_supported_periods(trend_slug):+ if trend_slug in TREND_PERIOD_OVERRIDES:+ slugs = TREND_PERIOD_OVERRIDES[trend_slug]+ return {s: PERIOD_LABELS[s] for s in slugs}+ if trend_slug in TIME_BOUND_TRENDS:+ return dict(PERIOD_LABELS)+ return {"all_time": PERIOD_LABELS["all_time"]}+++def get_period_days(period):+ return PERIOD_DAYS.get(period)+++def get_date_range(period):+ days = get_period_days(period)+ if days is None:+ return None, None+ now = timezone.now()+ return now - timedelta(days=days), now+++def get_period_nav(current_period, trend_slug):+ supported = get_supported_periods(trend_slug)+ keys = list(supported.keys())+ try:+ idx = keys.index(current_period)+ except ValueError:+ return None, None+ prev_period = keys[idx - 1] if idx > 0 else None+ next_period = keys[idx + 1] if idx < len(keys) - 1 else None+ return prev_period, next_period+++def compute_and_save_trend(user, slug, period="all_time"):+ """Compute a single trend for a given period and persist the result.++ Returns elapsed seconds on success, raises on failure.+ """+ from trends.trends import TREND_REGISTRY++ fn = TREND_REGISTRY[slug]+ start = timezone.now()+ data = fn(user, period=period)+ TrendResult.objects.update_or_create(+ user=user,+ trend_slug=slug,+ period=period,+ defaults={"data": data, "computed_at": timezone.now()},+ )+ return (timezone.now() - start).total_seconds()@@ -1,8 +1,8 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.views.generic import TemplateView- from trends.models import TrendResult from trends.trends import TREND_REGISTRY+from trends.utils import get_period_nav, get_supported_periods TREND_METADATA = { "activity-distribution": {@@ -48,24 +48,29 @@ class TrendListView(LoginRequiredMixin, TemplateView): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs)- results = {- r.trend_slug: r- for r in TrendResult.objects.filter(- user=self.request.user- )- }+ results = TrendResult.objects.filter(+ user=self.request.user,+ ).order_by("trend_slug", "-computed_at")++ latest_by_slug = {}+ for r in results:+ if r.trend_slug not in latest_by_slug:+ latest_by_slug[r.trend_slug] = r+ trends = [] for slug in TREND_REGISTRY: meta = TREND_METADATA.get(slug, {})- result = results.get(slug)- trends.append({+ result = latest_by_slug.get(slug)+ trends.append(+ { "slug": slug, "title": meta.get("title", slug), "description": meta.get("description", ""), "icon": meta.get("icon", ""), "computed_at": result.computed_at if result else None, "has_data": result is not None,- })+ }+ ) ctx["trends"] = trends return ctx @@ -81,6 +86,8 @@ class TrendDetailView(LoginRequiredMixin, TemplateView): ctx["trend_not_found"] = True return ctx + period = self.request.GET.get("period", "all_time")+ meta = TREND_METADATA.get(slug, {}) ctx["trend"] = { "slug": slug,@@ -89,9 +96,19 @@ class TrendDetailView(LoginRequiredMixin, TemplateView): "icon": meta.get("icon", ""), } + supported = get_supported_periods(slug)+ ctx["supported_periods"] = supported+ ctx["current_period"] = period+ ctx["current_period_label"] = supported.get(period, "")++ prev_period, next_period = get_period_nav(period, slug)+ ctx["prev_period"] = prev_period+ ctx["next_period"] = next_period+ result = TrendResult.objects.filter( user=self.request.user, trend_slug=slug,+ period=period, ).first() if result: