[trends] Add peak hours, weekly rhtyhms and activity dist trends
Some checks failed
build / test (push) Has been cancelled
Some checks failed
build / test (push) Has been cancelled
This commit is contained in:
@ -1,8 +1,13 @@
|
||||
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_user_trends
|
||||
from trends.tasks import _compute_and_save_trend
|
||||
from trends.trends import TREND_REGISTRY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@ -25,16 +30,57 @@ class Command(BaseCommand):
|
||||
)
|
||||
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...")
|
||||
|
||||
total_users = len(users)
|
||||
self.stdout.write(f"Computing trends for {total_users} user(s)...")
|
||||
|
||||
overall_start = timezone.now()
|
||||
ok_count = 0
|
||||
fail_count = 0
|
||||
|
||||
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}"))
|
||||
total_trends = len(TREND_REGISTRY)
|
||||
self.stdout.write(f" {user} ({user.id}): {total_trends} trends...")
|
||||
user_start = timezone.now()
|
||||
user_ok = 0
|
||||
user_fail = 0
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Done!"))
|
||||
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}"
|
||||
)
|
||||
)
|
||||
user_fail += 1
|
||||
|
||||
user_elapsed = (timezone.now() - user_start).total_seconds()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f" {user}: {user_ok} OK, {user_fail} failed "
|
||||
f"({user_elapsed:.1f}s total)"
|
||||
)
|
||||
)
|
||||
ok_count += user_ok
|
||||
fail_count += user_fail
|
||||
|
||||
overall_elapsed = (timezone.now() - overall_start).total_seconds()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Done! {ok_count} OK, {fail_count} failed "
|
||||
f"({overall_elapsed:.1f}s across {total_users} user(s))"
|
||||
)
|
||||
)
|
||||
|
||||
@ -11,10 +11,30 @@ 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():
|
||||
for user in User.objects.filter(is_active=True):
|
||||
compute_user_trends.delay(user.id)
|
||||
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)
|
||||
|
||||
|
||||
@shared_task
|
||||
@ -25,15 +45,38 @@ def compute_user_trends(user_id):
|
||||
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
|
||||
)
|
||||
total = len(TREND_REGISTRY)
|
||||
logger.info(
|
||||
"Computing %d trends for user %s (%d)",
|
||||
total, user, user_id,
|
||||
)
|
||||
|
||||
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
|
||||
compute_single_trend.delay(user_id, slug)
|
||||
|
||||
logger.info("Dispatched all %d trends for user %s (%d)", total, user, user_id)
|
||||
|
||||
|
||||
@shared_task
|
||||
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
|
||||
)
|
||||
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)
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% if data.distribution %}
|
||||
<p class="text-muted mb-3">
|
||||
Total scrobbles: <strong>{{ data.total_count }}</strong>
|
||||
</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Media Type</th>
|
||||
<th class="text-end">Total</th>
|
||||
<th class="text-end">Completed</th>
|
||||
<th class="text-end">%</th>
|
||||
<th>Distribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% with max=data.distribution.0.count %}
|
||||
{% for entry in data.distribution %}
|
||||
<tr>
|
||||
<td>{{ entry.media_type }}</td>
|
||||
<td class="text-end">{{ entry.count }}</td>
|
||||
<td class="text-end">{{ entry.completed }}</td>
|
||||
<td class="text-end">{{ entry.pct }}%</td>
|
||||
<td style="width: 30%;">
|
||||
{% if max > 0 %}
|
||||
<div class="progress" style="height: 12px;">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
style="width: {{ entry.pct }}%;"
|
||||
aria-valuenow="{{ entry.count }}"
|
||||
aria-valuemin="0" aria-valuemax="{{ max }}">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No activity data found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
52
vrobbler/apps/trends/templates/trends/_peak_hours.html
Normal file
52
vrobbler/apps/trends/templates/trends/_peak_hours.html
Normal file
@ -0,0 +1,52 @@
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% if data.hours %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hour</th>
|
||||
<th class="text-end">Scrobbles</th>
|
||||
<th>Distribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% with total=data.hours|dictsortreversed:"count"|first %}
|
||||
{% with max_count=total.count %}
|
||||
{% for entry in data.hours %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if entry.hour == 0 %}
|
||||
12 AM
|
||||
{% elif entry.hour < 12 %}
|
||||
{{ entry.hour }} AM
|
||||
{% elif entry.hour == 12 %}
|
||||
12 PM
|
||||
{% else %}
|
||||
{{ entry.hour|add:"-12" }} PM
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">{{ entry.count }}</td>
|
||||
<td style="width: 40%;">
|
||||
{% if max_count > 0 %}
|
||||
<div class="progress" style="height: 12px;">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
style="width: {{ entry.count|floatformat:0 }}%;"
|
||||
aria-valuenow="{{ entry.count }}"
|
||||
aria-valuemin="0" aria-valuemax="{{ max_count }}">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No activity data found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
42
vrobbler/apps/trends/templates/trends/_weekly_rhythm.html
Normal file
42
vrobbler/apps/trends/templates/trends/_weekly_rhythm.html
Normal file
@ -0,0 +1,42 @@
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% if data.days %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Day</th>
|
||||
<th class="text-end">Scrobbles</th>
|
||||
<th>Distribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% with total=data.days|dictsortreversed:"count"|first %}
|
||||
{% with max_count=total.count %}
|
||||
{% for entry in data.days %}
|
||||
<tr>
|
||||
<td>{{ entry.day_name }}</td>
|
||||
<td class="text-end">{{ entry.count }}</td>
|
||||
<td style="width: 40%;">
|
||||
{% if max_count > 0 %}
|
||||
<div class="progress" style="height: 12px;">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
style="width: {{ entry.count|floatformat:0 }}%;"
|
||||
aria-valuenow="{{ entry.count }}"
|
||||
aria-valuemin="0" aria-valuemax="{{ max_count }}">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No weekly data found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@ -34,5 +34,14 @@
|
||||
{% elif trend.slug == "trending-up" %}
|
||||
{% include "trends/_trending_up.html" %}
|
||||
|
||||
{% elif trend.slug == "peak-hours" %}
|
||||
{% include "trends/_peak_hours.html" %}
|
||||
|
||||
{% elif trend.slug == "weekly-rhythm" %}
|
||||
{% include "trends/_weekly_rhythm.html" %}
|
||||
|
||||
{% elif trend.slug == "activity-distribution" %}
|
||||
{% include "trends/_activity_distribution.html" %}
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
from trends.trends.activity import (
|
||||
compute_activity_distribution,
|
||||
compute_peak_hours,
|
||||
compute_weekly_rhythm,
|
||||
)
|
||||
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
|
||||
@ -11,15 +16,24 @@ def register(slug):
|
||||
return decorator
|
||||
|
||||
|
||||
compute_activity_distribution = register("activity-distribution")(
|
||||
compute_activity_distribution
|
||||
)
|
||||
compute_concurrent_listening = register("concurrent-listening")(
|
||||
compute_concurrent_listening
|
||||
)
|
||||
compute_concurrent_reading = register("concurrent-reading")(
|
||||
compute_concurrent_reading
|
||||
)
|
||||
compute_peak_hours = register("peak-hours")(
|
||||
compute_peak_hours
|
||||
)
|
||||
compute_reading_pace_vs_activity = register("reading-pace-vs-activity")(
|
||||
compute_reading_pace_vs_activity
|
||||
)
|
||||
compute_trending_up = register("trending-up")(
|
||||
compute_trending_up
|
||||
)
|
||||
compute_weekly_rhythm = register("weekly-rhythm")(
|
||||
compute_weekly_rhythm
|
||||
)
|
||||
|
||||
99
vrobbler/apps/trends/trends/activity.py
Normal file
99
vrobbler/apps/trends/trends/activity.py
Normal file
@ -0,0 +1,99 @@
|
||||
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):
|
||||
"""Group scrobbles by hour of day (0-23) and count them.
|
||||
|
||||
Returns dict: {"hours": [{"hour": N, "count": N}, ...]} sorted by hour.
|
||||
"""
|
||||
hours_qs = (
|
||||
Scrobble.objects.filter(user=user, timestamp__isnull=False)
|
||||
.annotate(hour=Extract("timestamp", "hour"))
|
||||
.values("hour")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("hour")
|
||||
)
|
||||
|
||||
hours = []
|
||||
raw = {row["hour"]: row["count"] for row in hours_qs}
|
||||
for h in range(24):
|
||||
hours.append({"hour": h, "count": raw.get(h, 0)})
|
||||
|
||||
return {"hours": hours}
|
||||
|
||||
|
||||
def compute_weekly_rhythm(user):
|
||||
"""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"),
|
||||
])
|
||||
|
||||
days_qs = (
|
||||
Scrobble.objects.filter(user=user, timestamp__isnull=False)
|
||||
.annotate(day=Extract("timestamp", "iso_week_day"))
|
||||
.values("day")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("day")
|
||||
)
|
||||
|
||||
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),
|
||||
})
|
||||
|
||||
return {"days": days}
|
||||
|
||||
|
||||
def compute_activity_distribution(user):
|
||||
"""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".
|
||||
"""
|
||||
dist_qs = (
|
||||
Scrobble.objects.filter(user=user)
|
||||
.values("media_type")
|
||||
.annotate(
|
||||
count=Count("id"),
|
||||
completed=Count("id", filter=Q(played_to_completion=True)),
|
||||
)
|
||||
.order_by("-count")
|
||||
)
|
||||
|
||||
rows = list(dist_qs)
|
||||
total = sum(r["count"] for r in rows) or 1
|
||||
|
||||
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),
|
||||
})
|
||||
|
||||
return {
|
||||
"distribution": distribution,
|
||||
"total_count": sum(r["count"] for r in rows),
|
||||
}
|
||||
@ -5,6 +5,11 @@ from trends.models import TrendResult
|
||||
from trends.trends import TREND_REGISTRY
|
||||
|
||||
TREND_METADATA = {
|
||||
"activity-distribution": {
|
||||
"title": "Activity Distribution",
|
||||
"description": "How your scrobbles are divided across media types.",
|
||||
"icon": "📊",
|
||||
},
|
||||
"concurrent-listening": {
|
||||
"title": "Concurrent Listening",
|
||||
"description": "What music were you listening to while on trails or at locations?",
|
||||
@ -15,6 +20,11 @@ TREND_METADATA = {
|
||||
"description": "What music did you listen to while reading books?",
|
||||
"icon": "📖",
|
||||
},
|
||||
"peak-hours": {
|
||||
"title": "Peak Activity Hours",
|
||||
"description": "What time of day are you most active?",
|
||||
"icon": "🕐",
|
||||
},
|
||||
"reading-pace-vs-activity": {
|
||||
"title": "Reading Pace vs Music",
|
||||
"description": "Compare how long you read per session with and without concurrent music.",
|
||||
@ -25,6 +35,11 @@ TREND_METADATA = {
|
||||
"description": "Which media types have you been consuming more or less of recently?",
|
||||
"icon": "📈",
|
||||
},
|
||||
"weekly-rhythm": {
|
||||
"title": "Weekly Rhythm",
|
||||
"description": "Which days of the week see the most scrobble activity?",
|
||||
"icon": "📅",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user