diff --git a/src/lib/workersView.js b/src/lib/workersView.js new file mode 100644 index 0000000..3fc8711 --- /dev/null +++ b/src/lib/workersView.js @@ -0,0 +1,188 @@ +// Reine Anzeige-Ableitungen der Hustler-Seite: Zahlenformate, Status, Kacheln, Zähler, sichere Links, Spur-Zeilen. +// Kein React, kein Appwrite, kein window/document: unter Node testbar (loadModule). + +const NBSP = String.fromCharCode(160) // geschütztes Leerzeichen vor $ und € +const DASH = '–' + +const ZAHL_2 = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +const ZAHL_0 = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 0 }) +const BERLIN = new Intl.DateTimeFormat('de-DE', { + timeZone: 'Europe/Berlin', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', +}) + +function isNum(n) { + return typeof n === 'number' && Number.isFinite(n) +} + +/** 7.84 -> '7,84 $' (geschütztes Leerzeichen); null/NaN/Text -> '–'. */ +export function formatUsd(n) { + if (!isNum(n)) return DASH + return `${ZAHL_2.format(n)}${NBSP}$` +} + +/** 120 -> '120 €', 19.5 -> '19,50 €'; null/NaN/Text -> '–'. */ +export function formatEur(n) { + if (!isNum(n)) return DASH + return `${(Number.isInteger(n) ? ZAHL_0 : ZAHL_2).format(n)}${NBSP}€` +} + +/** 1234 -> '1.234'; sonst '–'. */ +export function formatCount(n) { + if (!isNum(n)) return DASH + return ZAHL_0.format(n) +} + +// Berliner Datum und Uhrzeit als Teile { year, month, day, hour, minute } +function berlinParts(ms) { + const parts = {} + for (const p of BERLIN.formatToParts(ms)) parts[p.type] = p.value + return parts +} + +/** Berliner Zeit: am selben Berliner Tag wie `now` 'HH:MM', sonst 'TT.MM. HH:MM'; ungültig '–'. */ +export function formatTime(iso, now = Date.now()) { + const ms = typeof iso === 'string' ? Date.parse(iso) : NaN + if (!Number.isFinite(ms)) return DASH + const t = berlinParts(ms) + const uhr = `${t.hour}:${t.minute}` + if (isNum(now)) { + const heute = berlinParts(now) + if (heute.year === t.year && heute.month === t.month && heute.day === t.day) return uhr + } + return `${t.day}.${t.month}. ${uhr}` +} + +const STATUS_TEXT = { + arbeitet: 'Arbeitet', + wartet: 'Wartet auf Auftrag', + leerlauf: 'Leerlauf', + grau: 'Noch nicht angeschlossen', + fehler: 'Fehler', +} +const STATUS_TON = { arbeitet: 'green', wartet: 'orange', fehler: 'red' } + +/** Dienst-Status -> deutscher Text; alles andere 'Unbekannt'. */ +export function workerStatusLabel(status) { + return typeof status === 'string' && Object.hasOwn(STATUS_TEXT, status) ? STATUS_TEXT[status] : 'Unbekannt' +} + +/** Dienst-Status -> 'green' | 'orange' | 'plain' | 'red'. */ +export function statusTone(status) { + return typeof status === 'string' && Object.hasOwn(STATUS_TON, status) ? STATUS_TON[status] : 'plain' +} + +// Tagesgrenze: ab 80 % warnen, ab 100 % Alarm. In ganzen Cent gerechnet, damit 4,80 von 6,00 genau 80 % sind. +function apiTone(heute, grenze) { + if (!isNum(heute) || !isNum(grenze) || grenze <= 0) return '' + const ratio = Math.round(heute * 100) / Math.round(grenze * 100) + if (ratio >= 1) return 'neg' + if (ratio >= 0.8) return 'warn' + return '' +} + +/** Sechs Kennzahlen-Kacheln aus state.kpis; fehlende Daten ergeben Striche, nie NaN. */ +export function kpiTiles(kpis) { + const k = kpis && typeof kpis === 'object' ? kpis : {} + return [ + { + key: 'abos', + label: 'Neue Abos (Monat)', + value: formatCount(k.newAbosMonth), + sub: isNum(k.mrrNewEur) ? `${formatEur(k.mrrNewEur)} neu pro Monat` : '', + tone: '', + }, + { key: 'deals', label: 'Deals (Monat)', value: formatCount(k.dealsMonth), sub: '', tone: '' }, + { key: 'threads', label: 'Gespräche offen', value: formatCount(k.openThreads), sub: '', tone: '' }, + { + key: 'pult', + label: 'Wartet auf dich', + value: formatCount(k.pultOpen), + sub: '', + tone: isNum(k.pultOpen) && k.pultOpen > 0 ? 'warn' : '', + }, + { key: 'botleads', label: 'Bot-Leads gesamt', value: formatCount(k.botLeads), sub: '', tone: '' }, + { + key: 'api', + label: 'API heute', + value: formatUsd(k.apiTodayUsd), + sub: isNum(k.dailyBudgetUsd) ? `von ${formatUsd(k.dailyBudgetUsd)}` : '', + tone: apiTone(k.apiTodayUsd, k.dailyBudgetUsd), + }, + ] +} + +// Bekannte Tageszähler in Anzeigereihenfolge; Beschriftung = Schlüssel, außer hier abweichend +const ZAEHLER_BEKANNT = ['gelesen', 'gefunden', 'passend', 'bewertet', 'entwuerfe', 'gesendet', 'gebaut', 'geprueft'] +const ZAEHLER_TEXT = { entwuerfe: 'Entwürfe', geprueft: 'geprüft' } + +/** Zähler eines Workers als Zeilen: bekannte zuerst in fester Reihenfolge, der Rest alphabetisch. */ +export function counterLines(counters) { + if (!counters || typeof counters !== 'object' || Array.isArray(counters)) return [] + const keys = Object.keys(counters) + const bekannt = ZAEHLER_BEKANNT.filter((k) => keys.includes(k)) + const rest = keys.filter((k) => !ZAEHLER_BEKANNT.includes(k)).sort((a, b) => a.localeCompare(b, 'de')) + return [...bekannt, ...rest].map((key) => ({ + key, + label: Object.hasOwn(ZAEHLER_TEXT, key) ? ZAEHLER_TEXT[key] : key, + value: formatCount(counters[key]), + })) +} + +/** Tokens als Zahl oder { in, out } -> Summe; alles andere 0. */ +export function tokenTotal(tokens) { + if (isNum(tokens)) return tokens + if (tokens && typeof tokens === 'object') return (isNum(tokens.in) ? tokens.in : 0) + (isNum(tokens.out) ? tokens.out : 0) + return 0 +} + +/** + * Link aus dem Ereignisstrom: nur http(s) (extern) und interne Pfade; sonst null (nur Text). + * Interne Pfade dürfen weder mit "//" noch mit "/\" beginnen und keine Steuerzeichen (Tab, Zeilenumbruch) + * enthalten: Browser lesen das wie "//host" und würden zu einem fremden Host springen. + */ +export function safeLink(link) { + if (typeof link !== 'string') return null + const href = link.trim() + if (/[\u0000-\u001f\u007f]/.test(href)) return null + if (/^https?:\/\//i.test(href)) return { href, external: true } + if (/^\/(?![/\\])/.test(href)) return { href, external: false } + return null +} + +const SPUR_MAX = 50 + +/** Spur-Zeilen: neueste zuerst (ungültige Zeiten zuletzt), höchstens 50; unsichere Links bleiben Text. */ +export function traceRows(events, now) { + if (!Array.isArray(events)) return [] + return events + .filter((ev) => ev && typeof ev === 'object') + .map((ev) => { + const ms = typeof ev.ts === 'string' ? Date.parse(ev.ts) : NaN + return { ev, ms: Number.isFinite(ms) ? ms : -Infinity } + }) + .sort((a, b) => (b.ms > a.ms) - (b.ms < a.ms)) + .slice(0, SPUR_MAX) + .map(({ ev }) => ({ + id: String(ev.id ?? ''), + time: formatTime(ev.ts, now), + text: typeof ev.text === 'string' ? ev.text : '', + link: safeLink(ev.link), + })) +} + +/** Beschriftung des Pausenschalters. */ +export function pauseLabel(paused) { + return paused ? 'Fortsetzen' : 'Alles anhalten' +} + +/** Reiter zur Id; unbekannt oder leer -> erster Reiter. */ +export function pickTab(tabs, id) { + if (!Array.isArray(tabs)) return undefined + return tabs.find((t) => t && t.id === id) ?? tabs[0] +} diff --git a/tests/unit/workersView.test.mjs b/tests/unit/workersView.test.mjs new file mode 100644 index 0000000..e9fbedb --- /dev/null +++ b/tests/unit/workersView.test.mjs @@ -0,0 +1,139 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +// Anzeige-Logik der Hustler-Seite: Zahlenformate, Status, Kacheln, Zähler, sichere Links, Spur-Zeilen +const V = await loadModule('src/lib/workersView.js') + +const NB = String.fromCharCode(160) // geschütztes Leerzeichen vor $ und € +// Tagesgrenze 6 $, Monatsgrenze 150 $ (Kenso, 17.10.: max. 150 € im Monat) +const K = { + newAbosMonth: 3, + mrrNewEur: 60, + dealsMonth: 3, + openThreads: 7, + pultOpen: 2, + botLeads: 112, + apiTodayUsd: 5.1, + apiMonthUsd: 50, + dailyBudgetUsd: 6, + monthlyBudgetUsd: 150, +} + +test('Zahlenformate', () => { + assert.equal(V.formatUsd(7.84), `7,84${NB}$`) + assert.equal(V.formatUsd(1234.5), `1.234,50${NB}$`) + assert.equal(V.formatUsd(0), `0,00${NB}$`) + for (const bad of [null, undefined, NaN, 'x']) assert.equal(V.formatUsd(bad), '–') + assert.equal(V.formatEur(120), `120${NB}€`) + assert.equal(V.formatEur(19.5), `19,50${NB}€`) + assert.equal(V.formatCount(1234), '1.234') + assert.equal(V.formatCount(null), '–') +}) + +test('formatTime: Berlin-Zeit, heute nur Uhrzeit', () => { + const now = Date.parse('2026-10-17T10:00:00Z') + assert.equal(V.formatTime('2026-10-17T20:30:00Z', now), '22:30') + assert.equal(V.formatTime('2026-10-16T21:30:00Z', now), '16.10. 23:30') + assert.equal(V.formatTime('kaputt', now), '–') + assert.equal(V.formatTime(null, now), '–') +}) + +test('Status: Texte und Töne', () => { + const s = ['arbeitet', 'wartet', 'leerlauf', 'grau', 'fehler', 'x'] + assert.deepEqual(s.map(V.workerStatusLabel), ['Arbeitet', 'Wartet auf Auftrag', 'Leerlauf', 'Noch nicht angeschlossen', 'Fehler', 'Unbekannt']) + assert.deepEqual(s.map(V.statusTone), ['green', 'orange', 'plain', 'plain', 'red', 'plain']) +}) + +test('kpiTiles: sechs Kacheln, Töne nach 80-%-Regel', () => { + const t = V.kpiTiles(K) + assert.deepEqual(t.map((x) => x.key), ['abos', 'deals', 'threads', 'pult', 'botleads', 'api']) + assert.deepEqual(t.map((x) => x.label), ['Neue Abos (Monat)', 'Deals (Monat)', 'Gespräche offen', 'Wartet auf dich', 'Bot-Leads gesamt', 'API heute']) + assert.deepEqual(t.map((x) => x.value), ['3', '3', '7', '2', '112', `5,10${NB}$`]) + assert.equal(t[0].sub, `60${NB}€ neu pro Monat`) + assert.equal(t[5].sub, `von 6,00${NB}$`) + assert.deepEqual(t.map((x) => x.tone), ['', '', '', 'warn', '', 'warn']) + assert.equal(V.kpiTiles({ ...K, apiTodayUsd: 6 })[5].tone, 'neg') + assert.equal(V.kpiTiles({ ...K, apiTodayUsd: 4.7 })[5].tone, '') + assert.equal(V.kpiTiles({ ...K, dailyBudgetUsd: 0 })[5].tone, '') + assert.equal(V.kpiTiles({ ...K, pultOpen: 0 })[3].tone, '') +}) + +test('kpiTiles: genau 80 % der Tagesgrenze warnt (keine Gleitkomma-Lücke)', () => { + assert.equal(V.kpiTiles({ ...K, apiTodayUsd: 4.8 })[5].tone, 'warn') + assert.equal(V.kpiTiles({ ...K, apiTodayUsd: 4.79 })[5].tone, '') +}) + +test('kpiTiles ohne Daten: Striche, kein NaN (Review Focus 1)', () => { + for (const k of [null, undefined, {}]) { + const tiles = V.kpiTiles(k) + assert.equal(tiles.length, 6) + for (const x of tiles) { + assert.equal(x.value, '–') + assert.equal(typeof x.sub, 'string') + assert.ok(!/NaN|undefined/.test(x.sub)) + assert.equal(x.tone, '') + } + } +}) + +test('counterLines: bekannte Zähler zuerst, Rest alphabetisch, unbekannter Schlüssel als Beschriftung', () => { + assert.deepEqual(V.counterLines({ zeta: 1, gesendet: 3, gelesen: 41 }), [ + { key: 'gelesen', label: 'gelesen', value: '41' }, + { key: 'gesendet', label: 'gesendet', value: '3' }, + { key: 'zeta', label: 'zeta', value: '1' }, + ]) + assert.equal(V.counterLines({ entwuerfe: 2 })[0].label, 'Entwürfe') + assert.equal(V.counterLines({ geprueft: 2 })[0].label, 'geprüft') + assert.deepEqual(V.counterLines(null), []) + assert.equal(V.tokenTotal(1200), 1200) + assert.equal(V.tokenTotal({ in: 1000, out: 234 }), 1234) + assert.equal(V.tokenTotal(undefined), 0) +}) + +test('safeLink: nur http(s) und interne Pfade (Review Focus 5)', () => { + assert.deepEqual(V.safeLink('https://x.de/a'), { href: 'https://x.de/a', external: true }) + assert.deepEqual(V.safeLink('/customers/1'), { href: '/customers/1', external: false }) + const bad = ['javascript:alert(1)', ' JavaScript:alert(1)', 'data:text/html,x', '//evil.example', 'mailto:a@b.de', '', null, 42] + // Browser lesen "/\host" und "//host" wie "//host": auch das bleibt Text + bad.push('/\\evil.example', '/\t/evil.example', '/\n/evil.example') + for (const b of bad) assert.equal(V.safeLink(b), null, String(b)) +}) + +test('traceRows: neueste zuerst, höchstens 50, unsicherer Link bleibt Text', () => { + const evs = Array.from({ length: 60 }, (_, i) => ({ + id: `e${i}`, + ts: new Date(Date.UTC(2026, 9, 17, 8, 0, i)).toISOString(), + text: `T${i}`, + link: i === 59 ? 'javascript:x' : null, + })) + const rows = V.traceRows(evs, Date.parse('2026-10-17T12:00:00Z')) + assert.equal(rows.length, 50) + assert.equal(rows[0].id, 'e59') + assert.equal(rows[0].link, null) + assert.equal(rows[0].text, 'T59') + assert.equal(rows[0].time, '10:00') + assert.equal(rows[49].id, 'e10') +}) + +test('traceRows: gültiger Link wird übernommen, ungültige Zeiten zuletzt, Nicht-Listen ergeben []', () => { + const now = Date.parse('2026-10-17T12:00:00Z') + const rows = V.traceRows([ + { id: 'a', ts: 'kaputt', text: 'ohne Zeit', link: null }, + { id: 'b', ts: '2026-10-17T08:00:00Z', text: 'mit Link', link: '/customers/1' }, + ], now) + assert.deepEqual(rows.map((r) => r.id), ['b', 'a']) + assert.deepEqual(rows[0].link, { href: '/customers/1', external: false }) + assert.equal(rows[1].time, '–') + assert.deepEqual(V.traceRows(null, now), []) + assert.deepEqual(V.traceRows(undefined, now), []) +}) + +test('pauseLabel und pickTab', () => { + assert.equal(V.pauseLabel(false), 'Alles anhalten') + assert.equal(V.pauseLabel(true), 'Fortsetzen') + const tabs = [{ id: 'hochhaus' }, { id: 'pult' }] + assert.equal(V.pickTab(tabs, 'pult').id, 'pult') + assert.equal(V.pickTab(tabs, 'nope').id, 'hochhaus') + assert.equal(V.pickTab(tabs, null).id, 'hochhaus') +})