[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:
@ -88,7 +88,7 @@ fetching and simple saving.
|
|||||||
*** Metadata sources
|
*** Metadata sources
|
||||||
**** Scraper
|
**** Scraper
|
||||||
|
|
||||||
* Backlog [2/22] :vrobbler:project:personal:
|
* Backlog [3/23] :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,11 @@ 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] Add peak hour, weekly rhythm and activity dist trends :trends:scrobbles:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 5fa52fac-d5f0-4369-bcaa-589c886b07d3
|
||||||
|
:END:
|
||||||
|
|
||||||
** DONE [#A] Implement YouTube channel info scraping :videos:youtube:stub:
|
** DONE [#A] Implement YouTube channel info scraping :videos:youtube:stub:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: 1d3beafd-62cb-4735-a465-edb37bf885db
|
:ID: 1d3beafd-62cb-4735-a465-edb37bf885db
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
|
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 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()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
@ -25,16 +30,57 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
users = [user]
|
users = [user]
|
||||||
self.stdout.write(f"Computing trends for user: {user}")
|
|
||||||
else:
|
else:
|
||||||
users = User.objects.filter(is_active=True)
|
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:
|
for user in users:
|
||||||
try:
|
total_trends = len(TREND_REGISTRY)
|
||||||
compute_user_trends(user.id)
|
self.stdout.write(f" {user} ({user.id}): {total_trends} trends...")
|
||||||
self.stdout.write(self.style.SUCCESS(f" OK {user}"))
|
user_start = timezone.now()
|
||||||
except Exception as e:
|
user_ok = 0
|
||||||
self.stderr.write(self.style.ERROR(f" FAILED {user}: {e}"))
|
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()
|
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():
|
||||||
for user in User.objects.filter(is_active=True):
|
user_ids = list(
|
||||||
compute_user_trends.delay(user.id)
|
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
|
@shared_task
|
||||||
@ -25,15 +45,38 @@ def compute_user_trends(user_id):
|
|||||||
logger.warning("User %s not found, skipping trends", user_id)
|
logger.warning("User %s not found, skipping trends", user_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
for slug, fn in TREND_REGISTRY.items():
|
total = len(TREND_REGISTRY)
|
||||||
try:
|
logger.info(
|
||||||
data = fn(user)
|
"Computing %d trends for user %s (%d)",
|
||||||
TrendResult.objects.update_or_create(
|
total, user, user_id,
|
||||||
user=user,
|
)
|
||||||
trend_slug=slug,
|
|
||||||
defaults={"data": data, "computed_at": timezone.now()},
|
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
|
||||||
)
|
compute_single_trend.delay(user_id, slug)
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
logger.info("Dispatched all %d trends for user %s (%d)", total, user, user_id)
|
||||||
"Failed to compute trend '%s' for user %s", slug, 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" %}
|
{% elif trend.slug == "trending-up" %}
|
||||||
{% include "trends/_trending_up.html" %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% 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.concurrent import compute_concurrent_listening, compute_concurrent_reading
|
||||||
from trends.trends.reading import compute_reading_pace_vs_activity
|
from trends.trends.reading import compute_reading_pace_vs_activity
|
||||||
from trends.trends.trending import compute_trending_up
|
from trends.trends.trending import compute_trending_up
|
||||||
@ -11,15 +16,24 @@ def register(slug):
|
|||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
compute_activity_distribution = register("activity-distribution")(
|
||||||
|
compute_activity_distribution
|
||||||
|
)
|
||||||
compute_concurrent_listening = register("concurrent-listening")(
|
compute_concurrent_listening = register("concurrent-listening")(
|
||||||
compute_concurrent_listening
|
compute_concurrent_listening
|
||||||
)
|
)
|
||||||
compute_concurrent_reading = register("concurrent-reading")(
|
compute_concurrent_reading = register("concurrent-reading")(
|
||||||
compute_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 = register("reading-pace-vs-activity")(
|
||||||
compute_reading_pace_vs_activity
|
compute_reading_pace_vs_activity
|
||||||
)
|
)
|
||||||
compute_trending_up = register("trending-up")(
|
compute_trending_up = register("trending-up")(
|
||||||
compute_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
|
from trends.trends import TREND_REGISTRY
|
||||||
|
|
||||||
TREND_METADATA = {
|
TREND_METADATA = {
|
||||||
|
"activity-distribution": {
|
||||||
|
"title": "Activity Distribution",
|
||||||
|
"description": "How your scrobbles are divided across media types.",
|
||||||
|
"icon": "📊",
|
||||||
|
},
|
||||||
"concurrent-listening": {
|
"concurrent-listening": {
|
||||||
"title": "Concurrent Listening",
|
"title": "Concurrent Listening",
|
||||||
"description": "What music were you listening to while on trails or at locations?",
|
"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?",
|
"description": "What music did you listen to while reading books?",
|
||||||
"icon": "📖",
|
"icon": "📖",
|
||||||
},
|
},
|
||||||
|
"peak-hours": {
|
||||||
|
"title": "Peak Activity Hours",
|
||||||
|
"description": "What time of day are you most active?",
|
||||||
|
"icon": "🕐",
|
||||||
|
},
|
||||||
"reading-pace-vs-activity": {
|
"reading-pace-vs-activity": {
|
||||||
"title": "Reading Pace vs Music",
|
"title": "Reading Pace vs Music",
|
||||||
"description": "Compare how long you read per session with and without concurrent 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?",
|
"description": "Which media types have you been consuming more or less of recently?",
|
||||||
"icon": "📈",
|
"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