[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

@ -88,7 +88,7 @@ fetching and simple saving.
*** Metadata sources *** Metadata sources
**** Scraper **** 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: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
:PROPERTIES: :PROPERTIES:
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb :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 a helper method to create board game scrobbles given a json blob. It's
independent of the email flow it was originally creatdd for 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] * Version 54.1 [1/1]
** DONE [#A] Concurrent listening trend is inefficient and should be disabled :trends:scrobbles: ** DONE [#A] Concurrent listening trend is inefficient and should be disabled :trends:scrobbles:
:PROPERTIES: :PROPERTIES:

View File

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

View File

@ -3,9 +3,8 @@ import logging
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils import timezone from django.utils import timezone
from trends.tasks import _compute_and_save_trend
from trends.trends import TREND_REGISTRY from trends.trends import TREND_REGISTRY
from trends.utils import compute_and_save_trend, get_supported_periods
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
User = get_user_model() User = get_user_model()
@ -48,24 +47,21 @@ class Command(BaseCommand):
user_fail = 0 user_fail = 0
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1): for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
trend_start = timezone.now() periods = get_supported_periods(slug)
self.stdout.write( self.stdout.write(f" [{idx}/{total_trends}] {slug}...\n")
f" [{idx}/{total_trends}] {slug}... ", ending="" for period in periods:
) trend_start = timezone.now()
try: self.stdout.write(f" {period}... ", ending="")
elapsed = _compute_and_save_trend(user, slug) try:
self.stdout.write( elapsed = compute_and_save_trend(user, slug, period)
self.style.SUCCESS(f"OK ({elapsed:.1f}s)") self.stdout.write(self.style.SUCCESS(f"OK ({elapsed:.1f}s)"))
) user_ok += 1
user_ok += 1 except Exception as e:
except Exception as e: elapsed = (timezone.now() - trend_start).total_seconds()
elapsed = (timezone.now() - trend_start).total_seconds() self.stdout.write(
self.stdout.write( self.style.ERROR(f"FAILED after {elapsed:.1f}s: {e}")
self.style.ERROR(
f"FAILED after {elapsed:.1f}s: {e}"
) )
) user_fail += 1
user_fail += 1
user_elapsed = (timezone.now() - user_start).total_seconds() user_elapsed = (timezone.now() - user_start).total_seconds()
self.stdout.write( self.stdout.write(

View File

@ -1,9 +1,9 @@
# Generated by Django 4.2.29 on 2026-06-16 14:52 # 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.db.models.deletion
import django_extensions.db.fields import django_extensions.db.fields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration): 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() 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): class TrendResult(TimeStampedModel):
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
trend_slug = models.CharField(max_length=100, db_index=True) 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) computed_at = models.DateTimeField(auto_now_add=True)
data = models.JSONField(default=dict) data = models.JSONField(default=dict)
class Meta: class Meta:
unique_together = ["user", "trend_slug"] unique_together = ["user", "trend_slug", "period"]
def __str__(self): 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 celery import shared_task
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.utils import timezone from django.utils import timezone
from trends.models import TrendResult
from trends.trends import TREND_REGISTRY from trends.trends import TREND_REGISTRY
from trends.utils import compute_and_save_trend, get_supported_periods
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
User = get_user_model() 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 @shared_task
def compute_all_trends(): def compute_all_trends():
user_ids = list( user_ids = list(User.objects.filter(is_active=True).values_list("id", flat=True))
User.objects.filter(is_active=True).values_list("id", flat=True)
)
logger.info("Dispatching trend computation for %d users", len(user_ids)) logger.info("Dispatching trend computation for %d users", len(user_ids))
for uid in user_ids: for uid in user_ids:
compute_user_trends.delay(uid) compute_user_trends.delay(uid)
@ -48,7 +29,9 @@ def compute_user_trends(user_id):
total = len(TREND_REGISTRY) total = len(TREND_REGISTRY)
logger.info( logger.info(
"Computing %d trends for user %s (%d)", "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): for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
@ -62,21 +45,25 @@ def compute_single_trend(user_id, slug):
try: try:
user = User.objects.get(id=user_id) user = User.objects.get(id=user_id)
except User.DoesNotExist: except User.DoesNotExist:
logger.warning( logger.warning("User %d not found for trend '%s', skipping", user_id, slug)
"User %d not found for trend '%s', skipping", user_id, slug
)
return return
if slug not in TREND_REGISTRY: if slug not in TREND_REGISTRY:
logger.warning("Unknown trend slug '%s' for user %d", slug, user_id) logger.warning("Unknown trend slug '%s' for user %d", slug, user_id)
return return
logger.info("[%s] Computing for user %d...", slug, user_id) periods = get_supported_periods(slug)
try:
elapsed = _compute_and_save_trend(user, slug) for period in periods:
logger.info( logger.info("[%s/%s] Computing for user %d...", slug, period, user_id)
"[%s] Completed for user %d in %.1fs", try:
slug, user_id, elapsed, elapsed = compute_and_save_trend(user, slug, period)
) logger.info(
except Exception: "[%s/%s] Completed for user %d in %.1fs",
logger.exception("[%s] Failed for user %d", slug, user_id) 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"> <div class="col-12">
{% if data.distribution %} {% if data.distribution %}
<p class="text-muted mb-3"> <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> </p>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped table-sm"> <table class="table table-striped table-sm">

View File

@ -1,4 +1,9 @@
<div class="row"> <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="col-md-6 mb-3">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">

View File

@ -6,8 +6,8 @@
<thead> <thead>
<tr> <tr>
<th>Media Type</th> <th>Media Type</th>
<th class="text-end">Recent (30 days)</th> <th class="text-end">Recent ({{ current_period_label }})</th>
<th class="text-end">Previous (30 days)</th> <th class="text-end">Previous ({{ current_period_label }})</th>
<th class="text-end">Change</th> <th class="text-end">Change</th>
</tr> </tr>
</thead> </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> <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> <h2>{{ trend.icon }} {{ trend.title }}</h2>
<p class="text-muted">{{ trend.description }}</p> <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 %} {% if computed_at %}
<small class="text-muted">Last computed: {{ computed_at|date:"F j, Y H:i" }}</small> <small class="text-muted">Last computed: {{ computed_at|date:"F j, Y H:i" }}</small>
{% endif %} {% endif %}
@ -19,7 +43,7 @@
{% elif data is None %} {% elif data is None %}
<div class="alert alert-info"> <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> </div>
{% elif trend.slug == "concurrent-listening" %} {% 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 import Count, Q
from django.db.models.functions import Extract from django.db.models.functions import Extract
from django.utils import timezone from django.utils import timezone
from scrobbles.models import Scrobble 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. """Group scrobbles by hour of day (0-23) and count them.
Returns dict: {"hours": [{"hour": N, "count": N}, ...]} sorted by hour. Returns dict: {"hours": [{"hour": N, "count": N}, ...]} sorted by hour.
@ -28,21 +27,23 @@ def compute_peak_hours(user):
return {"hours": hours} return {"hours": hours}
def compute_weekly_rhythm(user): def compute_weekly_rhythm(user, period="all_time"):
"""Group scrobble counts by day of the week. """Group scrobble counts by day of the week.
Uses iso_week_day (1=Monday, 7=Sunday). Returns dict sorted by day index Uses iso_week_day (1=Monday, 7=Sunday). Returns dict sorted by day index
with human-readable day names. with human-readable day names.
""" """
DAY_NAMES = OrderedDict([ DAY_NAMES = OrderedDict(
(1, "Monday"), [
(2, "Tuesday"), (1, "Monday"),
(3, "Wednesday"), (2, "Tuesday"),
(4, "Thursday"), (3, "Wednesday"),
(5, "Friday"), (4, "Thursday"),
(6, "Saturday"), (5, "Friday"),
(7, "Sunday"), (6, "Saturday"),
]) (7, "Sunday"),
]
)
days_qs = ( days_qs = (
Scrobble.objects.filter(user=user, timestamp__isnull=False) 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} raw = {row["day"]: row["count"] for row in days_qs}
days = [] days = []
for idx, name in DAY_NAMES.items(): for idx, name in DAY_NAMES.items():
days.append({ days.append(
"day_index": idx, {
"day_name": name, "day_index": idx,
"count": raw.get(idx, 0), "day_name": name,
}) "count": raw.get(idx, 0),
}
)
return {"days": days} return {"days": days}
def compute_activity_distribution(user): def compute_activity_distribution(user, period="all_time"):
"""Proportion of total scrobbles per media type. """Proportion of total scrobbles per media type.
Returns dict: {"distribution": [{"media_type": "...", "count": N, Returns dict: {"distribution": [{"media_type": "...", "count": N,
"completed": N, "pct": float}, ...]} sorted by count desc, plus "completed": N, "pct": float}, ...]} sorted by count desc, plus
"total_count". "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 = ( dist_qs = (
Scrobble.objects.filter(user=user) Scrobble.objects.filter(filters)
.values("media_type") .values("media_type")
.annotate( .annotate(
count=Count("id"), count=Count("id"),
@ -86,12 +98,14 @@ def compute_activity_distribution(user):
distribution = [] distribution = []
for row in rows: for row in rows:
distribution.append({ distribution.append(
"media_type": row["media_type"], {
"count": row["count"], "media_type": row["media_type"],
"completed": row["completed"], "count": row["count"],
"pct": round((row["count"] / total) * 100, 1), "completed": row["completed"],
}) "pct": round((row["count"] / total) * 100, 1),
}
)
return { return {
"distribution": distribution, "distribution": distribution,

View File

@ -1,6 +1,7 @@
import datetime import datetime
from collections import defaultdict from collections import defaultdict
from django.db.models import Q
from scrobbles.models import Scrobble 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 Returns a dict mapping each anchor scrobble PK to a list of
paired scrobble PKs that overlap with it. paired scrobble PKs that overlap with it.
""" """
anchor_ranges = { anchor_ranges = {s.pk: _range_for(s) for s in anchor_scrobbles}
s.pk: _range_for(s) for s in anchor_scrobbles paired_ranges = {s.pk: _range_for(s) for s in paired_scrobbles}
}
paired_ranges = {
s.pk: _range_for(s) for s in paired_scrobbles
}
anchor_to_paired = defaultdict(list) anchor_to_paired = defaultdict(list)
@ -41,7 +38,10 @@ def _find_concurrent(anchor_scrobbles, paired_scrobbles):
def _get_media_name(scrobble): def _get_media_name(scrobble):
"""Return the name of the media object associated with a scrobble.""" """Return the name of the media object associated with a scrobble."""
for attr in [ for attr in [
"trail", "geo_location", "book", "track", "trail",
"geo_location",
"book",
"track",
]: ]:
obj = getattr(scrobble, attr, None) obj = getattr(scrobble, attr, None)
if obj is not None: if obj is not None:
@ -49,22 +49,45 @@ def _get_media_name(scrobble):
return "Unknown" 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. """Find what music was listened to while on trails or at locations.
Returns a dict with two keys: 'trails' and 'locations', each containing 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. a list of entries with the trail/location name and the tracks listened to.
""" """
media_types_to_exclude_from_anchor = ("Track", "Book", "Video", "PodcastEpisode", from trends.utils import get_date_range
"VideoGame", "BoardGame", "Puzzle", "Food",
"Beer", "Task", "WebPage", "LifeEvent", start, end = get_date_range(period)
"Mood", "BrickSet", "Channel", "BirdingLocation", base_filters = Q(user=user, timestamp__isnull=False)
"Paper", "SportEvent") 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( anchor_scrobbles = list(
Scrobble.objects.filter( Scrobble.objects.filter(
user=user, base_filters,
timestamp__isnull=False,
played_to_completion=True, played_to_completion=True,
) )
.exclude(media_type__in=media_types_to_exclude_from_anchor) .exclude(media_type__in=media_types_to_exclude_from_anchor)
@ -74,9 +97,8 @@ def compute_concurrent_listening(user):
paired_scrobbles = list( paired_scrobbles = list(
Scrobble.objects.filter( Scrobble.objects.filter(
user=user, base_filters,
media_type="Track", media_type="Track",
timestamp__isnull=False,
stop_timestamp__isnull=False, stop_timestamp__isnull=False,
played_to_completion=True, played_to_completion=True,
) )
@ -131,29 +153,45 @@ def compute_concurrent_listening(user):
} }
if anchor.media_type == "Trail": 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) trails.append(entry)
else: 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) locations.append(entry)
return { return {
"trails": sorted(trails, key=lambda x: x["total_sessions"], reverse=True)[:20], "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. """Find what music was listened to while reading books.
Returns a dict with key 'books' containing a list of entries with the Returns a dict with key 'books' containing a list of entries with the
book title and the tracks listened to while reading. 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( anchor_scrobbles = list(
Scrobble.objects.filter( Scrobble.objects.filter(
user=user, base_filters,
media_type="Book", media_type="Book",
timestamp__isnull=False,
stop_timestamp__isnull=False, stop_timestamp__isnull=False,
played_to_completion=True, played_to_completion=True,
) )
@ -163,9 +201,8 @@ def compute_concurrent_reading(user):
paired_scrobbles = list( paired_scrobbles = list(
Scrobble.objects.filter( Scrobble.objects.filter(
user=user, base_filters,
media_type="Track", media_type="Track",
timestamp__isnull=False,
stop_timestamp__isnull=False, stop_timestamp__isnull=False,
played_to_completion=True, played_to_completion=True,
) )
@ -203,19 +240,21 @@ def compute_concurrent_reading(user):
} }
book = anchor.book book = anchor.book
books.append({ books.append(
"book_title": str(book) if book else "Unknown", {
"book_uuid": str(book.uuid) if book and book.uuid else "", "book_title": str(book) if book else "Unknown",
"total_sessions": len(paired_pks), "book_uuid": str(book.uuid) if book and book.uuid else "",
"tracks": sorted( "total_sessions": len(paired_pks),
[ "tracks": sorted(
{**track_details[name], "count": count} [
for name, count in tracks_by_name.items() {**track_details[name], "count": count}
], for name, count in tracks_by_name.items()
key=lambda x: x["count"], ],
reverse=True, key=lambda x: x["count"],
)[:20], reverse=True,
}) )[:20],
}
)
return { return {
"books": sorted(books, key=lambda x: x["total_sessions"], reverse=True)[:20], "books": sorted(books, key=lambda x: x["total_sessions"], reverse=True)[:20],

View File

@ -1,21 +1,30 @@
import datetime import datetime
from collections import defaultdict from collections import defaultdict
from django.db.models import Q
from scrobbles.models import Scrobble 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. """Compare reading pace (seconds per session) when music is playing vs. not.
For each Book scrobble with a playback_position_seconds value, checks For each Book scrobble with a playback_position_seconds value, checks
whether there is an overlapping Track scrobble and groups the data. whether there is an overlapping Track scrobble and groups the data.
Returns average session duration for both groups. 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( book_scrobbles = list(
Scrobble.objects.filter( Scrobble.objects.filter(
user=user, base_filters,
media_type="Book", media_type="Book",
timestamp__isnull=False,
playback_position_seconds__isnull=False, playback_position_seconds__isnull=False,
played_to_completion=True, played_to_completion=True,
) )
@ -28,12 +37,10 @@ def compute_reading_pace_vs_activity(user):
track_scrobbles = list( track_scrobbles = list(
Scrobble.objects.filter( Scrobble.objects.filter(
user=user, base_filters,
media_type="Track", media_type="Track",
timestamp__isnull=False,
played_to_completion=True, played_to_completion=True,
) ).order_by("-timestamp")
.order_by("-timestamp")
) )
track_ranges = [] track_ranges = []

View File

@ -2,18 +2,21 @@ from collections import defaultdict
from django.db.models import Count from django.db.models import Count
from django.utils import timezone from django.utils import timezone
from scrobbles.models import Scrobble 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. """Compare scrobble counts per media type between two periods.
Compares the most recent N days against the N days before that, Compares the most recent N days against the N days before that,
returning the count for each period and the percentage change. 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. 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() now = timezone.now()
recent_start = now - timezone.timedelta(days=days) recent_start = now - timezone.timedelta(days=days)
previous_start = recent_start - timezone.timedelta(days=days) previous_start = recent_start - timezone.timedelta(days=days)

View File

@ -1,5 +1,4 @@
from django.urls import path from django.urls import path
from trends.views import TrendDetailView, TrendListView from trends.views import TrendDetailView, TrendListView
app_name = "trends" 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.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import TemplateView from django.views.generic import TemplateView
from trends.models import TrendResult from trends.models import TrendResult
from trends.trends import TREND_REGISTRY from trends.trends import TREND_REGISTRY
from trends.utils import get_period_nav, get_supported_periods
TREND_METADATA = { TREND_METADATA = {
"activity-distribution": { "activity-distribution": {
@ -48,24 +48,29 @@ class TrendListView(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
results = { results = TrendResult.objects.filter(
r.trend_slug: r user=self.request.user,
for r in TrendResult.objects.filter( ).order_by("trend_slug", "-computed_at")
user=self.request.user
) latest_by_slug = {}
} for r in results:
if r.trend_slug not in latest_by_slug:
latest_by_slug[r.trend_slug] = r
trends = [] trends = []
for slug in TREND_REGISTRY: for slug in TREND_REGISTRY:
meta = TREND_METADATA.get(slug, {}) meta = TREND_METADATA.get(slug, {})
result = results.get(slug) result = latest_by_slug.get(slug)
trends.append({ trends.append(
"slug": slug, {
"title": meta.get("title", slug), "slug": slug,
"description": meta.get("description", ""), "title": meta.get("title", slug),
"icon": meta.get("icon", ""), "description": meta.get("description", ""),
"computed_at": result.computed_at if result else None, "icon": meta.get("icon", ""),
"has_data": result is not None, "computed_at": result.computed_at if result else None,
}) "has_data": result is not None,
}
)
ctx["trends"] = trends ctx["trends"] = trends
return ctx return ctx
@ -81,6 +86,8 @@ class TrendDetailView(LoginRequiredMixin, TemplateView):
ctx["trend_not_found"] = True ctx["trend_not_found"] = True
return ctx return ctx
period = self.request.GET.get("period", "all_time")
meta = TREND_METADATA.get(slug, {}) meta = TREND_METADATA.get(slug, {})
ctx["trend"] = { ctx["trend"] = {
"slug": slug, "slug": slug,
@ -89,9 +96,19 @@ class TrendDetailView(LoginRequiredMixin, TemplateView):
"icon": meta.get("icon", ""), "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( result = TrendResult.objects.filter(
user=self.request.user, user=self.request.user,
trend_slug=slug, trend_slug=slug,
period=period,
).first() ).first()
if result: if result: