[trends] Initial trends app
Some checks failed
build / test (push) Has been cancelled

This commit is contained in:
2026-06-16 11:12:13 -04:00
parent ac82292200
commit 57f273b0cc
26 changed files with 978 additions and 8 deletions

View File

View File

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

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class TrendsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "trends"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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