This commit has no recorded session.
diff
commit a74a89c747deed4e23f43a4e8b3ee8082d68737fAuthor: Colin Powell <colin@unbl.ink>Date: Wed Jul 1 23:22:38 2026 -0400 [trends] Clean up display@@ -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:@@ -447,9 +447,13 @@ 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)+ now = now_user_timezone(user.profile) today = now.date() context["chart_keys"] = {@@ -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 %}@@ -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> · 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>+ <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 %}@@ -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">- </div>- </div>- </td>- </tr>- {% endfor %}- </tbody>- </table>+ {{ 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>+ <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 %}@@ -1,50 +1,90 @@ <div class="row"> <div class="col-12"> {% if data.hours %}- <div class="table-responsive">- <table class="table table-striped table-sm">- <thead>- <tr>- <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>- {% 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">{{ 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>+ {{ 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>+ </tr>+ </thead>+ <tbody>+ {% 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+ {% 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">{{ entry.count }}</td>+ </tr>+ {% endfor %}+ </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 %}@@ -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>- {% 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>- {% 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="mb-4">+ {% for slug, info in data.categories.items %}+ {% 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 %}+ <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>@@ -1,38 +1,27 @@-<div class="row">- <div class="col-12">- {% 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">- {% if info.change_pct > 0 %}- <span class="text-success">+{{ info.change_pct }}%</span>- {% elif info.change_pct < 0 %}- <span class="text-danger">{{ info.change_pct }}%</span>- {% else %}- <span class="text-muted">0%</span>- {% endif %}- </td>- </tr>- {% endfor %}- </tbody>- </table>+<div class="row g-3">+ {% if data %}+ {% for mt, info in data.items %}+ <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 %}+ <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 %}+ <div class="display-3 lh-1 text-danger">โ</div>+ <div class="fs-6 text-danger mt-1">{{ info.change_pct }}%</div>+ {% else %}+ <div class="display-3 lh-1 text-muted">โ</div>+ <div class="fs-6 text-muted mt-1">0%</div>+ {% endif %}+ <div class="fw-bold mt-2">{{ mt }}</div>+ </div>+ </div> </div>- {% else %}- <p class="text-muted">No trending data found.</p>- {% endif %}- </div>+ {% endfor %}+ {% else %}+ <div class="col-12">+ <p class="text-muted">No trending data found.</p>+ </div>+ {% endif %} </div>@@ -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,