Workers P2: Anzeige-Logik (Formate, Kacheln, safeLink, Spur-Zeilen)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
92ee4bfbad
commit
79be67706c
2 files changed
+327
No files matched your search
@@ -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]
|
||||
}
|
||||
Reference in new issue
Block a user