diff --git a/PROJECT.org b/PROJECT.org index 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.py index 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.py index 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.py index 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.py new file mode 100644 index 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.py index 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.py index 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.html index 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 @@
{% if data.distribution %}

- Total scrobbles: {{ data.total_count }} + Total scrobbles{% if current_period_label %} ({{ current_period_label }}){% endif %}: {{ data.total_count }}

diff --git a/vrobbler/apps/trends/templates/trends/_reading_pace.html b/vrobbler/apps/trends/templates/trends/_reading_pace.html index 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 @@
+ {% if current_period_label %} +
+ Period: {{ current_period_label }} +
+ {% endif %}
diff --git a/vrobbler/apps/trends/templates/trends/_trending_up.html b/vrobbler/apps/trends/templates/trends/_trending_up.html index 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 @@
- - + + diff --git a/vrobbler/apps/trends/templates/trends/trend_detail.html b/vrobbler/apps/trends/templates/trends/trend_detail.html index 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 @@ ← All Trends

{{ trend.icon }} {{ trend.title }}

{{ trend.description }}

+ + {% if supported_periods|length > 1 %} +
+ + {% if prev_period or next_period %} +
+ {% if prev_period %} + « Prev + {% endif %} + {% if next_period %} + Next » + {% endif %} +
+ {% endif %} +
+ {% endif %} + {% if computed_at %} Last computed: {{ computed_at|date:"F j, Y H:i" }} {% endif %} @@ -19,7 +43,7 @@ {% elif data is None %}
- 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.
{% elif trend.slug == "concurrent-listening" %} diff --git a/vrobbler/apps/trends/trends/activity.py b/vrobbler/apps/trends/trends/activity.py index 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.py index 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.py index 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.py index 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.py index 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.py new file mode 100644 index 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.py index 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:
Media TypeRecent (30 days)Previous (30 days)Recent ({{ current_period_label }})Previous ({{ current_period_label }}) Change