From 15c3e85e266ce72ea5903e63014c3f64d6944220 Mon Sep 17 00:00:00 2001 From: mhoennig Date: Sat, 22 Aug 2026 17:15:04 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Effektiver=20Status=20=E2=80=94=20Knote?= =?UTF-8?q?nfarbe=20zeigt=20ihn,=20Diskrepanz-Marke=20unten=20links=20(SPE?= =?UTF-8?q?C=20=C2=A74/=C2=A79,=20D39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jeder Status bekommt einen Fortschritts-Rang entlang der Ergebnis-Skala (D5); effektiv ist das Minimum über die Abhängigkeits-Hülle (Fixpunkt — Zyklen teilen ihr Minimum, wörtlich das „wird gemeinsam fertig" aus D34). Neutral und [-] zählen als 0, [!] als 1; bei doppelter ID gilt die erste Vergabe. Entschieden (Nutzer): Die Knotenfarbe zeigt den effektiven Status — das Diagramm beantwortet „wie weit ist das wirklich?". Wo der eigene Status weiter ist, sitzt unten links die eigene Statusbox als Marke in den eigenen §4-Farben (auch im Export/Druck); Tooltip und a11yEffective benennen beides. XOR-„realisiert" und „Was ist neu?" bleiben bewusst intrinsisch. i18n in allen 9 Sprachen; 12 neue Tests (tests/effective.test.js). Co-Authored-By: Claude Fable 5 --- docs/DECISIONS.md | 40 ++++++++++++++ docs/SPEC.md | 43 +++++++++++---- docs/TASKS.md | 7 ++- docs/examples/werkbaum.werkbaum | 6 +-- frontend/CLAUDE.md | 9 ++++ frontend/index.html | 1 + frontend/src/app.js | 41 +++++++++----- frontend/src/model.js | 45 ++++++++++++++++ frontend/src/parser.js | 21 ++++---- frontend/src/render.js | 24 +++++++-- frontend/src/style.css | 8 +++ frontend/tests/effective.test.js | 92 ++++++++++++++++++++++++++++++++ 12 files changed, 297 insertions(+), 40 deletions(-) create mode 100644 frontend/tests/effective.test.js diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index b8ab4e8..3457955 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1834,3 +1834,43 @@ Stiel-Messung, Treppe und Pfadlinie sind damit ohne Zusatzcode konsistent per CSS zu verstecken). Die Kennzeichnung „▸ n“ ist Teil des Knotentexts und wandert von selbst in den SVG-Export; das ▾ offener Knoten wird dort und im Druck entfernt — es ist Bedienelement, keine Aussage über den Plan. + +## D39 — Effektiver Status: die Farbe sagt die Wahrheit, die Marke die Box +Mit den Abhängigkeiten (D37) gibt es zwei Aussagen je Knoten: was in der +Statusbox steht (intrinsisch) und wie weit er wirklich ist (effektiv, §4). +Erwogen waren drei Darstellungen — Diskrepanz-Kennzeichnung bei intrinsischer +Farbe (die ursprüngliche Empfehlung), ein Umschalter, oder Farbe = effektiv. +**Entschieden (Nutzer): Die Knotenfarbe zeigt den effektiven Status; wo der +eigene Status weiter ist, kommt eine Diskrepanz-Marke dazu.** Das ist die +stärkere Wahl: Das Diagramm beantwortet „wie weit ist das wirklich?“ — genau +die Frage, für die man auf einen Plan schaut. Ein grünes `[x]`, das auf ein +rosé `[~]` wartet, wäre die hübschere Lüge. + +**Die Rechenregel ist ein Minimum über die Abhängigkeits-Hülle.** Jeder Status +bekommt einen Fortschritts-Rang entlang der Ergebnis-Skala (D5); effektiv ist +das Minimum des intrinsischen Rangs über den Knoten selbst und alles, was er +direkt oder mittelbar braucht. Diese Form hat zwei angenehme Folgen: **Zyklen +brauchen keine Sonderregel** — alle Knoten eines Zyklus teilen ihr Minimum, +und das ist wörtlich das „wird gemeinsam fertig“ aus D34; und die Rechnung ist +eine schlichte Fixpunkt-Iteration (Ränge sinken nur, Abbruch garantiert). +Außerhalb der Skala: neutral und `[-]` zählen als 0 — wer auf etwas +Verworfenes oder nie Begonnenes zeigt, ist effektiv am Anfang, und genau das +soll auffallen —, `[!]` als 1 (die Absicht-ohne-Investition-Gruppe aus D35). +Bei doppelter ID gilt die **erste** Vergabe — das löst das in D36 offen +gelassene „wer gewinnt bei Verweisen“ konsistent zur `duplicateId`-Warnung. + +**Die Diskrepanz-Marke ist die eigene Statusbox in den eigenen Farben.** +Unten links (die letzte freie Knoten-Ecke: oben rechts Größe, unten rechts +Tags, oben links ⚠) sitzt ein kleines `[x]`-Etikett in den §4-Farben des +intrinsischen Status — die Notation kennzeichnet sich mit ihrem eigenen +Vokabular, niemand lernt ein neues Symbol, und die Farben tragen die ganze +Geschichte: Knoten rosé (effektiv in Arbeit), Etikett grün (selbst fertig). +Tooltip („effektiv … — selbst schon …, wartet auf Abhängigkeiten“) und +`a11yEffective` sagen es in Worten. Marke und Färbung gehören in Export und +Druck — sie sind eine Aussage über den Plan, nicht über den Betrachter. + +**Was beim intrinsischen Status bleibt:** die XOR-Regel (§3) — „realisiert“ +heißt Kosten investiert, und investiert ist investiert, auch wenn +Abhängigkeiten den Knoten zurückhalten; und „Was ist neu?“ (D28) — der gelbe +Kranz meldet das `[^]` im Text, also den Deploy des Knotens selbst. Beide +Prüfungen laufen im Parser bzw. auf dem Text und bleiben unberührt. diff --git a/docs/SPEC.md b/docs/SPEC.md index eccc482..a99f9e1 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -190,6 +190,24 @@ Codiert als Checkbox nach dem Zeichen (Erweiterung der Markdown-Task-Syntax): verloren, Folgezeilen bleiben unberührt. (Eine mehrzeichige Klammer wie `[xyz]` ist keine Statusbox und bleibt Teil des Labels.) +**Intrinsischer und effektiver Status (Abhängigkeiten, §1).** Der Status in +der Box ist der **intrinsische** — der Bearbeitungsstand des Knotens selbst. +Der **effektive** Status berücksichtigt die Abhängigkeiten: Ein Knoten kann +effektiv nicht weiter sein als das, was er braucht. + +- **Fortschritts-Rang** entlang der Ergebnis-Skala (D5): + `[?]` 0 · `[ ]` 1 · `[~]` 2 · `[/]` 3 · `[x]` 4 · `[^]` 5. Außerhalb der + Skala zählen neutrale Knoten und `[-]` als Rang 0 („nichts Anrechenbares“), + `[!]` als Rang 1 (Absicht ohne Investition, D35). +- **Effektiver Rang = Minimum des intrinsischen Rangs über die + Abhängigkeits-Hülle**: der Knoten selbst plus alles, was er per `:#…` direkt + oder mittelbar braucht. Zyklen brauchen keine Sonderregel — alle Knoten + eines Zyklus teilen so von selbst ihr Minimum („wird gemeinsam fertig“, §1). + Unbekannte IDs zählen nicht (sie sind schon gewarnt); bei doppelter ID gilt + die **erste** Vergabe (D36). +- Der effektive Status wird **gerechnet, nie geschrieben** (D14: der Text ist + die eine Quelle der Wahrheit). Darstellung: §9; Begründung: D39. + ## 5. Aufwand (T-Shirt-Größen) - Werte: `XS < S < M < L < XL < XXL`, notiert in Klammern, z. B. `(L)`. @@ -241,6 +259,17 @@ Stück zwischen Elternknoten und erstem Abzweig — eine kleine **„1“-Plaket (weißer Kreis mit grauem Rand, graue Ziffer): „genau eine“. Sie erscheint auch im Grafikexport. Siehe D35. +**Die Knotenfarbe zeigt den effektiven Status (§4)**, nicht den intrinsischen — +das Diagramm beantwortet „wie weit ist das wirklich?“. Wo der eigene Status +**weiter** ist als der effektive (der Knoten wird von Abhängigkeiten +zurückgehalten), sitzt unten links eine kleine **Status-Marke** mit der +eigenen Statusbox (z. B. `[x]`) in den §4-Farben des intrinsischen Status; +Tooltip und `aria-label` benennen beide. Ohne Diskrepanz ändert sich nichts — +und für die XOR-Regel (§3) und „Was ist neu?“ (unten) zählt weiterhin der +**intrinsische** Status: Investiert ist investiert, und `[^]` im Text bleibt +die Deploy-Aussage. Marke und Färbung erscheinen auch im Grafikexport und im +Druck. Siehe D39. + **Optionale Knoten (`+`, §3):** Sie hängen an der normalen all-of-Zerlegung, die Anordnung bleibt unverändert. Zwei Kennzeichen, beide auch im Grafikexport: @@ -661,17 +690,9 @@ die Closure-Rechnung des günstigsten Pfads (alle drei unten). ### Intrinsischer und effektiver Status -- Der **intrinsische** Status ist der, der in der Statusbox steht (§4) — der - Bearbeitungsstand des Knotens selbst. -- Der **effektive** Status berücksichtigt zusätzlich die Abhängigkeiten: Ein - intrinsisch fertiger Knoten ist effektiv **nicht** fertig, solange eine - Abhängigkeit es nicht ist. -- Der effektive Status wird **gerechnet, nie geschrieben**. Im Notationstext - steht ausschließlich der intrinsische — sonst gäbe es zwei Quellen der - Wahrheit für dieselbe Aussage. -- **Offen ist die Darstellung.** Die Knotenfarbe zeigt heute den intrinsischen - Status (§4); beide zugleich brauchen entweder einen zweiten visuellen Kanal - oder einen Umschalter. +**Umgesetzt** — Rechenregel in §4 (Fortschritts-Rang, Minimum über die +Abhängigkeits-Hülle), Darstellung in §9 (Knotenfarbe = effektiver Status, +Diskrepanz-Marke unten links). Siehe D39. ### Querverbindungen im Diagramm diff --git a/docs/TASKS.md b/docs/TASKS.md index 938fc28..ca17d0a 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -83,8 +83,13 @@ entscheiden, **bevor** Code entsteht. `deps` als ID-Strings am Knoten, Warnung `unknownDep`, Zyklen bewusst ungeprüft; sichtbar als `→ #a, #b` im Tooltip + `a11yDeps`; SPEC-§11-Teil nach §1 überführt; `tests/deps.test.js`. -- [ ] Effektiven Status rechnen (intrinsisch + Abhängigkeiten); Darstellung +- [x] Effektiven Status rechnen (intrinsisch + Abhängigkeiten); Darstellung entscheiden — die Knotenfarbe zeigt heute den intrinsischen Status. + → Entschieden und umgesetzt (D39): Fortschritts-Rang (SPEC §4), effektiv + = Minimum über die Abhängigkeits-Hülle (Fixpunkt, Zyklen teilen ihr + Minimum); **Knotenfarbe = effektiver Status**, bei Diskrepanz die eigene + Statusbox als Marke unten links in den eigenen Farben (auch im Export); + Tooltip + `a11yEffective`; `tests/effective.test.js`. - [ ] Günstigsten Pfad auf die Dependency Closure umstellen (gemeinsame Abhängigkeiten nur einmal zählen). Erweitert D18; die gierige Wahl je Alternativgruppe ist damit nicht mehr optimal — Verfahren wählen und diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index 66ea85b..237ffa9 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -12,9 +12,9 @@ - [x] Node IDs (#auth) (S) %% often just the ticket number, see SPEC §11 + [?] Free tags, &tag (M) %% only together with a consumer, see D34 - [?] A lens: highlight every node tagged &x (S) - - [~] Dependencies across the tree, (:#auth,#api) (M) - - [?] They constrain the status, not when work may start (S) - - [?] Effective status: intrinsic, plus what it depends on (S) + - [x] Dependencies across the tree, (:#auth,#api) (M) + - [x] They constrain the status, not when work may start (S) + - [x] Effective status: intrinsic, plus what it depends on (S) - [x] Cycles are legal — they mean "finished together" (XS) - [x] An id with no node behind it is a warning (XS) - [x] XOR, = — exactly one alternative, not at least one (S) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 8eb82e9..27981c7 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -292,6 +292,15 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der Knoten-Referenzen; der Parser prüft nur Existenz (`unknownDep`), Zyklen werden bewusst nicht einmal erkannt (zulässig, „gemeinsam fertig"). Keine Diagramm-Darstellung — nur Tooltip (`→ #a, #b`) und `a11yDeps`. +- Effektiver Status (SPEC §4/§9, D39): `effectiveStatus(roots)` in model.js + liefert NUR die Diskrepanzen (Map Knoten → effektiver Status-Key; Minimum + des Fortschritts-Rangs über die Abhängigkeits-Hülle, Fixpunkt — Zyklen + teilen ihr Minimum, erste ID-Vergabe gewinnt). Der Renderer färbt dann + `st-` statt des intrinsischen und hängt die Marke + `.chip.ownst.st-` (eigene Statusbox, eigene Farben) unten links an — + im Export via `drawBadge`, aus dem Label-Klon entfernen (`.ownst`). XOR- + „realisiert" (Parser) und „Was ist neu?" (`[^]` im Text) bleiben bewusst + **intrinsisch**. - XOR-Gruppen `=` (SPEC §3/D35): Der Parser setzt `type:'xor'` (nur mit folgendem Leerraum — `=SUMME(…)` bleibt Label); der Renderer gibt `
    ` aus, damit die **gesamte** any-of-Geometrie (alle drei diff --git a/frontend/index.html b/frontend/index.html index 54fc7c0..55ca83d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -122,6 +122,7 @@ Personen mit @name — erscheinen unten rechts am Knoten. Knoten-ID mit #name — erscheint im Tooltip des Knotens. Abhängigkeiten mit :#name,#name — erscheinen im Tooltip. + Die Knotenfarbe zeigt den effektiven Status (mit Abhängigkeiten); ist der eigene weiter, steht er als Marke unten links. Falten: - > [x] … startet eingeklappt, < holt hervor; ▾/▸ am Knoten klappt um (Tastatur: ←/→). diff --git a/frontend/src/app.js b/frontend/src/app.js index b771052..dc013b0 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -1,6 +1,6 @@ import './style.css'; import { parse } from './parser.js'; -import { computeCheapSet, freshProdSet, initialCollapsed, nodeKeys } from './model.js'; +import { computeCheapSet, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus } from './model.js'; import { esc, renderTreeHtml } from './render.js'; import { formatWarning } from './warnings.js'; import { padUrls } from './remote.js'; @@ -112,7 +112,8 @@ function render(){ foldByLine.set(n.line, {key, collapsed, canFold: n.children.length > 0}); }); const r = renderTreeHtml(roots, {t, showDiscarded, cheapPath: cheapPathOn, cheapSet, - freshSet, collapsedSet}); + freshSet, collapsedSet, + effStatus: effectiveStatus(roots)}); out.innerHTML = r.html; warnings = warnings.concat(r.warnings); } @@ -402,7 +403,7 @@ function diagramToSvg(){ §9/D38 — das Bild darf keine Vollständigkeit behaupten); das ▾ offener Knoten ist Bedienelement und fällt weg. */ const stripFold = node.classList.contains('folded') ? '' : ',.fold'; - clone.querySelectorAll('.size,.tags,.ext,.risk' + stripFold).forEach(e => e.remove()); + clone.querySelectorAll('.size,.tags,.ext,.risk,.ownst' + stripFold).forEach(e => e.remove()); const label = clone.textContent.replace(/\s+/g,' ').trim(); const deco = cs.textDecorationLine.includes('line-through') ? ' text-decoration="line-through"' : ''; parts.push(`${esc(label)}`); @@ -418,6 +419,12 @@ function diagramToSvg(){ const t = getComputedStyle(tg); drawBadge(tg, t.backgroundColor, t.color, t.borderTopColor); }); + /* Diskrepanz-Marke des effektiven Status (D39) — Aussage über den Plan, + gehört in den Export wie Größe und Tags. */ + node.querySelectorAll('.ownst').forEach(c => { + const s = getComputedStyle(c); + drawBadge(c, s.backgroundColor, s.color, s.borderTopColor); + }); }); /* 3a) Optionale Knoten: hohler Kreis auf der Kante (nach den Knoten) */ @@ -1170,7 +1177,7 @@ const I18N = { unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.", sourceLoadWarn:"„{url}“ konnte nicht geladen werden ({error}). Die Datei muss per http(s) erreichbar sein und CORS erlauben (Access-Control-Allow-Origin).", sourceTimeoutWarn:"„{url}“ hat innerhalb von {seconds} s nicht geantwortet — der Abruf wurde abgebrochen. Etherpad begrenzt, wie oft der Export geholt werden darf (serienmäßig 10-mal pro 90 s); warte einen Moment und lade dann erneut.", - a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yId:"ID: #{id}", a11yDeps:"hängt ab von: {ids}", a11yFolded:"eingeklappt, {n} verborgen", a11yOptional:"optional", a11yFocusMark:"hierhin schauen", a11yLink:"verlinkt", + a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yId:"ID: #{id}", a11yDeps:"hängt ab von: {ids}", a11yFolded:"eingeklappt, {n} verborgen", a11yEffective:"effektiv: {status}", heldTooltip:"effektiv {eff} — selbst schon {own}, wartet auf Abhängigkeiten", a11yOptional:"optional", a11yFocusMark:"hierhin schauen", a11yLink:"verlinkt", hint_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.", hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen", hint_xor:"Alternative, genau eine", @@ -1184,6 +1191,7 @@ const I18N = { hint_people:"Personen mit @name — erscheinen unten rechts am Knoten.", hint_id:"Knoten-ID mit #name — erscheint im Tooltip des Knotens.", hint_deps:"Abhängigkeiten mit :#name,#name — erscheinen im Tooltip.", + hint_eff:"Die Knotenfarbe zeigt den effektiven Status (mit Abhängigkeiten); ist der eigene weiter, steht er als Marke unten links.", hint_fold:"Falten: - > [x] … startet eingeklappt, < holt hervor; ▾/▸ am Knoten klappt um (Tastatur: ←/→).", hint_jump:"Alt+Klick auf einen Knoten (mobil: langer Druck) springt zur zugehörigen Textzeile; Alt+Klick im Text holt den Knoten ins Bild." }, @@ -1241,7 +1249,7 @@ const I18N = { unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.", sourceLoadWarn:"Could not load “{url}” ({error}). The file must be reachable via http(s) and allow CORS (Access-Control-Allow-Origin).", sourceTimeoutWarn:"“{url}” did not answer within {seconds} s — the request was aborted. Etherpad limits how often the export may be fetched (10 times per 90 s by default); wait a moment, then reload.", - a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yId:"ID: #{id}", a11yDeps:"depends on: {ids}", a11yFolded:"collapsed, {n} hidden", a11yOptional:"optional", a11yFocusMark:"look here", a11yLink:"has link", + a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yId:"ID: #{id}", a11yDeps:"depends on: {ids}", a11yFolded:"collapsed, {n} hidden", a11yEffective:"effective: {status}", heldTooltip:"effectively {eff} — itself already {own}, waiting on dependencies", a11yOptional:"optional", a11yFocusMark:"look here", a11yLink:"has link", hint_indent:"Indentation (2 spaces or a tab) defines the hierarchy.", hint_all:"sub-task, all required", hint_any:"alternative, choose one", hint_xor:"alternative, exactly one", @@ -1255,6 +1263,7 @@ const I18N = { hint_people:"People with @name — shown at the bottom-right of the node.", hint_id:"Node ID with #name — shown in the node's tooltip.", hint_deps:"Dependencies with :#name,#name — shown in the tooltip.", + hint_eff:"Node colour shows the effective status (with dependencies); if its own is further along, it appears as a mark at the bottom left.", hint_fold:"Folding: - > [x] … starts collapsed, < brings it back; ▾/▸ on a node toggles (keyboard: ←/→).", hint_jump:"Alt+click a node (long press on touch) jumps to its line in the text; Alt+click in the text brings the node into view." }, @@ -1312,7 +1321,7 @@ const I18N = { unknownStatusWarn:"Línea {line}: código de estado desconocido «{code}» — mostrado como neutral.", sourceLoadWarn:"No se pudo cargar «{url}» ({error}). El archivo debe ser accesible por http(s) y permitir CORS (Access-Control-Allow-Origin).", sourceTimeoutWarn:"«{url}» no respondió en {seconds} s — se canceló la petición. Etherpad limita la frecuencia de descarga del export (10 veces por 90 s de forma predeterminada); espera un momento y vuelve a cargar.", - a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yId:"ID: #{id}", a11yDeps:"depende de: {ids}", a11yFolded:"plegado, {n} ocultos", a11yOptional:"opcional", a11yFocusMark:"mirar aquí", a11yLink:"con enlace", + a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yId:"ID: #{id}", a11yDeps:"depende de: {ids}", a11yFolded:"plegado, {n} ocultos", a11yEffective:"efectivo: {status}", heldTooltip:"efectivamente {eff} — por sí mismo ya {own}, espera dependencias", a11yOptional:"opcional", a11yFocusMark:"mirar aquí", a11yLink:"con enlace", hint_indent:"La sangría (2 espacios o un tabulador) define la jerarquía.", hint_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una", hint_xor:"alternativa, exactamente una", @@ -1326,6 +1335,7 @@ const I18N = { hint_people:"Personas con @nombre — aparecen abajo a la derecha del nodo.", hint_id:"ID de nodo con #nombre — visible en el tooltip del nodo.", hint_deps:"Dependencias con :#nombre,#nombre — visibles en el tooltip.", + hint_eff:"El color del nodo muestra el estado efectivo (con dependencias); si el propio va más adelante, aparece como marca abajo a la izquierda.", hint_fold:"Plegado: - > [x] … empieza plegado, < lo recupera; ▾/▸ en el nodo alterna (teclado: ←/→).", hint_jump:"Alt+clic en un nodo (pulsación larga en táctil) salta a su línea en el texto; Alt+clic en el texto trae el nodo a la vista." }, @@ -1383,7 +1393,7 @@ const I18N = { unknownStatusWarn:"Ligne {line} : code de statut inconnu « {code} » — affiché comme neutre.", sourceLoadWarn:"Impossible de charger « {url} » ({error}). Le fichier doit être accessible en http(s) et autoriser CORS (Access-Control-Allow-Origin).", sourceTimeoutWarn:"« {url} » n’a pas répondu en {seconds} s — la requête a été interrompue. Etherpad limite la fréquence de récupération de l’export (10 fois par 90 s par défaut) ; attends un instant, puis recharge.", - a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yId:"ID : #{id}", a11yDeps:"dépend de : {ids}", a11yFolded:"replié, {n} masqués", a11yOptional:"facultatif", a11yFocusMark:"regarder ici", a11yLink:"avec lien", + a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yId:"ID : #{id}", a11yDeps:"dépend de : {ids}", a11yFolded:"replié, {n} masqués", a11yEffective:"effectif : {status}", heldTooltip:"effectivement {eff} — lui-même déjà {own}, en attente de dépendances", a11yOptional:"facultatif", a11yFocusMark:"regarder ici", a11yLink:"avec lien", hint_indent:"L'indentation (2 espaces ou une tabulation) définit la hiérarchie.", hint_all:"sous-tâche, toutes requises", hint_any:"alternative, en choisir une", hint_xor:"alternative, exactement une", @@ -1397,6 +1407,7 @@ const I18N = { hint_people:"Personnes avec @nom — affichées en bas à droite du nœud.", hint_id:"ID de nœud avec #nom — visible dans l’infobulle du nœud.", hint_deps:"Dépendances avec :#nom,#nom — visibles dans l’infobulle.", + hint_eff:"La couleur du nœud montre le statut effectif (avec dépendances) ; si le sien est plus avancé, il apparaît en marque en bas à gauche.", hint_fold:"Pliage : - > [x] … démarre replié, < le fait ressortir ; ▾/▸ sur le nœud bascule (clavier : ←/→).", hint_jump:"Alt+clic sur un nœud (appui long sur tactile) saute à sa ligne dans le texte ; Alt+clic dans le texte amène le nœud à l’écran." }, @@ -1454,7 +1465,7 @@ const I18N = { unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.", sourceLoadWarn:"Nie udało się wczytać „{url}” ({error}). Plik musi być dostępny przez http(s) i zezwalać na CORS (Access-Control-Allow-Origin).", sourceTimeoutWarn:"„{url}” nie odpowiedział w ciągu {seconds} s — żądanie przerwano. Etherpad ogranicza częstość pobierania eksportu (domyślnie 10 razy na 90 s); odczekaj chwilę i wczytaj ponownie.", - a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yId:"ID: #{id}", a11yDeps:"zależy od: {ids}", a11yFolded:"zwinięte, ukrytych: {n}", a11yOptional:"opcjonalny", a11yFocusMark:"spójrz tutaj", a11yLink:"z linkiem", + a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yId:"ID: #{id}", a11yDeps:"zależy od: {ids}", a11yFolded:"zwinięte, ukrytych: {n}", a11yEffective:"efektywnie: {status}", heldTooltip:"efektywnie {eff} — sam już {own}, czeka na zależności", a11yOptional:"opcjonalny", a11yFocusMark:"spójrz tutaj", a11yLink:"z linkiem", hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.", hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną", hint_xor:"alternatywa, dokładnie jedna", @@ -1468,6 +1479,7 @@ const I18N = { hint_people:"Osoby z @nazwa — pokazywane w prawym dolnym rogu węzła.", hint_id:"ID węzła przez #nazwa — widoczne w podpowiedzi węzła.", hint_deps:"Zależności przez :#nazwa,#nazwa — widoczne w podpowiedzi.", + hint_eff:"Kolor węzła pokazuje status efektywny (z zależnościami); jeśli własny jest dalej, widnieje jako znacznik u dołu po lewej.", hint_fold:"Zwijanie: - > [x] … zaczyna zwinięte, < przywraca; ▾/▸ na węźle przełącza (klawiatura: ←/→).", hint_jump:"Alt+kliknięcie węzła (długie naciśnięcie na dotyku) przechodzi do jego wiersza w tekście; Alt+kliknięcie w tekście pokazuje węzeł na diagramie." }, @@ -1525,7 +1537,7 @@ const I18N = { unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.", sourceLoadWarn:"Не удалось загрузить «{url}» ({error}). Файл должен быть доступен по http(s) и разрешать CORS (Access-Control-Allow-Origin).", sourceTimeoutWarn:"«{url}» не ответил за {seconds} с — запрос прерван. Etherpad ограничивает частоту загрузки экспорта (по умолчанию 10 раз за 90 с); подождите немного и обновите снова.", - a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yId:"ID: #{id}", a11yDeps:"зависит от: {ids}", a11yFolded:"свёрнуто, скрыто: {n}", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой", + a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yId:"ID: #{id}", a11yDeps:"зависит от: {ids}", a11yFolded:"свёрнуто, скрыто: {n}", a11yEffective:"фактически: {status}", heldTooltip:"фактически {eff} — сам уже {own}, ждёт зависимости", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой", hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.", hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну", hint_xor:"альтернатива, ровно одна", @@ -1539,6 +1551,7 @@ const I18N = { hint_people:"Люди через @имя — показываются справа внизу узла.", hint_id:"ID узла через #имя — виден во всплывающей подсказке узла.", hint_deps:"Зависимости через :#имя,#имя — видны в подсказке.", + hint_eff:"Цвет узла показывает фактический статус (с учётом зависимостей); если собственный дальше, он показан меткой слева внизу.", hint_fold:"Сворачивание: - > [x] … открывается свёрнутым, < возвращает; ▾/▸ на узле переключает (клавиши: ←/→).", hint_jump:"Alt+клик по узлу (долгое нажатие на сенсоре) переходит к его строке в тексте; Alt+клик в тексте показывает узел на диаграмме." }, @@ -1596,7 +1609,7 @@ const I18N = { unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।", sourceLoadWarn:"„{url}“ लोड नहीं हो सका ({error})। फ़ाइल http(s) से उपलब्ध होनी चाहिए और CORS की अनुमति देनी चाहिए (Access-Control-Allow-Origin)।", sourceTimeoutWarn:"„{url}“ ने {seconds} स॰ में उत्तर नहीं दिया — अनुरोध रद्द कर दिया गया। Etherpad सीमित करता है कि एक्सपोर्ट कितनी बार लिया जा सके (डिफ़ॉल्ट रूप से 90 स॰ में 10 बार); कुछ क्षण रुकें, फिर दोबारा लोड करें।", - a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yId:"आईडी: #{id}", a11yDeps:"निर्भर: {ids}", a11yFolded:"समेटा हुआ, {n} छिपे", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित", + a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yId:"आईडी: #{id}", a11yDeps:"निर्भर: {ids}", a11yFolded:"समेटा हुआ, {n} छिपे", a11yEffective:"प्रभावी: {status}", heldTooltip:"प्रभावी रूप से {eff} — स्वयं {own} है, निर्भरताओं की प्रतीक्षा में", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित", hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।", hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें", hint_xor:"विकल्प, ठीक एक", @@ -1610,6 +1623,7 @@ const I18N = { hint_people:"@नाम से व्यक्ति — नोड के नीचे-दाएँ दिखते हैं।", hint_id:"#नाम से नोड आईडी — नोड के टूलटिप में दिखती है।", hint_deps:":#नाम,#नाम से निर्भरताएँ — टूलटिप में दिखती हैं।", + hint_eff:"नोड का रंग प्रभावी स्थिति दिखाता है (निर्भरताओं सहित); यदि अपनी स्थिति आगे है, तो वह नीचे-बाएँ चिह्न के रूप में दिखती है।", hint_fold:"फ़ोल्डिंग: - > [x] … समेटा हुआ खुलता है, < वापस लाता है; नोड पर ▾/▸ टॉगल करता है (कीबोर्ड: ←/→)।", hint_jump:"किसी नोड पर Alt+क्लिक (टच पर लंबा दबाव) टेक्स्ट में उसकी पंक्ति पर ले जाता है; टेक्स्ट में Alt+क्लिक उस नोड को आरेख में दिखाता है।" }, @@ -1667,7 +1681,7 @@ const I18N = { unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。", sourceLoadWarn:"无法加载“{url}”({error})。该文件必须可通过 http(s) 访问并允许 CORS(Access-Control-Allow-Origin)。", sourceTimeoutWarn:"“{url}” 在 {seconds} 秒内没有响应 — 请求已中止。Etherpad 会限制导出的获取频率(默认每 90 秒 10 次);请稍候再重新加载。", - a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yId:"ID:#{id}", a11yDeps:"依赖:{ids}", a11yFolded:"已折叠,隐藏 {n} 项", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接", + a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yId:"ID:#{id}", a11yDeps:"依赖:{ids}", a11yFolded:"已折叠,隐藏 {n} 项", a11yEffective:"实际:{status}", heldTooltip:"实际为 {eff}——自身已是 {own},等待依赖完成", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接", hint_indent:"缩进(2 个空格或制表符)定义层级。", hint_all:"子任务,全部必需", hint_any:"备选项,择其一", hint_xor:"备选项,恰好一个", @@ -1681,6 +1695,7 @@ const I18N = { hint_people:"用 @姓名 表示人员——显示在节点右下角。", hint_id:"用 #名称 指定节点 ID——显示在节点提示中。", hint_deps:"用 :#名称,#名称 表示依赖——显示在提示中。", + hint_eff:"节点颜色显示实际状态(含依赖);若自身状态更靠前,会以左下角标记显示。", hint_fold:"折叠:- > [x] … 打开时即折叠,< 将其展开;节点上的 ▾/▸ 切换(键盘:←/→)。", hint_jump:"Alt+点击节点(触摸屏为长按)可跳转到文本中对应的行;在文本中 Alt+点击则把该节点带入视野。" }, @@ -1738,7 +1753,7 @@ const I18N = { unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。", sourceLoadWarn:"「{url}」を読み込めませんでした({error})。ファイルは http(s) でアクセス可能で、CORS(Access-Control-Allow-Origin)を許可する必要があります。", sourceTimeoutWarn:"「{url}」が {seconds} 秒以内に応答しませんでした — 要求を中止しました。Etherpad はエクスポートの取得回数を制限します(既定で 90 秒あたり 10 回)。少し待ってから再読み込みしてください。", - a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yId:"ID: #{id}", a11yDeps:"依存先: {ids}", a11yFolded:"折りたたみ中、{n} 件非表示", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり", + a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yId:"ID: #{id}", a11yDeps:"依存先: {ids}", a11yFolded:"折りたたみ中、{n} 件非表示", a11yEffective:"実効: {status}", heldTooltip:"実効では {eff} — 自身は既に {own}、依存待ち", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり", hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。", hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ", hint_xor:"選択肢、ちょうど1つ", @@ -1752,6 +1767,7 @@ const I18N = { hint_people:"@名前 で担当者 — ノードの右下に表示されます。", hint_id:"#名前 でノード ID — ノードのツールチップに表示されます。", hint_deps:":#名前,#名前 で依存関係 — ツールチップに表示されます。", + hint_eff:"ノードの色は実効ステータス(依存関係込み)を示します。自身が先行している場合は左下のマークで表示されます。", hint_fold:"折りたたみ:- > [x] … は折りたたんだ状態で開き、< は呼び戻します。ノードの ▾/▸ で切替(キー:←/→)。", hint_jump:"ノードを Alt+クリック(タッチでは長押し)すると、テキストの該当行へ移動します。テキスト内で Alt+クリックすると、そのノードが図の中央に表示されます。" } @@ -1787,6 +1803,7 @@ function buildHint(){ ${esc(t('hint_people'))} ${esc(t('hint_id'))} ${esc(t('hint_deps'))} + ${esc(t('hint_eff'))} ${esc(t('hint_fold'))} !!!  ${esc(t('hint_focus'))}
    ${esc(t('hint_jump'))}
    `; diff --git a/frontend/src/model.js b/frontend/src/model.js index c310a89..d78b04f 100644 --- a/frontend/src/model.js +++ b/frontend/src/model.js @@ -78,6 +78,51 @@ export function cheapCls(n, cheapSet){ return leaf ? 'cheap cheap-leaf' : 'cheap'; } +/* ---------- Effektiver Status (SPEC §4/§9, D39) ---------- + Fortschritts-Rang entlang der Ergebnis-Skala (D5). Außerhalb der Skala: + neutrale und verworfene Knoten zählen als 0 („nichts Anrechenbares"), + High Risk als 1 (Absicht ohne Investition, D35). */ +export const PROGRESS_RANK = { idee:0, geplant:1, highrisk:1, arbeit:2, durchstich:3, fertig:4, prod:5 }; +const RANK_STATUS_KEY = ['idee', 'geplant', 'arbeit', 'durchstich', 'fertig', 'prod']; +export function progressRank(n){ + if(!n.status) return 0; + const r = PROGRESS_RANK[n.status.key]; + return r === undefined ? 0 : r; /* verworfen -> 0 */ +} +/* Diskrepanzen des effektiven Status: Map Knoten -> effektiver Status-KEY, + nur für Knoten, deren eigener Status weiter ist. Effektiver Rang = Minimum + des intrinsischen Rangs über die Abhängigkeits-Hülle (Knoten selbst plus + alles per `:#…` Erreichbare), als Fixpunkt-Iteration — Ränge sinken nur, + Zyklen teilen so von selbst ihr Minimum („wird gemeinsam fertig", §1). + Unbekannte IDs zählen nicht (schon gewarnt, `unknownDep`); bei doppelter + ID gilt die ERSTE Vergabe (D36/D39). */ +export function effectiveStatus(roots){ + const nodes = [], byId = new Map(); + (function walk(ns){ + for(const n of ns){ + nodes.push(n); + if(n.id != null && !byId.has(n.id)) byId.set(n.id, n); + walk(n.children); + } + })(roots); + const eff = new Map(nodes.map(n => [n, progressRank(n)])); + let changed = true; + while(changed){ + changed = false; + for(const n of nodes){ + let v = eff.get(n); + for(const d of n.deps || []){ + const target = byId.get(d); + if(target && eff.get(target) < v) v = eff.get(target); + } + if(v < eff.get(n)){ eff.set(n, v); changed = true; } + } + } + const map = new Map(); + eff.forEach((r, n) => { if(r < progressRank(n)) map.set(n, RANK_STATUS_KEY[r]); }); + return map; +} + /* ---------- Was ist neu? (D28) ---------- „Neu" heißt hier bewusst nicht „Zeile hinzugefügt", sondern **neu in Produktion**: ein Knoten, der jetzt `[^]` trägt und es in der zuletzt diff --git a/frontend/src/parser.js b/frontend/src/parser.js index a5d0e54..f079656 100644 --- a/frontend/src/parser.js +++ b/frontend/src/parser.js @@ -6,17 +6,18 @@ /* T-Shirt-Größen (SPEC §5), aufsteigend geordnet. */ export const SIZE_RANK = { XS: 0, S: 1, M: 2, L: 3, XL: 4, XXL: 5 }; -/* Status-Vokabular (SPEC §4): Checkbox-Code -> {key, name}. - `name` ist der deutsche Anzeigename (Quellsprache). */ +/* Status-Vokabular (SPEC §4): Checkbox-Code -> {code, key, name}. + `name` ist der deutsche Anzeigename (Quellsprache); `code` das kanonische + Box-Zeichen (für die Diskrepanz-Marke des effektiven Status, D39). */ export const STATUS_BY_CODE = { - '?': {key:'idee', name:'Idee'}, - ' ': {key:'geplant', name:'geplant'}, - '~': {key:'arbeit', name:'in Arbeit'}, - '/': {key:'durchstich', name:'Durchstich – funktionsbereit, Feinarbeiten offen'}, - 'x': {key:'fertig', name:'fertig'}, - '^': {key:'prod', name:'in Produktion'}, - '-': {key:'verworfen', name:'verworfen'}, - '!': {key:'highrisk', name:'High Risk – Aufwand unklar'} + '?': {code:'?', key:'idee', name:'Idee'}, + ' ': {code:' ', key:'geplant', name:'geplant'}, + '~': {code:'~', key:'arbeit', name:'in Arbeit'}, + '/': {code:'/', key:'durchstich', name:'Durchstich – funktionsbereit, Feinarbeiten offen'}, + 'x': {code:'x', key:'fertig', name:'fertig'}, + '^': {code:'^', key:'prod', name:'in Produktion'}, + '-': {code:'-', key:'verworfen', name:'verworfen'}, + '!': {code:'!', key:'highrisk', name:'High Risk – Aufwand unklar'} }; /* Status, die als „realisiert" zählen (XOR-Regel, SPEC §3/D35): Kosten sind diff --git a/frontend/src/render.js b/frontend/src/render.js index 11958a3..232b95e 100644 --- a/frontend/src/render.js +++ b/frontend/src/render.js @@ -11,6 +11,8 @@ cheapSet, // Set der nötigen Knoten (leer, wenn Pfad aus) freshSet, // optional: Knoten, die neu in Produktion sind (D28) collapsedSet, // optional: eingeklappte Knoten (Faltung, SPEC §9/D38) + effStatus, // optional: Map Knoten -> effektiver Status-Key, nur + // Diskrepanzen (effectiveStatus() in model.js, D39) } */ import { gateOf, needsBreakdown, visibleChildren, cheapCls } from './model.js'; @@ -52,6 +54,10 @@ function nodeAria(n, opts, fold){ Ansage wüsste ein Screenreader nicht, dass hier etwas verborgen ist. */ if(fold && fold.collapsed) parts.push(t('a11yFolded', {n: fold.count})); if(n.status) parts.push(t('a11yStatus', {status: t('st_' + n.status.key)})); + /* Diskrepanz (D39): die Farbe zeigt den effektiven Status — der Screenreader + bekommt beide, wie der Tooltip. */ + const effKey = opts.effStatus ? opts.effStatus.get(n) : undefined; + if(effKey) parts.push(t('a11yEffective', {status: t('st_' + effKey)})); if(n.size) parts.push(t('a11ySize', {size: n.size})); else if(cheapPath) parts.push(t('a11ySizeImplicit')); if(n.tags && n.tags.length) parts.push(t('a11yTags', {names: n.tags.join(', ')})); @@ -72,8 +78,12 @@ function nodeAria(n, opts, fold){ function nodeHtml(n, extra, opts, fold){ const { t, cheapPath } = opts; const need = needsBreakdown(n); + /* Die Knotenfarbe zeigt den EFFEKTIVEN Status (SPEC §9/D39); bei Diskrepanz + trägt die Marke unten links die eigene Statusbox in den eigenen Farben. */ + const effKey = opts.effStatus ? opts.effStatus.get(n) : undefined; const cls = ['node', extra || '', fold && fold.collapsed ? 'folded' : '', - n.status ? 'st-' + n.status.key : ''] + effKey ? 'held' : '', + n.status ? 'st-' + (effKey || n.status.key) : ''] .filter(Boolean).join(' '); /* Zeilennummer am Knoten (D25): Grundlage für den Sprung ins Textfeld und für die Gegenrichtung (Cursor-Zeile -> Knoten hervorheben). Der Hinweis im @@ -81,7 +91,9 @@ function nodeHtml(n, extra, opts, fold){ const lineAttr = n.line ? ` data-line="${n.line}"` : ''; const tip = [n.id ? '#' + n.id : '', n.deps && n.deps.length ? '→ ' + n.deps.map(d => '#' + d).join(', ') : '', - n.status ? t('st_' + n.status.key) : '', + effKey + ? t('heldTooltip', {eff: t('st_' + effKey), own: t('st_' + n.status.key)}) + : (n.status ? t('st_' + n.status.key) : ''), n.optional ? t('a11yOptional') : '', t('jumpHint')] .filter(Boolean).join(' · '); const title = ` title="${attr(tip)}"`; @@ -104,12 +116,18 @@ function nodeHtml(n, extra, opts, fold){ ? `` : ''; const expanded = fold ? ` aria-expanded="${!fold.collapsed}"` : ''; + /* Diskrepanz-Marke: die eigene Statusbox in den eigenen §4-Farben — + „selbst schon [x], wartet auf Abhängigkeiten" (D39). */ + const ownChip = effKey + ? `` + : ''; const inner = foldHtml + esc(n.label) + (n.url ? '' : '') + riskMark + sizeBadge + - tagsHtml; + tagsHtml + + ownChip; const aria = ` aria-label="${attr(nodeAria(n, opts, fold))}"`; const html = n.url ? `${inner}` diff --git a/frontend/src/style.css b/frontend/src/style.css index 84b531e..dfb5f2d 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -644,6 +644,14 @@ position:absolute;right:-9px;bottom:-9px; display:flex;gap:3px; } + /* Diskrepanz-Marke des effektiven Status (SPEC §9, D39): die eigene + Statusbox in den eigenen §4-Farben (nutzt die .chip-Farbregeln der + Legende), unten links — die letzte freie Knoten-Ecke. */ + .node .chip.ownst{ + position:absolute;left:-9px;bottom:-9px; + font-size:.58rem;font-weight:500;letter-spacing:.02em; + padding:1px 5px;line-height:1.4;border-radius:999px; + } .tag{ font-family:'IBM Plex Mono',monospace; font-size:.58rem;font-weight:500;letter-spacing:.02em; diff --git a/frontend/tests/effective.test.js b/frontend/tests/effective.test.js new file mode 100644 index 0000000..0992883 --- /dev/null +++ b/frontend/tests/effective.test.js @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest'; +import { parse } from '../src/parser.js'; +import { effectiveStatus, progressRank } from '../src/model.js'; +import { renderTreeHtml } from '../src/render.js'; + +const t = key => key; +const roots = txt => parse(txt).roots; +/* Map Label -> effektiver Status-Key (nur Diskrepanzen). */ +const disc = txt => { + const m = effectiveStatus(roots(txt)); + const out = {}; + m.forEach((key, n) => { out[n.label] = key; }); + return out; +}; +const render = txt => { + const r = roots(txt); + return renderTreeHtml(r, {t, showDiscarded: false, cheapPath: false, + cheapSet: new Set(), effStatus: effectiveStatus(r)}); +}; + +/* Effektiver Status (SPEC §4/§9, D39): Minimum des Fortschritts-Rangs über + die Abhängigkeits-Hülle; gemeldet werden nur Diskrepanzen. */ +describe('effectiveStatus — Minimum über die Abhängigkeits-Hülle', () => { + it('hält einen fertigen Knoten auf dem Rang seiner Abhängigkeit', () => { + expect(disc(`[~] API #api\n[x] Deploy :#api`)) + .toEqual({Deploy: 'arbeit'}); + }); + + it('reicht das Minimum über Ketten durch', () => { + expect(disc(`[ ] C #c\n[/] B #b :#c\n[x] A :#b`)) + .toEqual({A: 'geplant', B: 'geplant'}); + }); + + it('meldet nichts, wenn die Abhängigkeit gleichauf oder weiter ist', () => { + expect(disc(`[x] API #api\n[x] Deploy :#api`)).toEqual({}); + expect(disc(`[^] API #api\n[x] Deploy :#api`)).toEqual({}); + }); + + it('lässt Zyklen ihr Minimum teilen — „wird gemeinsam fertig"', () => { + expect(disc(`[x] A #a :#b\n[x] B #b :#a`)).toEqual({}); + expect(disc(`[x] A #a :#b\n[~] B #b :#a`)).toEqual({A: 'arbeit'}); + expect(disc(`[x] A #a :#a`)).toEqual({}); /* Selbst-Abhängigkeit */ + }); + + it('ignoriert unbekannte IDs (dafür gibt es die unknownDep-Warnung)', () => { + expect(disc(`[x] A :#gibtsnicht`)).toEqual({}); + }); + + it('zählt neutrale und verworfene Ziele als Rang 0, High Risk als 1', () => { + expect(disc(`Neutral #n\n[x] A :#n`)).toEqual({A: 'idee'}); + expect(disc(`[-] Weg #w\n[x] A :#w`)).toEqual({A: 'idee'}); + expect(disc(`[!] Risiko #r\n[x] A :#r`)).toEqual({A: 'geplant'}); + }); + + it('macht neutrale und verworfene Knoten selbst nie zur Diskrepanz', () => { + expect(disc(`[?] Ziel #z\nNeutral :#z\n`)).toEqual({}); + expect(disc(`[?] Ziel #z\n[-] Weg :#z`)).toEqual({}); + expect(progressRank(roots(`[-] Weg`)[0])).toBe(0); + }); + + it('löst Verweise auf die ERSTE Vergabe einer doppelten ID auf (D36/D39)', () => { + expect(disc(`[~] Erste #x\n[x] Zweite #x\n[x] A :#x`)).toEqual({A: 'arbeit'}); + }); +}); + +describe('Darstellung — Farbe effektiv, Marke intrinsisch', () => { + const TXT = `[~] API #api (M)\n[x] Deploy :#api`; + + it('färbt den Knoten mit dem effektiven Status und markiert ihn als held', () => { + const {html} = render(TXT); + expect(html).toContain('class="node root-node held st-arbeit" tabindex="0" data-line="2"'); + expect(html).not.toContain('st-fertig" tabindex="0" data-line="2"'); + }); + + it('hängt die eigene Statusbox als Marke in den eigenen Farben an', () => { + const {html} = render(TXT); + expect(html).toContain(''); + }); + + it('benennt beide Status in Tooltip und aria-label', () => { + const {html} = render(TXT); + expect(html).toContain('title="→ #api · heldTooltip · jumpHint"'); + expect(html).toContain('aria-label="Deploy, a11yStatus, a11yEffective, a11yDeps"'); + }); + + it('ändert Knoten ohne Diskrepanz nicht', () => { + const {html} = render(`[x] API #api\n[x] Deploy :#api`); + expect(html).not.toContain('ownst'); + expect(html).not.toContain('held'); + expect((html.match(/st-fertig/g) || []).length).toBe(2); + }); +});