diff --git a/PROJECT.org b/PROJECT.org
index 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.html
new file mode 100644
index 0000000..3505d40
--- /dev/null
+++ b/vrobbler/apps/trends/templates/trends/_mood_by_time.html
@@ -0,0 +1,78 @@
+
+
+
By Hour of Day
+ {% if data.hours %}
+
+
+
+
+ | Hour |
+ Avg Quality |
+ Check-ins |
+
+
+
+ {% for entry in data.hours %}
+ {% if entry.count > 0 %}
+
+ |
+ {% 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 %}
+ |
+
+
+ {{ entry.avg_quality }}
+
+ |
+ {{ entry.count }} |
+
+ {% endif %}
+ {% endfor %}
+
+
+
+ {% else %}
+
No hourly data.
+ {% endif %}
+
+
+
By Day of Week
+ {% if data.days %}
+
+
+
+
+ | Day |
+ Avg Quality |
+ Check-ins |
+
+
+
+ {% for entry in data.days %}
+ {% if entry.count > 0 %}
+
+ | {{ entry.day_name }} |
+
+
+ {{ entry.avg_quality }}
+
+ |
+ {{ entry.count }} |
+
+ {% endif %}
+ {% endfor %}
+
+
+
+ {% else %}
+
No daily data.
+ {% endif %}
+
+
diff --git a/vrobbler/apps/trends/templates/trends/_mood_distribution.html b/vrobbler/apps/trends/templates/trends/_mood_distribution.html
new file mode 100644
index 0000000..78438cc
--- /dev/null
+++ b/vrobbler/apps/trends/templates/trends/_mood_distribution.html
@@ -0,0 +1,45 @@
+
+
+ {% if data.moods %}
+
+ Total mood check-ins{% if current_period_label %} ({{ current_period_label }}){% endif %}: {{ data.total }}
+ · Positive: {{ data.positive_count }}
+ · Negative: {{ data.negative_count }}
+
+
+
+
+
+ | Mood |
+ Count |
+ Distribution |
+
+
+
+ {% with max=data.moods.0.count %}
+ {% for entry in data.moods %}
+
+ | {{ entry.mood }} |
+ {{ entry.count }} |
+
+ {% if max > 0 %}
+
+ {% endif %}
+ |
+
+ {% endfor %}
+ {% endwith %}
+
+
+
+ {% else %}
+
No mood distribution data found.
+ {% endif %}
+
+
diff --git a/vrobbler/apps/trends/templates/trends/_mood_streaks.html b/vrobbler/apps/trends/templates/trends/_mood_streaks.html
new file mode 100644
index 0000000..3592076
--- /dev/null
+++ b/vrobbler/apps/trends/templates/trends/_mood_streaks.html
@@ -0,0 +1,47 @@
+
+
+ {% if data.current_streak %}
+
+ Current streak:
+ {{ data.current_streak.length }} consecutive
+
+ {{ data.current_streak.mood_type }}
+
+ check-ins since {{ data.current_streak.start_date }}.
+
+ {% endif %}
+
+ {% if data.streaks %}
+
+
+
+
+ | # |
+ Mood Type |
+ Length |
+ Start |
+ End |
+
+
+
+ {% for streak in data.streaks %}
+
+ | {{ forloop.counter }} |
+
+
+ {{ streak.mood_type|title }}
+
+ |
+ {{ streak.length }} |
+ {{ streak.start_date }} |
+ {{ streak.end_date }} |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
No streak data found.
+ {% endif %}
+
+
diff --git a/vrobbler/apps/trends/templates/trends/_mood_trajectory.html b/vrobbler/apps/trends/templates/trends/_mood_trajectory.html
new file mode 100644
index 0000000..78ff5dc
--- /dev/null
+++ b/vrobbler/apps/trends/templates/trends/_mood_trajectory.html
@@ -0,0 +1,39 @@
+
+
+ {% if data.trajectory %}
+
+
+
+
+ | Date |
+ Avg Quality |
+ Check-ins |
+ Mood Bar |
+
+
+
+ {% for entry in data.trajectory %}
+
+ | {{ entry.date }} |
+ {{ entry.avg_quality }} |
+ {{ entry.count }} |
+
+
+ |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
No mood check-in data found.
+ {% endif %}
+
+
diff --git a/vrobbler/apps/trends/templates/trends/_mood_weather.html b/vrobbler/apps/trends/templates/trends/_mood_weather.html
new file mode 100644
index 0000000..c4c0207
--- /dev/null
+++ b/vrobbler/apps/trends/templates/trends/_mood_weather.html
@@ -0,0 +1,64 @@
+
+
+
By Weather Condition
+ {% if data.conditions %}
+
+
+
+
+ | Condition |
+ Avg Quality |
+ Check-ins |
+
+
+
+ {% for entry in data.conditions %}
+
+ | {{ entry.condition }} |
+
+
+ {{ entry.avg_quality }}
+
+ |
+ {{ entry.count }} |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
No weather-linked mood data found.
+ {% endif %}
+
+
+
By Temperature Range
+ {% if data.temp_ranges %}
+
+
+
+
+ | Temp Range |
+ Avg Quality |
+ Check-ins |
+
+
+
+ {% for entry in data.temp_ranges %}
+
+ | {{ entry.range }} |
+
+
+ {{ entry.avg_quality }}
+
+ |
+ {{ entry.count }} |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
No temperature-linked mood data found.
+ {% endif %}
+
+
diff --git a/vrobbler/apps/trends/templates/trends/trend_detail.html b/vrobbler/apps/trends/templates/trends/trend_detail.html
index 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__.py
index 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_activity
diff --git a/vrobbler/apps/trends/trends/mood.py b/vrobbler/apps/trends/trends/mood.py
new file mode 100644
index 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.py
index 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.py
index 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?",