sessions / a74a89c747deed4e23f43a4e8b3ee8082d68737f

/home/powellc/src/code.unbl.ink/secstate/vrobbler

This commit has no recorded session.

diff

ignore whitespace

commit a74a89c747deed4e23f43a4e8b3ee8082d68737fAuthor: Colin Powell <colin@unbl.ink>Date:   Wed Jul 1 23:22:38 2026 -0400    [trends] Clean up displaydiff --git a/PROJECT.org b/PROJECT.orgindex f0cf3b8..1421346 100644--- a/PROJECT.org+++ b/PROJECT.org@@ -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:diff --git a/vrobbler/apps/charts/views.py b/vrobbler/apps/charts/views.pyindex 9a88541..beb1831 100644--- a/vrobbler/apps/charts/views.py+++ b/vrobbler/apps/charts/views.py@@ -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"] = {diff --git a/vrobbler/apps/trends/templates/trends/_activity_distribution.html b/vrobbler/apps/trends/templates/trends/_activity_distribution.htmlindex aa480d3..c086b60 100644--- a/vrobbler/apps/trends/templates/trends/_activity_distribution.html+++ b/vrobbler/apps/trends/templates/trends/_activity_distribution.html@@ -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 %}diff --git a/vrobbler/apps/trends/templates/trends/_mood_distribution.html b/vrobbler/apps/trends/templates/trends/_mood_distribution.htmlindex 78438cc..3231166 100644--- a/vrobbler/apps/trends/templates/trends/_mood_distribution.html+++ b/vrobbler/apps/trends/templates/trends/_mood_distribution.html@@ -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 %}diff --git a/vrobbler/apps/trends/templates/trends/_mood_trajectory.html b/vrobbler/apps/trends/templates/trends/_mood_trajectory.htmlindex 78ff5dc..ecbe15e 100644--- a/vrobbler/apps/trends/templates/trends/_mood_trajectory.html+++ b/vrobbler/apps/trends/templates/trends/_mood_trajectory.html@@ -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 %}diff --git a/vrobbler/apps/trends/templates/trends/_peak_hours.html b/vrobbler/apps/trends/templates/trends/_peak_hours.htmlindex 5385e96..eb4ea22 100644--- a/vrobbler/apps/trends/templates/trends/_peak_hours.html+++ b/vrobbler/apps/trends/templates/trends/_peak_hours.html@@ -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 %}diff --git a/vrobbler/apps/trends/templates/trends/_time_of_day_categories.html b/vrobbler/apps/trends/templates/trends/_time_of_day_categories.htmlindex a80a5de..31f0e25 100644--- a/vrobbler/apps/trends/templates/trends/_time_of_day_categories.html+++ b/vrobbler/apps/trends/templates/trends/_time_of_day_categories.html@@ -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>diff --git a/vrobbler/apps/trends/templates/trends/_trending_up.html b/vrobbler/apps/trends/templates/trends/_trending_up.htmlindex c849e24..f9b3a77 100644--- a/vrobbler/apps/trends/templates/trends/_trending_up.html+++ b/vrobbler/apps/trends/templates/trends/_trending_up.html@@ -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>diff --git a/vrobbler/apps/trends/trends/time_of_day.py b/vrobbler/apps/trends/trends/time_of_day.pyindex 9ac4b21..9fcd5e6 100644--- a/vrobbler/apps/trends/trends/time_of_day.py+++ b/vrobbler/apps/trends/trends/time_of_day.py@@ -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,