diff --git a/public/neu/ansicht-funktionen.js b/public/neu/ansicht-funktionen.js index b03272c..fa87ccb 100644 --- a/public/neu/ansicht-funktionen.js +++ b/public/neu/ansicht-funktionen.js @@ -1,7 +1,124 @@ -/* Kundenansicht neu: Platzhalter, den das nächste Paket füllt (Stub aus P1). */ +/* Kundenansicht neu (P3): Bereich „Funktionen“ mit den Reitern Meine und Entdecken. + Der erste Teil ist rein (Modell aus der Antwort von GET /api/funktionen im Abo-Modell, V5); die Ansicht darunter zeichnet es. + Keine Preise: Der Katalog trägt keinen. Fremdtext (Funktions- und Websitenamen) wird nur als Text gesetzt. */ import { h } from './dom.js' +import { ETAGE_FUER, anfrageStatusVon } from './funktion-einschalten.js' +const ANFRAGE_SICHTBAR = ['offen', 'angenommen', 'abgelehnt'] // zurückgezogen zählt nicht + +/** Zeitpunkt oder 'JJJJ-MM-TT' → 'TT.MM.JJJJ' (Europe/Berlin); unlesbar ergibt ''. Ein Tagestext wird als Tag um 12:00 UTC gelesen. */ +export function datumDe(wert) { + if (typeof wert !== 'string' || wert === '') return '' + const ms = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(wert) ? `${wert}T12:00:00Z` : wert) + if (Number.isNaN(ms)) return '' + return new Date(ms).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' }) +} + +const verfuegbare = (d) => (Array.isArray(d?.katalog) ? d.katalog.filter((e) => e && typeof e === 'object' && e.verfuegbar === true && typeof e.key === 'string') : []) +const websites = (d) => (Array.isArray(d?.projects) ? d.projects.filter((p) => p && typeof p === 'object' && typeof p.projectId === 'string') : []) +const zustandVon = (projekt, key) => (key === 'analyse' ? projekt.analyse?.state : projekt.features?.[key]?.state) + +/** Hat diese Website die Funktion schon (Zeile in Meine)? Eine Zeile ist Zustand, Entwurf des Fragebogens oder sichtbare Anfrage. */ +function istInMeine(eintrag, projekt) { + if (eintrag.key === 'analyse') return ['active', 'disabled'].includes(projekt.analyse?.state) + const f = projekt.features?.[eintrag.key] + if (!f || typeof f !== 'object') return false + if (f.state !== 'off') return typeof f.state === 'string' + if (eintrag.ablauf === 'fragebogen') return Boolean(f.siteKey) + if (eintrag.ablauf === 'anfrage') return ANFRAGE_SICHTBAR.includes(anfrageStatusVon(projekt, eintrag.key)) + return false +} + +/** „Dein Abo ist beendet.“ mit dem Löschtag, wenn einer da ist. */ +function aboEndeText(f) { + const tag = datumDe(f.loeschungAm) + return tag ? `Dein Abo ist beendet. Deine Daten bleiben bis ${tag} gespeichert.` : 'Dein Abo ist beendet.' +} + +/** Pille und Zeile einer Funktion an einer Website (nur für Websites, bei denen istInMeine gilt). */ +function statusVon(eintrag, projekt) { + if (eintrag.key === 'analyse') { + const a = projekt.analyse + if (a.state === 'active') return { pille: { text: 'Aktiv', ton: 'gruen' }, status: 'Läuft auf deiner Website' } + return { pille: { text: 'Gesperrt', ton: 'grau' }, status: a.disabledReason === 'abo_ende' ? aboEndeText(a) : 'Schreib uns im Chat' } + } + const f = projekt.features[eintrag.key] + switch (f.state) { + case 'setup': + return { pille: { text: 'In Einrichtung', ton: 'blau' }, status: f.ticketWoid ? `Einrichtung läuft · Ticket #${f.ticketWoid}` : 'Einrichtung läuft' } + case 'ordered': + return { pille: { text: 'Zahlung offen', ton: 'orange' }, status: 'Wir warten auf deine Zahlung' } + case 'active': { + const seit = datumDe(f.activatedAt) + return { pille: { text: 'Aktiv', ton: 'gruen' }, status: seit ? `Aktiv seit ${seit}` : 'Läuft auf deiner Website' } + } + case 'disabled': + return { pille: { text: 'Abgeschaltet', ton: 'grau' }, status: f.disabledReason === 'abo_ende' ? aboEndeText(f) : 'Schreib uns im Chat, wenn du die Funktion wieder möchtest.' } + default: { + if (eintrag.ablauf === 'fragebogen') return { pille: { text: 'Entwurf', ton: 'grau' }, status: 'Fragebogen fortsetzen' } + const anfrage = anfrageStatusVon(projekt, eintrag.key) + if (anfrage === 'angenommen') return { pille: { text: 'Angenommen', ton: 'blau' }, status: 'Jetzt einschalten' } + if (anfrage === 'abgelehnt') return { pille: { text: 'Abgelehnt', ton: 'rot' }, status: 'Schreib uns im Chat' } + return { pille: { text: 'Angefragt', ton: 'violett' }, status: 'Wir prüfen deine Anfrage' } + } + } +} + +/** + * Die Zeilen von „Meine“: je Funktion und Website eine, in Katalogreihenfolge, dann Websitereihenfolge. Funktionen mit + * verfuegbar !== true fehlen (C9). zeile2 ist bei mehreren Websites der Websitename, sonst die Statuszeile. + * @returns {Array<{ key: string, name: string, projectId: string, projektName: string, zustand: string, anfrageStatus: string, pille: { text: string, ton: string }, status: string, zeile2: string }>} + */ +export function meineZeilen(daten) { + const seiten = websites(daten) + const mehrere = seiten.length > 1 + const zeilen = [] + for (const eintrag of verfuegbare(daten)) { + for (const projekt of seiten) { + if (!istInMeine(eintrag, projekt)) continue + const { pille, status } = statusVon(eintrag, projekt) + zeilen.push({ + key: eintrag.key, + name: String(eintrag.name ?? ''), + projectId: projekt.projectId, + projektName: String(projekt.projectName ?? ''), + zustand: String(zustandVon(projekt, eintrag.key) ?? ''), + anfrageStatus: eintrag.key === 'analyse' ? '' : anfrageStatusVon(projekt, eintrag.key), + pille, + status, + zeile2: mehrere ? String(projekt.projectName ?? '') : status, + }) + } + } + return zeilen +} + +/** + * Die Einträge von „Entdecken“ in Katalogreihenfolge: solange eine Website die Funktion nicht hat oder es keine Website gibt. + * kandidaten sind die Websites ohne die Funktion (Analyse: nur mit Zustand off; unknown und comingSoon sind kein Kandidat). + * @returns {Array<{ eintrag: object, kandidaten: Array<{ projectId: string, projektName: string }> }>} + */ +export function entdeckenEintraege(daten) { + const seiten = websites(daten) + const ergebnis = [] + for (const eintrag of verfuegbare(daten)) { + const ohne = seiten.filter((p) => !istInMeine(eintrag, p)) + if (seiten.length > 0 && ohne.length === 0) continue + const kandidaten = (eintrag.key === 'analyse' ? ohne.filter((p) => p.analyse?.state === 'off') : ohne) + .map((p) => ({ projectId: p.projectId, projektName: String(p.projectName ?? '') })) + ergebnis.push({ eintrag, kandidaten }) + } + return ergebnis +} + +/** Tipp auf eine Zeile in Meine: eine angenommene Anfrage geht zur Bestätigung, alles andere in die Etage der Funktion. */ +export function zeilenWeg(zeile) { + if (zeile.zustand === 'off' && zeile.anfrageStatus === 'angenommen') return { art: 'seite' } + return { art: 'etage', etage: ETAGE_FUER[zeile.key] } +} + +// Platzhalter bis Task 5 (zeichnet die Ansicht) export async function zeigeFunktionen(wurzel, { route, me, nav }) { wurzel.replaceChildren(h('p', { class: 'kn-leer' }, 'Funktionen (folgt)')) } diff --git a/test/knFunktionenModell.test.js b/test/knFunktionenModell.test.js new file mode 100644 index 0000000..574fb13 --- /dev/null +++ b/test/knFunktionenModell.test.js @@ -0,0 +1,82 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { meineZeilen, entdeckenEintraege, zeilenWeg, datumDe } from '../public/neu/ansicht-funktionen.js' +import { daten, projekt, feature } from './support/knFunktionenDaten.js' + +test('Meine: alles außer off, dazu Entwurf und Anfrage; zurückgezogen zählt nicht', () => { + const d = daten({ projekte: [projekt('p1', 'Bäckerei', { + news: feature('active', { activatedAt: '2026-10-05T08:00:00.000Z' }), + termine: feature('off', { siteKey: 'abcdefghijklmnop' }), + shop: feature('off', { anfrageStatus: 'offen' }), + unterkunft: feature('off', { anfrageStatus: 'zurueckgezogen' }), + zeiten: feature('disabled', { disabledReason: 'abo_ende', loeschungAm: '2026-11-20' }), + })] }) + const z = meineZeilen(d) + assert.deepEqual(z.map((r) => [r.key, r.pille.text, r.pille.ton]), [['news', 'Aktiv', 'gruen'], ['termine', 'Entwurf', 'grau'], ['shop', 'Angefragt', 'violett'], ['zeiten', 'Abgeschaltet', 'grau']]) + assert.deepEqual(z.map((r) => r.status), ['Aktiv seit 05.10.2026', 'Fragebogen fortsetzen', 'Wir prüfen deine Anfrage', 'Dein Abo ist beendet. Deine Daten bleiben bis 20.11.2026 gespeichert.']) + assert.equal(z[0].zeile2, z[0].status) +}) + +test('Status je Zustand: Pille, Ton, Zeile', () => { + const zeile = (key, f) => meineZeilen(daten({ projekte: [projekt('p1', 'B', { [key]: f })] })).find((r) => r.key === key) + const faelle = [ + ['news', feature('setup', { ticketWoid: '100201' }), 'In Einrichtung', 'blau', 'Einrichtung läuft · Ticket #100201'], + ['news', feature('setup'), 'In Einrichtung', 'blau', 'Einrichtung läuft'], + ['news', feature('ordered'), 'Zahlung offen', 'orange', 'Wir warten auf deine Zahlung'], + ['news', feature('active'), 'Aktiv', 'gruen', 'Läuft auf deiner Website'], + ['news', feature('disabled'), 'Abgeschaltet', 'grau', 'Schreib uns im Chat, wenn du die Funktion wieder möchtest.'], + ['news', feature('disabled', { disabledReason: 'abo_ende' }), 'Abgeschaltet', 'grau', 'Dein Abo ist beendet.'], + ['shop', feature('off', { anfrageStatus: 'angenommen' }), 'Angenommen', 'blau', 'Jetzt einschalten'], + ['shop', feature('off', { anfrageStatus: 'abgelehnt' }), 'Abgelehnt', 'rot', 'Schreib uns im Chat'], + ] + for (const [key, f, pille, ton, status] of faelle) { + const r = zeile(key, f) + assert.deepEqual([r.pille.text, r.pille.ton, r.status], [pille, ton, status], `${key} ${f.state}`) + } +}) + +test('mehrere Websites: eine Zeile je Funktion und Website, zweite Zeile ist der Websitename', () => { + const d = daten({ projekte: [projekt('p1', 'Bäckerei', { termine: feature('active') }), projekt('p2', 'Café', { termine: feature('setup'), news: feature('active') })] }) + assert.deepEqual(meineZeilen(d).map((r) => [r.key, r.projectId, r.zeile2]), [['news', 'p2', 'Café'], ['termine', 'p1', 'Bäckerei'], ['termine', 'p2', 'Café']]) +}) + +test('Entdecken: Kandidaten sind die Websites ohne die Funktion; eine einzelne Website mit aktiver Funktion blendet sie aus', () => { + const d = daten({ projekte: [projekt('p1', 'Bäckerei', { news: feature('active') }), projekt('p2', 'Café')] }) + assert.deepEqual(entdeckenEintraege(d).find((e) => e.eintrag.key === 'news').kandidaten, [{ projectId: 'p2', projektName: 'Café' }]) + assert.equal(entdeckenEintraege(daten({ projekte: [projekt('p1', 'B', { news: feature('active') })] })).some((e) => e.eintrag.key === 'news'), false) +}) + +test('C9: verfuegbar:false fehlt in Meine und Entdecken; ohne Websites zeigt Entdecken alles ohne Kandidaten', () => { + const d = daten({ katalogEdit: { shop: { verfuegbar: false } }, projekte: [projekt('p1', 'B', { shop: feature('active') }, { state: 'active' })] }) + assert.deepEqual(meineZeilen(d).map((r) => r.key), ['analyse']) + assert.deepEqual(entdeckenEintraege(d).map((e) => e.eintrag.key), ['news', 'termine', 'unterkunft', 'zeiten']) + const leer = daten({ projekte: [] }) + assert.deepEqual(meineZeilen(leer), []) + assert.deepEqual(entdeckenEintraege(leer).map((e) => e.kandidaten.length), [0, 0, 0, 0, 0, 0]) +}) + +test('Analyse: Zeile nur bei active/disabled; unknown und comingSoon sind kein Kandidat', () => { + const zeilen = (analyse) => meineZeilen(daten({ projekte: [projekt('p1', 'B', {}, analyse)] })).filter((r) => r.key === 'analyse').map((r) => r.pille.text) + assert.deepEqual([zeilen({ state: 'active' }), zeilen({ state: 'disabled' }), zeilen({ state: 'off' }), zeilen({ state: 'unknown' }), zeilen({ comingSoon: true })], [['Aktiv'], ['Gesperrt'], [], [], []]) + const kand = (analyse) => entdeckenEintraege(daten({ projekte: [projekt('p1', 'B', {}, analyse)] })).find((e) => e.eintrag.key === 'analyse')?.kandidaten.length ?? -1 + assert.deepEqual([kand({ state: 'off' }), kand({ state: 'unknown' }), kand({ comingSoon: true }), kand({ state: 'active' })], [1, 0, 0, -1]) +}) + +test('zeilenWeg: angenommene Anfrage geht zur Bestätigung, alles andere in die Etage', () => { + const z = meineZeilen(daten({ projekte: [projekt('p1', 'B', { shop: feature('off', { anfrageStatus: 'angenommen' }), termine: feature('active') })] })) + assert.deepEqual(z.map(zeilenWeg), [{ art: 'etage', etage: 'termine' }, { art: 'seite' }]) +}) + +test('Analyse-Zeilen: Texte, Etage; Abo-Ende mit Löschtag; fehlende Felder brechen nichts', () => { + const zeile = (analyse) => meineZeilen(daten({ projekte: [projekt('p1', 'B', {}, analyse)] })).find((r) => r.key === 'analyse') + assert.deepEqual([zeile({ state: 'active' }).status, zeile({ state: 'disabled' }).status], ['Läuft auf deiner Website', 'Schreib uns im Chat']) + assert.equal(zeile({ state: 'disabled', disabledReason: 'abo_ende', loeschungAm: '2026-12-01' }).status, 'Dein Abo ist beendet. Deine Daten bleiben bis 01.12.2026 gespeichert.') + assert.deepEqual(zeilenWeg(zeile({ state: 'active' })), { art: 'etage', etage: 'analyse' }) + const kaputt = { modell: 'abo', katalog: [], projects: [{ projectId: 'p1' }] } + assert.deepEqual([meineZeilen(kaputt), entdeckenEintraege(kaputt)], [[], []]) + assert.deepEqual([meineZeilen(null), entdeckenEintraege(undefined)], [[], []]) +}) + +test('datumDe: Zeitpunkt oder Tag als TT.MM.JJJJ in Berliner Zeit', () => { + assert.deepEqual([datumDe('2026-11-20'), datumDe('2026-10-05T08:00:00.000Z'), datumDe('2026-10-30T23:30:00.000Z'), datumDe(''), datumDe(null), datumDe('kein Datum')], ['20.11.2026', '05.10.2026', '31.10.2026', '', '', '']) +})