diff --git a/public/neu/ansicht-website.js b/public/neu/ansicht-website.js index 1911530..d80f4bf 100644 --- a/public/neu/ansicht-website.js +++ b/public/neu/ansicht-website.js @@ -4,7 +4,8 @@ import { api } from './api.js' import { h, leeren } from './dom.js' import { K, ersetze, zuAnsicht, meldeTitel, fmtDatumZeit, sichereAdresse } from './ansicht-hilfen.js' -import { baueAktivitaet, baueProjektDetails, ladeProjektDetails } from './website-details.js' +import { baueProjektDetails, ladeProjektDetails } from './website-details.js' +import { baueAktivitaet } from './auftrags-zeitleiste.js' import { ladeOffeneRechnungen, rechnungOffenZeile } from './rechnung-offen.js' import { starteInstallQuelle, installAngebot, baueInstallHinweis, istAusgeblendet, merkeAusgeblendet } from './install-hinweis.js' import { HANDY_ABFRAGE } from './leiste.js' diff --git a/public/neu/ansichten.css b/public/neu/ansichten.css index 6f80df3..07362e0 100644 --- a/public/neu/ansichten.css +++ b/public/neu/ansichten.css @@ -85,10 +85,7 @@ .kn-projekt-links { display: flex; flex-wrap: wrap; gap: 8px; } /* Auftrag, Änderungen, Arbeiten, Aktivität */ -.kn-aktivitaet { margin: 0; } [data-abschnitt="aktivitaet"] { scroll-margin-top: 16px; } /* #fortschritt: scrollIntoView lässt den Abschnitt nicht hinter dem haftenden Kopf beginnen (am Handy unten) */ -.kn-auftrag-karte { padding: 16px 0; border-top: 1px solid var(--kn-line); } -.kn-auftrag-karte > .kn-auftrag + * { margin-top: 12px; } .kn-auftrag-kopf { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } .kn-auftrag-name { min-width: 0; } .kn-auftrag-nr { display: block; font-size: 12px; color: var(--kn-text-2); font-variant-numeric: tabular-nums; } @@ -113,6 +110,33 @@ .kn-eintrag-meta { font-size: 13px; color: var(--kn-text-2); } .kn-eintrag-text { margin: 4px 0 0; color: var(--kn-text-2); font-size: 13px; } .kn-unterstuetzung li { display: flex; justify-content: space-between; gap: 12px; margin-top: 4px; font-size: 13px; color: var(--kn-text-2); } +/* „Was wir zuletzt erledigt haben“ als Zeitleiste (Variante C): je Auftrag Überschrift mit Pille, schmaler Balken in vier Abschnitten, Zeitleiste mit Haarlinie */ +.kn-az-liste { margin: 0; } +.kn-az-liste:not(.is-open) > .kn-az-auftraege > [data-extra] { display: none; } /* nur direkte Kinder: die Zeitleisten darin klappen für sich */ +.kn-az-auftraege { list-style: none; margin: 0; padding: 0; } +.kn-az { padding: 20px 0; border-top: 1px solid var(--kn-line); } +.kn-az-kopf { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 6px 12px; } +.kn-az-titel { flex: 1 1 12em; min-width: 0; margin: 0; font-size: 16px; line-height: 24px; font-weight: 600; overflow-wrap: anywhere; } +.kn-az-kopf .kn-pille { flex: none; margin-top: 2px; } +.kn-az-info { margin: 8px 0 0; font-size: 14px; line-height: 20px; } +.kn-az-info a { color: var(--kn-orange-fg); text-decoration: none; } +.kn-az-info-link { font-weight: 600; text-decoration: underline; text-decoration-color: currentColor; text-underline-offset: 3px; } +.kn-az-balken { display: flex; gap: 3px; margin: 12px 0 0; padding: 0; list-style: none; } +.kn-az-balken li { position: relative; flex: 1; height: 4px; } +.kn-az-balken li::before { content: ""; position: absolute; inset: 0; border-radius: 2px; background: var(--kn-line); } /* die Fläche liegt im Pseudo-Element: der Lesetext darunter bleibt auf Weiß */ +.kn-az-balken li.is-done::before { background: var(--kn-accent); } +.kn-nur-lesen { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; } +.kn-az-zeitleiste { margin: 14px 0 0; } +.kn-az-zeilen { list-style: none; margin: 0 0 0 3px; padding: 0; border-left: 1px solid var(--kn-line-strong); } +.kn-az-zeilen li { position: relative; display: flex; gap: 10px; padding: 4px 0 4px 16px; font-size: 14px; line-height: 20px; } +.kn-az-zeilen li::before { content: ""; position: absolute; left: -3px; top: 12px; width: 5px; height: 5px; border-radius: 50%; background: var(--kn-line-strong); } +.kn-az-datum { flex: none; min-width: 3.2em; color: var(--kn-text-2); font-variant-numeric: tabular-nums; white-space: nowrap; } +.kn-az-mit-jahr .kn-az-datum { min-width: 4.4em; } +.kn-az-text { min-width: 0; overflow-wrap: anywhere; } +.kn-az-zeitleiste:not(.is-open) > .kn-az-zeilen > [data-extra] { display: none; } +.kn-az-mehr { margin: 4px 0 0 8px; } +.kn-az-leer { margin: 12px 0 0; font-size: 14px; color: var(--kn-text-2); } +.kn-az-aeltere { margin: 8px 0 0; } .kn-dateien { margin: 0; } .kn-details { margin: 0; } @@ -361,6 +385,9 @@ .kn-zeilenlink { display: inline-flex; align-items: center; min-height: 44px; } /* Pflicht- und Zahlungslinks (E-Fix F3): Verträge hier kündigen, Vertrag widerrufen, Vertragsbedingungen, Zahlen in der Zeile „Rechnung offen“ */ .kn-kuendigen a, .kn-widerruf a, .kn-links a, .kn-rechnung-offen a { display: inline-flex; align-items: center; min-height: 44px; } + .kn-az-info { margin-top: 0; } + .kn-az-info a { display: block; padding: 12px 0; } + .kn-az-info + .kn-az-balken { margin-top: 0; } .kn-rechnung-offen { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .kn-rechnung-offen .kn-trenner { display: none; } .kn-rechnung-offen a { flex: none; font-weight: 600; } diff --git a/public/neu/auftrags-zeitleiste.js b/public/neu/auftrags-zeitleiste.js new file mode 100644 index 0000000..aa9f350 --- /dev/null +++ b/public/neu/auftrags-zeitleiste.js @@ -0,0 +1,176 @@ +/* Kundenansicht neu: „Was wir zuletzt erledigt haben“ als Zeitleiste (Variante C, Pilot-Feedback 17.10.). + Je Auftrag aus GET /api/activity: Überschrift mit Status-Pille, schmaler Fortschrittsbalken, bei Rückfrage der Hinweis zum Chat und eine + kompakte Zeitleiste der Einträge. Die Logik (Reihenfolge, Gruppierung, Kürzen, Fortschritt, Kurztexte) sind reine Funktionen; gezeichnet wird + mit h() und Textknoten, nie als HTML. Kein DOM-Zugriff beim Import. + Antwortform laut server/routes/customerData.js und services/customerProjectView.js: + { number, topic, status: { label, tone, step }, createdAt, entries: [{ kind, label, date: 'JJJJ-MM-TT', minutes, person, text | changes }] }. */ + +import { h } from './dom.js' +import { K, berlinTag, zuAnsicht } from './ansicht-hilfen.js' +import { AUFTRAG_SCHRITTE, statusTon } from './website-details.js' + +/** Die vier Abschnitte des Balkens (dieselben Namen wie der Fortschritt der Projektdetails). */ +export const STUFEN = AUFTRAG_SCHRITTE +/** So viele Aufträge stehen offen, der Rest hinter „Ältere Aufträge anzeigen“. */ +export const MAX_AUFTRAEGE = 5 +/** So viele Zeilen der Zeitleiste stehen offen, der Rest hinter „mehr anzeigen“. */ +export const MAX_ZEILEN = 3 +const MAX_KURZTEXT = 90 + +/* ---------------------------------------------------------------- Fortschritt --- */ + +/** Die vier Abschnitte des Balkens: erreicht bis zum aktuellen Stand; Stufe 0 (pausiert, storniert) und Unbekanntes erreichen nichts. */ +export function fortschritt(status) { + const schritt = Number(status?.step) + const gueltig = Number.isInteger(schritt) && schritt >= 1 && schritt <= STUFEN.length + return STUFEN.map((name, i) => ({ name, erreicht: gueltig && i < schritt, aktuell: gueltig && i === schritt - 1 })) +} + +/** Braucht der Auftrag eine Info vom Kunden? Der Server meldet das mit dem Ton „waiting“ (Ticketstatus „awaiting“, „Wartet auf deine Rückmeldung“). */ +export const braucheInfo = (auftrag) => auftrag?.status?.tone === 'waiting' + +/* ---------------------------------------------------------- Reihenfolge, Kürzen --- */ + +const eintraegeVon = (auftrag) => (Array.isArray(auftrag?.entries) ? auftrag.entries.filter((e) => e && typeof e === 'object') : []) + +// 'JJJJ-MM-TT' (auch als Anfang eines ISO-Zeitpunkts) → { j, m, t } ohne Zeitzonen-Umrechnung; sonst null +function tagTeile(wert) { + const treffer = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(wert ?? '')) + if (!treffer) return null + const [j, m, t] = [Number(treffer[1]), Number(treffer[2]), Number(treffer[3])] + return m >= 1 && m <= 12 && t >= 1 && t <= 31 ? { j, m, t } : null +} + +const zweistellig = (zahl) => String(zahl).padStart(2, '0') +const isoTag = (wert) => { const tag = tagTeile(wert); return tag ? `${tag.j}-${zweistellig(tag.m)}-${zweistellig(tag.t)}` : '' } + +/** Zeitpunkt der jüngsten Aktivität (ms): der späteste Eintrag oder die Anlage des Auftrags; 0, wenn nichts lesbar ist. */ +export function letzteAktivitaet(auftrag) { + const zeiten = [Date.parse(String(auftrag?.createdAt ?? '')), ...eintraegeVon(auftrag).map((e) => { const tag = isoTag(e.date); return tag ? Date.parse(`${tag}T00:00:00Z`) : NaN })] + return Math.max(0, ...zeiten.filter((zeit) => Number.isFinite(zeit))) +} + +// 0 = laufend (auch pausiert), 1 = erledigt, 2 = storniert oder abgebrochen +const rang = (auftrag) => (auftrag?.status?.tone === 'done' ? 1 : auftrag?.status?.tone === 'stopped' ? 2 : 0) + +/** Laufende Aufträge zuerst, dann erledigte, Storniertes zuletzt; innerhalb jeder Gruppe die jüngste Aktivität vorn, bei Gleichstand die Reihenfolge des Servers. */ +export function sortiereAuftraege(tickets) { + const liste = Array.isArray(tickets) ? tickets.filter((t) => t && typeof t === 'object') : [] + return liste + .map((auftrag, i) => ({ auftrag, i, rang: rang(auftrag), zeit: letzteAktivitaet(auftrag) })) + .sort((a, b) => a.rang - b.rang || b.zeit - a.zeit || a.i - b.i) + .map((e) => e.auftrag) +} + +/** Die ersten n sichtbar, der Rest getrennt; die Eingabe bleibt unverändert. */ +export function kuerze(liste, max) { + const alle = Array.isArray(liste) ? liste : [] + return { sichtbar: alle.slice(0, max), rest: alle.slice(max) } +} + +/** Sortierte Aufträge: höchstens max offen, der Rest als „älter“. */ +export function gruppiereAuftraege(tickets, max = MAX_AUFTRAEGE) { + const { sichtbar, rest } = kuerze(sortiereAuftraege(tickets), max) + return { sichtbar, aeltere: rest } +} + +/* ---------------------------------------------------------------- Zeitleiste --- */ + +/** 'TT.MM.' für einen Tag 'JJJJ-MM-TT'; fällt er nicht ins laufende Jahr, steht 'TT.MM.JJ' da. Unlesbares ergibt ''. */ +export function datumKurz(wert, jetzt = new Date()) { + const tag = tagTeile(wert) + if (!tag) return '' + const laufend = berlinTag(jetzt)?.j ?? jetzt.getUTCFullYear() + const jahr = tag.j === laufend ? '' : zweistellig(tag.j % 100) + return `${zweistellig(tag.t)}.${zweistellig(tag.m)}.${jahr}` +} + +// erste nichtleere Zeile, Leerraum zusammengezogen +const ersteZeile = (text) => String(text ?? '').split(/\r?\n/).map((zeile) => zeile.replace(/\s+/g, ' ').trim()).find(Boolean) ?? '' + +function kuerzeText(text, max) { + if (text.length <= max) return text + const schnitt = text.slice(0, max) + const leer = schnitt.lastIndexOf(' ') + return `${(leer > max * 0.6 ? schnitt.slice(0, leer) : schnitt).replace(/[\s,;:.–-]+$/, '')}…` +} + +/** Kurzer Text einer Zeile: die erste Änderung (mit „und n weitere“), sonst der freie Text der Arbeit, sonst die Art der Arbeit. Nie lang, nie ein Name. */ +export function eintragsKurztext(eintrag) { + const aenderungen = Array.isArray(eintrag?.changes) ? eintrag.changes.map(ersteZeile).filter(Boolean) : [] + if (aenderungen.length) { + const rest = aenderungen.length - 1 + const erste = kuerzeText(aenderungen[0], MAX_KURZTEXT) + return rest ? `${erste} und ${rest} weitere ${rest === 1 ? 'Änderung' : 'Änderungen'}` : erste + } + return kuerzeText(ersteZeile(eintrag?.text), MAX_KURZTEXT) || ersteZeile(eintrag?.label) || 'Arbeit am Projekt' +} + +/** Zeilen der Zeitleiste eines Auftrags, neueste zuerst; Einträge ohne lesbares Datum stehen unten. */ +export function zeitleistenZeilen(auftrag, jetzt = new Date()) { + return eintraegeVon(auftrag) + .map((e, i) => ({ i, iso: isoTag(e.date), datum: datumKurz(e.date, jetzt), text: eintragsKurztext(e) })) + .sort((a, b) => (a.iso === b.iso ? a.i - b.i : !a.iso ? 1 : !b.iso ? -1 : b.iso.localeCompare(a.iso))) + .map(({ datum, iso, text }) => ({ datum, iso, text })) +} + +/* ------------------------------------------------------------------------ DOM --- */ + +const LEER = 'Es wurden noch keine Arbeiten dokumentiert.' +const KEINE_EINTRAEGE = 'Noch keine Arbeiten dokumentiert.' + +/** Knopf, der den nächsten Wrapper [data-einklappbar] auf- und zuklappt (is-open); die Zeilen dahinter tragen [data-extra]. Die CSS-Regeln dazu gelten nur für + direkte Kinder (ansichten.css, .kn-az-…): die allgemeine Regel .kn-einklappbar würde bei verschachtelten Wrappern auch offene Zeilen verbergen. */ +function umschalter({ attribut, klasse, mehr, weniger }) { + const knopf = h('button', { type: 'button', class: klasse, [attribut]: true, 'aria-expanded': 'false' }, mehr) + knopf.addEventListener('click', () => { + const offen = knopf.closest('[data-einklappbar]').classList.toggle('is-open') + knopf.textContent = offen ? weniger : mehr + knopf.setAttribute('aria-expanded', String(offen)) + }) + return knopf +} + +const zustandText = (stufe) => (stufe.aktuell ? 'aktueller Stand' : stufe.erreicht ? 'erreicht' : 'noch offen') + +// Vier Abschnitte, erreichte schwarz; die Namen stehen nur für Screenreader als Text. Ohne erreichte Stufe (pausiert, storniert) gibt es keinen Balken. +function baueBalken(status) { + const stufen = fortschritt(status) + if (!stufen.some((stufe) => stufe.erreicht)) return null + return h('ol', { class: 'kn-az-balken', 'aria-label': 'Fortschritt des Auftrags' }, stufen.map((stufe) => h('li', + { class: stufe.erreicht ? 'is-done' : null, 'aria-current': stufe.aktuell ? 'step' : null }, + h('span', { class: 'kn-nur-lesen' }, `${stufe.name}, ${zustandText(stufe)}`)))) +} + +function baueZeitleiste(zeilen) { + const { sichtbar, rest } = kuerze(zeilen, MAX_ZEILEN) + const zeile = (z, extra) => h('li', { 'data-extra': extra }, + z.datum ? h('time', { class: 'kn-az-datum', datetime: z.iso || null }, z.datum) : h('span', { class: 'kn-az-datum', 'aria-hidden': 'true' }), + h('span', { class: 'kn-az-text' }, z.text)) + return h('div', { class: 'kn-az-zeitleiste', 'data-einklappbar': true }, + // Trägt ein Datum das Jahr (TT.MM.JJ), bekommt die Datumsspalte aller Zeilen die Breite dafür, damit die Texte bündig bleiben + h('ol', { class: zeilen.some((z) => z.datum.length > 6) ? 'kn-az-zeilen kn-az-mit-jahr' : 'kn-az-zeilen' }, sichtbar.map((z) => zeile(z, false)), rest.map((z) => zeile(z, true))), + rest.length ? umschalter({ attribut: 'data-mehr', klasse: 'kn-textknopf kn-az-mehr', mehr: 'mehr anzeigen', weniger: 'weniger anzeigen' }) : null) +} + +function baueAuftragsBlock(auftrag, jetzt, extra) { + const status = auftrag.status && typeof auftrag.status === 'object' ? auftrag.status : {} + const zeilen = zeitleistenZeilen(auftrag, jetzt) + return h('li', { class: 'kn-az', 'data-auftrag': auftrag.number || null, 'data-extra': extra }, + h('div', { class: 'kn-az-kopf' }, + h('h3', { class: 'kn-az-titel' }, auftrag.topic || 'Dein Auftrag'), + status.label ? h('span', { class: K.pille[statusTon(status.tone)] }, status.label) : null), + // Die ganze Zeile führt in den Chat (große Tippfläche am Handy); unterstrichen ist nur „Im Chat antworten“ + braucheInfo(auftrag) ? h('p', { class: 'kn-az-info' }, h('a', zuAnsicht('chat'), 'Wir brauchen eine Info von dir · ', h('span', { class: 'kn-az-info-link' }, 'Im Chat antworten'))) : null, + baueBalken(status), + zeilen.length ? baueZeitleiste(zeilen) : h('p', { class: 'kn-az-leer' }, KEINE_EINTRAEGE)) +} + +/** „Was wir zuletzt erledigt haben“: ein Block je Auftrag; fünf offen, der Rest hinter „Ältere Aufträge anzeigen“. Ohne Aufträge ein ruhiger Satz. */ +export function baueAktivitaet(tickets, jetzt = new Date()) { + const { sichtbar, aeltere } = gruppiereAuftraege(tickets) + if (!sichtbar.length) return h('p', { class: 'kn-hinweis' }, LEER) + return h('div', { class: 'kn-az-liste', 'data-einklappbar': true }, + h('ul', { class: 'kn-az-auftraege' }, sichtbar.map((a) => baueAuftragsBlock(a, jetzt, false)), aeltere.map((a) => baueAuftragsBlock(a, jetzt, true))), + aeltere.length ? umschalter({ attribut: 'data-aeltere', klasse: 'kn-knopf kn-az-aeltere', mehr: 'Ältere Aufträge anzeigen', weniger: 'Weniger anzeigen' }) : null) +} diff --git a/public/neu/website-details.js b/public/neu/website-details.js index 7d5994b..656fe14 100644 --- a/public/neu/website-details.js +++ b/public/neu/website-details.js @@ -1,4 +1,4 @@ -/* Kundenansicht neu (P2): Bausteine der Projektdetails und der Aktivität (Auftrag, Änderungen, Arbeiten, Dateien). +/* Kundenansicht neu (P2): Bausteine der Projektdetails (Auftrag, Änderungen, Arbeiten, Dateien); die Aktivität der Website-Seite steht in auftrags-zeitleiste.js. Alles über h() und Textknoten, nie als HTML. Kein DOM-Zugriff beim Import. */ import { api } from './api.js' @@ -8,7 +8,6 @@ import { K, zuAnsicht, fmtBytes, fmtMinuten, fmtUhrzeit, berlinTag } from './ans export const AUFTRAG_SCHRITTE = ['Eingegangen', 'In Arbeit', 'Prüfung', 'Erledigt'] const ANZAHL_AENDERUNGEN = 5 const ANZAHL_EINTRAEGE = 4 -const ANZAHL_AKTIVITAET = 3 const TON = { new: 'grau', active: 'blau', waiting: 'orange', review: 'violett', done: 'gruen', paused: 'grau', stopped: 'rot' } /** Farbton der Status-Pille zum Ton des Servers. */ @@ -136,24 +135,6 @@ export function baueDateien(files) { h('tbody', null, zeilen)))) } -/** Aktivität: je Auftrag Kopf und Arbeiten, die drei neuesten sichtbar, „Alle anzeigen (n)“ zeigt den Rest. */ -export function baueAktivitaet(tickets, jetzt = new Date()) { - const liste = Array.isArray(tickets) ? tickets : [] - if (!liste.length) return leerText('Es wurden noch keine Arbeiten dokumentiert.') - const sortiert = [...liste].sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || ''))) - const behaelter = h('div', { class: 'kn-aktivitaet' }) - const zeichne = (alle) => { - const knopf = sortiert.length > ANZAHL_AKTIVITAET - ? h('button', { type: 'button', class: K.knopf, on: { click: () => zeichne(!alle) } }, alle ? 'Weniger anzeigen' : `Alle anzeigen (${sortiert.length})`) - : null - behaelter.replaceChildren( - ...sortiert.slice(0, alle ? sortiert.length : ANZAHL_AKTIVITAET).map((t) => h('article', { class: 'kn-auftrag-karte' }, baueAuftrag(t), baueArbeiten(t.entries, 'Noch keine Arbeiten dokumentiert.'))), - ...(knopf ? [knopf] : [])) - } - zeichne(false) - return behaelter -} - /** Die drei Quellen der Projektdetails; jede einzeln, ein Fehler ergibt null. */ export async function ladeProjektDetails(projectId, apiFn = api) { const id = encodeURIComponent(projectId) diff --git a/test/knAnsichtQuelltext.test.js b/test/knAnsichtQuelltext.test.js index 30a27a3..2e1d669 100644 --- a/test/knAnsichtQuelltext.test.js +++ b/test/knAnsichtQuelltext.test.js @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url' const WURZEL = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') const NEU = path.join(WURZEL, 'public', 'neu') -const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort', 'bestell-bausteine'] +const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'auftrags-zeitleiste', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort', 'bestell-bausteine'] // Seit P7 sprechen auch die vier Bestellseiten „du“ (Fassung 6, C14); einzige Ausnahme ist der Rechtstext TAKEOVER_CONDITIONS (hash-gepinnt) const WORTLAUT = [] const BESTELLSEITEN = ['ansicht-bestellen', 'ansicht-wechsel', 'ansicht-speicher', 'ansicht-gutschrift', 'bestell-bausteine'] diff --git a/test/knAnsichtStil.test.js b/test/knAnsichtStil.test.js index 6ebcd90..6d23fc4 100644 --- a/test/knAnsichtStil.test.js +++ b/test/knAnsichtStil.test.js @@ -6,7 +6,7 @@ import { K } from '../public/neu/ansicht-hilfen.js' const css = (f) => readFileSync(new URL(`../public/neu/${f}`, import.meta.url), 'utf8') const lese = (p) => readFileSync(new URL(`../public/${p}`, import.meta.url), 'utf8') const A = css('ansichten.css'); const O = css('oeffentlich/oeffentlich.css'); const C = css('chat-composer.css'); const BASIS = css('basis.css'); const TOKENS = css('tokens.css') -const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort', 'bestell-bausteine'] +const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'auftrags-zeitleiste', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort', 'bestell-bausteine'] const existiert = (n) => existsSync(new URL(`../public/neu/${n}.js`, import.meta.url)) const lies = (n) => css(`${n}.js`).replace(/\/\*[\s\S]*?\*\//g, '') const VERBOTEN = /linear-gradient|radial-gradient|conic-gradient|backdrop-filter|prefers-color-scheme:\s*dark|font-weight:\s*800|#9d3dfe|rgba?\(\s*157\s*,\s*61/i diff --git a/test/knAnsichtWebsite.test.js b/test/knAnsichtWebsite.test.js index 26b2069..1ed3dfe 100644 --- a/test/knAnsichtWebsite.test.js +++ b/test/knAnsichtWebsite.test.js @@ -32,6 +32,22 @@ test('Website: Aktivität fällt aus, die Projekte bleiben', mit({ ...HAUPT, 'GE const w = await zeige(fx) assert.match(fx.text(w), /Aktivität konnte nicht geladen werden: Server kaputt/); assert.ok(fx.finde(w, '[data-projekt="p1"]')) })) +const ZEIT_AUFTRAEGE = [ + { number: '100207', topic: 'Öffnungszeiten aktualisieren', createdAt: '2026-10-02T10:00:00.000Z', status: { label: 'Erledigt', tone: 'done', step: 4 }, entries: [{ kind: 'online', label: 'Online erledigt', date: '2026-10-04', minutes: 50, person: 'Lena Vogel', text: 'Neue Öffnungszeiten eingetragen.' }] }, + { number: '100231', topic: 'Neue Fotos für die Startseite', createdAt: '2026-10-13T10:00:00.000Z', status: { label: 'Wartet auf deine Rückmeldung', tone: 'waiting', step: 2 }, entries: [] }, +] +test('Website: „Was wir zuletzt erledigt haben“ ist eine Zeitleiste, laufende Aufträge zuerst, Rückfrage mit Link in den Chat', mit({ ...HAUPT, 'GET /api/activity': { tickets: ZEIT_AUFTRAEGE } }, async (fx) => { + const w = await zeige(fx) + const abschnitt = fx.finde(w, '[data-abschnitt="aktivitaet"]') + assert.equal(fx.finde(abschnitt, 'h2').textContent, 'Was wir zuletzt erledigt haben') + assert.deepEqual(fx.alle(abschnitt, '[data-auftrag]').map((b) => b.getAttribute('data-auftrag')), ['100231', '100207']) + assert.equal(fx.finde(abschnitt, '.kn-az-info').textContent, 'Wir brauchen eine Info von dir · Im Chat antworten') + assert.equal(fx.finde(abschnitt, '.kn-az-info a').getAttribute('href'), '#chat') + assert.match(fx.finde(abschnitt, '[data-auftrag="100207"]').textContent, /04\.10\.Neue Öffnungszeiten eingetragen\./) +})) +test('Website: ohne Aufträge steht der ruhige Satz', mit(HAUPT, async (fx) => { + assert.match(fx.text(fx.finde(await zeige(fx), '[data-abschnitt="aktivitaet"]')), /Es wurden noch keine Arbeiten dokumentiert\./) +})) test('Website: leer', mit({ ...HAUPT, 'GET /api/projects': { projects: [] } }, async (fx) => { assert.match(fx.text(await zeige(fx)), /Noch keine Website-Projekte zugewiesen\./) })) test('Website: offene Rechnung steht als erste Zeile', mit({ ...HAUPT, 'GET /api/invoices': { invoices: [rechnung({ number: 'RE-2026-0042', balance: 50 })] } }, async (fx) => { const w = await zeige(fx); assert.match(fx.finde(w, '.kn-rechnung-offen').textContent, /^Rechnung offen · RE-2026-0042/) diff --git a/test/knAnsichtWebsiteDetails.test.js b/test/knAnsichtWebsiteDetails.test.js index ca10d3d..3b02f4a 100644 --- a/test/knAnsichtWebsiteDetails.test.js +++ b/test/knAnsichtWebsiteDetails.test.js @@ -1,7 +1,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { - AUFTRAG_SCHRITTE, statusTon, tagesUeberschrift, baueAuftrag, baueAenderungen, baueArbeiten, baueDateien, baueAktivitaet, ladeProjektDetails, baueProjektDetails, + AUFTRAG_SCHRITTE, statusTon, tagesUeberschrift, baueAuftrag, baueAenderungen, baueArbeiten, baueDateien, ladeProjektDetails, baueProjektDetails, } from '../public/neu/website-details.js' import { mit, mitDom } from './support/knDaten.js' @@ -45,15 +45,7 @@ test('Auftrag: Nummer, Betreff, Status, Fortschritt, Hinweis bei Rückfrage', () assert.match(schlicht.textContent, /Dein Auftrag/); assert.equal(fx.alle(schlicht, 'li').length, 0); assert.doesNotMatch(schlicht.textContent, /Wir brauchen/) })) -test('Aktivität: neueste zuerst, drei sichtbar, „Alle anzeigen (n)“ klappt auf; fremde Inhalte bleiben Text', () => mitDom((fx) => { - const t = (n, createdAt, topic) => ({ number: String(n), topic, createdAt, status: { label: 'Offen', tone: 'active', step: 2 }, entries: [] }) - const el = baueAktivitaet([t(1, '2026-10-01T00:00:00Z', 'Eins'), t(4, '2026-10-04T00:00:00Z', ''), t(2, '2026-10-02T00:00:00Z', 'Zwei'), t(3, '2026-10-03T00:00:00Z', 'Drei')], new Date('2026-10-16T09:00:00Z')) - assert.equal(fx.alle(el, 'article').length, 3); assert.match(el.textContent, /Auftrag 4/); assert.doesNotMatch(el.textContent, /Auftrag 1/) - assert.equal(fx.finde(el, 'img'), null) - const alle = fx.finde(el, (e) => e.textContent === 'Alle anzeigen (4)'); assert.ok(alle) - fx.klick(alle); assert.equal(fx.alle(el, 'article').length, 4); assert.ok(fx.finde(el, (e) => e.tagName === 'BUTTON' && e.textContent === 'Weniger anzeigen')) - assert.equal(baueAktivitaet([], new Date()).textContent, 'Es wurden noch keine Arbeiten dokumentiert.') -})) +// Die Aktivität der Website-Seite (Zeitleiste) prüfen knAuftragsZeitleiste.test.js und knAuftragsZeitleisteDom.test.js. test('Arbeiten: Unterstützung, Änderungsliste und Text; Zeitangaben', () => mitDom((fx) => { const el = baueArbeiten([{ date: '2026-09-20', kind: 'update', label: 'Aktualisierung', person: 'Kenso', minutes: 90, changes: ['Menü angepasst'], supports: [{ name: 'Justin', task: 'Bilder', minutes: 15 }] }, diff --git a/test/knAuftragsZeitleiste.test.js b/test/knAuftragsZeitleiste.test.js new file mode 100644 index 0000000..a2d0268 --- /dev/null +++ b/test/knAuftragsZeitleiste.test.js @@ -0,0 +1,132 @@ +// „Was wir zuletzt erledigt haben“ als Zeitleiste (Variante C, Pilot-Feedback 17.10.): reine Logik (Fortschritt, Reihenfolge, Gruppierung, Kürzen). +// Antwortform von GET /api/activity laut server/routes/customerData.js und services/customerProjectView.js. Der DOM-Bau steht in knAuftragsZeitleisteDom.test.js. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + MAX_AUFTRAEGE, MAX_ZEILEN, STUFEN, fortschritt, braucheInfo, letzteAktivitaet, sortiereAuftraege, gruppiereAuftraege, kuerze, + eintragsKurztext, datumKurz, zeitleistenZeilen, +} from '../public/neu/auftrags-zeitleiste.js' +import { AUFTRAG_SCHRITTE } from '../public/neu/website-details.js' + +const JETZT = new Date('2026-10-16T09:00:00Z') +const ST = { + neu: { label: 'Eingegangen', tone: 'new', step: 1 }, + arbeit: { label: 'In Arbeit', tone: 'active', step: 2 }, + wartet: { label: 'Wartet auf deine Rückmeldung', tone: 'waiting', step: 2 }, + pruefung: { label: 'Wird geprüft', tone: 'review', step: 3 }, + fertig: { label: 'Erledigt', tone: 'done', step: 4 }, + pause: { label: 'Pausiert', tone: 'paused', step: 0 }, + storniert: { label: 'Storniert', tone: 'stopped', step: 0 }, +} +const auftrag = (nr, st, createdAt, entries = [], topic = `Thema ${nr}`) => ({ number: String(nr), topic, status: ST[st], createdAt, entries }) +const eintrag = (date, text, over = {}) => ({ kind: 'online', label: 'Online erledigt', date, minutes: 15, person: 'Lena Vogel', text, ...over }) +const nummern = (liste) => liste.map((a) => a.number) + +/* ------------------------------------------------------------------ reine Logik --- */ + +test('Stufen: dieselben vier Namen wie der Fortschritt der Projektdetails', () => { + assert.deepEqual(STUFEN, ['Eingegangen', 'In Arbeit', 'Prüfung', 'Erledigt']) + assert.deepEqual(STUFEN, AUFTRAG_SCHRITTE) +}) + +test('Fortschritt: erreichte Abschnitte bis zum aktuellen, Stufe 0 ist leer', () => { + const stand = (step) => fortschritt({ step }).map((s) => [s.erreicht, s.aktuell]) + assert.deepEqual(stand(1), [[true, true], [false, false], [false, false], [false, false]]) + assert.deepEqual(stand(2), [[true, false], [true, true], [false, false], [false, false]]) + assert.deepEqual(stand(3), [[true, false], [true, false], [true, true], [false, false]]) + assert.deepEqual(stand(4), [[true, false], [true, false], [true, false], [true, true]]) + assert.deepEqual(fortschritt({ step: 4 }).map((s) => s.name), STUFEN) + for (const leer of [0, undefined, null, 'x', -1, 9]) assert.deepEqual(fortschritt({ step: leer }).map((s) => s.erreicht), [false, false, false, false], `step ${String(leer)}`) + assert.deepEqual(fortschritt(null).map((s) => s.erreicht), [false, false, false, false]) +}) + +test('Info vom Kunden nötig: genau der Ton „waiting“ (Status „awaiting“ des Servers)', () => { + assert.equal(braucheInfo(auftrag(1, 'wartet', '2026-10-01T00:00:00Z')), true) + for (const st of ['neu', 'arbeit', 'pruefung', 'fertig', 'pause', 'storniert']) assert.equal(braucheInfo(auftrag(1, st, '2026-10-01T00:00:00Z')), false, st) + assert.equal(braucheInfo({}), false); assert.equal(braucheInfo(null), false) +}) + +test('Letzte Aktivität: der jüngste Eintrag oder die Anlage des Auftrags, je nachdem, was später ist', () => { + const a = auftrag(1, 'arbeit', '2026-10-01T10:00:00Z', [eintrag('2026-10-05', 'x'), eintrag('2026-10-09', 'y'), eintrag('', 'ohne Datum')]) + assert.equal(letzteAktivitaet(a), Date.parse('2026-10-09T00:00:00Z')) + assert.equal(letzteAktivitaet(auftrag(2, 'arbeit', '2026-10-12T10:00:00Z', [eintrag('2026-10-05', 'x')])), Date.parse('2026-10-12T10:00:00Z')) + assert.equal(letzteAktivitaet(auftrag(3, 'arbeit', '', [])), 0) + assert.equal(letzteAktivitaet({ entries: 'kaputt', createdAt: 'nie' }), 0) +}) + +test('Reihenfolge: laufende zuerst (letzte Aktivität), dann erledigte (neueste zuerst), Storniertes ganz hinten', () => { + const liste = [ + auftrag(1, 'fertig', '2026-09-01T00:00:00Z', [eintrag('2026-10-14', 'fertig, aber jüngster Eintrag')]), + auftrag(2, 'arbeit', '2026-10-01T00:00:00Z', [eintrag('2026-10-03', 'alt')]), + auftrag(3, 'storniert', '2026-10-15T00:00:00Z'), + auftrag(4, 'wartet', '2026-10-02T00:00:00Z', [eintrag('2026-10-13', 'neuer')]), + auftrag(5, 'fertig', '2026-09-20T00:00:00Z', [eintrag('2026-09-25', 'älter')]), + auftrag(6, 'pause', '2026-10-04T00:00:00Z'), + auftrag(7, 'pruefung', '2026-10-05T00:00:00Z'), + auftrag(8, 'neu', '2026-10-12T00:00:00Z'), + ] + assert.deepEqual(nummern(sortiereAuftraege(liste)), ['4', '8', '7', '6', '2', '1', '5', '3']) + assert.deepEqual(nummern(liste), ['1', '2', '3', '4', '5', '6', '7', '8'], 'die Eingabe bleibt unverändert') +}) + +test('Reihenfolge: bei gleicher Aktivität bleibt die Reihenfolge des Servers', () => { + const liste = ['9', '8', '7'].map((n) => auftrag(n, 'arbeit', '2026-10-10T00:00:00Z')) + assert.deepEqual(nummern(sortiereAuftraege(liste)), ['9', '8', '7']) +}) + +test('Gruppierung: höchstens fünf Aufträge sichtbar, der Rest ist „älter“', () => { + assert.equal(MAX_AUFTRAEGE, 5) + const liste = Array.from({ length: 7 }, (_, i) => auftrag(100 + i, 'arbeit', `2026-10-0${i + 1}T00:00:00Z`)) + const g = gruppiereAuftraege(liste) + assert.deepEqual(nummern(g.sichtbar), ['106', '105', '104', '103', '102']); assert.deepEqual(nummern(g.aeltere), ['101', '100']) + const fuenf = gruppiereAuftraege(liste.slice(0, 5)); assert.equal(fuenf.sichtbar.length, 5); assert.deepEqual(fuenf.aeltere, []) + assert.deepEqual(gruppiereAuftraege([]), { sichtbar: [], aeltere: [] }); assert.deepEqual(gruppiereAuftraege(undefined), { sichtbar: [], aeltere: [] }) + assert.equal(gruppiereAuftraege(liste, 2).aeltere.length, 5) +}) + +test('Gruppierung: ein erledigter Auftrag rutscht hinter laufende, auch wenn er neuer ist', () => { + const liste = [auftrag(1, 'fertig', '2026-10-15T00:00:00Z'), ...Array.from({ length: 5 }, (_, i) => auftrag(10 + i, 'arbeit', `2026-09-0${i + 1}T00:00:00Z`))] + const g = gruppiereAuftraege(liste) + assert.deepEqual(nummern(g.aeltere), ['1']) +}) + +test('Kürzen: die ersten n sichtbar, der Rest getrennt; Eingabe bleibt', () => { + assert.equal(MAX_ZEILEN, 3) + const a = [1, 2, 3, 4, 5] + assert.deepEqual(kuerze(a, 3), { sichtbar: [1, 2, 3], rest: [4, 5] }) + assert.deepEqual(kuerze([1, 2], 3), { sichtbar: [1, 2], rest: [] }) + assert.deepEqual(kuerze(undefined, 3), { sichtbar: [], rest: [] }) + assert.deepEqual(a, [1, 2, 3, 4, 5]) +}) + +test('Datum: TT.MM., mit Jahr nur wenn es nicht das laufende ist', () => { + assert.equal(datumKurz('2026-10-02', JETZT), '02.10.') + assert.equal(datumKurz('2025-12-24', JETZT), '24.12.25') + assert.equal(datumKurz('2026-10-02T08:00:00.000Z', JETZT), '02.10.') + for (const kaputt of ['', null, undefined, 'gestern', '2026-13']) assert.equal(datumKurz(kaputt, JETZT), '', String(kaputt)) +}) + +test('Kurztext: Änderungen, freier Text oder Art der Arbeit; nie lang', () => { + assert.equal(eintragsKurztext({ kind: 'update', label: 'Website aktualisiert', changes: ['Menü angepasst'] }), 'Menü angepasst') + assert.equal(eintragsKurztext({ kind: 'update', label: 'Website aktualisiert', changes: ['Menü angepasst', 'Bilder getauscht'] }), 'Menü angepasst und 1 weitere Änderung') + assert.equal(eintragsKurztext({ kind: 'update', label: 'Website aktualisiert', changes: ['A', 'B', 'C', 'D'] }), 'A und 3 weitere Änderungen') + assert.equal(eintragsKurztext({ kind: 'online', label: 'Online erledigt', text: 'Neue Öffnungszeiten eingetragen.' }), 'Neue Öffnungszeiten eingetragen.') + assert.equal(eintragsKurztext({ kind: 'onsite', label: 'Vor Ort bei dir', text: '' }), 'Vor Ort bei dir') + assert.equal(eintragsKurztext({ kind: 'work', label: '', text: '' }), 'Arbeit am Projekt') + assert.equal(eintragsKurztext({ kind: 'online', label: 'Online erledigt', text: 'Erste Zeile\n\nZweite Zeile mit Lücken' }), 'Erste Zeile') + assert.equal(eintragsKurztext({ kind: 'online', label: 'x', text: ' viel Leerraum ' }), 'viel Leerraum') + const lang = eintragsKurztext({ kind: 'online', label: 'x', text: 'Wort '.repeat(60) }) + assert.ok(lang.length <= 91 && lang.endsWith('…') && !lang.includes(' '), lang) + assert.equal(eintragsKurztext(null), 'Arbeit am Projekt') +}) + +test('Zeitleiste: neueste zuerst, Datum TT.MM., ohne Datum ganz unten, keine Namen und Minuten', () => { + const a = auftrag(1, 'arbeit', '2026-10-01T00:00:00Z', [ + eintrag('2026-10-03', 'Alt'), eintrag('2026-10-12', 'Neu', { person: 'Kenso', minutes: 90 }), eintrag('', 'Ohne Datum'), eintrag('2026-10-07', 'Mitte'), + ]) + const zeilen = zeitleistenZeilen(a, JETZT) + assert.deepEqual(zeilen.map((z) => [z.datum, z.text]), [['12.10.', 'Neu'], ['07.10.', 'Mitte'], ['03.10.', 'Alt'], ['', 'Ohne Datum']]) + assert.deepEqual(Object.keys(zeilen[0]).sort(), ['datum', 'iso', 'text']) + assert.equal(zeilen[0].iso, '2026-10-12') + assert.deepEqual(zeitleistenZeilen({ entries: null }, JETZT), []); assert.deepEqual(zeitleistenZeilen(null, JETZT), []) +}) diff --git a/test/knAuftragsZeitleisteDom.test.js b/test/knAuftragsZeitleisteDom.test.js new file mode 100644 index 0000000..2afff6f --- /dev/null +++ b/test/knAuftragsZeitleisteDom.test.js @@ -0,0 +1,183 @@ +// „Was wir zuletzt erledigt haben“ als Zeitleiste (Variante C): der DOM-Bau mit dem Fake-DOM. Die reine Logik prüft knAuftragsZeitleiste.test.js. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { baueAktivitaet } from '../public/neu/auftrags-zeitleiste.js' +import { mitDom } from './support/knDaten.js' +import { customerOrder, customerWorkEntries } from '../server/services/customerProjectView.js' + +const JETZT = new Date('2026-10-16T09:00:00Z') +const ST = { + neu: { label: 'Eingegangen', tone: 'new', step: 1 }, + arbeit: { label: 'In Arbeit', tone: 'active', step: 2 }, + wartet: { label: 'Wartet auf deine Rückmeldung', tone: 'waiting', step: 2 }, + pruefung: { label: 'Wird geprüft', tone: 'review', step: 3 }, + fertig: { label: 'Erledigt', tone: 'done', step: 4 }, + pause: { label: 'Pausiert', tone: 'paused', step: 0 }, + storniert: { label: 'Storniert', tone: 'stopped', step: 0 }, +} +const auftrag = (nr, st, createdAt, entries = [], topic = `Thema ${nr}`) => ({ number: String(nr), topic, status: ST[st], createdAt, entries }) +const eintrag = (date, text, over = {}) => ({ kind: 'online', label: 'Online erledigt', date, minutes: 15, person: 'Lena Vogel', text, ...over }) + +/* ---------------------------------------------------------------------- DOM --- */ + +const dreiAuftraege = () => [ + auftrag(100207, 'fertig', '2026-10-02T10:00:00Z', [eintrag('2026-10-04', 'Neue Öffnungszeiten eingetragen.')], 'Öffnungszeiten aktualisieren'), + auftrag(100231, 'wartet', '2026-10-13T10:00:00Z', [eintrag('2026-10-15', 'Fotos erhalten.'), eintrag('2026-10-13', 'Bildauswahl vorbereitet.')], 'Neue Fotos für die Startseite'), + auftrag(100224, 'pruefung', '2026-10-06T10:00:00Z', [ + eintrag('2026-10-14', 'Speisekarte gesetzt.'), eintrag('2026-10-13', 'Layout geprüft.'), eintrag('2026-10-12', 'Entwurf erstellt.'), eintrag('2026-10-09', 'Texte gesammelt.'), + ], 'Speisekarte als PDF'), +] + +test('Aktivität: ein Block je Auftrag, Titel als Überschrift, Pille in Farbe des Status, Reihenfolge laufend vor erledigt', () => mitDom((fx) => { + const el = baueAktivitaet(dreiAuftraege(), JETZT) + const bloecke = fx.alle(el, '[data-auftrag]') + assert.deepEqual(bloecke.map((b) => b.getAttribute('data-auftrag')), ['100231', '100224', '100207']) + const kopf = (b) => ({ titel: fx.finde(b, 'h3').textContent, pille: fx.finde(b, '.kn-pille').textContent, klasse: fx.finde(b, '.kn-pille').className }) + assert.deepEqual(kopf(bloecke[0]), { titel: 'Neue Fotos für die Startseite', pille: 'Wartet auf deine Rückmeldung', klasse: 'kn-pille kn-pille-orange' }) + assert.deepEqual(kopf(bloecke[1]), { titel: 'Speisekarte als PDF', pille: 'Wird geprüft', klasse: 'kn-pille kn-pille-violett' }) + assert.deepEqual(kopf(bloecke[2]), { titel: 'Öffnungszeiten aktualisieren', pille: 'Erledigt', klasse: 'kn-pille kn-pille-gruen' }) + assert.equal(fx.finde(bloecke[0], 'h3').className, 'kn-az-titel') +})) + +test('Aktivität: In Arbeit ist blau, Eingegangen und Pausiert grau, Storniert rot', () => mitDom((fx) => { + const el = baueAktivitaet([auftrag(1, 'arbeit', '2026-10-05T00:00:00Z'), auftrag(2, 'neu', '2026-10-04T00:00:00Z'), auftrag(3, 'pause', '2026-10-03T00:00:00Z'), auftrag(4, 'storniert', '2026-10-02T00:00:00Z')], JETZT) + assert.deepEqual(fx.alle(el, '.kn-pille').map((p) => p.className.split(' ')[1]), ['kn-pille-blau', 'kn-pille-grau', 'kn-pille-grau', 'kn-pille-rot']) +})) + +test('Aktivität: schmaler Balken mit vier Abschnitten, Stufennamen als Text für Screenreader', () => mitDom((fx) => { + const el = baueAktivitaet(dreiAuftraege(), JETZT) + const [wartet, pruefung, fertig] = fx.alle(el, '[data-auftrag]') + const stand = (b) => fx.alle(fx.finde(b, '.kn-az-balken'), 'li').map((li) => [li.classList.contains('is-done'), li.getAttribute('aria-current')]) + assert.deepEqual(stand(wartet), [[true, null], [true, 'step'], [false, null], [false, null]]) + assert.deepEqual(stand(pruefung), [[true, null], [true, null], [true, 'step'], [false, null]]) + assert.deepEqual(stand(fertig), [[true, null], [true, null], [true, null], [true, 'step']]) + const balken = fx.finde(wartet, '.kn-az-balken'); assert.equal(balken.tagName, 'OL'); assert.equal(balken.getAttribute('aria-label'), 'Fortschritt des Auftrags') + assert.deepEqual(fx.alle(balken, '.kn-nur-lesen').map((s) => s.textContent), ['Eingegangen, erreicht', 'In Arbeit, aktueller Stand', 'Prüfung, noch offen', 'Erledigt, noch offen']) + assert.deepEqual(fx.alle(fertig, '.kn-nur-lesen').map((s) => s.textContent), ['Eingegangen, erreicht', 'In Arbeit, erreicht', 'Prüfung, erreicht', 'Erledigt, aktueller Stand']) +})) + +test('Aktivität: Stufe 0 (pausiert, storniert) zeigt keinen Balken, nur die Pille', () => mitDom((fx) => { + const el = baueAktivitaet([auftrag(1, 'pause', '2026-10-05T00:00:00Z'), auftrag(2, 'storniert', '2026-10-04T00:00:00Z')], JETZT) + assert.equal(fx.alle(el, '[data-auftrag]').length, 2); assert.equal(fx.finde(el, '.kn-az-balken'), null) +})) + +test('Aktivität: Rückfrage steht unter der Überschrift mit Link in den Chat, sonst keine Zeile', () => mitDom((fx) => { + const el = baueAktivitaet(dreiAuftraege(), JETZT) + const [wartet, pruefung, fertig] = fx.alle(el, '[data-auftrag]') + const info = fx.finde(wartet, '.kn-az-info') + assert.equal(info.textContent, 'Wir brauchen eine Info von dir · Im Chat antworten') + // Die ganze Zeile ist der Link in den Chat (eine große Tippfläche am Handy); nur „Im Chat antworten“ ist unterstrichen + const chat = fx.finde(info, 'a'); assert.deepEqual([chat.textContent, chat.getAttribute('href'), chat.getAttribute('data-view')], ['Wir brauchen eine Info von dir · Im Chat antworten', '#chat', 'chat']) + assert.equal(fx.finde(chat, '.kn-az-info-link').textContent, 'Im Chat antworten'); assert.equal(fx.alle(info, 'a').length, 1) + assert.equal(fx.alle(el, '.kn-az-info').length, 1); assert.equal(fx.finde(pruefung, '.kn-az-info'), null); assert.equal(fx.finde(fertig, '.kn-az-info'), null) + // Reihenfolge im Block: Kopf, Info, Balken, Zeitleiste + const klassen = [...wartet.children].map((k) => k.className.split(' ')[0]); assert.deepEqual(klassen, ['kn-az-kopf', 'kn-az-info', 'kn-az-balken', 'kn-az-zeitleiste']) +})) + +test('Aktivität: Auftragsnummer erscheint nicht als Text (ruhiger), bleibt aber als Datenattribut', () => mitDom((fx) => { + const el = baueAktivitaet(dreiAuftraege(), JETZT) + assert.doesNotMatch(fx.text(el), /1002\d\d|Auftrag \d/) +})) + +test('Aktivität: Zeitleiste mit Datum und Kurztext, drei sichtbar, „mehr anzeigen“ klappt den Rest auf', () => mitDom((fx) => { + const el = baueAktivitaet(dreiAuftraege(), JETZT) + const pruefung = fx.alle(el, '[data-auftrag]')[1] + const zeilen = fx.alle(pruefung, '.kn-az-zeitleiste li') + assert.deepEqual(zeilen.map((z) => fx.finde(z, 'time').textContent), ['14.10.', '13.10.', '12.10.', '09.10.']) + assert.deepEqual(zeilen.map((z) => fx.finde(z, '.kn-az-text').textContent), ['Speisekarte gesetzt.', 'Layout geprüft.', 'Entwurf erstellt.', 'Texte gesammelt.']) + assert.equal(fx.finde(zeilen[0], 'time').getAttribute('datetime'), '2026-10-14') + assert.deepEqual(zeilen.map((z) => z.hasAttribute('data-extra')), [false, false, false, true]) + const wrapper = fx.finde(pruefung, '[data-einklappbar]'); assert.ok(wrapper); assert.ok(!wrapper.classList.contains('is-open')) + const mehr = fx.finde(pruefung, '[data-mehr]'); assert.equal(mehr.textContent, 'mehr anzeigen'); assert.equal(mehr.getAttribute('aria-expanded'), 'false') + fx.klick(mehr) + assert.ok(wrapper.classList.contains('is-open')); assert.equal(mehr.textContent, 'weniger anzeigen'); assert.equal(mehr.getAttribute('aria-expanded'), 'true') + fx.klick(mehr) + assert.ok(!wrapper.classList.contains('is-open')); assert.equal(mehr.textContent, 'mehr anzeigen'); assert.equal(mehr.getAttribute('aria-expanded'), 'false') + // genau drei Zeilen: kein Knopf; weniger: kein Knopf + assert.equal(fx.finde(fx.alle(el, '[data-auftrag]')[0], '[data-mehr]'), null) + assert.equal(fx.alle(el, '[data-mehr]').length, 1) +})) + +test('Aktivität: Auftrag ohne Einträge zeigt einen ruhigen Satz statt einer leeren Zeitleiste', () => mitDom((fx) => { + const el = baueAktivitaet([auftrag(1, 'arbeit', '2026-10-05T00:00:00Z', [])], JETZT) + assert.equal(fx.finde(el, '.kn-az-zeitleiste'), null); assert.match(fx.text(el), /Noch keine Arbeiten dokumentiert\./) +})) + +test('Aktivität: fünf Aufträge sichtbar, „Ältere Aufträge anzeigen“ klappt den Rest auf', () => mitDom((fx) => { + const liste = Array.from({ length: 8 }, (_, i) => auftrag(200 + i, i === 7 ? 'fertig' : 'arbeit', `2026-10-0${i + 1}T00:00:00Z`, [eintrag(`2026-10-0${i + 1}`, `Eintrag ${i}`)])) + const el = baueAktivitaet(liste, JETZT) + const bloecke = fx.alle(el, '[data-auftrag]') + assert.equal(bloecke.length, 8) + assert.deepEqual(bloecke.map((b) => b.hasAttribute('data-extra')), [false, false, false, false, false, true, true, true]) + assert.deepEqual(bloecke.map((b) => b.getAttribute('data-auftrag')), ['206', '205', '204', '203', '202', '201', '200', '207']) + const alt = fx.finde(el, '[data-aeltere]'); assert.equal(alt.textContent, 'Ältere Aufträge anzeigen'); assert.equal(alt.getAttribute('aria-expanded'), 'false') + const huelle = alt.closest('[data-einklappbar]'); assert.ok(!huelle.classList.contains('is-open')) + fx.klick(alt); assert.ok(huelle.classList.contains('is-open')); assert.equal(alt.textContent, 'Weniger anzeigen'); assert.equal(alt.getAttribute('aria-expanded'), 'true') + fx.klick(alt); assert.ok(!huelle.classList.contains('is-open')); assert.equal(alt.textContent, 'Ältere Aufträge anzeigen') + assert.equal(fx.finde(baueAktivitaet(liste.slice(0, 5), JETZT), '[data-aeltere]'), null) +})) + +test('Aktivität: Zeilen-Knopf und Aufträge-Knopf sind getrennt (mehr anzeigen öffnet nicht die älteren Aufträge)', () => mitDom((fx) => { + const viele = Array.from({ length: 6 }, (_, i) => auftrag(300 + i, 'arbeit', `2026-10-0${i + 1}T00:00:00Z`, ['a', 'b', 'c', 'd'].map((t, j) => eintrag(`2026-10-1${j}`, t)))) + const el = baueAktivitaet(viele, JETZT) + const huelle = fx.finde(el, '[data-aeltere]').closest('[data-einklappbar]') + const ersterMehr = fx.finde(fx.alle(el, '[data-auftrag]')[0], '[data-mehr]') + fx.klick(ersterMehr); assert.ok(!huelle.classList.contains('is-open')); assert.ok(ersterMehr.closest('[data-einklappbar]').classList.contains('is-open')) +})) + +test('Aktivität: fremde Inhalte bleiben Text; leer und kaputte Elemente', () => mitDom((fx) => { + const boese = '' + const el = baueAktivitaet([auftrag(1, 'arbeit', '2026-10-05T00:00:00Z', [eintrag('2026-10-06', boese), eintrag('2026-10-05', 'x', { changes: [boese] })], boese)], JETZT) + assert.equal(fx.finde(el, 'img'), null); assert.ok(fx.text(el).includes(boese)) + assert.equal(baueAktivitaet([], JETZT).textContent, 'Es wurden noch keine Arbeiten dokumentiert.') + assert.equal(baueAktivitaet(undefined, JETZT).textContent, 'Es wurden noch keine Arbeiten dokumentiert.') + const roh = baueAktivitaet([{ number: '9' }, { topic: 'Nur Titel', status: null, entries: null }], JETZT) + assert.equal(fx.alle(roh, '.kn-az').length, 2); assert.match(fx.text(roh), /Dein Auftrag/); assert.doesNotMatch(fx.text(roh), /null|undefined/) +})) + +// Vertrag mit dem Server: GET /api/activity baut jeden Auftrag aus customerOrder() und customerWorkEntries() (server/routes/customerData.js). +// Hier läuft die echte Server-Aufbereitung in die Zeitleiste, damit eine geänderte Antwortform den Test rot macht. +test('Vertrag: Aufträge in der Form von /api/activity (customerOrder, customerWorkEntries) erscheinen mit Datum und Kurztext', () => mitDom((fx) => { + const ticket = (woid, status, createdAt, worksheets) => ({ woid, topic: `Thema ${woid}`, status, createdAt, worksheets }) + const antwort = [ + ticket('100231', 'awaiting', '2026-10-13T10:00:00.000Z', [ + { serviceType: 'REMOTE', date: '14.10.2026', totalTime: 50, employeeName: 'Lena Vogel', details: 'Fotos erhalten und gesichtet.\nWeitere Zeile.', supporters: '' }, + { serviceType: 'GIT', date: '2026-10-12', totalTime: 0, isComment: true, details: '- [abcdef1](https://git.example/x/commit/abcdef1): feat: Menü angepasst (Kenso)\n- [abcdef2](https://git.example/x/commit/abcdef2): Bilder getauscht (Kenso)' }, + ]), + ticket('100207', 'closed', '2026-10-02T10:00:00.000Z', [{ serviceType: 'ON SITE', date: '04.10.2026', totalTime: 60, employeeName: 'Kenso', details: '' }]), + ].map((t) => ({ ...customerOrder(t), createdAt: t.createdAt, entries: customerWorkEntries(t.worksheets) })) + const el = baueAktivitaet(antwort, JETZT) + const [wartet, fertig] = fx.alle(el, '[data-auftrag]') + assert.deepEqual([wartet.getAttribute('data-auftrag'), fertig.getAttribute('data-auftrag')], ['100231', '100207']) + assert.equal(fx.finde(wartet, '.kn-pille').textContent, 'Wartet auf deine Rückmeldung'); assert.ok(fx.finde(wartet, '.kn-az-info')) + assert.deepEqual(fx.alle(wartet, '.kn-az-zeilen li').map((z) => z.textContent), ['14.10.Fotos erhalten und gesichtet.', '12.10.Menü angepasst und 1 weitere Änderung']) + assert.equal(fx.finde(fertig, '.kn-pille').textContent, 'Erledigt'); assert.deepEqual(fx.alle(fertig, '.kn-az-zeilen li').map((z) => z.textContent), ['04.10.Vor Ort bei dir']) +})) + +// Regression (Browserlauf 17.10.): Die allgemeine Regel `.kn-einklappbar:not(.is-open) [data-extra]` trifft jeden Nachfahren. Läge sie an den verschachtelten Wrappern +// (Aufträge, darin die Zeitleisten), verbärge ein zugeklappter äußerer Wrapper auch die Zeilen einer aufgeklappten Zeitleiste. Darum tragen beide Wrapper eigene Klassen +// und eigene Regeln nur für direkte Kinder. +test('Einklappen: die Wrapper verschachteln sich nicht über .kn-einklappbar, die Regeln gelten nur für direkte Kinder', () => mitDom((fx) => { + const viele = Array.from({ length: 6 }, (_, i) => auftrag(400 + i, 'arbeit', `2026-10-0${i + 1}T00:00:00Z`, ['a', 'b', 'c', 'd'].map((t, j) => eintrag(`2026-10-1${j}`, t)))) + const el = baueAktivitaet(viele, JETZT) + assert.deepEqual(fx.alle(el, '[data-einklappbar]').map((w) => w.classList.contains('kn-einklappbar')), new Array(7).fill(false)) + assert.equal(el.classList.contains('kn-az-liste'), true); assert.equal(fx.finde(el, '[data-einklappbar] [data-einklappbar]').classList.contains('kn-az-zeitleiste'), true) + const css = readFileSync(new URL('../public/neu/ansichten.css', import.meta.url), 'utf8') + assert.match(css, /\.kn-az-liste:not\(\.is-open\) > \.kn-az-auftraege > \[data-extra\] \{ display: none; \}/) + assert.match(css, /\.kn-az-zeitleiste:not\(\.is-open\) > \.kn-az-zeilen > \[data-extra\] \{ display: none; \}/) +})) + +// Browserlauf 17.10.: Ein display:flex am Link zerlegte den Satz in zwei Spalten (Text | „Im Chat antworten“). Der Link ist ein Block; die Tippfläche kommt aus dem Innenabstand. +test('Rückfrage-Link: am Handy ein Block (kein flex) mit Innenabstand für 44 px Tippfläche', () => { + const css = readFileSync(new URL('../public/neu/ansichten.css', import.meta.url), 'utf8') + assert.match(css, /\n \.kn-az-info a \{ display: block; padding: 12px 0; \}/) + assert.doesNotMatch(css, /\.kn-az-info a \{[^}]*display: *(inline-)?flex/) +}) + +test('Zeitleiste: trägt ein Eintrag das Jahr (Vorjahr), richten sich alle Datumsspalten des Auftrags danach', () => mitDom((fx) => { + const mitJahr = baueAktivitaet([auftrag(1, 'arbeit', '2026-10-05T00:00:00Z', [eintrag('2026-10-06', 'neu'), eintrag('2025-12-24', 'alt')])], JETZT) + assert.deepEqual(fx.alle(mitJahr, 'time').map((t) => t.textContent), ['06.10.', '24.12.25']); assert.ok(fx.finde(mitJahr, '.kn-az-zeilen').classList.contains('kn-az-mit-jahr')) + const ohne = baueAktivitaet([auftrag(2, 'arbeit', '2026-10-05T00:00:00Z', [eintrag('2026-10-06', 'neu')])], JETZT) + assert.ok(!fx.finde(ohne, '.kn-az-zeilen').classList.contains('kn-az-mit-jahr')) +}))