From f231f4b88796d8c692fe8186f9d146ce220c383d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:05:48 +0000 Subject: [PATCH] =?UTF-8?q?Kundenansicht=20neu=20(Auftr=C3=A4ge):=20Logik?= =?UTF-8?q?=20der=20Zeitleiste=20(Fortschritt,=20Reihenfolge,=20Gruppierun?= =?UTF-8?q?g,=20K=C3=BCrzen,=20Kurztexte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reine Funktionen in public/neu/auftrags-zeitleiste.js: laufende Aufträge vor erledigten (jeweils jüngste Aktivität zuerst, Storniertes zuletzt), höchstens fünf Aufträge offen, vier Fortschrittsabschnitte aus status.step, Zeilen der Zeitleiste neueste zuerst mit Datum TT.MM. und Kurztext. Test zuerst (rot: Modul fehlte), dann grün. Co-Authored-By: Claude Sonnet 5.5 --- public/neu/auftrags-zeitleiste.js | 113 +++++++++++++++++++++++++ test/knAuftragsZeitleiste.test.js | 132 ++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 public/neu/auftrags-zeitleiste.js create mode 100644 test/knAuftragsZeitleiste.test.js 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), []) +})