Der „Was ist neu?"-Knopf stand im Diagramm-Kopf und war verborgen, solange es nichts gab — er konnte also nur etwas über das offene Dokument sagen, und meistens sagte er gar nichts. Jetzt steht er permanent in der oberen Bedienleiste und öffnet ein Popup mit der Chronik der letzten Tage; je Tag führt ein Link die betroffenen Knoten im Diagramm vor (dieselbe gelbe Kranz-Ansicht wie D28, nur mit einer anderen Frage). Zwei Quellen, jede in ihrer Rolle: docs/CHANGELOG.md sagt, WAS geschehen ist (englisch — das Popup ist Oberfläche in neun Sprachen, die Commit-Betreffs sind deutsch); die git-Historie des mitgelieferten Plans sagt, WELCHE Knoten sich bewegt haben. Beides wird zur Bauzeit eingelesen (Vite-Plugin), zur Laufzeit lädt Werkbaum weiterhin nichts nach. Bernstein heißt ungesehen, Petrol heißt „wird gerade vorgeführt". Der Besuchsvergleich (D28) steht als abgesetzter Abschnitt zuoberst im Popup und trägt den „gesehen"-Knopf, den vorher der Knopf selbst war. SPEC §9 (Neuigkeiten) und D58; 20 neue Tests, davon einer auf der ausgelieferten CHANGELOG.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
131 lines
6.2 KiB
JavaScript
131 lines
6.2 KiB
JavaScript
/* Neuigkeiten für das Popup (D58) — aus zwei Quellen, jede in ihrer Rolle:
|
|
*
|
|
* docs/CHANGELOG.md → WAS geschehen ist, als englischer Satz je Änderung.
|
|
* git-Historie → WELCHE Knoten des mitgelieferten Plans sich an
|
|
* diesem Tag bewegt haben (für die Vorführung im
|
|
* Diagramm, „Was ist neu?"-Ansicht, D28).
|
|
*
|
|
* Warum die Notizen nicht aus den Commit-Betreffs kommen, obwohl sie dort
|
|
* stünden: Die Betreffs sind **deutsch** (CLAUDE.md: Doku auf Deutsch), das
|
|
* Popup aber ist Produkt-Oberfläche in neun Sprachen. Der Changelog ist
|
|
* englisch wie der mitgelieferte Plan und `llms.md` — ausgelieferte Artefakte
|
|
* mit weltweitem Publikum (D22, D43). Preis: eine Datei, die beim Bauen eines
|
|
* Features mitgeschrieben werden muss.
|
|
*
|
|
* Zwei Hälften, nach der Hausregel getrennt (frontend/CLAUDE.md): Was
|
|
* ENTSCHEIDBAR ist, steht hier als reine Funktion und ist getestet
|
|
* (frontend/tests/news.test.js); was git und Dateisystem liefern, steht in
|
|
* `collectNews()` ganz unten und läuft nur zur Bauzeit (Vite-Plugin).
|
|
*
|
|
* Das Ergebnis ist ein Array, neueste zuerst:
|
|
* [{date: '2026-08-24', lines: ['…', …], keys: ['… > Notation', …]}]
|
|
* `keys` sind Label-Pfade — dieselbe Identität wie „Was ist neu?" (D28).
|
|
*/
|
|
|
|
export const PLAN_FILE = 'docs/examples/werkbaum.werkbaum';
|
|
export const CHANGELOG_FILE = 'docs/CHANGELOG.md';
|
|
export const MAX_DAYS = 20; /* so weit reicht das Popup zurück */
|
|
|
|
const RE_DAY = /^##\s+(\d{4}-\d{2}-\d{2})\s*$/;
|
|
const RE_ITEM = /^[-*]\s+(.*\S)\s*$/;
|
|
|
|
/* `docs/CHANGELOG.md` → Tageseinträge, neueste zuerst. Erkannt wird genau
|
|
zweierlei: eine Überschrift `## JJJJ-MM-TT` eröffnet einen Tag, eine
|
|
Aufzählungszeile darunter ist eine Notiz. Alles andere ist Fließtext für
|
|
Menschen und wird überlesen — so kann die Datei oben erklären, was sie ist,
|
|
ohne dass daraus Notizen werden. Kein Deckel je Tag: Die Datei ist gepflegt,
|
|
ihr Autor entscheidet, wie viel ein Tag hergibt. */
|
|
export function parseChangelog(md, opts){
|
|
const {maxDays = MAX_DAYS} = opts || {};
|
|
const byDay = new Map();
|
|
let day = null;
|
|
for(const raw of String(md || '').split('\n')){
|
|
const kopf = raw.match(RE_DAY);
|
|
if(kopf){ day = kopf[1]; if(!byDay.has(day)) byDay.set(day, []); continue; }
|
|
if(/^#{1,6}\s/.test(raw)){ day = null; continue; } /* andere Überschrift beendet den Tag */
|
|
if(!day) continue;
|
|
const item = raw.match(RE_ITEM);
|
|
if(item) byDay.get(day).push(item[1]);
|
|
}
|
|
return [...byDay.entries()]
|
|
.filter(([, lines]) => lines.length)
|
|
.sort((a, b) => a[0] < b[0] ? 1 : -1)
|
|
.slice(0, maxDays)
|
|
.map(([date, lines]) => ({date, lines}));
|
|
}
|
|
|
|
/* Knoten, die sich zwischen zwei Fassungen des Plans bewegt haben: neu
|
|
hinzugekommen oder mit anderem Status. `prev == null` (die erste bekannte
|
|
Fassung) ergibt die leere Menge — dieselbe Zurückhaltung wie beim
|
|
Erstkontakt in D28, sonst leuchtete der halbe Plan als „Änderung von damals". */
|
|
export function changedKeys(prev, curr){
|
|
if(!prev) return [];
|
|
const out = [];
|
|
for(const [key, st] of curr){
|
|
if(!prev.has(key) || prev.get(key) !== st) out.push(key);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/* Tageseinträge + Fassungen des Plans (chronologisch, je `{date, status}` mit
|
|
`status` = Map Schlüssel → Status-Key) → dieselben Einträge mit `keys`.
|
|
Ein Tag, an dem sich nur der Plan bewegt hat, bekommt einen **eigenen**
|
|
Eintrag ohne Zeilen — typischerweise der Deploy-Tag (D30) oder einer, an dem
|
|
der Changelog vergessen wurde. Der Link führt dort trotzdem vor, was sich
|
|
bewegt hat; ohne diese Vereinigung fiele der Tag stumm unter den Tisch. */
|
|
export function attachKeys(entries, versions, opts){
|
|
const {maxDays = MAX_DAYS} = opts || {};
|
|
const keysByDay = new Map();
|
|
for(let i = 0; i < versions.length; i++){
|
|
const k = changedKeys(i ? versions[i - 1].status : null, versions[i].status);
|
|
if(k.length) keysByDay.set(versions[i].date, k);
|
|
}
|
|
const out = entries.map(e => ({...e, keys: keysByDay.get(e.date) || []}));
|
|
const haben = new Set(out.map(e => e.date));
|
|
for(const [date, keys] of keysByDay){
|
|
if(!haben.has(date)) out.push({date, lines: [], keys});
|
|
}
|
|
return out.sort((a, b) => a.date < b.date ? 1 : -1).slice(0, maxDays);
|
|
}
|
|
|
|
/* ---------- ab hier git und Dateisystem (nur zur Bauzeit) ---------- */
|
|
|
|
const SEP_FIELD = '\x1f', SEP_REC = '\x1e';
|
|
/* Der Satztrenner steht **vorn**, nicht hinten: Mit `--name-only` hängt git die
|
|
Dateinamen hinter die Formatzeile, und nur so gehören sie beim Zerlegen zum
|
|
richtigen Commit. */
|
|
export const LOG_FORMAT = `--format=${SEP_REC}%H${SEP_FIELD}%cd${SEP_FIELD}%s`;
|
|
|
|
export function parseLog(raw){
|
|
return String(raw).split(SEP_REC).filter(r => r.trim()).map(r => {
|
|
const [kopf, ...rest] = r.split('\n');
|
|
const [sha, date, subject] = kopf.split(SEP_FIELD);
|
|
return {sha, date, subject: subject || '', files: rest.filter(l => l.trim())};
|
|
});
|
|
}
|
|
|
|
/* `run`, `changelog` und `parsePlan` werden hereingereicht, damit diese
|
|
Funktion ohne child_process, ohne Dateisystem und ohne den Parser prüfbar
|
|
bleibt. */
|
|
export function collectNews({run, changelog, parsePlan, statusByKey, planFile = PLAN_FILE, opts} = {}){
|
|
const entries = parseChangelog(changelog, opts);
|
|
|
|
/* Je Tag, an dem der Plan angefasst wurde, zählt sein LETZTER Stand.
|
|
`--follow` reicht über die Umbenennung hinweg (`example-werkbaum.werkbaum`
|
|
→ `werkbaum.werkbaum`, 16.08.) — ohne das begänne die Geschichte des Plans
|
|
dort, und der erste Tag danach hätte keine Vergleichsfassung. Deshalb
|
|
`--name-only`: Vor der Umbenennung heißt die Datei anders, und `git show`
|
|
braucht den Namen, den sie **in diesem Commit** trug. */
|
|
const planLog = parseLog(run(
|
|
['log', '--follow', '--name-only', '--date=short', LOG_FORMAT, '--', planFile]));
|
|
const lastOfDay = new Map(); /* neueste zuerst ⇒ der erste gewinnt */
|
|
for(const c of planLog) if(!lastOfDay.has(c.date)) lastOfDay.set(c.date, c);
|
|
const versions = [...lastOfDay.entries()].sort((a, b) => a[0] < b[0] ? -1 : 1)
|
|
.map(([date, c]) => ({
|
|
date,
|
|
status: statusByKey(parsePlan(run(['show', `${c.sha}:${c.files[0] || planFile}`]))),
|
|
}));
|
|
|
|
return attachKeys(entries, versions, opts);
|
|
}
|