Sparklines and misc tweaks
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled

This commit is contained in:
dgtlmoon
2026-06-01 20:39:04 +02:00
parent 06e91708e9
commit 491d1b02e9
8 changed files with 328 additions and 34 deletions
@@ -0,0 +1,266 @@
/*
* Queue activity sparkline — a compact "is it alive?" activity strip for the sidebar.
*
* UX goal: answer the user's gut question "is this thing actually doing anything?".
* - A pinned, gently PULSING leading dot at the right edge beats like a heartbeat
* even when totally idle, so the UI always reads as alive/monitoring — a frozen
* dot would read as broken.
* - Real in-flight work bumps the line, which then scrolls left across the window,
* so you can see it really does check things over the last few minutes.
*
* Signal plotted = queue_size + checking_now (total in-flight work). Either being
* non-zero lifts the trace; plain queue size alone sits at 0 while workers drain it.
*
* Rendering: a scrolling strip chart. The right edge is "now", the left edge is
* "now - WINDOW_SECONDS". New samples always enter from the right; old data scrolls
* off the left and fades out as it goes (left-edge brightness ramp).
*
* Hardening (lessons from common sparkline libs, e.g. fnando/sparkline #14/#24):
* - Autoscaled 0..max with headroom + a MIN floor, so it always stays inside the
* canvas height and never divides by zero on a flat/all-zero queue.
* - devicePixelRatio aware (crisp on retina), re-measured on resize (the rail
* expands 64px->190px on hover).
* - Pauses the rAF loop when the tab is hidden.
*
* History survives a full page reload via localStorage: we persist the queue/checking
* EVENT LOG (a small step-function, not pixels). On load we sample that log across the
* visible window, so the strip immediately shows the last few minutes instead of a
* blank screen.
*
* Fed by the same Socket.IO 'queue_size' / 'checking_now' events as realtime.js.
*/
(function () {
"use strict";
const STORE_KEY = "cdio-queue-spark-v1";
const WINDOW_SECONDS = 180; // how much recent history the strip shows (a few minutes)
const RETAIN_MS = WINDOW_SECONDS * 1000 * 1.5; // keep a little more than one screenful
const MAX_EVENTS = 600; // hard cap on stored events (localStorage size guard)
const MIN_SCALE = 1; // autoscale floor: avoids /0 and stops a 0/1 queue filling the panel
const PULSE_MS = 1500; // leading-dot heartbeat period — liveness even when idle
const RISE_LERP = 0.30; // autoscale rises quickly so a new bump shows promptly
const FALL_LERP = 0.04; // ...and falls slowly so it doesn't jitter
const reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const FRAME_MS = reduceMotion ? 250 : 100; // 10fps (plenty here); slower under reduced-motion
// --- Shared state across all canvases (desktop rail + mobile drawer share one history) ---
let eventLog = []; // [{t: epoch_ms, v: number}] step-function of total activity
let currentQueue = 0;
let currentChecking = 0;
function totalNow() { return currentQueue + currentChecking; }
function stepValueAt(t) {
// Last event at or before t (step function). Log is small; scan from the end.
for (let i = eventLog.length - 1; i >= 0; i--) {
if (eventLog[i].t <= t) return eventLog[i].v;
}
return NaN; // before our earliest knowledge -> leave that column blank
}
function pushEvent(now) {
const v = totalNow();
const last = eventLog[eventLog.length - 1];
if (!last || last.v !== v) eventLog.push({ t: now, v: v });
}
function trimLog(now) {
const cutoff = now - RETAIN_MS;
// Keep the most recent event that predates the cutoff as a baseline anchor.
let anchor = -1;
for (let i = 0; i < eventLog.length; i++) {
if (eventLog[i].t <= cutoff) anchor = i; else break;
}
if (anchor > 0) eventLog.splice(0, anchor);
if (eventLog.length > MAX_EVENTS) eventLog.splice(0, eventLog.length - MAX_EVENTS);
}
function loadLog() {
try {
const raw = localStorage.getItem(STORE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
const log = Array.isArray(parsed) ? parsed : parsed.log;
if (!Array.isArray(log)) return [];
eventLog = log.filter(e => e && typeof e.t === "number" && typeof e.v === "number");
trimLog(Date.now());
return eventLog;
} catch (e) {
return [];
}
}
let saveTimer = null;
function saveLog() {
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
try {
trimLog(Date.now());
localStorage.setItem(STORE_KEY, JSON.stringify({ savedAt: Date.now(), log: eventLog }));
} catch (e) { /* quota / private mode — history is best-effort */ }
}
function scheduleSave() {
if (saveTimer) return;
saveTimer = setTimeout(function () { saveTimer = null; saveLog(); }, 750);
}
// --- One Spark per canvas (different widths: rail vs drawer) ---
function Spark(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext("2d");
this.W = 1;
this.cssW = 0;
this.cssH = 0;
this.displayMax = MIN_SCALE;
this.resize();
}
Spark.prototype.resize = function () {
const cssW = Math.max(0, Math.round(this.canvas.clientWidth));
const cssH = Math.max(0, Math.round(this.canvas.clientHeight));
if (cssW === this.cssW && cssH === this.cssH) return;
this.cssW = cssW;
this.cssH = cssH;
const dpr = window.devicePixelRatio || 1;
this.canvas.width = Math.max(1, Math.round(cssW * dpr));
this.canvas.height = Math.max(1, Math.round(cssH * dpr));
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = Math.max(1, cssW);
};
// Wall-clock time represented by pixel column x: right edge = now, left edge = now - window.
Spark.prototype.columnTime = function (x, now) {
return now - ((this.W - 1 - x) / Math.max(1, this.W - 1)) * (WINDOW_SECONDS * 1000);
};
Spark.prototype.y = function (v) {
const pad = 2;
const h = Math.max(1, this.cssH - pad * 2);
const t = Math.min(1, v / this.displayMax);
return pad + h * (1 - t);
};
Spark.prototype.render = function (now) {
if (this.cssW < 4 || this.cssH < 4) return;
if (this.canvas.clientWidth !== this.cssW || this.canvas.clientHeight !== this.cssH) this.resize();
const W = this.W;
// Sample the step-function across the visible window (newest at right) and
// find the autoscale max in the same pass.
const ys = new Array(W);
let maxV = MIN_SCALE;
for (let x = 0; x < W; x++) {
const v = stepValueAt(this.columnTime(x, now));
ys[x] = v;
if (!isNaN(v) && v > maxV) maxV = v;
}
const tv = totalNow();
if (tv > maxV) maxV = tv;
const rate = maxV > this.displayMax ? RISE_LERP : FALL_LERP;
this.displayMax += (maxV - this.displayMax) * rate;
const ctx = this.ctx;
ctx.clearRect(0, 0, this.cssW, this.cssH);
// Whole trace in a SINGLE stroke; the left->right fade (old data dimming as it
// scrolls out of view) is a gradient strokeStyle rather than one stroke per
// pixel column — ~140 draw calls collapse to 1.
const grad = ctx.createLinearGradient(0, 0, this.cssW, 0);
grad.addColorStop(0, "rgba(255,255,255,0.10)");
grad.addColorStop(1, "rgba(255,255,255,0.95)");
ctx.strokeStyle = grad;
ctx.lineWidth = 1.5;
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.beginPath();
let pen = false;
for (let x = 0; x < W; x++) {
const v = ys[x];
if (isNaN(v)) { pen = false; continue; } // gap (no history yet) breaks the line
const py = this.y(v);
if (pen) ctx.lineTo(x + 0.5, py);
else { ctx.moveTo(x + 0.5, py); pen = true; }
}
ctx.stroke();
// Pinned, gently pulsing leading dot at the right edge = the "it's alive" tell.
const rightV = isNaN(ys[W - 1]) ? tv : ys[W - 1];
const pulse = 0.5 + 0.5 * Math.sin(((now % PULSE_MS) / PULSE_MS) * Math.PI * 2);
ctx.save();
ctx.shadowColor = "rgba(255,255,255,0.9)";
ctx.shadowBlur = 4 + 5 * pulse;
ctx.fillStyle = "rgba(255,255,255," + (0.75 + 0.25 * pulse).toFixed(3) + ")";
ctx.beginPath();
ctx.arc(W - 1.5, this.y(rightV), 1.6 + 0.7 * pulse, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
};
// --- Animation loop ---
let instances = [];
let rafId = null;
let lastFrame = 0;
function frame(ts) {
rafId = requestAnimationFrame(frame);
if (ts - lastFrame < FRAME_MS) return;
lastFrame = ts;
const now = Date.now();
for (let i = 0; i < instances.length; i++) instances[i].render(now);
}
function start() { if (rafId === null) { lastFrame = 0; rafId = requestAnimationFrame(frame); } }
function stop() { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } }
function attach(socket) {
if (!socket) return;
socket.on("queue_size", function (d) {
currentQueue = parseInt(d && d.q_length, 10) || 0;
pushEvent(Date.now());
scheduleSave();
});
socket.on("checking_now", function (d) {
currentChecking = parseInt(d && d.count, 10) || 0;
pushEvent(Date.now());
scheduleSave();
});
}
function init() {
const canvases = Array.prototype.slice.call(document.querySelectorAll("canvas.queue-spark"));
if (!canvases.length) return;
loadLog();
// Seed current levels from the server-rendered numbers so the line starts at
// the right height before the first socket event arrives.
const qEl = document.querySelector(".queue-size-int");
const cEl = document.querySelector(".checking-now-int");
if (qEl) currentQueue = parseInt(qEl.textContent, 10) || 0;
if (cEl) currentChecking = parseInt(cEl.textContent, 10) || 0;
pushEvent(Date.now());
instances = canvases.map(function (c) { return new Spark(c); });
if (typeof ResizeObserver !== "undefined") {
const ro = new ResizeObserver(function () {
for (let i = 0; i < instances.length; i++) instances[i].resize();
});
canvases.forEach(function (c) { ro.observe(c); });
}
document.addEventListener("visibilitychange", function () {
if (document.hidden) { saveLog(); stop(); }
else { start(); }
});
window.addEventListener("beforeunload", saveLog);
// realtime.js exposes the socket either already on window or via this event.
if (window.cdioSocket) attach(window.cdioSocket);
else document.addEventListener("cdio:socket-ready", function (e) { attach(e.detail && e.detail.socket); });
start();
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
else init();
})();
@@ -19,8 +19,6 @@ $action-sidebar-content-slot: 1100px;
width: $action-sidebar-width-expanded; // reserve enough to host the expanded inner block
z-index: 60;
pointer-events: none; // empty space doesn't intercept clicks/hovers
@media only screen and (max-width: $desktop-wide-breakpoint) {
// Hidden on mobile: the mobile drawer (hamburger) carries these items
display: none;
@@ -72,6 +70,24 @@ $action-sidebar-content-slot: 1100px;
margin: 0;
padding: 0;
position: relative; // anchor for rollout label
color: var(--color-white);
}
// Live "is it alive?" activity monitor — compact scrolling strip under the queue count.
// New data enters from the right (pulsing dot); old data scrolls left and fades out.
// Drawn by static/js/queue-sparkline.js. Full rail width, deliberately short.
.action-sidebar-li--spark {
padding: 2px 4px 4px;
.queue-spark {
display: block;
width: 100%;
height: 15px;
border-radius: 3px;
// Faint "screen" so the trace reads as a small instrument even when idle.
background: rgba(255, 255, 255, 0.05);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
}
}
.action-sidebar-divider {
@@ -165,7 +165,7 @@ $watch-table-mobile-max: 767px;
}
}
.pure-table td {
padding: 3px !important;
padding: 0 !important;
}
}
@@ -179,7 +179,9 @@ $watch-table-mobile-max: 767px;
@media (min-width: 1600px) {
.watch-table {
td.last-checked, td.last-changed {
white-space: nowrap;
.innertext {
white-space: nowrap;
}
}
}
}
@@ -44,7 +44,6 @@ body.has-queue {
td,
th {
padding: $table-cell-padding;
vertical-align: middle;
}
@@ -62,9 +61,16 @@ body.has-queue {
onto its own row, centered beneath the three columns above it.
*/
td.inline.title-col {
width: 100%;
.grid-wrapper {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
/* Only 3 explicit columns. The optional restock column is placed into an
IMPLICIT 4th track (grid-column: 4 below), which is created only when
.restock-info-wrap is actually present. Reserving an explicit 4th track
here would leave a phantom 0.8rem gap-gutter after .status-icons on the
(common) watches that have no restock info. */
grid-template-columns: auto minmax(0, 1fr) auto;
grid-auto-columns: auto;
align-items: center;
gap: 0.8rem;
File diff suppressed because one or more lines are too long
+1
View File
@@ -44,6 +44,7 @@
{% if socket_io_enabled %}
<script src="{{url_for('static_content', group='js', filename='socket.io.min.js')}}"></script>
<script src="{{url_for('static_content', group='js', filename='realtime.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='queue-sparkline.js')}}" defer></script>
{% endif %}
{%- set _html_head_extras = get_html_head_extras() -%}
{%- if _html_head_extras %}
+25
View File
@@ -11,6 +11,31 @@
<li class="pure-menu-item " id="menu-mute">
<a href="{{ url_for('settings.toggle_all_muted') }}" ><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="{% if all_muted %}{{ _('Unmute notifications') }}{% else %}{{ _('Mute notifications') }}{% endif %}" title="{% if all_muted %}{{ _('Notifications are muted - click to unmute') }}{% else %}{{ _('Mute notifications') }}{% endif %}" class="icon icon-mute"{% if not all_muted %} style="opacity: 0.3"{% endif %}></a>
</li>
<li class="pure-menu-item " >
<a href="{{ url_for('settings.settings_page') }}"
title="{{ _('Settings') }}">
<svg class="action-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.01a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.01a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.01a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span class="action-label">{{ _('Settings') }}</span>
</a>
</li>
<li class="pure-menu-item " >
<a href="{{ url_for('imports.import_page') }}"
title="{{ _('Import') }}">
<svg class="action-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 9 12 4 17 9"/>
<line x1="12" y1="4" x2="12" y2="16"/>
</svg>
<span class="action-label">{{ _('Import') }}</span>
</a>
</li>
{% else %}
<li class="pure-menu-item menu-collapsible">
<a href="{{ url_for('ui.ui_edit.edit_page', uuid=uuid, next='diff') }}"
+5 -27
View File
@@ -95,39 +95,17 @@
<li class="action-sidebar-divider" aria-hidden="true"></li>
<li class="action-sidebar-li">
<a href="{{ url_for('settings.settings_page') }}"
class="action-sidebar-item {% if ep.startswith('settings.') %}active{% endif %}"
title="{{ _('Settings') }}">
<svg class="action-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.01a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.01a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.01a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span class="action-label">{{ _('Settings') }}</span>
</a>
</li>
<li class="action-sidebar-li">
<a href="{{ url_for('imports.import_page') }}"
class="action-sidebar-item {% if ep.startswith('imports.') %}active{% endif %}"
title="{{ _('Import') }}">
<svg class="action-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 9 12 4 17 9"/>
<line x1="12" y1="4" x2="12" y2="16"/>
</svg>
<span class="action-label">{{ _('Import') }}</span>
</a>
</li>
<li class="action-sidebar-divider" aria-hidden="true"></li>
<li class="action-sidebar-li" id="checking-now-stats-sidebar">
{{ _('Checking now') }}: <span class="checking-now-int">{{ checking_now_size }}</span>
</li>
<li class="action-sidebar-li" id="queue-stats-sidebar">
{{ _('In queue') }}: <span class="queue-size-int">{{ queue_size }}</span>
</li>
{% if socket_io_enabled %}
<li class="action-sidebar-li action-sidebar-li--spark" aria-hidden="true">
<canvas class="queue-spark" title="{{ _('Live activity') }}"></canvas>
</li>
{% endif %}
<li class="action-sidebar-li">
{% if hosted_sticky %}
<div id="hosted-sticky">