sessions / 19f2b5e80164366d2f4ccb5f0895befef43c2a9f

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

This commit has no recorded session.

diff

ignore whitespace

commit 19f2b5e80164366d2f4ccb5f0895befef43c2a9fAuthor: Colin Powell <colin@unbl.ink>Date:   Wed Jun 17 10:50:16 2026 -0400    [trends] Add time periodsdiff --git a/PROJECT.org b/PROJECT.orgindex 409424f..4004bd8 100644--- a/PROJECT.org+++ b/PROJECT.org@@ -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:diff --git a/vrobbler/apps/trends/admin.py b/vrobbler/apps/trends/admin.pyindex 50a65ab..943072e 100644--- a/vrobbler/apps/trends/admin.py+++ b/vrobbler/apps/trends/admin.py@@ -1,5 +1,4 @@ from django.contrib import admin- from trends.models import TrendResult  diff --git a/vrobbler/apps/trends/management/commands/compute_trends.py b/vrobbler/apps/trends/management/commands/compute_trends.pyindex 51f7f67..76e2027 100644--- a/vrobbler/apps/trends/management/commands/compute_trends.py+++ b/vrobbler/apps/trends/management/commands/compute_trends.py@@ -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,24 +47,21 @@ class Command(BaseCommand):             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}"+                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"      {period}... ", ending="")+                    try:+                        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}")                         )-                    )-                    user_fail += 1+                        user_fail += 1              user_elapsed = (timezone.now() - user_start).total_seconds()             self.stdout.write(diff --git a/vrobbler/apps/trends/migrations/0001_initial.py b/vrobbler/apps/trends/migrations/0001_initial.pyindex b30032a..307e1bb 100644--- a/vrobbler/apps/trends/migrations/0001_initial.py+++ b/vrobbler/apps/trends/migrations/0001_initial.py@@ -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):diff --git a/vrobbler/apps/trends/migrations/0002_alter_trendresult_unique_together_trendresult_period_and_more.py b/vrobbler/apps/trends/migrations/0002_alter_trendresult_unique_together_trendresult_period_and_more.pynew file mode 100644index 0000000..7292bf5--- /dev/null+++ b/vrobbler/apps/trends/migrations/0002_alter_trendresult_unique_together_trendresult_period_and_more.py@@ -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")},+        ),+    ]diff --git a/vrobbler/apps/trends/models.py b/vrobbler/apps/trends/models.pyindex 0998562..6b8f835 100644--- a/vrobbler/apps/trends/models.py+++ b/vrobbler/apps/trends/models.py@@ -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})"diff --git a/vrobbler/apps/trends/tasks.py b/vrobbler/apps/trends/tasks.pyindex 39e9500..d7e3fb7 100644--- a/vrobbler/apps/trends/tasks.py+++ b/vrobbler/apps/trends/tasks.py@@ -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)-    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)+    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, period)+            logger.info(+                "[%s/%s] Completed for user %d in %.1fs",+                slug,+                period,+                user_id,+                elapsed,+            )+        except Exception:+            logger.exception("[%s/%s] Failed for user %d", slug, period, user_id)diff --git a/vrobbler/apps/trends/templates/trends/_activity_distribution.html b/vrobbler/apps/trends/templates/trends/_activity_distribution.htmlindex e48545c..aa480d3 100644--- a/vrobbler/apps/trends/templates/trends/_activity_distribution.html+++ b/vrobbler/apps/trends/templates/trends/_activity_distribution.html@@ -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">diff --git a/vrobbler/apps/trends/templates/trends/_reading_pace.html b/vrobbler/apps/trends/templates/trends/_reading_pace.htmlindex 0a9196d..8433605 100644--- a/vrobbler/apps/trends/templates/trends/_reading_pace.html+++ b/vrobbler/apps/trends/templates/trends/_reading_pace.html@@ -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">diff --git a/vrobbler/apps/trends/templates/trends/_trending_up.html b/vrobbler/apps/trends/templates/trends/_trending_up.htmlindex a6c6a3c..c849e24 100644--- a/vrobbler/apps/trends/templates/trends/_trending_up.html+++ b/vrobbler/apps/trends/templates/trends/_trending_up.html@@ -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>diff --git a/vrobbler/apps/trends/templates/trends/trend_detail.html b/vrobbler/apps/trends/templates/trends/trend_detail.htmlindex fef417e..7f8eb53 100644--- a/vrobbler/apps/trends/templates/trends/trend_detail.html+++ b/vrobbler/apps/trends/templates/trends/trend_detail.html@@ -8,6 +8,30 @@     <a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary mb-2">&larr; 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">&laquo; Prev</a>+        {% endif %}+        {% if next_period %}+        <a href="?period={{ next_period }}" class="btn btn-outline-secondary">Next &raquo;</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" %}diff --git a/vrobbler/apps/trends/trends/activity.py b/vrobbler/apps/trends/trends/activity.pyindex 9ffb2df..cb5a6cd 100644--- a/vrobbler/apps/trends/trends/activity.py+++ b/vrobbler/apps/trends/trends/activity.py@@ -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,21 +27,23 @@ 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([-        (1, "Monday"),-        (2, "Tuesday"),-        (3, "Wednesday"),-        (4, "Thursday"),-        (5, "Friday"),-        (6, "Saturday"),-        (7, "Sunday"),-    ])+    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)@@ -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({-            "day_index": idx,-            "day_name": name,-            "count": raw.get(idx, 0),-        })+        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({-            "media_type": row["media_type"],-            "count": row["count"],-            "completed": row["completed"],-            "pct": round((row["count"] / total) * 100, 1),-        })+        distribution.append(+            {+                "media_type": row["media_type"],+                "count": row["count"],+                "completed": row["completed"],+                "pct": round((row["count"] / total) * 100, 1),+            }+        )      return {         "distribution": distribution,diff --git a/vrobbler/apps/trends/trends/concurrent.py b/vrobbler/apps/trends/trends/concurrent.pyindex 9f6d406..eb9ac4b 100644--- a/vrobbler/apps/trends/trends/concurrent.py+++ b/vrobbler/apps/trends/trends/concurrent.py@@ -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,19 +240,21 @@ def compute_concurrent_reading(user):                 }          book = anchor.book-        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),-            "tracks": sorted(-                [-                    {**track_details[name], "count": count}-                    for name, count in tracks_by_name.items()-                ],-                key=lambda x: x["count"],-                reverse=True,-            )[:20],-        })+        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),+                "tracks": sorted(+                    [+                        {**track_details[name], "count": count}+                        for name, count in tracks_by_name.items()+                    ],+                    key=lambda x: x["count"],+                    reverse=True,+                )[:20],+            }+        )      return {         "books": sorted(books, key=lambda x: x["total_sessions"], reverse=True)[:20],diff --git a/vrobbler/apps/trends/trends/reading.py b/vrobbler/apps/trends/trends/reading.pyindex b27fc4c..c1ceec1 100644--- a/vrobbler/apps/trends/trends/reading.py+++ b/vrobbler/apps/trends/trends/reading.py@@ -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 = []diff --git a/vrobbler/apps/trends/trends/trending.py b/vrobbler/apps/trends/trends/trending.pyindex 188bade..101d34b 100644--- a/vrobbler/apps/trends/trends/trending.py+++ b/vrobbler/apps/trends/trends/trending.py@@ -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)diff --git a/vrobbler/apps/trends/urls.py b/vrobbler/apps/trends/urls.pyindex cbab5da..f0c4071 100644--- a/vrobbler/apps/trends/urls.py+++ b/vrobbler/apps/trends/urls.py@@ -1,5 +1,4 @@ from django.urls import path- from trends.views import TrendDetailView, TrendListView  app_name = "trends"diff --git a/vrobbler/apps/trends/utils.py b/vrobbler/apps/trends/utils.pynew file mode 100644index 0000000..333d172--- /dev/null+++ b/vrobbler/apps/trends/utils.py@@ -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()diff --git a/vrobbler/apps/trends/views.py b/vrobbler/apps/trends/views.pyindex 587981a..613e133 100644--- a/vrobbler/apps/trends/views.py+++ b/vrobbler/apps/trends/views.py@@ -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({-                "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,-            })+            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: