Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f1882b21f | |||
| e819a2db0d | |||
| e03cf6c9b1 | |||
| 471e70ff7f | |||
| 255e335d7a |
21
PROJECT.org
21
PROJECT.org
@ -590,6 +590,27 @@ 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
|
||||||
|
|
||||||
|
* Version 54.5 [1/1]
|
||||||
|
** DONE Fix bug in generating mood trends :trends:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 8e75abfa-8e70-d85b-00a4-a4813bbce879
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 54.4 [2/2]
|
||||||
|
** DONE [#A] Remove all-time trends :trends:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 53b231d1-7677-8cd3-1d88-dae110aba1e6
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
All time trends take forever to calculate and don't provide too much data
|
||||||
|
|
||||||
|
** DONE [#B] Add a trend around moods :moods:trends:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: fba3f4ae-8f97-ee0b-e762-31630884518a
|
||||||
|
:END:
|
||||||
|
|
||||||
* Version 54.3 [1/1]
|
* Version 54.3 [1/1]
|
||||||
** DONE [#B] Fix bug in series metadata cleanup script :videos:metadta:
|
** DONE [#B] Fix bug in series metadata cleanup script :videos:metadta:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "54.3"
|
version = "54.5"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,6 @@ PERIOD_CHOICES = [
|
|||||||
("last_30", "Last 30 days"),
|
("last_30", "Last 30 days"),
|
||||||
("last_90", "Last 90 days"),
|
("last_90", "Last 90 days"),
|
||||||
("last_year", "Last year"),
|
("last_year", "Last year"),
|
||||||
("all_time", "All time"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -18,7 +17,7 @@ class TrendResult(TimeStampedModel):
|
|||||||
period = models.CharField(
|
period = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=PERIOD_CHOICES,
|
choices=PERIOD_CHOICES,
|
||||||
default="all_time",
|
default="last_30",
|
||||||
)
|
)
|
||||||
computed_at = models.DateTimeField(auto_now_add=True)
|
computed_at = models.DateTimeField(auto_now_add=True)
|
||||||
data = models.JSONField(default=dict)
|
data = models.JSONField(default=dict)
|
||||||
|
|||||||
78
vrobbler/apps/trends/templates/trends/_mood_by_time.html
Normal file
78
vrobbler/apps/trends/templates/trends/_mood_by_time.html
Normal file
@ -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>
|
||||||
47
vrobbler/apps/trends/templates/trends/_mood_streaks.html
Normal file
47
vrobbler/apps/trends/templates/trends/_mood_streaks.html
Normal file
@ -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>
|
||||||
39
vrobbler/apps/trends/templates/trends/_mood_trajectory.html
Normal file
39
vrobbler/apps/trends/templates/trends/_mood_trajectory.html
Normal file
@ -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>
|
||||||
64
vrobbler/apps/trends/templates/trends/_mood_weather.html
Normal file
64
vrobbler/apps/trends/templates/trends/_mood_weather.html
Normal file
@ -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" %}
|
{% elif trend.slug == "activity-distribution" %}
|
||||||
{% include "trends/_activity_distribution.html" %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -7,6 +7,13 @@ from trends.trends.concurrent import (
|
|||||||
compute_concurrent_listening,
|
compute_concurrent_listening,
|
||||||
compute_concurrent_reading,
|
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.reading import compute_reading_pace_vs_activity
|
||||||
from trends.trends.trending import compute_trending_up
|
from trends.trends.trending import compute_trending_up
|
||||||
|
|
||||||
@ -28,6 +35,11 @@ compute_activity_distribution = register("activity-distribution")(
|
|||||||
# compute_concurrent_listening
|
# compute_concurrent_listening
|
||||||
# )
|
# )
|
||||||
compute_concurrent_reading = register("concurrent-reading")(compute_concurrent_reading)
|
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_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
|
||||||
|
|||||||
208
vrobbler/apps/trends/trends/mood.py
Normal file
208
vrobbler/apps/trends/trends/mood.py
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
from collections import Counter, defaultdict
|
||||||
|
|
||||||
|
from django.db.models import Q
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
|
||||||
|
def _mood_scrobbles(user, period="last_30"):
|
||||||
|
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 _parse_quality(raw):
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _avg_quality(values):
|
||||||
|
nums = [v for v in values if v is not None]
|
||||||
|
if not nums:
|
||||||
|
return 0.0
|
||||||
|
return round(sum(nums) / len(nums), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_mood_trajectory(user, period="last_30"):
|
||||||
|
scrobbles = _mood_scrobbles(user, period).order_by("timestamp")
|
||||||
|
by_date = defaultdict(list)
|
||||||
|
for s in scrobbles:
|
||||||
|
quality = _parse_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="last_30"):
|
||||||
|
scrobbles = _mood_scrobbles(user, period)
|
||||||
|
by_hour = defaultdict(list)
|
||||||
|
by_day = defaultdict(list)
|
||||||
|
|
||||||
|
for s in scrobbles:
|
||||||
|
quality = _parse_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="last_30"):
|
||||||
|
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="last_30"):
|
||||||
|
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="last_30"):
|
||||||
|
scrobbles = _mood_scrobbles(user, period)
|
||||||
|
by_condition = defaultdict(list)
|
||||||
|
by_temp_range = defaultdict(list)
|
||||||
|
|
||||||
|
for s in scrobbles:
|
||||||
|
quality = _parse_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:
|
||||||
|
try:
|
||||||
|
temp_f = float(temp)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
bucket = f"{(int(temp_f) // 10) * 10}-{(int(temp_f) // 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}
|
||||||
@ -10,7 +10,6 @@ PERIOD_DAYS = {
|
|||||||
"last_30": 30,
|
"last_30": 30,
|
||||||
"last_90": 90,
|
"last_90": 90,
|
||||||
"last_year": 365,
|
"last_year": 365,
|
||||||
"all_time": None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PERIOD_LABELS = dict(PERIOD_CHOICES)
|
PERIOD_LABELS = dict(PERIOD_CHOICES)
|
||||||
@ -19,8 +18,15 @@ TIME_BOUND_TRENDS = {
|
|||||||
"activity-distribution",
|
"activity-distribution",
|
||||||
"concurrent-reading",
|
"concurrent-reading",
|
||||||
"concurrent-listening",
|
"concurrent-listening",
|
||||||
|
"mood-by-time",
|
||||||
|
"mood-distribution",
|
||||||
|
"mood-streaks",
|
||||||
|
"mood-trajectory",
|
||||||
|
"mood-weather",
|
||||||
|
"peak-hours",
|
||||||
"reading-pace-vs-activity",
|
"reading-pace-vs-activity",
|
||||||
"trending-up",
|
"trending-up",
|
||||||
|
"weekly-rhythm",
|
||||||
}
|
}
|
||||||
|
|
||||||
TREND_PERIOD_OVERRIDES = {
|
TREND_PERIOD_OVERRIDES = {
|
||||||
@ -32,9 +38,7 @@ def get_supported_periods(trend_slug):
|
|||||||
if trend_slug in TREND_PERIOD_OVERRIDES:
|
if trend_slug in TREND_PERIOD_OVERRIDES:
|
||||||
slugs = TREND_PERIOD_OVERRIDES[trend_slug]
|
slugs = TREND_PERIOD_OVERRIDES[trend_slug]
|
||||||
return {s: PERIOD_LABELS[s] for s in slugs}
|
return {s: PERIOD_LABELS[s] for s in slugs}
|
||||||
if trend_slug in TIME_BOUND_TRENDS:
|
return dict(PERIOD_LABELS)
|
||||||
return dict(PERIOD_LABELS)
|
|
||||||
return {"all_time": PERIOD_LABELS["all_time"]}
|
|
||||||
|
|
||||||
|
|
||||||
def get_period_days(period):
|
def get_period_days(period):
|
||||||
@ -61,7 +65,7 @@ def get_period_nav(current_period, trend_slug):
|
|||||||
return prev_period, next_period
|
return prev_period, next_period
|
||||||
|
|
||||||
|
|
||||||
def compute_and_save_trend(user, slug, period="all_time"):
|
def compute_and_save_trend(user, slug, period="last_30"):
|
||||||
"""Compute a single trend for a given period and persist the result.
|
"""Compute a single trend for a given period and persist the result.
|
||||||
|
|
||||||
Returns elapsed seconds on success, raises on failure.
|
Returns elapsed seconds on success, raises on failure.
|
||||||
|
|||||||
@ -20,6 +20,31 @@ TREND_METADATA = {
|
|||||||
"description": "What music did you listen to while reading books?",
|
"description": "What music did you listen to while reading books?",
|
||||||
"icon": "📖",
|
"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": {
|
"peak-hours": {
|
||||||
"title": "Peak Activity Hours",
|
"title": "Peak Activity Hours",
|
||||||
"description": "What time of day are you most active?",
|
"description": "What time of day are you most active?",
|
||||||
@ -86,7 +111,7 @@ class TrendDetailView(LoginRequiredMixin, TemplateView):
|
|||||||
ctx["trend_not_found"] = True
|
ctx["trend_not_found"] = True
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
period = self.request.GET.get("period", "all_time")
|
period = self.request.GET.get("period", "last_30")
|
||||||
|
|
||||||
meta = TREND_METADATA.get(slug, {})
|
meta = TREND_METADATA.get(slug, {})
|
||||||
ctx["trend"] = {
|
ctx["trend"] = {
|
||||||
|
|||||||
Reference in New Issue
Block a user