sessions / 255e335d7a3243516048bbad66b4953054f06036

/home/powellc/src/code.unbl.ink/secstate/vrobbler

This commit has no recorded session.

diff

ignore whitespace

commit 255e335d7a3243516048bbad66b4953054f06036Author: Colin Powell <colin@unbl.ink>Date:   Wed Jun 17 17:07:16 2026 -0400    [trends] Add some mood related trendsdiff --git a/PROJECT.org b/PROJECT.orgindex 59aec56..53204fe 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,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:diff --git a/vrobbler/apps/trends/templates/trends/_mood_by_time.html b/vrobbler/apps/trends/templates/trends/_mood_by_time.htmlnew file mode 100644index 0000000..3505d40--- /dev/null+++ b/vrobbler/apps/trends/templates/trends/_mood_by_time.html@@ -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>diff --git a/vrobbler/apps/trends/templates/trends/_mood_distribution.html b/vrobbler/apps/trends/templates/trends/_mood_distribution.htmlnew file mode 100644index 0000000..78438cc--- /dev/null+++ b/vrobbler/apps/trends/templates/trends/_mood_distribution.html@@ -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>+      &middot; Positive: <strong>{{ data.positive_count }}</strong>+      &middot; 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>diff --git a/vrobbler/apps/trends/templates/trends/_mood_streaks.html b/vrobbler/apps/trends/templates/trends/_mood_streaks.htmlnew file mode 100644index 0000000..3592076--- /dev/null+++ b/vrobbler/apps/trends/templates/trends/_mood_streaks.html@@ -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>diff --git a/vrobbler/apps/trends/templates/trends/_mood_trajectory.html b/vrobbler/apps/trends/templates/trends/_mood_trajectory.htmlnew file mode 100644index 0000000..78ff5dc--- /dev/null+++ b/vrobbler/apps/trends/templates/trends/_mood_trajectory.html@@ -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>diff --git a/vrobbler/apps/trends/templates/trends/_mood_weather.html b/vrobbler/apps/trends/templates/trends/_mood_weather.htmlnew file mode 100644index 0000000..c4c0207--- /dev/null+++ b/vrobbler/apps/trends/templates/trends/_mood_weather.html@@ -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>diff --git a/vrobbler/apps/trends/templates/trends/trend_detail.html b/vrobbler/apps/trends/templates/trends/trend_detail.htmlindex 7f8eb53..8cfb46b 100644--- a/vrobbler/apps/trends/templates/trends/trend_detail.html+++ b/vrobbler/apps/trends/templates/trends/trend_detail.html@@ -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 %}diff --git a/vrobbler/apps/trends/trends/__init__.py b/vrobbler/apps/trends/trends/__init__.pyindex 01656c1..c365ae4 100644--- a/vrobbler/apps/trends/trends/__init__.py+++ b/vrobbler/apps/trends/trends/__init__.py@@ -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_activitydiff --git a/vrobbler/apps/trends/trends/mood.py b/vrobbler/apps/trends/trends/mood.pynew file mode 100644index 0000000..88714b1--- /dev/null+++ b/vrobbler/apps/trends/trends/mood.py@@ -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}diff --git a/vrobbler/apps/trends/utils.py b/vrobbler/apps/trends/utils.pyindex 333d172..bd399b8 100644--- a/vrobbler/apps/trends/utils.py+++ b/vrobbler/apps/trends/utils.py@@ -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", }diff --git a/vrobbler/apps/trends/views.py b/vrobbler/apps/trends/views.pyindex 613e133..a9ee73b 100644--- a/vrobbler/apps/trends/views.py+++ b/vrobbler/apps/trends/views.py@@ -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?",