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("/")