diff --git a/public/neu/zaehler.js b/public/neu/zaehler.js new file mode 100644 index 0000000..0f08030 --- /dev/null +++ b/public/neu/zaehler.js @@ -0,0 +1,79 @@ +/* Kundenansicht neu: Zähler für Chat (ungelesen) und Verträge (offene Rechnungen plus Rückfragen, "!" bei Zahlungsproblem). Vertrag V3. + Jede der vier Quellen wird einzeln abgefragt; nur eine gültige Antwort ersetzt den gemerkten Wert, ein Fehler lässt den letzten Wert stehen. + Kein DOM-Zugriff, api und Zeitgeber kommen als Parameter. */ + +export const CHAT_POLL_MS = 45000 + +const QUELLEN = { + chat: { pfad: '/api/chat/unread-count', nimm: (a) => (typeof a?.unread === 'number' && Number.isFinite(a.unread) ? Math.max(0, Math.trunc(a.unread)) : undefined) }, + rechnungen: { pfad: '/api/invoices', nimm: (a) => (Array.isArray(a?.invoices) ? a.invoices : undefined) }, + rueckfragen: { pfad: '/api/abo/takeover-credits', nimm: (a) => (Array.isArray(a?.requests) ? a.requests : undefined) }, + // switchPending ("Wechsel läuft") ist kein Zahlungsproblem und löst kein "!" aus + abo: { pfad: '/api/abo', nimm: (a) => (a && typeof a === 'object' && 'subscription' in a ? a.subscription?.paymentProblem === true : undefined) }, +} +const GRUPPEN = { chat: ['chat'], vertraege: ['rechnungen', 'rueckfragen', 'abo'], alle: ['chat', 'rechnungen', 'rueckfragen', 'abo'] } + +/** Verträge-Zähler: Rechnungen mit offenem Betrag plus Rückfragen zur Übernahmegutschrift; "!" nur bei Zahlungsproblem. */ +export function berechneVertraege({ rechnungen = [], rueckfragen = [], paymentProblem = false } = {}) { + const offen = rechnungen.filter((r) => Number(r?.balance) > 0).length + const fragen = rueckfragen.filter((r) => r?.status === 'rueckfrage').length + return { zahl: offen + fragen, ausruf: paymentProblem === true } +} + +export function erzeugeZaehler({ api, onChange = () => {}, sichtbar = () => true, imChat = () => false, zeitgeber = { setze: (fn, ms) => setInterval(fn, ms), loesche: (id) => clearInterval(id) } }) { + const daten = { chat: 0, rechnungen: [], rueckfragen: [], abo: false } + const lauf = {} // je Quelle die Nummer der jüngsten Anfrage: eine ältere, später eintreffende Antwort gilt nicht + let letzte = '' + let laeuft = false + let timer + + const stand = () => ({ chat: daten.chat, vertraege: berechneVertraege({ rechnungen: daten.rechnungen, rueckfragen: daten.rueckfragen, paymentProblem: daten.abo }) }) + letzte = JSON.stringify(stand()) + + function melde() { + const s = stand() + const schluessel = JSON.stringify(s) + if (schluessel === letzte) return + letzte = schluessel + onChange(s) + } + + async function hole(name) { + const nr = (lauf[name] = (lauf[name] ?? 0) + 1) + let wert + try { wert = QUELLEN[name].nimm(await api(QUELLEN[name].pfad)) } catch { wert = undefined } + if (nr !== lauf[name] || wert === undefined) return + daten[name] = wert + } + + /** was: 'chat' | 'vertraege' | 'alle'. Wirft nie. */ + async function aktualisiere(was = 'alle') { + await Promise.all((GRUPPEN[was] ?? GRUPPEN.alle).map(hole)) + melde() + } + + /** Chat-Zähler von außen setzen (z. B. 0 beim Öffnen des Chats); eine laufende Abfrage überschreibt ihn nicht. */ + function setzeChat(zahl) { + lauf.chat = (lauf.chat ?? 0) + 1 + daten.chat = Math.max(0, Math.trunc(Number(zahl)) || 0) + melde() + } + + async function takt() { + if (!sichtbar() || imChat()) return + await aktualisiere('chat') + } + + async function start() { + if (!laeuft) { laeuft = true; timer = zeitgeber.setze(takt, CHAT_POLL_MS) } + await aktualisiere('alle') + } + + function stop() { + if (!laeuft) return + laeuft = false + zeitgeber.loesche(timer) + } + + return { start, stop, aktualisiere, setzeChat, stand } +} diff --git a/test/knHuelleZaehler.test.js b/test/knHuelleZaehler.test.js new file mode 100644 index 0000000..4074216 --- /dev/null +++ b/test/knHuelleZaehler.test.js @@ -0,0 +1,50 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { berechneVertraege, erzeugeZaehler, CHAT_POLL_MS } from '../public/neu/zaehler.js' + +test('Verträge: Rechnungen plus Rückfragen; "!" nur bei paymentProblem, nie bei switchPending (V3)', async () => { + assert.deepEqual(berechneVertraege({ rechnungen: [{ balance: 50 }, { balance: 0 }, { balance: '12.50' }], rueckfragen: [{ status: 'rueckfrage' }, { status: 'offen' }, { status: 'rueckfrage' }] }), { zahl: 4, ausruf: false }) + assert.deepEqual(berechneVertraege({ paymentProblem: true }), { zahl: 0, ausruf: true }) + const karte = { '/api/invoices': { invoices: [{ id: 'i1', number: 'RE-1', balance: 50 }] }, '/api/abo/takeover-credits': { requests: [{ status: 'rueckfrage' }] }, '/api/abo': { subscription: { paymentProblem: false, switchPending: { since: 'x', paymentUrl: null } } } } + const z = erzeugeZaehler({ api: async (p) => karte[p], onChange() {} }); await z.aktualisiere('vertraege') + assert.deepEqual(z.stand().vertraege, { zahl: 2, ausruf: false }) +}) + +test('Fehler einer Quelle behalten ihren letzten Wert (Review Focus 5)', async () => { + let kaputt = false + const api = async (p) => { + if (p === '/api/invoices') { if (kaputt) throw Object.assign(new Error('x'), { status: 500 }); return { invoices: [{ id: 'i1', number: 'RE-1', balance: 50 }] } } + return p === '/api/abo' ? { subscription: null } : { requests: [] } + } + const z = erzeugeZaehler({ api, onChange() {} }); await z.aktualisiere('vertraege'); kaputt = true; await z.aktualisiere('vertraege') + assert.deepEqual(z.stand().vertraege, { zahl: 1, ausruf: false }) +}) + +test('Chat: Poll alle 45 s, nicht im Chat und nicht bei verstecktem Tab; setzeChat(0) sofort', async () => { + const timer = []; const staende = []; let sicht = true; let chat = false; let aufrufe = 0 + const z = erzeugeZaehler({ + api: async (p) => { if (p === '/api/chat/unread-count') { aufrufe += 1; return { unread: 3 } } return p === '/api/abo' ? { subscription: null } : p === '/api/invoices' ? { invoices: [] } : { requests: [] } }, + onChange: (s) => staende.push(s.chat), sichtbar: () => sicht, imChat: () => chat, zeitgeber: { setze: (fn, ms) => timer.push({ fn, ms }) - 1, loesche() {} } }) + await z.start(); assert.deepEqual([timer.map((t) => t.ms), z.stand().chat], [[CHAT_POLL_MS], 3]) + await timer[0].fn(); assert.equal(aufrufe, 2) + sicht = false; await timer[0].fn(); sicht = true; chat = true; await timer[0].fn(); assert.equal(aufrufe, 2) + z.setzeChat(0); assert.deepEqual([z.stand().chat, staende.at(-1)], [0, 0]) +}) + +test('Zähler: onChange nur bei Änderung, "!" vor Zahl, ältere Antworten überholen keine neueren, stop räumt den Zeitgeber ab', async () => { + const meldungen = []; const geloescht = []; let gelaufen = 0 + const antworten = { '/api/invoices': [], '/api/abo': { subscription: { paymentProblem: true } } } + const wartend = [] // die ersten zwei Chat-Antworten löst der Test selbst auf + const api = async (p) => { + if (p === '/api/chat/unread-count') return wartend.length < 2 ? new Promise((ok) => wartend.push(ok)) : { unread: 5 } + return p === '/api/invoices' ? { invoices: antworten[p] } : p === '/api/abo' ? antworten[p] : { requests: [] } + } + const z = erzeugeZaehler({ api, onChange: (s) => meldungen.push(JSON.stringify(s)), zeitgeber: { setze: () => { gelaufen += 1; return 7 }, loesche: (id) => geloescht.push(id) } }) + await z.aktualisiere('vertraege'); await z.aktualisiere('vertraege') + assert.deepEqual(meldungen, [JSON.stringify({ chat: 0, vertraege: { zahl: 0, ausruf: true } })]) // zweiter Lauf ohne Änderung meldet nichts + const alt = z.aktualisiere('chat'); const neu = z.aktualisiere('chat') + wartend[1]({ unread: 5 }); await neu; wartend[0]({ unread: 2 }); await alt + assert.equal(z.stand().chat, 5) // die zuerst gestartete, später eingetroffene Antwort gilt nicht + await z.start(); await z.start(); assert.equal(gelaufen, 1) // Zeitgeber nur einmal + z.stop(); z.stop(); assert.deepEqual(geloescht, [7]) +})