[trends] Add time periods

This commit is contained in:
2026-06-17 10:50:16 -04:00
parent 9e3288a5ff
commit 19f2b5e801
18 changed files with 383 additions and 152 deletions

View File

@ -1,5 +1,4 @@
from django.contrib import admin
from trends.models import TrendResult

View File

@ -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(

View File

@ -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):

View File

@ -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")},
),
]

View File

@ -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})"

View File

@ -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)

View File

@ -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">

View File

@ -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">

View File

@ -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>

View File

@ -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" %}

View File

@ -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,

View File

@ -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],

View File

@ -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 = []

View File

@ -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)

View File

@ -1,5 +1,4 @@
from django.urls import path
from trends.views import TrendDetailView, TrendListView
app_name = "trends"

View File

@ -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()

View File

@ -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: