sessions / 57f273b0cc424598fd97458b749f2185dacd1eaf

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

This commit has no recorded session.

diff

ignore whitespace

commit 57f273b0cc424598fd97458b749f2185dacd1eafAuthor: Colin Powell <colin@unbl.ink>Date:   Tue Jun 16 11:12:13 2026 -0400    [trends] Initial trends appdiff --git a/PROJECT.org b/PROJECT.orgindex 392b4ee..96c1fdb 100644--- a/PROJECT.org+++ b/PROJECT.org@@ -523,21 +523,19 @@ so we can probably use backtrace=True and diagnose=True to help us root cause bugs faster.  ** TODO [#B] Add a /trends/ page that shows trends based on scrobble data :feature:trends:scrobbles:+:PROPERTIES:+:ID:       03e9fe30-2bc6-4062-bb24-e95b98daf05b+:END:  *** Description  This project is a bit invovled. But we should add a top level URL `trends` that shows various trends as defined either in a static settings file, or dynamically via a database table. -Examples of trends:+Trends could be things like doing multiple things at the same time, like while driving, what+did we listen to this week, or while running, what were listening to this week? -- How often does the user:-  + watch sports while doing a task?-  + do a task while watching a video?-  * how often do I do -  -- trail_scrobble__average_heartrate per trail -- ...+Or more complicated trends like, how time per page changes based on the book I was reading, or if I was doing something else (music or sport event) while reading.  ** TODO [#B] Scrape ComicBookRoundUp ratings for comic book metadata :books:feature:comicbook: :PROPERTIES:@@ -564,6 +562,7 @@ File: ~vrobbler/apps/podcasts/utils.py~ (line 13) :END:  *** Description+ The zombie scrobble cleanup query lives in a utility function. Should be a custom model manager method (e.g. =Scrobble.objects.zombies()=). diff --git a/vrobbler/apps/trends/__init__.py b/vrobbler/apps/trends/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/trends/admin.py b/vrobbler/apps/trends/admin.pynew file mode 100644index 0000000..50a65ab--- /dev/null+++ b/vrobbler/apps/trends/admin.py@@ -0,0 +1,10 @@+from django.contrib import admin++from trends.models import TrendResult+++@admin.register(TrendResult)+class TrendResultAdmin(admin.ModelAdmin):+    list_display = ("user", "trend_slug", "computed_at", "created")+    list_filter = ("user", "trend_slug")+    ordering = ("-computed_at",)diff --git a/vrobbler/apps/trends/apps.py b/vrobbler/apps/trends/apps.pynew file mode 100644index 0000000..77005c0--- /dev/null+++ b/vrobbler/apps/trends/apps.py@@ -0,0 +1,6 @@+from django.apps import AppConfig+++class TrendsConfig(AppConfig):+    default_auto_field = "django.db.models.BigAutoField"+    name = "trends"diff --git a/vrobbler/apps/trends/management/__init__.py b/vrobbler/apps/trends/management/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/trends/management/commands/__init__.py b/vrobbler/apps/trends/management/commands/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/trends/management/commands/compute_trends.py b/vrobbler/apps/trends/management/commands/compute_trends.pynew file mode 100644index 0000000..76f605e--- /dev/null+++ b/vrobbler/apps/trends/management/commands/compute_trends.py@@ -0,0 +1,40 @@+from django.contrib.auth import get_user_model+from django.core.management.base import BaseCommand++from trends.tasks import compute_user_trends++User = get_user_model()+++class Command(BaseCommand):+    help = "Compute trends for all users (or a specific user)"++    def add_arguments(self, parser):+        parser.add_argument(+            "--user-id",+            type=int,+            help="Compute trends for a specific user only",+        )++    def handle(self, *args, **options):+        if options["user_id"]:+            user = User.objects.filter(id=options["user_id"]).first()+            if not user:+                self.stderr.write(+                    self.style.ERROR(f"User with id {options['user_id']} not found")+                )+                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...")++        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}"))++        self.stdout.write(self.style.SUCCESS("Done!"))diff --git a/vrobbler/apps/trends/migrations/0001_initial.py b/vrobbler/apps/trends/migrations/0001_initial.pynew file mode 100644index 0000000..b30032a--- /dev/null+++ b/vrobbler/apps/trends/migrations/0001_initial.py@@ -0,0 +1,57 @@+# 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+++class Migration(migrations.Migration):++    initial = True++    dependencies = [+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),+    ]++    operations = [+        migrations.CreateModel(+            name="TrendResult",+            fields=[+                (+                    "id",+                    models.BigAutoField(+                        auto_created=True,+                        primary_key=True,+                        serialize=False,+                        verbose_name="ID",+                    ),+                ),+                (+                    "created",+                    django_extensions.db.fields.CreationDateTimeField(+                        auto_now_add=True, verbose_name="created"+                    ),+                ),+                (+                    "modified",+                    django_extensions.db.fields.ModificationDateTimeField(+                        auto_now=True, verbose_name="modified"+                    ),+                ),+                ("trend_slug", models.CharField(db_index=True, max_length=100)),+                ("computed_at", models.DateTimeField(auto_now_add=True)),+                ("data", models.JSONField(default=dict)),+                (+                    "user",+                    models.ForeignKey(+                        on_delete=django.db.models.deletion.CASCADE,+                        to=settings.AUTH_USER_MODEL,+                    ),+                ),+            ],+            options={+                "unique_together": {("user", "trend_slug")},+            },+        ),+    ]diff --git a/vrobbler/apps/trends/migrations/__init__.py b/vrobbler/apps/trends/migrations/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/trends/models.py b/vrobbler/apps/trends/models.pynew file mode 100644index 0000000..0998562--- /dev/null+++ b/vrobbler/apps/trends/models.py@@ -0,0 +1,18 @@+from django.contrib.auth import get_user_model+from django.db import models+from django_extensions.db.models import TimeStampedModel++User = get_user_model()+++class TrendResult(TimeStampedModel):+    user = models.ForeignKey(User, on_delete=models.CASCADE)+    trend_slug = models.CharField(max_length=100, db_index=True)+    computed_at = models.DateTimeField(auto_now_add=True)+    data = models.JSONField(default=dict)++    class Meta:+        unique_together = ["user", "trend_slug"]++    def __str__(self):+        return f"{self.user} - {self.trend_slug} ({self.computed_at})"diff --git a/vrobbler/apps/trends/tasks.py b/vrobbler/apps/trends/tasks.pynew file mode 100644index 0000000..85e6a7e--- /dev/null+++ b/vrobbler/apps/trends/tasks.py@@ -0,0 +1,39 @@+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++logger = logging.getLogger(__name__)+User = get_user_model()+++@shared_task+def compute_all_trends():+    for user in User.objects.filter(is_active=True):+        compute_user_trends.delay(user.id)+++@shared_task+def compute_user_trends(user_id):+    try:+        user = User.objects.get(id=user_id)+    except User.DoesNotExist:+        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+            )diff --git a/vrobbler/apps/trends/templates/trends/_concurrent_listening.html b/vrobbler/apps/trends/templates/trends/_concurrent_listening.htmlnew file mode 100644index 0000000..f7f83d5--- /dev/null+++ b/vrobbler/apps/trends/templates/trends/_concurrent_listening.html@@ -0,0 +1,89 @@+<div class="row">+  {% if data.trails %}+  <div class="col-md-6 mb-3">+    <h4>๐Ÿฅพ While on Trails</h4>+    {% for trail in data.trails %}+    <div class="card mb-2">+      <div class="card-body py-2">+        <h6 class="card-title mb-1">+          {% if trail.uuid %}+          <a href="{% url 'trails:trail_detail' trail.uuid %}">{{ trail.name }}</a>+          {% else %}+          {{ trail.name }}+          {% endif %}+          <small class="text-muted">({{ trail.total_sessions }} sessions)</small>+        </h6>+        {% if trail.tracks %}+        <table class="table table-sm table-borderless mb-0">+          <thead>+            <tr>+              <th>Track</th>+              <th>Artist</th>+              <th class="text-end">Plays</th>+            </tr>+          </thead>+          <tbody>+            {% for t in trail.tracks %}+            <tr>+              <td>{% if t.track_uuid %}<a href="{% url 'music:track_detail' t.track_uuid %}">{{ t.track_name }}</a>{% else %}{{ t.track_name }}{% endif %}</td>+              <td>{{ t.artist_name }}</td>+              <td class="text-end">{{ t.count }}</td>+            </tr>+            {% endfor %}+          </tbody>+        </table>+        {% endif %}+      </div>+    </div>+    {% empty %}+    <p class="text-muted">No concurrent listening data for trails.</p>+    {% endfor %}+  </div>+  {% endif %}++  {% if data.locations %}+  <div class="col-md-6 mb-3">+    <h4>๐Ÿ“ While at Locations</h4>+    {% for loc in data.locations %}+    <div class="card mb-2">+      <div class="card-body py-2">+        <h6 class="card-title mb-1">+          {% if loc.uuid %}+          <a href="{% url 'locations:geolocation_detail' loc.uuid %}">{{ loc.name }}</a>+          {% else %}+          {{ loc.name }}+          {% endif %}+          <small class="text-muted">({{ loc.total_sessions }} sessions)</small>+        </h6>+        {% if loc.tracks %}+        <table class="table table-sm table-borderless mb-0">+          <thead>+            <tr>+              <th>Track</th>+              <th>Artist</th>+              <th class="text-end">Plays</th>+            </tr>+          </thead>+          <tbody>+            {% for t in loc.tracks %}+            <tr>+              <td>{% if t.track_uuid %}<a href="{% url 'music:track_detail' t.track_uuid %}">{{ t.track_name }}</a>{% else %}{{ t.track_name }}{% endif %}</td>+              <td>{{ t.artist_name }}</td>+              <td class="text-end">{{ t.count }}</td>+            </tr>+            {% endfor %}+          </tbody>+        </table>+        {% endif %}+      </div>+    </div>+    {% empty %}+    <p class="text-muted">No concurrent listening data for locations.</p>+    {% endfor %}+  </div>+  {% endif %}+</div>++{% if not data.trails and not data.locations %}+<p class="text-muted">No concurrent listening data found.</p>+{% endif %}diff --git a/vrobbler/apps/trends/templates/trends/_concurrent_reading.html b/vrobbler/apps/trends/templates/trends/_concurrent_reading.htmlnew file mode 100644index 0000000..0adc7a6--- /dev/null+++ b/vrobbler/apps/trends/templates/trends/_concurrent_reading.html@@ -0,0 +1,42 @@+<div class="row">+  {% if data.books %}+  {% for book in data.books %}+  <div class="col-md-6 mb-3">+    <div class="card">+      <div class="card-body py-2">+        <h6 class="card-title mb-1">+          {% if book.book_uuid %}+          <a href="{% url 'books:book_detail' book.book_uuid %}">{{ book.book_title }}</a>+          {% else %}+          {{ book.book_title }}+          {% endif %}+          <small class="text-muted">({{ book.total_sessions }} listening sessions)</small>+        </h6>+        {% if book.tracks %}+        <table class="table table-sm table-borderless mb-0">+          <thead>+            <tr>+              <th>Track</th>+              <th>Artist</th>+              <th class="text-end">Plays</th>+            </tr>+          </thead>+          <tbody>+            {% for t in book.tracks %}+            <tr>+              <td>{% if t.track_uuid %}<a href="{% url 'music:track_detail' t.track_uuid %}">{{ t.track_name }}</a>{% else %}{{ t.track_name }}{% endif %}</td>+              <td>{{ t.artist_name }}</td>+              <td class="text-end">{{ t.count }}</td>+            </tr>+            {% endfor %}+          </tbody>+        </table>+        {% endif %}+      </div>+    </div>+  </div>+  {% endfor %}+  {% else %}+  <p class="text-muted">No concurrent reading data found.</p>+  {% endif %}+</div>diff --git a/vrobbler/apps/trends/templates/trends/_reading_pace.html b/vrobbler/apps/trends/templates/trends/_reading_pace.htmlnew file mode 100644index 0000000..0a9196d--- /dev/null+++ b/vrobbler/apps/trends/templates/trends/_reading_pace.html@@ -0,0 +1,52 @@+<div class="row">+  <div class="col-md-6 mb-3">+    <div class="card">+      <div class="card-body">+        <h5 class="card-title">๐ŸŽต Reading with Music</h5>+        {% if data.with_music %}+        <table class="table table-sm mb-0">+          <tr>+            <th>Avg session duration</th>+            <td>{{ data.with_music.avg_seconds }} seconds</td>+          </tr>+          <tr>+            <th>Total reading time</th>+            <td>{{ data.with_music.total_seconds }} seconds</td>+          </tr>+          <tr>+            <th>Reading sessions</th>+            <td>{{ data.with_music.sessions_count }}</td>+          </tr>+        </table>+        {% else %}+        <p class="text-muted mb-0">No data.</p>+        {% endif %}+      </div>+    </div>+  </div>+  <div class="col-md-6 mb-3">+    <div class="card">+      <div class="card-body">+        <h5 class="card-title">๐Ÿ”‡ Reading without Music</h5>+        {% if data.without_music %}+        <table class="table table-sm mb-0">+          <tr>+            <th>Avg session duration</th>+            <td>{{ data.without_music.avg_seconds }} seconds</td>+          </tr>+          <tr>+            <th>Total reading time</th>+            <td>{{ data.without_music.total_seconds }} seconds</td>+          </tr>+          <tr>+            <th>Reading sessions</th>+            <td>{{ data.without_music.sessions_count }}</td>+          </tr>+        </table>+        {% else %}+        <p class="text-muted mb-0">No data.</p>+        {% endif %}+      </div>+    </div>+  </div>+</div>diff --git a/vrobbler/apps/trends/templates/trends/_trending_up.html b/vrobbler/apps/trends/templates/trends/_trending_up.htmlnew file mode 100644index 0000000..a6c6a3c--- /dev/null+++ b/vrobbler/apps/trends/templates/trends/_trending_up.html@@ -0,0 +1,38 @@+<div class="row">+  <div class="col-12">+    {% if data %}+    <div class="table-responsive">+      <table class="table table-striped table-sm">+        <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">Change</th>+          </tr>+        </thead>+        <tbody>+          {% for mt, info in data.items %}+          <tr>+            <td>{{ mt }}</td>+            <td class="text-end">{{ info.recent }}</td>+            <td class="text-end">{{ info.previous }}</td>+            <td class="text-end">+              {% if info.change_pct > 0 %}+              <span class="text-success">+{{ info.change_pct }}%</span>+              {% elif info.change_pct < 0 %}+              <span class="text-danger">{{ info.change_pct }}%</span>+              {% else %}+              <span class="text-muted">0%</span>+              {% endif %}+            </td>+          </tr>+          {% endfor %}+        </tbody>+      </table>+    </div>+    {% else %}+    <p class="text-muted">No trending 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.htmlnew file mode 100644index 0000000..cf6b569--- /dev/null+++ b/vrobbler/apps/trends/templates/trends/trend_detail.html@@ -0,0 +1,38 @@+{% extends "base_list.html" %}++{% block title %}{{ trend.title }}{% endblock %}++{% block lists %}+<div class="row mb-3">+  <div class="col-12">+    <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 computed_at %}+    <small class="text-muted">Last computed: {{ computed_at|date:"F j, Y H:i" }}</small>+    {% endif %}+  </div>+</div>++{% if trend_not_found %}+<div class="alert alert-warning">Trend not found.</div>++{% elif data is None %}+<div class="alert alert-info">+  No data computed yet. Trends are updated once daily, check back later.+</div>++{% elif trend.slug == "concurrent-listening" %}+  {% include "trends/_concurrent_listening.html" %}++{% elif trend.slug == "concurrent-reading" %}+  {% include "trends/_concurrent_reading.html" %}++{% elif trend.slug == "reading-pace-vs-activity" %}+  {% include "trends/_reading_pace.html" %}++{% elif trend.slug == "trending-up" %}+  {% include "trends/_trending_up.html" %}++{% endif %}+{% endblock %}diff --git a/vrobbler/apps/trends/templates/trends/trend_list.html b/vrobbler/apps/trends/templates/trends/trend_list.htmlnew file mode 100644index 0000000..a204b3d--- /dev/null+++ b/vrobbler/apps/trends/templates/trends/trend_list.html@@ -0,0 +1,39 @@+{% extends "base_list.html" %}++{% block title %}Trends{% endblock %}++{% block lists %}+<div class="row">+  {% if not user.is_authenticated %}+  <div class="col-12">+    <div class="alert alert-info">Log in to see your trends.</div>+  </div>+  {% elif not trends %}+  <div class="col-12">+    <div class="alert alert-info">+      No trends computed yet. Trends are computed once daily, check back later.+    </div>+  </div>+  {% else %}+  {% for trend in trends %}+  <div class="col-md-6 mb-3">+    <div class="card h-100">+      <div class="card-body">+        <h5 class="card-title">+          <a href="{% url 'trends:trend-detail' trend.slug %}" class="stretched-link text-decoration-none">+            {{ trend.icon }} {{ trend.title }}+          </a>+        </h5>+        <p class="card-text text-muted">{{ trend.description }}</p>+        {% if trend.computed_at %}+        <small class="text-muted">Last computed: {{ trend.computed_at|date:"M j, Y H:i" }}</small>+        {% else %}+        <span class="badge bg-warning text-dark">Pending</span>+        {% endif %}+      </div>+    </div>+  </div>+  {% endfor %}+  {% endif %}+</div>+{% endblock %}diff --git a/vrobbler/apps/trends/templatetags/__init__.py b/vrobbler/apps/trends/templatetags/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/trends/trends/__init__.py b/vrobbler/apps/trends/trends/__init__.pynew file mode 100644index 0000000..369f46c--- /dev/null+++ b/vrobbler/apps/trends/trends/__init__.py@@ -0,0 +1,25 @@+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++TREND_REGISTRY = {}++def register(slug):+    def decorator(fn):+        TREND_REGISTRY[slug] = fn+        return fn+    return decorator+++compute_concurrent_listening = register("concurrent-listening")(+    compute_concurrent_listening+)+compute_concurrent_reading = register("concurrent-reading")(+    compute_concurrent_reading+)+compute_reading_pace_vs_activity = register("reading-pace-vs-activity")(+    compute_reading_pace_vs_activity+)+compute_trending_up = register("trending-up")(+    compute_trending_up+)diff --git a/vrobbler/apps/trends/trends/concurrent.py b/vrobbler/apps/trends/trends/concurrent.pynew file mode 100644index 0000000..9f6d406--- /dev/null+++ b/vrobbler/apps/trends/trends/concurrent.py@@ -0,0 +1,222 @@+import datetime+from collections import defaultdict++from scrobbles.models import Scrobble+++def _range_for(scrobble):+    start = scrobble.timestamp+    end = scrobble.stop_timestamp+    if end is None:+        try:+            end = start + datetime.timedelta(hours=12)+        except AttributeError:+            end = start+    return start, end+++def _find_concurrent(anchor_scrobbles, paired_scrobbles):+    """Find paired scrobbles that overlap in time with anchor 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_to_paired = defaultdict(list)++    for a_pk, (a_start, a_end) in anchor_ranges.items():+        for p_pk, (p_start, p_end) in paired_ranges.items():+            if a_start <= p_end and p_start <= a_end:+                anchor_to_paired[a_pk].append(p_pk)++    return anchor_to_paired+++def _get_media_name(scrobble):+    """Return the name of the media object associated with a scrobble."""+    for attr in [+        "trail", "geo_location", "book", "track",+    ]:+        obj = getattr(scrobble, attr, None)+        if obj is not None:+            return str(obj)+    return "Unknown"+++def compute_concurrent_listening(user):+    """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")++    anchor_scrobbles = list(+        Scrobble.objects.filter(+            user=user,+            timestamp__isnull=False,+            played_to_completion=True,+        )+        .exclude(media_type__in=media_types_to_exclude_from_anchor)+        .select_related("trail", "geo_location")+        .order_by("-timestamp")+    )++    paired_scrobbles = list(+        Scrobble.objects.filter(+            user=user,+            media_type="Track",+            timestamp__isnull=False,+            stop_timestamp__isnull=False,+            played_to_completion=True,+        )+        .select_related("track")+        .order_by("-timestamp")+    )++    if not anchor_scrobbles or not paired_scrobbles:+        return {"trails": [], "locations": []}++    anchor_to_paired = _find_concurrent(anchor_scrobbles, paired_scrobbles)++    paired_by_pk = {s.pk: s for s in paired_scrobbles}++    trails = []+    locations = []++    for anchor in anchor_scrobbles:+        paired_pks = anchor_to_paired.get(anchor.pk, [])+        if not paired_pks:+            continue++        tracks_by_name = defaultdict(int)+        track_details = {}+        for p_pk in paired_pks:+            ps = paired_by_pk[p_pk]+            track = ps.track+            if track is None:+                continue+            name = str(track)+            tracks_by_name[name] += 1+            if name not in track_details:+                track_details[name] = {+                    "track_name": name,+                    "track_uuid": str(track.uuid) if track.uuid else "",+                    "artist_name": str(track.artist) if track.artist else "",+                }++        anchor_name = _get_media_name(anchor)+        entry = {+            "name": anchor_name,+            "uuid": "",+            "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],+        }++        if anchor.media_type == "Trail":+            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 ""+            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],+    }+++def compute_concurrent_reading(user):+    """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.+    """+    anchor_scrobbles = list(+        Scrobble.objects.filter(+            user=user,+            media_type="Book",+            timestamp__isnull=False,+            stop_timestamp__isnull=False,+            played_to_completion=True,+        )+        .select_related("book")+        .order_by("-timestamp")+    )++    paired_scrobbles = list(+        Scrobble.objects.filter(+            user=user,+            media_type="Track",+            timestamp__isnull=False,+            stop_timestamp__isnull=False,+            played_to_completion=True,+        )+        .select_related("track")+        .order_by("-timestamp")+    )++    if not anchor_scrobbles or not paired_scrobbles:+        return {"books": []}++    anchor_to_paired = _find_concurrent(anchor_scrobbles, paired_scrobbles)+    paired_by_pk = {s.pk: s for s in paired_scrobbles}++    books = []++    for anchor in anchor_scrobbles:+        paired_pks = anchor_to_paired.get(anchor.pk, [])+        if not paired_pks:+            continue++        tracks_by_name = defaultdict(int)+        track_details = {}+        for p_pk in paired_pks:+            ps = paired_by_pk[p_pk]+            track = ps.track+            if track is None:+                continue+            name = str(track)+            tracks_by_name[name] += 1+            if name not in track_details:+                track_details[name] = {+                    "track_name": name,+                    "track_uuid": str(track.uuid) if track.uuid else "",+                    "artist_name": str(track.artist) if track.artist else "",+                }++        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],+        })++    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.pynew file mode 100644index 0000000..b27fc4c--- /dev/null+++ b/vrobbler/apps/trends/trends/reading.py@@ -0,0 +1,86 @@+import datetime+from collections import defaultdict++from scrobbles.models import Scrobble+++def compute_reading_pace_vs_activity(user):+    """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.+    """+    book_scrobbles = list(+        Scrobble.objects.filter(+            user=user,+            media_type="Book",+            timestamp__isnull=False,+            playback_position_seconds__isnull=False,+            played_to_completion=True,+        )+        .select_related("book")+        .order_by("-timestamp")+    )++    if not book_scrobbles:+        return {"with_music": None, "without_music": None}++    track_scrobbles = list(+        Scrobble.objects.filter(+            user=user,+            media_type="Track",+            timestamp__isnull=False,+            played_to_completion=True,+        )+        .order_by("-timestamp")+    )++    track_ranges = []+    for ts in track_scrobbles:+        p_start = ts.timestamp+        p_end = ts.stop_timestamp+        if p_end is None:+            try:+                p_end = p_start + datetime.timedelta(hours=12)+            except AttributeError:+                p_end = p_start+        track_ranges.append((p_start, p_end))++    with_music_durations = []+    without_music_durations = []++    for bs in book_scrobbles:+        b_start = bs.timestamp+        b_end = bs.stop_timestamp+        if b_end is None:+            try:+                b_end = b_start + datetime.timedelta(hours=12)+            except AttributeError:+                b_end = b_start++        has_overlap = False+        for p_start, p_end in track_ranges:+            if b_start <= p_end and p_start <= b_end:+                has_overlap = True+                break++        duration = bs.playback_position_seconds+        if has_overlap:+            with_music_durations.append(duration)+        else:+            without_music_durations.append(duration)++    def _stats(durations):+        if not durations:+            return None+        return {+            "avg_seconds": int(sum(durations) / len(durations)),+            "sessions_count": len(durations),+            "total_seconds": sum(durations),+        }++    return {+        "with_music": _stats(with_music_durations),+        "without_music": _stats(without_music_durations),+    }diff --git a/vrobbler/apps/trends/trends/trending.py b/vrobbler/apps/trends/trends/trending.pynew file mode 100644index 0000000..188bade--- /dev/null+++ b/vrobbler/apps/trends/trends/trending.py@@ -0,0 +1,64 @@+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):+    """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.++    Returns a dict keyed by media_type with count and change info.+    """+    now = timezone.now()+    recent_start = now - timezone.timedelta(days=days)+    previous_start = recent_start - timezone.timedelta(days=days)++    recent_counts = defaultdict(int)+    for row in (+        Scrobble.objects.filter(+            user=user,+            timestamp__gte=recent_start,+            timestamp__lte=now,+            played_to_completion=True,+        )+        .values("media_type")+        .annotate(count=Count("id"))+    ):+        recent_counts[row["media_type"]] = row["count"]++    previous_counts = defaultdict(int)+    for row in (+        Scrobble.objects.filter(+            user=user,+            timestamp__gte=previous_start,+            timestamp__lt=recent_start,+            played_to_completion=True,+        )+        .values("media_type")+        .annotate(count=Count("id"))+    ):+        previous_counts[row["media_type"]] = row["count"]++    all_types = set(list(recent_counts.keys()) + list(previous_counts.keys()))+    changes = {}+    for mt in sorted(all_types):+        rc = recent_counts.get(mt, 0)+        pc = previous_counts.get(mt, 0)+        if pc > 0:+            change_pct = round(((rc - pc) / pc) * 100, 1)+        elif rc > 0:+            change_pct = 100.0+        else:+            change_pct = 0.0+        changes[mt] = {+            "recent": rc,+            "previous": pc,+            "change_pct": change_pct,+        }++    return changesdiff --git a/vrobbler/apps/trends/urls.py b/vrobbler/apps/trends/urls.pynew file mode 100644index 0000000..cbab5da--- /dev/null+++ b/vrobbler/apps/trends/urls.py@@ -0,0 +1,10 @@+from django.urls import path++from trends.views import TrendDetailView, TrendListView++app_name = "trends"++urlpatterns = [+    path("trends/", TrendListView.as_view(), name="trends-home"),+    path("trends/<slug:trend_slug>/", TrendDetailView.as_view(), name="trend-detail"),+]diff --git a/vrobbler/apps/trends/views.py b/vrobbler/apps/trends/views.pynew file mode 100644index 0000000..2600554--- /dev/null+++ b/vrobbler/apps/trends/views.py@@ -0,0 +1,89 @@+from django.contrib.auth.mixins import LoginRequiredMixin+from django.views.generic import TemplateView++from trends.models import TrendResult+from trends.trends import TREND_REGISTRY++TREND_METADATA = {+    "concurrent-listening": {+        "title": "Concurrent Listening",+        "description": "What music were you listening to while on trails or at locations?",+        "icon": "๐ŸŽง",+    },+    "concurrent-reading": {+        "title": "Concurrent Reading",+        "description": "What music did you listen to while reading books?",+        "icon": "๐Ÿ“–",+    },+    "reading-pace-vs-activity": {+        "title": "Reading Pace vs Music",+        "description": "Compare how long you read per session with and without concurrent music.",+        "icon": "๐Ÿ“Š",+    },+    "trending-up": {+        "title": "Trending Media Types",+        "description": "Which media types have you been consuming more or less of recently?",+        "icon": "๐Ÿ“ˆ",+    },+}+++class TrendListView(LoginRequiredMixin, TemplateView):+    template_name = "trends/trend_list.html"++    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+            )+        }+        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,+            })+        ctx["trends"] = trends+        return ctx+++class TrendDetailView(LoginRequiredMixin, TemplateView):+    template_name = "trends/trend_detail.html"++    def get_context_data(self, **kwargs):+        ctx = super().get_context_data(**kwargs)+        slug = kwargs["trend_slug"]++        if slug not in TREND_REGISTRY:+            ctx["trend_not_found"] = True+            return ctx++        meta = TREND_METADATA.get(slug, {})+        ctx["trend"] = {+            "slug": slug,+            "title": meta.get("title", slug),+            "description": meta.get("description", ""),+            "icon": meta.get("icon", ""),+        }++        result = TrendResult.objects.filter(+            user=self.request.user,+            trend_slug=slug,+        ).first()++        if result:+            ctx["computed_at"] = result.computed_at+            ctx["data"] = result.data+        else:+            ctx["computed_at"] = None+            ctx["data"] = None++        return ctxdiff --git a/vrobbler/settings.py b/vrobbler/settings.pyindex 813e533..d37f04e 100644--- a/vrobbler/settings.py+++ b/vrobbler/settings.py@@ -134,6 +134,10 @@ CELERY_BEAT_SCHEDULE = {         "task": "scrobbles.tasks.rebuild_yearly_charts",         "schedule": crontab(hour=0, minute=30, day_of_month=1, month_of_year=1),     },+    "compute-daily-trends": {+        "task": "trends.tasks.compute_all_trends",+        "schedule": crontab(hour=0, minute=10),+    },     # โ”€โ”€ Crontab replacements โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€     "database-backup": {         "task": "scrobbles.tasks.backup_database",@@ -192,6 +196,7 @@ INSTALLED_APPS = [     "scrobbles",     "people",     "charts",+    "trends",     "videos",     "music",     "podcasts",diff --git a/vrobbler/urls.py b/vrobbler/urls.pyindex f0ae218..6e0418d 100644--- a/vrobbler/urls.py+++ b/vrobbler/urls.py@@ -105,6 +105,7 @@ from vrobbler.apps.webpages.api.views import DomainViewSet, WebPageViewSet  from vrobbler.apps.people import urls as people_urls from vrobbler.apps.charts import urls as charts_urls+from vrobbler.apps.trends import urls as trends_urls  # from vrobbler.apps.modern_ui import urls as modern_ui_urls @@ -182,6 +183,7 @@ urlpatterns = [     path("", include(profiles_urls, namespace="profiles")),     path("", include(people_urls, namespace="people")),     path("", include(charts_urls, namespace="charts")),+    path("", include(trends_urls, namespace="trends")),     path("", scrobbles_views.RecentScrobbleList.as_view(), name="vrobbler-home"), ] if settings.DEBUG: