// js/gap-utils.js
export function median(arr) {
const a = arr.slice().sort((x, y) => x - y);
const n = a.length;
if (!n) return null;
const m = n >> 1;
return n % 2 ? a[m] : (a[m - 1] + a[m]) / 2;
}
/** Hybrid-Gap-Erkennung: erwartet 1-Min-Takt, erlaubt Jitter & fasst nahe Gaps zusammen. */
export function detectGapsHybrid(
series,
{
expectedMs = 60_000,
tolerancePct = 0.35, // ±35% Jitter tolerieren
factor = 1.6, // zusätzlicher Median-Faktor
minDurationMs = 0, // alles markieren (wir zeigen die Schwelle im Badge)
mergeWithinMs = 90_000, // nahe Gaps zusammenfassen
} = {}
) {
if (!series || series.length < 3) return [];
const diffs = [];
for (let i = 1; i < series.length; i++) {
const d = series[i].t - series[i - 1].t;
if (d > 0) diffs.push(d);
}
const med = median(diffs) || expectedMs;
const thresh = Math.max(expectedMs * (1 + tolerancePct), med * factor);
const raw = [];
for (let i = 1; i < series.length; i++) {
const a = series[i - 1].t,
b = series[i].t;
const dt = b - a;
if (dt >= thresh) raw.push({ from: a, to: b, dt });
}
if (!raw.length) return [];
// mergen
const merged = [];
let cur = raw[0];
for (let i = 1; i < raw.length; i++) {
const g = raw[i];
if (g.from - cur.to <= mergeWithinMs) {
cur.to = Math.max(cur.to, g.to);
cur.dt = cur.to - cur.from;
} else {
merged.push(cur);
cur = g;
}
}
merged.push(cur);
return merged
.filter((g) => g.dt >= minDurationMs)
.map((g) => ({ ...g, missed: Math.max(1, Math.round(g.dt / expectedMs) - 1) }));
}
/** Metriken über den sichtbaren Zeitraum (Qualität etc.). */
export function summarizeQuality(series, { start, end, expectedMs = 60_000, gapOpts = {} } = {}) {
const s = Array.isArray(series) ? series.filter((p) => p.t >= start && p.t <= end) : [];
const gaps = detectGapsHybrid(s, gapOpts);
// „vorhandene Minuten“: wir gehen davon aus, dass mindestens ein Wert (innen/außen) pro Minute reicht
const minutes = new Set();
for (const p of s) minutes.add(Math.floor(p.t / expectedMs));
const expectedMinutes = Math.max(0, Math.round((end - start) / expectedMs));
const presentMinutes = Math.min(expectedMinutes, minutes.size);
const missingMinutes = Math.max(0, expectedMinutes - presentMinutes);
const longestGapMs = gaps.reduce((m, g) => Math.max(m, g.dt), 0);
const lastGapEnd = gaps.length ? Math.max(...gaps.map((g) => g.to)) : null;
const quality = expectedMinutes ? presentMinutes / expectedMinutes : 1;
return {
gaps,
expectedMinutes,
presentMinutes,
missingMinutes,
quality, // 0…1
longestGapMs,
lastGapEnd,
};
}
/** hübsche Farben (Bootstrap) je nach Schwellwerten */
export function colorByQuality(pct) {
// Qualität (in Prozent, 0..100):
// ≥99 → grün, ≥97 → gelb, sonst rot
const p = Number(pct);
if (p >= 99) return 'success';
if (p >= 97) return 'warning';
return 'danger';
}
export function colorByMissingRatio(ratio) {
// Fehlende Minuten relativ (0..1):
// ≤1% → grün, ≤3% → gelb, sonst rot
const r = Number(ratio);
if (r <= 0.01) return 'success';
if (r <= 0.03) return 'warning';
return 'danger';
}
export function colorByMinutes(mins, { ok = 5, warn = 15 } = {}) {
if (mins <= ok) return 'success';
if (mins <= warn) return 'warning';
return 'danger';
}
export function colorSince(mins, { good = 60, warn = 15 } = {}) {
// Gegenteil-Logik: je MEHR Minuten, desto besser
if (mins >= good) return 'success';
if (mins >= warn) return 'warning';
return 'danger';
}
export const fmtHM = (ms) =>
new Date(ms).toLocaleString('de-AT', { hour: '2-digit', minute: '2-digit' });
export function fmtDateHM(ms) {
// DD.MM., HH:MM (ohne "Uhr")
const d = new Date(ms);
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${dd}.${mm}., ${hh}:${mi}`;
}
export function fmtDateHMSms(ms) {
// DD.MM., HH:MM:SS[.mmm] – Millisekunden nur, wenn ≠ 0
const d = new Date(ms);
const dd = String(d.getDate()).padStart(2, '0');
const mo = String(d.getMonth() + 1).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
const ms3 = d.getMilliseconds();
const tail = ms3 ? `.${String(ms3).padStart(3, '0')}` : '';
return `${dd}.${mo}., ${hh}:${mi}:${ss}${tail}`;
}
// --- Präsenz-Lader & Bucket-Helper (für Charts, die Gaps anzeigen wollen) ---
/** Bucket je Range (day/week/month/auto) */
export const bucketForRange = (range) =>
range === 'week' ? 'hour' : range === 'month' ? 'day' : 'minute';
/** erwartetes Intervall (ms) je Bucket */
export const expectedMsForBucket = (b) => (b === 'day' ? 86400000 : b === 'hour' ? 3600000 : 60000);
/** Lädt reine Präsenzzeiten aus /api/series.php, um Gaps zuverlässig zu zeichnen */
export async function loadPresence(win, bucket = 'minute') {
const data = await getSeries({ bucket: bucket, start: win.start, end: win.end });
const rows = Array.isArray(data?.rows) ? data.rows : Array.isArray(data) ? data : [];
return rows.map((r) => (r.time > 2_000_000_000 ? r.time : r.time * 1000)).sort((a, b) => a - b);
/*
const url = `/api/series.php?bucket=${bucket}&start=${win.start}&end=${win.end}`;
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) throw new Error(`series.php HTTP ${res.status}`);
const { rows = [] } = await res.json();
return rows.map((r) => (r.time > 2_000_000_000 ? r.time : r.time * 1000)).sort((a, b) => a - b);
*/
}
export function gapOptsFor(mode) {
switch (mode) {
case 'auto': // alle zeigen
return {
mode: 'auto',
minDurationMs: 0,
mergeWithinMs: 90_000,
tolerancePct: 0.35,
factor: 1.6,
};
case 'day': // ≥ 5 min
return {
mode: 'day',
minDurationMs: 5 * 60_000,
mergeWithinMs: 90_000,
tolerancePct: 0.35,
factor: 1.6,
};
case 'week': // ≥ 15 min
return {
mode: 'week',
minDurationMs: 15 * 60_000,
mergeWithinMs: 90_000,
tolerancePct: 0.35,
factor: 1.6,
};
case 'month': // ≥ 60 min
return {
mode: 'month',
minDurationMs: 60 * 60_000,
mergeWithinMs: 90_000,
tolerancePct: 0.35,
factor: 1.6,
};
default:
return {
mode: 'auto',
minDurationMs: 0,
mergeWithinMs: 90_000,
tolerancePct: 0.35,
factor: 1.6,
};
}
}