diff --git a/public/neu/auftrags-zeitleiste.js b/public/neu/auftrags-zeitleiste.js new file mode 100644 index 0000000..bfa7e09 --- /dev/null +++ b/public/neu/auftrags-zeitleiste.js @@ -0,0 +1,113 @@ +/* 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. + 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' + +/** 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 })) +} 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), []) +})