add stuff

This commit is contained in:
2026-10-21 19:10:45 +02:00
parent 530c2b1ecc
commit 85b0aead6b
3 changed files with 237 additions and 7 deletions
+199 -7
View File
@@ -200,6 +200,44 @@ HTML = r"""
.metric-panel { display: none; }
.metric-panel.active { display: block; }
.goal {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 15px;
padding: 14px 18px;
margin: 0 0 18px;
}
.goal-label { font-weight: 600; font-size: 0.95rem; }
.goal-input {
width: 10rem;
background: rgba(128,128,128,0.10);
border: 1px solid var(--border);
border-radius: 9px;
color: var(--text);
font-family: inherit;
font-size: 1rem;
padding: 7px 10px;
}
.goal-input:focus { outline: none; border-color: var(--accent); }
.goal-unit { color: var(--muted); font-size: 0.85rem; }
.goal-eta {
margin-left: auto;
font-size: 1.35rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.goal-hint { flex-basis: 100%; color: var(--muted); font-size: 0.8rem; }
.section-title-first { margin-top: 6px; }
.range-btn {
@@ -310,6 +348,8 @@ HTML = r"""
@media (max-width: 600px) {
.metric-btn { padding: 10px 12px; font-size: 0.9rem; gap: 9px; }
.metric-icon { font-size: 1.3rem; }
.goal-eta { margin-left: 0; font-size: 1.15rem; }
.goal-input { width: 8rem; }
.current { font-size: 2.4rem; }
.container { padding: 20px 14px 40px; }
.chart-container { padding: 14px; }
@@ -355,6 +395,15 @@ HTML = r"""
</button>
</div>
<div class="goal">
<label class="goal-label" for="goal-input">Reach target</label>
<input class="goal-input" id="goal-input" type="number" inputmode="numeric"
min="0" step="1" placeholder="e.g. 2000000" autocomplete="off">
<span class="goal-unit" id="goal-unit">Pages</span>
<div class="goal-eta" id="goal-eta">–</div>
<div class="goal-hint" id="goal-hint">Enter a number – how long until the selected tab reaches it at the current speed.</div>
</div>
<div class="badges">
<span class="badge"><span class="dot" id="status-dot"></span><span id="status-text">Loading…</span></span>
<span class="badge" id="badge-interval">Interval: –</span>
@@ -892,6 +941,13 @@ function setMetric(next, persist) {
try { localStorage.setItem('js-metric', activeMetric); } catch (e) {}
}
/* the target field belongs to the active tab, so recall its own value */
const goalInput = document.getElementById('goal-input');
if (goalInput) {
goalInput.value = goalTargets[activeMetric] || '';
renderGoal();
}
if (rawValues.length) renderAll();
}
@@ -1473,6 +1529,147 @@ function renderSummary(containerId, all, points) {
el.innerHTML = cards.join('');
}
/* ---------- target ETA: "how long until we reach N?" ---------- */
// Linear trend over the last two hours of the given series (at most 96
// points). Falls back to the whole series when that window is too short.
function recentTrend(points) {
if (!points || points.length < 2) return null;
const anchor = points[points.length - 1].x;
const tail = [];
for (let i = points.length - 1; i >= 0; i--) {
tail.unshift(points[i]);
if (points.length - i >= 96) break;
if (anchor - points[i].x >= 2 * 3600000) break;
}
return regress(tail.length >= 2 ? tail : points);
}
// Longer horizons than fmtDur handles need months/years instead of "4123 d".
function fmtEta(ms) {
const min = ms / 60000;
if (min < 1) return '<1 min';
if (min < 60) return Math.round(min) + ' min';
const hours = min / 60;
if (hours < 24) return Math.floor(hours) + ' h ' + Math.round(min % 60) + ' min';
const days = hours / 24;
if (days < 90) return Math.floor(days) + ' d ' + Math.floor(hours % 24) + ' h';
const months = days / 30.44;
if (months < 24) return '≈ ' + Math.round(months) + ' months';
return '≈ ' + (days / 365.25).toFixed(1) + ' years';
}
// Remember one target per tab; the field always belongs to the active tab.
const goalTargets = (function () {
const saved = { pages: '', images: '', queue: '' };
try {
Object.keys(saved).forEach(function (metric) {
saved[metric] = localStorage.getItem('js-goal-' + metric) || '';
});
} catch (e) {}
return saved;
})();
// Series the active tab is showing (images honour the estimate toggle).
function goalSeries() {
const all = parsePoints(rawValues);
if (activeMetric === 'images') {
const points = toImagePoints(all, showEstimatedImages);
return { all: points, points: filterRange(points) };
}
if (activeMetric === 'queue') {
const points = toPendingPoints(all);
return { all: points, points: filterRange(points) };
}
return { all: all, points: filterRange(all) };
}
function renderGoal() {
const input = document.getElementById('goal-input');
const eta = document.getElementById('goal-eta');
const hint = document.getElementById('goal-hint');
const unit = document.getElementById('goal-unit');
if (!input || !eta || !hint || !unit) return;
const label = METRIC_LABELS[activeMetric];
unit.textContent = label;
eta.textContent = '–';
const raw = String(input.value || '').trim();
if (raw === '') {
hint.textContent = 'Enter a number – how long until ' + label +
' reach it at the current speed.';
return;
}
const target = Number(raw);
if (!isFinite(target) || target < 0) {
hint.textContent = 'Please enter a number of 0 or more.';
return;
}
const series = goalSeries();
const latest = series.all[series.all.length - 1];
if (!latest) {
hint.textContent = 'No ' + label + ' measurements yet – cannot estimate.';
return;
}
const all = series.all;
const points = series.points.length >= 2 ? series.points : all;
const trend = recentTrend(points);
const speed = trend ? trend.slope : null; // per hour
const current = latest.y;
const remaining = target - current;
if (remaining === 0) {
eta.textContent = 'Now';
hint.textContent = 'Exactly at ' + fmt(current) + ' ' + label.toLowerCase() + '.';
return;
}
if (speed === null || speed === 0) {
hint.textContent = 'No measurable change at the current speed – cannot estimate.';
return;
}
if (remaining * speed < 0) {
hint.textContent = 'Moving away from the target (' + fmtSigned(speed) + ' ' + label +
'/h in the selected range) – it will not be reached at this speed.';
return;
}
const etaMs = remaining / speed * 3600000;
eta.textContent = fmtEta(etaMs);
eta.classList.remove('pos', 'neg');
eta.classList.add(speed > 0 ? 'pos' : 'neg');
hint.textContent = (remaining > 0 ? '+' : '−') + fmt(Math.abs(remaining)) +
' to ' + fmt(target) + ' at ' + fmtSigned(speed) + ' ' + label + '/h → around ' +
fmtDateFull(latest.x + etaMs) + ' · now ' + fmt(current) + ' ' + label + '.';
}
(function () {
const input = document.getElementById('goal-input');
if (!input) return;
input.value = goalTargets[activeMetric] || '';
input.addEventListener('input', function () {
const raw = input.value.trim();
const value = Number(raw);
// Only remember valid targets, so a typo does not replace the saved one.
if (raw === '' || (isFinite(value) && value >= 0)) {
goalTargets[activeMetric] = raw;
try { localStorage.setItem('js-goal-' + activeMetric, raw); } catch (e) {}
}
renderGoal();
});
})();
/* ---------- main update ---------- */
function renderAll() {
@@ -1635,13 +1832,7 @@ function renderAll() {
document.getElementById('stat-min-hint').textContent = 'On ' + fmtDateFull(low.x);
/* forecast */
const regWindow = [];
for (let i = fpoints.length - 1; i >= 0; i--) {
regWindow.unshift(fpoints[i]);
if (fpoints.length - i >= 96) break;
if (fpoints[fpoints.length - 1].x - fpoints[i].x >= 2 * 3600000) break;
}
const reg = regress(regWindow);
const reg = recentTrend(fpoints);
if (reg) {
const elF = document.getElementById('stat-forecast');
const fc = fpoints[fpoints.length - 1].y + reg.slope;
@@ -1748,6 +1939,7 @@ function renderAll() {
renderHeatmap(pendingAll, 'heatmap-pending', 'Pending');
renderSummary('summary-images', imageAll, imageFpoints);
renderSummary('summary-queue', pendingAll, pendingFpoints);
renderGoal();
}
async function update() {
+34
View File
@@ -289,6 +289,40 @@ class StatsPageTest(unittest.TestCase):
self.assertIn("return { name: 'jonassearch_images'", html)
self.assertIn("return { name: 'jonassearch_queue'", html)
def test_page_has_target_eta_field_for_active_tab(self):
client = stats_app.app.test_client()
response = client.get("/")
self.assertEqual(response.status_code, 200)
html = response.get_data(as_text=True)
# a plain number field at the top, plus the computed duration
self.assertIn('id="goal-input"', html)
self.assertIn('type="number" inputmode="numeric"', html)
self.assertIn('id="goal-eta"', html)
self.assertIn('id="goal-hint"', html)
self.assertIn('id="goal-unit"', html)
self.assertIn("Reach target", html)
# the estimate uses the trend of the selected range (last 2 h first)
self.assertIn("function recentTrend(points)", html)
self.assertIn("const reg = recentTrend(fpoints);", html)
self.assertIn("function fmtEta(ms)", html)
self.assertIn("const speed = trend ? trend.slope : null;", html)
self.assertIn("const etaMs = remaining / speed * 3600000;", html)
self.assertIn("if (remaining === 0) {", html)
self.assertIn("Moving away from the target", html)
# one target per tab, restored on tab switch and persisted per browser
self.assertIn("function goalSeries()", html)
self.assertIn("goalTargets[activeMetric] || ''", html)
self.assertIn("localStorage.setItem('js-goal-' + activeMetric, raw)", html)
self.assertIn("localStorage.getItem('js-goal-' + metric)", html)
self.assertIn("toImagePoints(all, showEstimatedImages)", html)
# recomputed on refresh, tab switch and range switch
self.assertIn("renderSummary('summary-queue', pendingAll, pendingFpoints);\n renderGoal();", html)
self.assertIn("goalInput.value = goalTargets[activeMetric] || '';\n renderGoal();", html)
def test_page_contains_pending_queue_counter_and_graphs(self):
client = stats_app.app.test_client()
response = client.get("/")