From 85b0aead6b3d01cb0dad56569d9431989b2320bc Mon Sep 17 00:00:00 2001 From: jhkaktus Date: Wed, 21 Oct 2026 19:10:45 +0200 Subject: [PATCH] add stuff --- README.md | 4 + web/app.py | 206 ++++++++++++++++++++++++++++++++++++++++++++-- web/test_stats.py | 34 ++++++++ 3 files changed, 237 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index bac56a6..f195323 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ das komplette serverjonas search Die Hauptseite hat drei Tabs mit Symbolen (Pages, Images, Queue): Jeder Tab zeigt nur die Kennzahlen und Diagramme seines Datentyps, die Auswahl wird im Browser gespeichert. CSV/JSON-Export folgt dem aktiven Tab. + Über das Feld „Reach target“ lässt sich eine beliebige Zielzahl eingeben; + die Seite schätzt aus dem Trend der letzten zwei Stunden (des gewählten + Zeitraums), wann dieser Stand erreicht ist – inklusive Datum und Uhrzeit. + Das Ziel wird pro Tab im Browser gespeichert. - `web/tui.py` – Terminal-Dashboard für dieselben Statistiken (siehe unten) - `priorityweb/` – Vorschlagsverfeinerer (Port 8082, `jonassearch-priority.service`) - `search/jonassearch-engine.service` – systemd-Unit für die Suchmaschine diff --git a/web/app.py b/web/app.py index 7cb3af1..cbdaa9d 100644 --- a/web/app.py +++ b/web/app.py @@ -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""" +
+ + + Pages +
–
+
Enter a number – how long until the selected tab reaches it at the current speed.
+
+
Loading… Interval: – @@ -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() { diff --git a/web/test_stats.py b/web/test_stats.py index 5e19312..ac3423a 100644 --- a/web/test_stats.py +++ b/web/test_stats.py @@ -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("/")