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'))
+}))