[trends] Clean up display

This commit is contained in:
2026-07-01 23:22:38 -04:00
parent 1695f7393e
commit a74a89c747
9 changed files with 399 additions and 210 deletions

View File

@ -88,7 +88,7 @@ fetching and simple saving.
*** Metadata sources
**** Scraper
* Backlog [0/23] :vrobbler:project:personal:
* Backlog [1/24] :vrobbler:project:personal:
** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata:
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
:PROPERTIES:
@ -605,6 +605,10 @@ independent of the email flow it was originally creatdd for
** TODO [#B] Is there way to create unique slugs for media instances :media_types:
** DONE [#B] Clean up trend templates :trends:templates:
:PROPERTIES:
:ID: 83237e2c-857b-47c9-c86c-32a5e3f1359d
:END:
* Version 58.7 [2/2]
** DONE [#B] Split up chart page between tables and maloja :charts:templates:
:PROPERTIES:

View File

@ -447,8 +447,12 @@ class MalojaChartsView(ChartRecordView):
context = super(ChartRecordView, self).get_context_data(**kwargs)
user = self.request.user
if not user.is_authenticated:
context["maloja_charts"] = {}
context["chart_keys"] = {}
return context
now = timezone.now()
if user.is_authenticated:
now = now_user_timezone(user.profile)
today = now.date()

View File

@ -1,45 +1,78 @@
<div class="row">
<div class="col-12">
{% if data.distribution %}
{{ data.distribution|json_script:"activity-distribution-data" }}
<p class="text-muted mb-3">
Total scrobbles{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total_count }}</strong>
</p>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Media Type</th>
<th class="text-end">Total</th>
<th class="text-end">Completed</th>
<th class="text-end">%</th>
<th>Distribution</th>
</tr>
</thead>
<tbody>
{% with max=data.distribution.0.count %}
{% for entry in data.distribution %}
<tr>
<td>{{ entry.media_type }}</td>
<td class="text-end">{{ entry.count }}</td>
<td class="text-end">{{ entry.completed }}</td>
<td class="text-end">{{ entry.pct }}%</td>
<td style="width: 30%;">
{% if max > 0 %}
<div class="progress" style="height: 12px;">
<div class="progress-bar" role="progressbar"
style="width: {{ entry.pct }}%;"
aria-valuenow="{{ entry.count }}"
aria-valuemin="0" aria-valuemax="{{ max }}">
</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
{% endwith %}
</tbody>
</table>
</div>
<canvas id="activityDistChart" width="700" style="max-width:100%;"></canvas>
<script>
(function() {
var el = document.getElementById('activity-distribution-data');
if (!el) return;
var data = JSON.parse(el.textContent);
if (!data.length) return;
var canvas = document.getElementById('activityDistChart');
var ctx = canvas.getContext('2d');
var W = canvas.width;
var rowH = 34;
var labelW = 140;
var barLeft = labelW + 8;
var barRight = W - 80;
var barMaxW = barRight - barLeft;
var padTop = 8;
var maxCount = data[0].count;
// Set canvas height based on data
canvas.height = data.length * rowH + padTop;
function roundRect(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
data.forEach(function(entry, i) {
var y = padTop + i * rowH;
var pct = entry.count / maxCount;
var barW = pct * barMaxW;
// Label
ctx.fillStyle = '#374151';
ctx.font = '13px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(entry.media_type, labelW - 6, y + rowH / 2);
// Bar background
ctx.fillStyle = '#f3f4f6';
roundRect(barLeft, y + 4, barMaxW, rowH - 8, 4);
ctx.fill();
// Bar fill — green (top) to red (bottom)
var hue = 120 * (1 - i / (data.length - 1 || 1));
ctx.fillStyle = 'hsl(' + hue + ', 65%, 50%)';
roundRect(barLeft, y + 4, Math.max(barW, 2), rowH - 8, 4);
ctx.fill();
// Count + percentage label on the right
ctx.fillStyle = '#374151';
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(entry.count + ' (' + entry.pct + '%)', barRight + 6, y + rowH / 2);
});
})();
</script>
{% else %}
<p class="text-muted">No activity data found.</p>
{% endif %}

View File

@ -1,43 +1,74 @@
<div class="row">
<div class="col-12">
{% if data.moods %}
{{ data.moods|json_script:"mood-distribution-data" }}
<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>
<canvas id="moodDistChart" width="700" style="max-width:100%;"></canvas>
<script>
(function() {
var el = document.getElementById('mood-distribution-data');
if (!el) return;
var data = JSON.parse(el.textContent);
if (!data.length) return;
var canvas = document.getElementById('moodDistChart');
var ctx = canvas.getContext('2d');
var rowH = 34;
var labelW = 140;
var barLeft = labelW + 8;
var barRight = canvas.width - 80;
var barMaxW = barRight - barLeft;
var padTop = 8;
canvas.height = data.length * rowH + padTop;
var maxCount = data[0].count;
function roundRect(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
data.forEach(function(entry, i) {
var y = padTop + i * rowH;
var pct = entry.count / maxCount;
var barW = pct * barMaxW;
ctx.fillStyle = '#374151';
ctx.font = '13px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(entry.mood, labelW - 6, y + rowH / 2);
ctx.fillStyle = '#f3f4f6';
roundRect(barLeft, y + 4, barMaxW, rowH - 8, 4);
ctx.fill();
var hue = 120 * (1 - i / (data.length - 1 || 1));
ctx.fillStyle = 'hsl(' + hue + ', 65%, 50%)';
roundRect(barLeft, y + 4, Math.max(barW, 2), rowH - 8, 4);
ctx.fill();
ctx.fillStyle = '#374151';
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText('' + entry.count, barRight + 6, y + rowH / 2);
});
})();
</script>
{% else %}
<p class="text-muted">No mood distribution data found.</p>
{% endif %}

View File

@ -1,37 +1,105 @@
<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">
{{ data.trajectory|json_script:"mood-trajectory-data" }}
<div class="d-flex flex-column align-items-center">
<canvas id="moodTrajectoryChart" width="700" height="300" style="max-width:100%;"></canvas>
<div class="d-flex gap-4 mt-2 small text-muted">
<span>← Earlier</span>
<span>Later →</span>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
(function() {
var el = document.getElementById('mood-trajectory-data');
if (!el) return;
var data = JSON.parse(el.textContent);
if (!data.length) return;
var canvas = document.getElementById('moodTrajectoryChart');
var ctx = canvas.getContext('2d');
var W = canvas.width, H = canvas.height;
var pad = { top: 20, right: 20, bottom: 30, left: 40 };
var plotW = W - pad.left - pad.right;
var plotH = H - pad.top - pad.bottom;
var yMin = 1, yMax = 7;
var yRange = yMax - yMin;
var maxCount = data.reduce(function(m, d) { return Math.max(m, d.count); }, 0);
function xPos(i) {
return pad.left + (i / (data.length - 1 || 1)) * plotW;
}
function yPos(val) {
return pad.top + (1 - (val - yMin) / yRange) * plotH;
}
// Background grid
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 0.5;
for (var q = 1; q <= 7; q++) {
var y = yPos(q);
ctx.beginPath();
ctx.moveTo(pad.left, y);
ctx.lineTo(W - pad.right, y);
ctx.stroke();
ctx.fillStyle = '#9ca3af';
ctx.font = '11px sans-serif';
ctx.textAlign = 'right';
ctx.fillText(q.toFixed(1), pad.left - 5, y + 4);
}
// Reference line at neutral (4)
var neutralY = yPos(4);
ctx.strokeStyle = '#d1d5db';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(pad.left, neutralY);
ctx.lineTo(W - pad.right, neutralY);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#9ca3af';
ctx.font = '11px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('neutral', W - pad.right + 4, neutralY + 4);
// Line chart
ctx.beginPath();
data.forEach(function(d, i) {
var x = xPos(i);
var y = yPos(d.avg_quality);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.strokeStyle = '#6366f1';
ctx.lineWidth = 2.5;
ctx.lineJoin = 'round';
ctx.stroke();
// Gradient fill below the line
var gradient = ctx.createLinearGradient(0, pad.top, 0, H - pad.bottom);
gradient.addColorStop(0, 'rgba(99, 102, 241, 0.25)');
gradient.addColorStop(1, 'rgba(99, 102, 241, 0.02)');
ctx.lineTo(xPos(data.length - 1), yPos(yMin));
ctx.lineTo(xPos(0), yPos(yMin));
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
// Dots
data.forEach(function(d, i) {
var x = xPos(i);
var y = yPos(d.avg_quality);
ctx.beginPath();
ctx.arc(x, y, 3.5, 0, 2 * Math.PI);
ctx.fillStyle = '#6366f1';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
});
})();
</script>
{% else %}
<p class="text-muted">No mood check-in data found.</p>
{% endif %}

View File

@ -1,20 +1,26 @@
<div class="row">
<div class="col-12">
{% if data.hours %}
<div class="table-responsive">
<table class="table table-striped table-sm">
{{ data.hours|json_script:"peak-hours-data" }}
<div class="d-flex flex-wrap align-items-start justify-content-center gap-4">
<div>
<canvas id="peakHoursChart" width="300" height="300" style="max-width:100%;"></canvas>
</div>
<div class="table-responsive" style="max-height:360px; overflow-y:auto;">
<table class="table table-sm table-borderless mb-0" id="peakHoursLegend">
<thead>
<tr>
<th style="width:14px; padding-right:0;"></th>
<th>Hour</th>
<th class="text-end">Scrobbles</th>
<th>Distribution</th>
</tr>
</thead>
<tbody>
{% with total=data.hours|dictsortreversed:"count"|first %}
{% with max_count=total.count %}
{% for entry in data.hours %}
<tr>
<td class="p-1 text-center">
<span class="legend-swatch" data-idx="{{ forloop.counter0 }}" style="display:inline-block; width:12px; height:12px; border-radius:2px;"></span>
</td>
<td>
{% if entry.hour == 0 %}
12 AM
@ -27,24 +33,58 @@
{% endif %}
</td>
<td class="text-end">{{ entry.count }}</td>
<td style="width: 40%;">
{% if max_count > 0 %}
<div class="progress" style="height: 12px;">
<div class="progress-bar" role="progressbar"
style="width: {{ entry.count|floatformat:0 }}%;"
aria-valuenow="{{ entry.count }}"
aria-valuemin="0" aria-valuemax="{{ max_count }}">
</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
{% endwith %}
{% endwith %}
</tbody>
</table>
</div>
</div>
<script>
(function() {
var dataEl = document.getElementById('peak-hours-data');
if (!dataEl) return;
var data = JSON.parse(dataEl.textContent);
var total = data.reduce(function(s, h) { return s + h.count; }, 0) || 1;
var canvas = document.getElementById('peakHoursChart');
if (!canvas) return;
var ctx = canvas.getContext('2d');
var cx = canvas.width / 2;
var cy = canvas.height / 2;
var radius = Math.min(cx, cy) - 10;
var startAngle = -Math.PI / 2;
var minCount = data.reduce(function(m, h) { return Math.min(m, h.count); }, Infinity);
var maxCount = data.reduce(function(m, h) { return Math.max(m, h.count); }, 0);
var range = maxCount - minCount || 1;
function countToHue(count) {
var t = (count - minCount) / range;
return 120 * t;
}
data.forEach(function(entry, i) {
var sliceAngle = (entry.count / total) * 2 * Math.PI;
var hue = countToHue(entry.count);
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, radius, startAngle, startAngle + sliceAngle);
ctx.closePath();
ctx.fillStyle = 'hsl(' + hue + ', 70%, 50%)';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
startAngle += sliceAngle;
});
document.querySelectorAll('.legend-swatch').forEach(function(el) {
var idx = parseInt(el.getAttribute('data-idx'), 10);
var entry = data[idx];
var hue = countToHue(entry.count);
el.style.backgroundColor = 'hsl(' + hue + ', 70%, 50%)';
});
})();
</script>
{% else %}
<p class="text-muted">No activity data found.</p>
{% endif %}

View File

@ -1,31 +1,45 @@
<div class="row">
<div class="col-12">
{% if data.total and data.total > 0 %}
<h5>Overall</h5>
<div class="table-responsive mb-4">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Category</th>
<th class="text-end">Scrobbles</th>
<th class="text-end">%</th>
</tr>
</thead>
<tbody>
<div class="mb-4">
{% for slug, info in data.categories.items %}
<tr>
<td>{{ info.label }}</td>
<td class="text-end">{{ info.count }}</td>
<td class="text-end">{{ info.pct }}%</td>
</tr>
{% if forloop.first %}
<div class="card text-center border-0 bg-light mb-3">
<div class="card-body py-4">
{% if slug == "early_bird" %}
<div class="display-1">🌅</div>
{% elif slug == "day_jay" %}
<div class="display-1">☀️</div>
{% else %}
<div class="display-1">🌙</div>
{% endif %}
<h3 class="mt-2">{{ info.label }}</h3>
<div class="display-4 fw-bold">{{ info.pct }}%</div>
<div class="text-muted">{{ info.count }} scrobbles</div>
</div>
</div>
{% else %}
<div class="card text-center border-0 bg-light mb-2">
<div class="card-body py-2 d-flex align-items-center justify-content-between px-4">
<div class="d-flex align-items-center gap-3">
{% if slug == "early_bird" %}
<span style="font-size:1.5rem;">🌅</span>
{% elif slug == "day_jay" %}
<span style="font-size:1.5rem;">☀️</span>
{% else %}
<span style="font-size:1.5rem;">🌙</span>
{% endif %}
<h5 class="mb-0">{{ info.label }}</h5>
</div>
<div class="text-end">
<span class="fs-4 fw-bold">{{ info.pct }}%</span>
<br><small class="text-muted">{{ info.count }} scrobbles</small>
</div>
</div>
</div>
{% endif %}
{% endfor %}
<tr class="table-secondary">
<td><strong>Total</strong></td>
<td class="text-end"><strong>{{ data.total }}</strong></td>
<td class="text-end"></td>
</tr>
</tbody>
</table>
<div class="text-center text-muted small mt-2">Total: {{ data.total }} scrobbles across Books, Trails, Birding Locations, and Board Games</div>
</div>
<h5>By Media Type</h5>

View File

@ -1,38 +1,27 @@
<div class="row">
<div class="col-12">
<div class="row g-3">
{% if data %}
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Media Type</th>
<th class="text-end">Recent ({{ current_period_label }})</th>
<th class="text-end">Previous ({{ current_period_label }})</th>
<th class="text-end">Change</th>
</tr>
</thead>
<tbody>
{% for mt, info in data.items %}
<tr>
<td>{{ mt }}</td>
<td class="text-end">{{ info.recent }}</td>
<td class="text-end">{{ info.previous }}</td>
<td class="text-end">
<div class="col-6 col-md-4 col-lg-3 col-xl-2">
<div class="card text-center h-100">
<div class="card-body d-flex flex-column align-items-center justify-content-center" style="aspect-ratio: 1;">
{% if info.change_pct > 0 %}
<span class="text-success">+{{ info.change_pct }}%</span>
<div class="display-3 lh-1 text-success"></div>
<div class="fs-6 text-success mt-1">+{{ info.change_pct }}%</div>
{% elif info.change_pct < 0 %}
<span class="text-danger">{{ info.change_pct }}%</span>
<div class="display-3 lh-1 text-danger"></div>
<div class="fs-6 text-danger mt-1">{{ info.change_pct }}%</div>
{% else %}
<span class="text-muted">0%</span>
<div class="display-3 lh-1 text-muted"></div>
<div class="fs-6 text-muted mt-1">0%</div>
{% endif %}
</td>
</tr>
<div class="fw-bold mt-2">{{ mt }}</div>
</div>
</div>
</div>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="col-12">
<p class="text-muted">No trending data found.</p>
</div>
{% endif %}
</div>
</div>

View File

@ -59,18 +59,21 @@ def compute_time_of_day_categories(user, period="last_30"):
if slug:
cat_counts[slug] += count
mt_total += count
by_media_type[mt] = {
"total": mt_total,
"categories": {},
}
mt_categories = {}
for slug in CATEGORIES:
c = cat_counts[slug]
by_media_type[mt]["categories"][slug] = {
mt_categories[slug] = {
"count": c,
"pct": round((c / mt_total * 100), 1) if mt_total else 0,
"label": CATEGORIES[slug]["label"],
}
grand_totals[slug] += c
by_media_type[mt] = {
"total": mt_total,
"categories": dict(
sorted(mt_categories.items(), key=lambda x: x[1]["count"], reverse=True)
),
}
grand_total += mt_total
categories = {}
@ -81,6 +84,9 @@ def compute_time_of_day_categories(user, period="last_30"):
"pct": round((c / grand_total * 100), 1) if grand_total else 0,
"label": CATEGORIES[slug]["label"],
}
categories = dict(
sorted(categories.items(), key=lambda x: x[1]["count"], reverse=True)
)
return {
"categories": categories,