This commit has no recorded session.
diff
commit 255e335d7a3243516048bbad66b4953054f06036Author: Colin Powell <colin@unbl.ink>Date: Wed Jun 17 17:07:16 2026 -0400 [trends] Add some mood related trends@@ -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,10 @@ 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 [#A] Add a trend around moods :moods:trends:+:PROPERTIES:+:ID: fba3f4ae-8f97-ee0b-e762-31630884518a+:END: * Version 54.3 [1/1] ** DONE [#B] Fix bug in series metadata cleanup script :videos:metadta: :PROPERTIES:@@ -0,0 +1,78 @@+<div class="row">+ <div class="col-md-6">+ <h5 class="mb-3">By Hour of Day</h5>+ {% if data.hours %}+ <div class="table-responsive">+ <table class="table table-striped table-sm">+ <thead>+ <tr>+ <th>Hour</th>+ <th class="text-end">Avg Quality</th>+ <th class="text-end">Check-ins</th>+ </tr>+ </thead>+ <tbody>+ {% for entry in data.hours %}+ {% if entry.count > 0 %}+ <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">+ <span class="{% if entry.avg_quality >= 5 %}text-success{% elif entry.avg_quality >= 4 %}text-info{% elif entry.avg_quality >= 3 %}text-warning{% else %}text-danger{% endif %}">+ {{ entry.avg_quality }}+ </span>+ </td>+ <td class="text-end">{{ entry.count }}</td>+ </tr>+ {% endif %}+ {% endfor %}+ </tbody>+ </table>+ </div>+ {% else %}+ <p class="text-muted">No hourly data.</p>+ {% endif %}+ </div>+ <div class="col-md-6">+ <h5 class="mb-3">By Day of Week</h5>+ {% if data.days %}+ <div class="table-responsive">+ <table class="table table-striped table-sm">+ <thead>+ <tr>+ <th>Day</th>+ <th class="text-end">Avg Quality</th>+ <th class="text-end">Check-ins</th>+ </tr>+ </thead>+ <tbody>+ {% for entry in data.days %}+ {% if entry.count > 0 %}+ <tr>+ <td>{{ entry.day_name }}</td>+ <td class="text-end">+ <span class="{% if entry.avg_quality >= 5 %}text-success{% elif entry.avg_quality >= 4 %}text-info{% elif entry.avg_quality >= 3 %}text-warning{% else %}text-danger{% endif %}">+ {{ entry.avg_quality }}+ </span>+ </td>+ <td class="text-end">{{ entry.count }}</td>+ </tr>+ {% endif %}+ {% endfor %}+ </tbody>+ </table>+ </div>+ {% else %}+ <p class="text-muted">No daily data.</p>+ {% endif %}+ </div>+</div>@@ -0,0 +1,45 @@+<div class="row">+ <div class="col-12">+ {% if data.moods %}+ <p class="text-muted mb-3">+ Total mood check-ins{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total }}</strong>+ · Positive: <strong>{{ data.positive_count }}</strong>+ · Negative: <strong>{{ data.negative_count }}</strong>+ </p>+ <div class="table-responsive">+ <table class="table table-striped table-sm">+ <thead>+ <tr>+ <th>Mood</th>+ <th class="text-end">Count</th>+ <th>Distribution</th>+ </tr>+ </thead>+ <tbody>+ {% with max=data.moods.0.count %}+ {% for entry in data.moods %}+ <tr>+ <td>{{ entry.mood }}</td>+ <td class="text-end">{{ entry.count }}</td>+ <td style="width: 40%;">+ {% if max > 0 %}+ <div class="progress" style="height: 12px;">+ <div class="progress-bar" role="progressbar"+ style="width: {% widthratio entry.count max 100 %}%;"+ 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 mood distribution data found.</p>+ {% endif %}+ </div>+</div>@@ -0,0 +1,47 @@+<div class="row">+ <div class="col-12">+ {% if data.current_streak %}+ <div class="alert alert-info">+ <strong>Current streak:</strong>+ {{ data.current_streak.length }} consecutive+ <span class="{% if data.current_streak.mood_type == 'positive' %}text-success{% else %}text-danger{% endif %}">+ {{ data.current_streak.mood_type }}+ </span>+ check-ins since {{ data.current_streak.start_date }}.+ </div>+ {% endif %}++ {% if data.streaks %}+ <div class="table-responsive">+ <table class="table table-striped table-sm">+ <thead>+ <tr>+ <th>#</th>+ <th>Mood Type</th>+ <th class="text-end">Length</th>+ <th>Start</th>+ <th>End</th>+ </tr>+ </thead>+ <tbody>+ {% for streak in data.streaks %}+ <tr>+ <td>{{ forloop.counter }}</td>+ <td>+ <span class="{% if streak.mood_type == 'positive' %}text-success{% elif streak.mood_type == 'negative' %}text-danger{% endif %}">+ {{ streak.mood_type|title }}+ </span>+ </td>+ <td class="text-end">{{ streak.length }}</td>+ <td>{{ streak.start_date }}</td>+ <td>{{ streak.end_date }}</td>+ </tr>+ {% endfor %}+ </tbody>+ </table>+ </div>+ {% else %}+ <p class="text-muted">No streak data found.</p>+ {% endif %}+ </div>+</div>@@ -0,0 +1,39 @@+<div class="row">+ <div class="col-12">+ {% if data.trajectory %}+ <div class="table-responsive">+ <table class="table table-striped table-sm">+ <thead>+ <tr>+ <th>Date</th>+ <th class="text-end">Avg Quality</th>+ <th class="text-end">Check-ins</th>+ <th>Mood Bar</th>+ </tr>+ </thead>+ <tbody>+ {% for entry in data.trajectory %}+ <tr>+ <td>{{ entry.date }}</td>+ <td class="text-end">{{ entry.avg_quality }}</td>+ <td class="text-end">{{ entry.count }}</td>+ <td style="width: 40%;">+ <div class="progress" style="height: 16px;">+ <div class="progress-bar {% if entry.avg_quality >= 5 %}bg-success{% elif entry.avg_quality >= 4 %}bg-info{% elif entry.avg_quality >= 3 %}bg-warning{% else %}bg-danger{% endif %}"+ role="progressbar"+ style="width: {% widthratio entry.avg_quality 7 100 %}%;"+ aria-valuenow="{{ entry.avg_quality }}"+ aria-valuemin="1" aria-valuemax="7">+ </div>+ </div>+ </td>+ </tr>+ {% endfor %}+ </tbody>+ </table>+ </div>+ {% else %}+ <p class="text-muted">No mood check-in data found.</p>+ {% endif %}+ </div>+</div>@@ -0,0 +1,64 @@+<div class="row">+ <div class="col-md-6">+ <h5 class="mb-3">By Weather Condition</h5>+ {% if data.conditions %}+ <div class="table-responsive">+ <table class="table table-striped table-sm">+ <thead>+ <tr>+ <th>Condition</th>+ <th class="text-end">Avg Quality</th>+ <th class="text-end">Check-ins</th>+ </tr>+ </thead>+ <tbody>+ {% for entry in data.conditions %}+ <tr>+ <td>{{ entry.condition }}</td>+ <td class="text-end">+ <span class="{% if entry.avg_quality >= 5 %}text-success{% elif entry.avg_quality >= 4 %}text-info{% elif entry.avg_quality >= 3 %}text-warning{% else %}text-danger{% endif %}">+ {{ entry.avg_quality }}+ </span>+ </td>+ <td class="text-end">{{ entry.count }}</td>+ </tr>+ {% endfor %}+ </tbody>+ </table>+ </div>+ {% else %}+ <p class="text-muted">No weather-linked mood data found.</p>+ {% endif %}+ </div>+ <div class="col-md-6">+ <h5 class="mb-3">By Temperature Range</h5>+ {% if data.temp_ranges %}+ <div class="table-responsive">+ <table class="table table-striped table-sm">+ <thead>+ <tr>+ <th>Temp Range</th>+ <th class="text-end">Avg Quality</th>+ <th class="text-end">Check-ins</th>+ </tr>+ </thead>+ <tbody>+ {% for entry in data.temp_ranges %}+ <tr>+ <td>{{ entry.range }}</td>+ <td class="text-end">+ <span class="{% if entry.avg_quality >= 5 %}text-success{% elif entry.avg_quality >= 4 %}text-info{% elif entry.avg_quality >= 3 %}text-warning{% else %}text-danger{% endif %}">+ {{ entry.avg_quality }}+ </span>+ </td>+ <td class="text-end">{{ entry.count }}</td>+ </tr>+ {% endfor %}+ </tbody>+ </table>+ </div>+ {% else %}+ <p class="text-muted">No temperature-linked mood data found.</p>+ {% endif %}+ </div>+</div>@@ -67,5 +67,20 @@ {% elif trend.slug == "activity-distribution" %} {% include "trends/_activity_distribution.html" %} +{% elif trend.slug == "mood-trajectory" %}+ {% include "trends/_mood_trajectory.html" %}++{% elif trend.slug == "mood-by-time" %}+ {% include "trends/_mood_by_time.html" %}++{% elif trend.slug == "mood-distribution" %}+ {% include "trends/_mood_distribution.html" %}++{% elif trend.slug == "mood-streaks" %}+ {% include "trends/_mood_streaks.html" %}++{% elif trend.slug == "mood-weather" %}+ {% include "trends/_mood_weather.html" %}+ {% endif %} {% endblock %}@@ -7,6 +7,13 @@ from trends.trends.concurrent import ( compute_concurrent_listening, compute_concurrent_reading, )+from trends.trends.mood import (+ compute_mood_by_time,+ compute_mood_distribution,+ compute_mood_streaks,+ compute_mood_trajectory,+ compute_mood_weather,+) from trends.trends.reading import compute_reading_pace_vs_activity from trends.trends.trending import compute_trending_up @@ -28,6 +35,11 @@ compute_activity_distribution = register("activity-distribution")( # compute_concurrent_listening # ) compute_concurrent_reading = register("concurrent-reading")(compute_concurrent_reading)+compute_mood_by_time = register("mood-by-time")(compute_mood_by_time)+compute_mood_distribution = register("mood-distribution")(compute_mood_distribution)+compute_mood_streaks = register("mood-streaks")(compute_mood_streaks)+compute_mood_trajectory = register("mood-trajectory")(compute_mood_trajectory)+compute_mood_weather = register("mood-weather")(compute_mood_weather) compute_peak_hours = register("peak-hours")(compute_peak_hours) compute_reading_pace_vs_activity = register("reading-pace-vs-activity")( compute_reading_pace_vs_activity@@ -0,0 +1,199 @@+from collections import Counter, defaultdict+from datetime import timedelta++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 _mood_scrobbles(user, period="all_time"):+ from trends.utils import get_date_range++ start, end = get_date_range(period)+ filters = Q(user=user, media_type=Scrobble.MediaType.MOOD)+ if start:+ filters &= Q(timestamp__gte=start)+ if end:+ filters &= Q(timestamp__lte=end)+ return Scrobble.objects.filter(filters).select_related("mood")+++def _avg_quality(values):+ if not values:+ return 0.0+ return round(sum(values) / len(values), 2)+++def compute_mood_trajectory(user, period="all_time"):+ scrobbles = _mood_scrobbles(user, period).order_by("timestamp")+ by_date = defaultdict(list)+ for s in scrobbles:+ quality = s.log.get("mood_quality")+ if quality is not None:+ day_key = s.timestamp.strftime("%Y-%m-%d")+ by_date[day_key].append(quality)++ trajectory = []+ for date_key in sorted(by_date):+ values = by_date[date_key]+ trajectory.append(+ {+ "date": date_key,+ "avg_quality": _avg_quality(values),+ "count": len(values),+ }+ )++ return {"trajectory": trajectory}+++def compute_mood_by_time(user, period="all_time"):+ scrobbles = _mood_scrobbles(user, period)+ by_hour = defaultdict(list)+ by_day = defaultdict(list)++ for s in scrobbles:+ quality = s.log.get("mood_quality")+ if quality is not None and s.timestamp:+ by_hour[s.timestamp.hour].append(quality)+ by_day[s.timestamp.isoweekday()].append(quality)++ hours = []+ for h in range(24):+ vals = by_hour.get(h, [])+ hours.append(+ {+ "hour": h,+ "avg_quality": _avg_quality(vals),+ "count": len(vals),+ }+ )++ DAY_NAMES = {+ 1: "Monday",+ 2: "Tuesday",+ 3: "Wednesday",+ 4: "Thursday",+ 5: "Friday",+ 6: "Saturday",+ 7: "Sunday",+ }+ days = []+ for d in range(1, 8):+ vals = by_day.get(d, [])+ days.append(+ {+ "day_index": d,+ "day_name": DAY_NAMES[d],+ "avg_quality": _avg_quality(vals),+ "count": len(vals),+ }+ )++ return {"hours": hours, "days": days}+++def compute_mood_distribution(user, period="all_time"):+ scrobbles = _mood_scrobbles(user, period)+ mood_counts = Counter()+ type_counts = Counter()++ for s in scrobbles:+ if s.mood and s.mood.title:+ mood_counts[s.mood.title] += 1+ mood_type = s.log.get("mood_type")+ if mood_type:+ type_counts[mood_type] += 1++ moods = [+ {"mood": mood, "count": count}+ for mood, count in mood_counts.most_common()+ ]+ total = sum(mood_counts.values())++ return {+ "moods": moods,+ "total": total,+ "positive_count": type_counts.get("positive", 0),+ "negative_count": type_counts.get("negative", 0),+ }+++def compute_mood_streaks(user, period="all_time"):+ scrobbles = list(+ _mood_scrobbles(user, period).order_by("timestamp")+ )+ if not scrobbles:+ return {"streaks": [], "current_streak": None}++ streaks = []+ current_start = scrobbles[0].timestamp.date()+ current_type = scrobbles[0].log.get("mood_type") or "unknown"+ current_length = 1++ for s in scrobbles[1:]:+ mood_type = s.log.get("mood_type") or "unknown"+ if mood_type == current_type:+ current_length += 1+ else:+ streaks.append(+ {+ "start_date": current_start.isoformat(),+ "end_date": scrobbles[scrobbles.index(s) - 1].timestamp.date().isoformat(),+ "mood_type": current_type,+ "length": current_length,+ }+ )+ current_start = s.timestamp.date()+ current_type = mood_type+ current_length = 1++ streaks.append(+ {+ "start_date": current_start.isoformat(),+ "end_date": scrobbles[-1].timestamp.date().isoformat(),+ "mood_type": current_type,+ "length": current_length,+ }+ )++ streaks.sort(key=lambda x: x["length"], reverse=True)++ current_streak = {+ "mood_type": current_type,+ "length": current_length,+ "start_date": current_start.isoformat(),+ }++ return {"streaks": streaks[:10], "current_streak": current_streak}+++def compute_mood_weather(user, period="all_time"):+ scrobbles = _mood_scrobbles(user, period)+ by_condition = defaultdict(list)+ by_temp_range = defaultdict(list)++ for s in scrobbles:+ quality = s.log.get("mood_quality")+ if quality is None:+ continue+ desc = s.log.get("weather_description")+ temp = s.log.get("weather_temp")+ if desc:+ by_condition[desc].append(quality)+ if temp is not None:+ bucket = f"{(int(temp) // 10) * 10}-{(int(temp) // 10) * 10 + 9}F"+ by_temp_range[bucket].append(quality)++ conditions = [+ {"condition": cond, "avg_quality": _avg_quality(vals), "count": len(vals)}+ for cond, vals in sorted(by_condition.items(), key=lambda x: len(x[1]), reverse=True)+ ]++ temp_ranges = [+ {"range": rng, "avg_quality": _avg_quality(vals), "count": len(vals)}+ for rng, vals in sorted(by_temp_range.items())+ ]++ return {"conditions": conditions, "temp_ranges": temp_ranges}@@ -19,6 +19,11 @@ TIME_BOUND_TRENDS = { "activity-distribution", "concurrent-reading", "concurrent-listening",+ "mood-by-time",+ "mood-distribution",+ "mood-streaks",+ "mood-trajectory",+ "mood-weather", "reading-pace-vs-activity", "trending-up", }@@ -20,6 +20,31 @@ TREND_METADATA = { "description": "What music did you listen to while reading books?", "icon": "๐", },+ "mood-trajectory": {+ "title": "Mood Trajectory",+ "description": "How your mood quality has changed over time.",+ "icon": "๐",+ },+ "mood-by-time": {+ "title": "Mood by Time",+ "description": "How your mood varies by hour of day and day of week.",+ "icon": "๐",+ },+ "mood-distribution": {+ "title": "Mood Distribution",+ "description": "Which moods you feel most often.",+ "icon": "๐ญ",+ },+ "mood-streaks": {+ "title": "Mood Streaks",+ "description": "Your longest runs of positive and negative moods.",+ "icon": "๐ฅ",+ },+ "mood-weather": {+ "title": "Mood & Weather",+ "description": "How weather conditions correlate with your mood.",+ "icon": "๐ค",+ }, "peak-hours": { "title": "Peak Activity Hours", "description": "What time of day are you most active?",