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 %} +
+ + + + + + + + + + {% for entry in data.hours %} + {% if entry.count > 0 %} + + + + + + {% endif %} + {% endfor %} + +
HourAvg QualityCheck-ins
+ {% 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 }}
+
+ {% else %} +

No hourly data.

+ {% endif %} +
+
+
By Day of Week
+ {% if data.days %} +
+ + + + + + + + + + {% for entry in data.days %} + {% if entry.count > 0 %} + + + + + + {% endif %} + {% endfor %} + +
DayAvg QualityCheck-ins
{{ entry.day_name }} + + {{ entry.avg_quality }} + + {{ entry.count }}
+
+ {% 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 }} +

+
+ + + + + + + + + + {% with max=data.moods.0.count %} + {% for entry in data.moods %} + + + + + + {% endfor %} + {% endwith %} + +
MoodCountDistribution
{{ entry.mood }}{{ entry.count }} + {% if max > 0 %} +
+
+
+
+ {% endif %} +
+
+ {% 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 %} +
+ + + + + + + + + + + + {% for streak in data.streaks %} + + + + + + + + {% endfor %} + +
#Mood TypeLengthStartEnd
{{ forloop.counter }} + + {{ streak.mood_type|title }} + + {{ streak.length }}{{ streak.start_date }}{{ streak.end_date }}
+
+ {% 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 %} +
+ + + + + + + + + + + {% for entry in data.trajectory %} + + + + + + + {% endfor %} + +
DateAvg QualityCheck-insMood Bar
{{ entry.date }}{{ entry.avg_quality }}{{ entry.count }} +
+
+
+
+
+
+ {% 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 %} +
+ + + + + + + + + + {% for entry in data.conditions %} + + + + + + {% endfor %} + +
ConditionAvg QualityCheck-ins
{{ entry.condition }} + + {{ entry.avg_quality }} + + {{ entry.count }}
+
+ {% else %} +

No weather-linked mood data found.

+ {% endif %} +
+
+
By Temperature Range
+ {% if data.temp_ranges %} +
+ + + + + + + + + + {% for entry in data.temp_ranges %} + + + + + + {% endfor %} + +
Temp RangeAvg QualityCheck-ins
{{ entry.range }} + + {{ entry.avg_quality }} + + {{ entry.count }}
+
+ {% 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?",