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 7467b6a..6e459f9 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,28 @@
.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-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; color: var(--kn-orange-fg); }
+.kn-az-info a { font-weight: 600; text-decoration-color: currentColor; }
+.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; border-radius: 2px; background: var(--kn-line); }
+.kn-az-balken li.is-done { 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-text { min-width: 0; overflow-wrap: anywhere; }
+.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; }
@@ -357,6 +376,8 @@
.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 a { display: inline-flex; align-items: center; min-height: 44px; }
+ .kn-az-info { margin-top: 4px; }
.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
index bfa7e09..81baad7 100644
--- a/public/neu/auftrags-zeitleiste.js
+++ b/public/neu/auftrags-zeitleiste.js
@@ -1,11 +1,13 @@
/* 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. Dieser Teil ist reine Logik (Reihenfolge, Gruppierung, Kürzen, Fortschritt, Kurztexte) ohne DOM-Zugriff.
+ 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 { berlinTag } from './ansicht-hilfen.js'
-import { AUFTRAG_SCHRITTE } from './website-details.js'
+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
@@ -111,3 +113,61 @@ export function zeitleistenZeilen(auftrag, jetzt = new Date()) {
.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]. */
+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 kn-einklappbar', 'data-einklappbar': true },
+ h('ol', { class: '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),
+ braucheInfo(auftrag) ? h('p', { class: 'kn-az-info' }, 'Wir brauchen eine Info von dir · ', h('a', zuAnsicht('chat'), '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 kn-einklappbar', '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/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/knAuftragsZeitleisteDom.test.js b/test/knAuftragsZeitleisteDom.test.js
new file mode 100644
index 0000000..cf4ddaa
--- /dev/null
+++ b/test/knAuftragsZeitleisteDom.test.js
@@ -0,0 +1,133 @@
+// „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 { baueAktivitaet } from '../public/neu/auftrags-zeitleiste.js'
+import { mitDom } from './support/knDaten.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')
+ const chat = fx.finde(info, 'a'); assert.deepEqual([chat.textContent, chat.getAttribute('href'), chat.getAttribute('data-view')], ['Im Chat antworten', '#chat', 'chat'])
+ 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/)
+}))