From 35b72fc17851163cfa83a986995fa9f5d68e663a Mon Sep 17 00:00:00 2001 From: mhoennig Date: Tue, 25 Aug 2026 11:29:52 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Zust=C3=A4ndigen-Engpass=20=E2=80=94=20?= =?UTF-8?q?Warnung,=20wenn=20eine=20Person=20mehr=20als=20die=20H=C3=A4lft?= =?UTF-8?q?e=20des=20Pfads=20tr=C3=A4gt=20(D71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trägt ein @name mehr als die Hälfte der offenen Arbeit des günstigsten Pfads (Marginalkosten-Maß, D69) und tragen mindestens zwei Personen Last auf der offenen Front, meldet die zeilenlose Warnung assigneeOverload die mögliche Engstelle; die Pillen der Person wechseln an offenen Pfad-Knoten auf die Warnfarbe. Knoten ohne Tags erben für die Rechnung vom nächsten getaggten Vorfahren, mehrere Tags teilen sich den Beitrag. Co-Authored-By: Claude Fable 5 --- docs/CHANGELOG.md | 1 + docs/DECISIONS.md | 69 +++++++++++++++ docs/SPEC.md | 31 +++++++ docs/examples/werkbaum.werkbaum | 7 ++ frontend/src/app.js | 21 ++++- frontend/src/model.js | 53 ++++++++++++ frontend/src/render.js | 10 ++- frontend/src/style.css | 4 + frontend/src/warnings.js | 8 ++ frontend/tests/overload.test.js | 143 ++++++++++++++++++++++++++++++++ 10 files changed, 343 insertions(+), 4 deletions(-) create mode 100644 frontend/tests/overload.test.js diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 12f7bde..a453dd1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,7 @@ reverse. ## 2026-08-25 +- A warning flags the bottleneck when one `@name` carries more than half of the open work on the cheapest path — their tag pills turn amber on the open nodes - The estimated size of an unsized node now prices the remaining work — done sub-packages drop out, and a subtree with everything named done counts as XS - The fold chip is solid white with an ink glyph now — the translucent box vanished on the pastel node colours - A size prices its whole subtree on the cheapest path — sub-packages no longer add on top, so a carefully decomposed `(S)` beats a coarse `(L)` diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index a9cdeac..ac24f41 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -5219,3 +5219,72 @@ fallen; XS-Boden auf M zurück → genau die drei XS-Tests; „Gruppe fertig" entfernt → genau die zwei disjunktiven. Der mitgelieferte Plan ist unberührt (sein einziger größenloser Knoten ist ein Blatt): weiterhin 0 Warnungen, 137 Pfadknoten, 57 Stationen, exakt gerechnet. + +## D71 — Zuständigen-Engpass: warnen, wenn eine Person mehr als die Hälfte des Pfads trägt +Gewünscht: „eine Warnung, wenn ein und derselbe Verantwortliche (@name) zu oft +auf dem Lean-Path liegt — so eine Art Konflikt-Indikator." Kein neues +Zeichen — Tags (§7) und der günstigste Pfad existieren, es fehlte der +Konsument, der sie übereinanderlegt. Die Entscheidungen (Vorschlag bestätigt): + +**Was der Indikator sagt:** Werkbaum ist bewusst kein Netzplan — die Warnung +behauptet nichts über Termine, sondern: Die **nächste Ausbaustufe ist nicht +parallelisierbar**, alles Weitere serialisiert sich durch eine Person. Genau +die Auskunft, die man beim Lean Pathfinding braucht. + +**Maß: Marginalkosten, Stationen nur für den Text.** Zählen und Gewichten +standen zur Wahl; gewählt ist die Rollenteilung — Schwelle und Anteil rechnen +**gewichtet** (5×XS sind weniger Last als 2×XL), der Meldungstext nennt +zusätzlich die **Stationszahl**, weil Stationen sichtbare, mit dem Auge +nachprüfbare Objekte sind (die Punkte, die der D47-Knopf abgeht). Das +Gewichts-Maß gab es schon: die Marginalkosten des Belegungs-Vergleichs +(D69) — jeder nötige Knoten zählt mit dem, was seine Größe über die nötigen +Teilpakete hinaus behauptet; Erledigtes hat `ownCost` 0 und fällt von selbst +heraus. Die Personen-Summen ergeben zusammen exakt den Pfadpreis — kein +zweites Kostenmodell. + +**Zuständigkeit erbt vom nächsten getaggten Vorfahren.** Die übliche +WBS-Lesart: Wer das Paket hat, hat die Teilpakete. Ohne Vererbung zählte in +sparsam getaggten Plänen fast nichts — im Test trüge die Person eines +zerlegten L-Pakets nur dessen Marge (1 statt 4). Mehrere Tags einer Zeile +**teilen** sich den Beitrag zu gleichen Teilen — voll doppelt gezählt bliese +gemeinsame Pakete künstlich zum Engpass auf. Beiträge ohne getaggten +Vorfahren gehen nur in die Gesamtsumme ein: Sie **verwässern** die Anteile +(konservativ — gemeldet wird nur, was sicher ist), warnen aber nicht selbst; +„viel ist niemandem zugewiesen" wäre eine eigene Aussage und, wenn überhaupt, +eine eigene Meldung. + +**Schwelle: strikt mehr als die Hälfte, und mindestens zwei Personen mit Last +auf der offenen Front.** Der Solo-Plan ist die Falle — eine Person mit 100 % +ist dort keine Engstelle, sondern die Realität. Gezählt werden Personen **auf +der Front** (Last > 0), nicht im Dokument: Wer nur erledigte Knoten trägt, +macht aus einem faktischen Solo-Rest kein Zwei-Personen-Problem. Mehr als +eine Person über der Hälfte kann es nicht geben — die Meldung ist eindeutig. +Die Schwelle ist gesetzt, nicht hergeleitet (wie die 32 Zeichen in D64); +justiert wird nach Erfahrung. + +**Anzeige: zeilenlose Warnung plus Warn-Pille.** Der Engpass hat keine +einzelne Zeile — `assigneeOverload` ist zeilenlos wie `cheapApprox` und nennt +Person, Anteil und Stationen. Zusätzlich wechseln die **Personen-Pillen** der +betroffenen Person an offenen Pfad-Knoten auf `--warn` — derselbe Griff wie +beim Größen-Konflikt-Badge (D62); der Grafikexport folgt von selbst, weil er +die gemessenen Pillen-Farben liest (D62). Geerbte Zuständigkeit hat keine +Pille und damit keine Färbung — benannt, kein Fehler. Kein neuer Umschalter, +kein Panel; bei ausgeschaltetem Pfad entfällt beides (ohne Pfad keine offene +Front). + +**Headless nach Hausregel** (D54-Nachtrag 3): `assigneeLoads`/ +`overloadedAssignee` in model.js, der Renderer bekommt nur `overloadTag`; +app.js verdrahtet Warnung und Option. Die mitgelieferten Dokumente bleiben +still (der Werkbaum-Plan hat keine Knoten-Tags; im Example liegt anna bei +3 von 13) — nachgemessen, nicht angenommen. + +**Nachgemessen** im Browser an einem Wegwerf-Dokument: „@anna trägt 80 % der +offenen Arbeit auf dem günstigsten Pfad (1 von 2 Stationen) — mögliche +Engstelle.", genau eine Pille bernstein (`rgb(180,83,9)`) — am offenen +Pfad-Knoten, nicht am erledigten und nicht bei @ben; Pfad aus ⇒ Warnung und +Färbung weg, wieder an ⇒ beides zurück. 470 Tests, davon 13 neue in +`tests/overload.test.js`. Gegenproben per Mutation: Solo-Wächter entfernt → +genau der Solo-Test fällt; Schwelle aufgeweicht → genau die drei +Schwellen-Tests; Vererbung entfernt → genau der Vererbungs-Test; Teilung +entfernt → genau der Mehrfach-Tag-Test; Erledigt-Ausnahme der Pille +entfernt → genau der Pillen-Test. diff --git a/docs/SPEC.md b/docs/SPEC.md index 2cb2fd4..61a37c2 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -396,6 +396,9 @@ Größe als **Bereich** gelesen (Untergrenzen verdoppeln sich): - `@name` mit `name` aus Unicode-Buchstaben, Ziffern, `.`, `_`, `-`. - Mehrere Tags pro Zeile möglich, Position im Text egal. - Anzeige: helle Pillen unten rechts an der Knoten-Ecke. +- Für den **Zuständigen-Engpass** (§9) erbt ein Knoten ohne Tags die Tags des + nächsten Vorfahren mit Tags — nur für diese Rechnung; an der Anzeige der + Pillen ändert das nichts. ## 8. Kommentare @@ -727,6 +730,34 @@ vorn. Der Knopf ist **verborgen**, solange es keine Station gibt — bei ausgeschaltetem Pfad ebenso wie bei einem durchweg erledigten Plan; sein Tooltip nennt die Zahl der offenen Stationen. Siehe D47. +**Zuständigen-Engpass.** Trägt **eine** Person zu viel der offenen Arbeit des +günstigsten Pfads, meldet eine zeilenlose Warnung `assigneeOverload` die +mögliche Engstelle: Die nächste Ausbaustufe ist dann nicht parallelisierbar — +alles Weitere serialisiert sich durch diese Person. Über Reihenfolge oder +Termine sagt die Warnung nichts (das tut in dieser Notation ohnehin nichts, +§1). Siehe D71. + +- **Last:** Jeder nötige Knoten zählt mit dem, was seine Größe über die + nötigen Teilpakete hinaus behauptet (dasselbe Maß wie der Belegungs-Vergleich + der Closure-Suche, oben) — Erledigtes also mit 0. Mehrere `@name`-Tags einer + Zeile teilen sich den Beitrag zu gleichen Teilen. +- **Zuständig** ist, wer auf der Zeile getaggt ist (§7); ein Knoten **ohne** + Tags erbt für diese Rechnung die Tags des nächsten Vorfahren **mit** Tags — + die übliche WBS-Lesart: Wer das Paket hat, hat die Teilpakete. Ohne + getaggten Vorfahren zählt der Beitrag als nicht zugewiesen — er verwässert + die Anteile, warnt aber nicht selbst. +- **Gemeldet** wird, wenn mindestens **zwei** Personen Last auf der offenen + Front tragen und eine davon **mehr als die Hälfte** der gesamten offenen + Pfad-Arbeit hält. Ein Solo-Plan warnt damit nie — eine Person, die alles + trägt, ist dort keine Engstelle, sondern die Realität. Die Schwelle ist + gesetzt, nicht hergeleitet (wie die ~32 Zeichen des Label-Umbruchs). +- **Anzeige:** Die Warnung nennt Person, Anteil und Stationen („@anna trägt + 62 % der offenen Arbeit … 7 von 9 Stationen"); zusätzlich wechseln die + Personen-Pillen (§7) der betroffenen Person an **offenen Pfad-Knoten** auf + die Warnfarbe (`--warn`) — auch im Grafikexport. Geerbte Zuständigkeit hat + keine Pille und damit keine Färbung. Bei ausgeschaltetem Pfad entfällt + beides — ohne Pfad gibt es keine offene Front. + ### Diagramm aus einer URL laden (`?sourceUrl=`) Der Editor kann den Notationstext aus einer externen Textdatei beziehen: `…?sourceUrl=https://example.org/plan.txt` (relative Angaben werden gegen die diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index cca3b93..1af3d89 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -49,6 +49,7 @@ - [^] #ed.path.started: Started work is on the path, extras included (S) %% see D61 - [^] #ed.path.line: Metro-map line through the leaves (S) - [^] #ed.path.step: Step from station to station, one button (S) %% see D47 + - [x] #ed.path.load: Warn when one person is the bottleneck (S) %% see D71 - [^] #ed.closure: Count the whole dependency closure (M) - [^] #ed.closure.once: Shared dependencies counted only once (S) - [^] #ed.closure.nonlocal: The cheapest alternative is no longer the local one (M) %% see D34, D42 @@ -423,6 +424,12 @@ next. Seeing the front was solved; walking it across a very wide tree was not. +#ed.path.load + Warns when one @name carries more than half of the open work on the + cheapest path while at least two people are on it: the next increment + would serialize through that person. Untagged nodes inherit the nearest + tagged ancestor, and the person's tag pills turn amber on open path nodes. + #ed.closure The cheapest path pulls every dependency of a needed node into the needed set, and shared targets count only once. That is why an alternative can win diff --git a/frontend/src/app.js b/frontend/src/app.js index 6dc4096..2fa3c45 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -1,6 +1,6 @@ import './style.css'; import { parse, setFoldMark, expandShortIds, shortIdClosed } from './parser.js'; -import { computeCheapPlan, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, atMostM, lineTargets } from './model.js'; +import { computeCheapPlan, overloadedAssignee, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, atMostM, lineTargets } from './model.js'; import { esc, renderTreeHtml, TIP_RULE } from './render.js'; import { formatWarning, warningText } from './warnings.js'; import { padUrls } from './remote.js'; @@ -133,11 +133,16 @@ function render(){ /* Günstigster Pfad auf der Dependency Closure (D42): scheitert die exakte Suche an zu vielen gekoppelten Gruppen, wird die gierige Schätzung BENANNT statt stillschweigend geliefert (zeilenlose Warnung). */ - let cheapSet = new Set(); + let cheapSet = new Set(), overload = null; if(cheapPathOn){ const plan = computeCheapPlan(roots); cheapSet = plan.set; if(!plan.exact) warnings = warnings.concat([{type: 'cheapApprox'}]); + /* Zuständigen-Engpass (SPEC §9/D71): Trägt eine Person mehr als die + Hälfte der offenen Pfad-Arbeit, wird das zeilenlos gemeldet und ihre + Pillen an offenen Pfad-Knoten warnfarben (overloadTag unten). */ + overload = overloadedAssignee(roots, cheapSet); + if(overload) warnings = warnings.concat([{type: 'assigneeOverload', ...overload}]); } out.classList.toggle('cheap-on', cheapPathOn); /* Faltung (D38): Anfangszustand aus den Textmarken (`!!!` holt sich mit @@ -185,7 +190,8 @@ function render(){ lineTargetMap = lineTargets(roots, collapsedSet, showDiscarded); const r = renderTreeHtml(roots, {t, showDiscarded, cheapPath: cheapPathOn, cheapSet, showIds, freshSet, collapsedSet, - effStatus: effectiveStatus(roots)}); + effStatus: effectiveStatus(roots), + overloadTag: overload ? overload.tag : null}); out.innerHTML = r.html; warnings = warnings.concat(r.warnings); } @@ -2220,6 +2226,7 @@ const I18N = { sizeConflictWarn:"Zeile {line}: Die Teilpakete übersteigen zusammen die angegebene Größe ({size}) — selbst in der günstigsten Lesart.", sizeConflictTooltip:"Die Teilpakete übersteigen zusammen die angegebene Größe", cheapApproxWarn:"Zu viele gekoppelte Alternativgruppen für die exakte Suche — der günstigste Pfad ist gierig geschätzt (je Gruppe lokal gewählt).", + assigneeOverloadWarn:"@{tag} trägt {share} % der offenen Arbeit auf dem günstigsten Pfad ({stations} von {total} Stationen) — mögliche Engstelle.", st_idee:"Idee", st_geplant:"geplant", st_arbeit:"in Arbeit", st_durchstich:"Durchstich", st_fertig:"fertig", st_prod:"in Produktion", st_highrisk:"High Risk", st_verworfen:"verworfen", unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.", @@ -2324,6 +2331,7 @@ const I18N = { sizeConflictWarn:"Line {line}: the sub-packages together exceed the given size ({size}) — even in the most optimistic reading.", sizeConflictTooltip:"The sub-packages together exceed the given size", cheapApproxWarn:"Too many coupled alternative groups for the exact search — the cheapest path is a greedy estimate (chosen locally per group).", + assigneeOverloadWarn:"@{tag} carries {share}% of the open work on the cheapest path ({stations} of {total} stations) — a possible bottleneck.", st_idee:"idea", st_geplant:"planned", st_arbeit:"in progress", st_durchstich:"walking skeleton", st_fertig:"done", st_prod:"in production", st_highrisk:"high risk", st_verworfen:"discarded", unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.", @@ -2428,6 +2436,7 @@ const I18N = { sizeConflictWarn:"Línea {line}: los subpaquetes juntos superan el tamaño indicado ({size}), incluso en la lectura más optimista.", sizeConflictTooltip:"Los subpaquetes juntos superan el tamaño indicado", cheapApproxWarn:"Demasiados grupos de alternativas acoplados para la búsqueda exacta — el camino más barato es una estimación voraz (elección local por grupo).", + assigneeOverloadWarn:"@{tag} lleva el {share} % del trabajo pendiente en el camino más barato ({stations} de {total} estaciones) — posible cuello de botella.", st_idee:"idea", st_geplant:"planificado", st_arbeit:"en curso", st_durchstich:"prototipo funcional", st_fertig:"terminado", st_prod:"en producción", st_highrisk:"alto riesgo", st_verworfen:"descartado", unknownStatusWarn:"Línea {line}: código de estado desconocido «{code}» — mostrado como neutral.", @@ -2532,6 +2541,7 @@ const I18N = { sizeConflictWarn:"Ligne {line} : les sous-lots dépassent ensemble la taille indiquée ({size}), même dans la lecture la plus optimiste.", sizeConflictTooltip:"Les sous-lots dépassent ensemble la taille indiquée", cheapApproxWarn:"Trop de groupes d’alternatives couplés pour la recherche exacte — le chemin le moins cher est une estimation gloutonne (choix local par groupe).", + assigneeOverloadWarn:"@{tag} porte {share} % du travail restant sur le chemin le moins cher ({stations} stations sur {total}) — goulot d’étranglement possible.", st_idee:"idée", st_geplant:"planifié", st_arbeit:"en cours", st_durchstich:"squelette fonctionnel", st_fertig:"terminé", st_prod:"en production", st_highrisk:"risque élevé", st_verworfen:"abandonné", unknownStatusWarn:"Ligne {line} : code de statut inconnu « {code} » — affiché comme neutre.", @@ -2636,6 +2646,7 @@ const I18N = { sizeConflictWarn:"Wiersz {line}: podzadania razem przekraczają podany rozmiar ({size}) — nawet w najkorzystniejszym odczycie.", sizeConflictTooltip:"Podzadania razem przekraczają podany rozmiar", cheapApproxWarn:"Zbyt wiele sprzężonych grup alternatyw dla dokładnego wyszukiwania — najtańsza ścieżka jest oszacowana zachłannie (wybór lokalny w każdej grupie).", + assigneeOverloadWarn:"@{tag} niesie {share} % otwartej pracy na najtańszej ścieżce ({stations} z {total} stacji) — możliwe wąskie gardło.", st_idee:"pomysł", st_geplant:"zaplanowane", st_arbeit:"w toku", st_durchstich:"działający szkielet", st_fertig:"gotowe", st_prod:"w produkcji", st_highrisk:"wysokie ryzyko", st_verworfen:"odrzucone", unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.", @@ -2740,6 +2751,7 @@ const I18N = { sizeConflictWarn:"Строка {line}: подзадачи вместе превышают указанный размер ({size}) — даже при самой оптимистичной оценке.", sizeConflictTooltip:"Подзадачи вместе превышают указанный размер", cheapApproxWarn:"Слишком много связанных групп альтернатив для точного поиска — самый дешёвый путь оценён жадно (локальный выбор в каждой группе).", + assigneeOverloadWarn:"@{tag} несёт {share} % открытой работы на самом дешёвом пути ({stations} из {total} станций) — возможное узкое место.", st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип", st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено", unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.", @@ -2844,6 +2856,7 @@ const I18N = { sizeConflictWarn:"पंक्ति {line}: उप-पैकेज मिलकर दिए गए आकार ({size}) से बड़े हैं — सबसे आशावादी आकलन में भी।", sizeConflictTooltip:"उप-पैकेज मिलकर दिए गए आकार से बड़े हैं", cheapApproxWarn:"सटीक खोज के लिए बहुत सारे युग्मित विकल्प-समूह — सबसे सस्ता पथ लालची अनुमान है (प्रति समूह स्थानीय चयन)।", + assigneeOverloadWarn:"@{tag} सबसे सस्ते पथ पर खुले काम का {share}% उठाए हुए है ({total} में से {stations} स्टेशन) — संभावित अड़चन।", st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा", st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत", unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।", @@ -2948,6 +2961,7 @@ const I18N = { sizeConflictWarn:"第 {line} 行:子项合计超出所标注的尺寸({size})——即使按最乐观的估算也是如此。", sizeConflictTooltip:"子项合计超出所标注的尺寸", cheapApproxWarn:"耦合的备选组过多,无法精确搜索——最便宜路径为贪心估计(每组就地选择)。", + assigneeOverloadWarn:"@{tag} 承担最便宜路径上 {share}% 的未完成工作({total} 个站点中的 {stations} 个)——可能的瓶颈。", st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架", st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃", unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。", @@ -3052,6 +3066,7 @@ const I18N = { sizeConflictWarn:"{line} 行目:サブパッケージの合計が指定サイズ({size})を超えています — 最も楽観的な見積もりでも。", sizeConflictTooltip:"サブパッケージの合計が指定サイズを超えています", cheapApproxWarn:"結合された選択肢グループが多すぎるため厳密探索は不可 — 最安パスは貪欲法による推定です(グループごとに局所選択)。", + assigneeOverloadWarn:"@{tag} が最安パスの未完了作業の {share}% を担っています(全 {total} 駅中 {stations} 駅)— ボトルネックの可能性。", st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン", st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄", unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。", diff --git a/frontend/src/model.js b/frontend/src/model.js index 1872381..6948632 100644 --- a/frontend/src/model.js +++ b/frontend/src/model.js @@ -318,6 +318,59 @@ export function cheapCls(n, cheapSet, collapsed){ return hidesOpenCheap(n, cheapSet) ? 'cheap' : 'cheap cheap-leaf'; } +/* ---------- Zuständigen-Engpass (SPEC §7/§9, D71) ---------- + Wer trägt wie viel der OFFENEN Arbeit des günstigsten Pfads? Jeder nötige + Knoten zählt mit seiner Marge — dem, was seine Größe über die nötigen + Teilpakete hinaus behauptet (dasselbe Maß wie der Belegungs-Vergleich in + `computeCheapPlan`, D69); Erledigtes hat `ownCost` 0 und trägt damit von + selbst nichts. Zuständig ist, wer auf der Zeile getaggt ist; ein Knoten + OHNE Tags erbt für diese Rechnung die Tags des nächsten Vorfahren mit Tags + (WBS-Lesart: Wer das Paket hat, hat die Teilpakete). Mehrere Tags teilen + sich den Beitrag zu gleichen Teilen. Beiträge ohne getaggten Vorfahren + gehen nur in die Gesamtsumme ein — sie verwässern die Anteile, warnen aber + nicht selbst. Stationen (die tiefsten offenen Pfad-Knoten, D46) werden für + den Meldungstext mitgezählt; ein Knoten mit mehreren Zuständigen zählt als + Station bei jedem von ihnen. */ +export function assigneeLoads(roots, cheapSet){ + const loads = new Map(), stations = new Map(); + let total = 0, totalStations = 0; + const walk = (ns, inherited) => { + for(const n of ns){ + const owners = n.tags && n.tags.length ? n.tags : inherited; + if(cheapSet.has(n)){ + let kids = 0; + for(const k of pathChildren(n)) if(cheapSet.has(k)) kids += ownCost(k); + const m = Math.max(0, ownCost(n) - kids); + total += m; + if(m > 0) for(const tag of owners) + loads.set(tag, (loads.get(tag) || 0) + m / owners.length); + if(!isDone(n) && !hidesOpenCheap(n, cheapSet)){ + totalStations++; + for(const tag of owners) stations.set(tag, (stations.get(tag) || 0) + 1); + } + } + walk(n.children, owners); + } + }; + walk(roots, []); + return {loads, total, stations, totalStations}; +} +/* Die Engpass-Entscheidung (SPEC §9, D71): gemeldet nur, wenn mindestens ZWEI + Personen Last auf der offenen Front tragen — ein Solo-Plan warnt nie, eine + Person mit allem ist dort keine Engstelle, sondern die Realität — und eine + davon MEHR ALS DIE HÄLFTE der gesamten offenen Pfad-Arbeit hält (strikt; + mehr als eine Person über der Hälfte kann es nicht geben). Die Schwelle ist + gesetzt, nicht hergeleitet (D64-Linie). */ +export function overloadedAssignee(roots, cheapSet){ + const {loads, total, stations, totalStations} = assigneeLoads(roots, cheapSet); + if(loads.size < 2 || total <= 0) return null; + let tag = null, load = 0; + loads.forEach((l, name) => { if(l > load){ load = l; tag = name; } }); + if(load * 2 <= total) return null; + return {tag, share: Math.round(load / total * 100), + stations: stations.get(tag) || 0, totalStations}; +} + /* ---------- 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"), diff --git a/frontend/src/render.js b/frontend/src/render.js index a9105b8..8686734 100644 --- a/frontend/src/render.js +++ b/frontend/src/render.js @@ -13,6 +13,9 @@ collapsedSet, // optional: eingeklappte Knoten (Faltung, SPEC §9/D38) effStatus, // optional: Map Knoten -> effektiver Status-Key, nur // Diskrepanzen (effectiveStatus() in model.js, D39) + overloadTag, // optional: Person, die mehr als die Hälfte der offenen + // Pfad-Arbeit trägt (overloadedAssignee(), SPEC §9/D71) — + // ihre Pillen an offenen Pfad-Knoten werden warnfarben } */ import { gateOf, needsBreakdown, visibleChildren, cheapCls, isDone, assumedSize } from './model.js'; @@ -190,8 +193,13 @@ function nodeHtml(n, extra, opts, fold){ als zweiten, dürftigeren Tooltip. Kein zweites Attribut mit demselben Text — es ist dasselbe, nur unter anderem Namen. */ const title = ` data-tip="${attr(tip)}"`; + /* Zuständigen-Engpass (SPEC §9/D71): Die Pille der überlasteten Person + wechselt an OFFENEN Pfad-Knoten auf die Warnfarbe — dort liegt die Last. + Nur direkte Tags haben eine Pille; geerbte Zuständigkeit (die in die + Rechnung eingeht) hat nichts, das sich färben ließe. */ + const hot = tag => opts.overloadTag === tag && opts.cheapSet.has(n) && !isDone(n); const tagsHtml = n.tags && n.tags.length - ? `` + ? `` : ''; /* Das implizite Größen-Badge macht eine KOSTENANNAHME sichtbar (D18) — seit D66 die aus den Teilpaketen geschätzte Größe statt pauschal M. An diff --git a/frontend/src/style.css b/frontend/src/style.css index 680deda..74b5c1c 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -885,6 +885,10 @@ border-radius:999px;padding:1px 6px;line-height:1.4; box-shadow:0 0 0 1.5px var(--card); } + /* Zuständigen-Engpass (SPEC §9/D71): die Pille der Person, die mehr als die + Hälfte der offenen Pfad-Arbeit trägt — Warnfarbe wie das + Größen-Konflikt-Badge (D62); der Export liest die gemessenen Farben. */ + .tag.overload{background:var(--warn);color:#fff;border-color:var(--warn)} .ghost-node{ position:relative;margin-top:14px; border:1.5px dashed var(--warn); diff --git a/frontend/src/warnings.js b/frontend/src/warnings.js index 78e20be..4225aaf 100644 --- a/frontend/src/warnings.js +++ b/frontend/src/warnings.js @@ -29,6 +29,11 @@ angegebene Größe des Elternknotens, selbst in der günstigsten Bereichs-Lesart (SPEC §5/D62); Zeile = Elternknoten + - assigneeOverload { tag, share, stations, totalStations } + — eine Person trägt mehr als die Hälfte der + offenen Arbeit des günstigsten Pfads + (SPEC §9/D71); zeilenlos — der Engpass + hat keine einzelne Zeile - cheapApprox { } — günstigster Pfad nur gierig geschätzt: zu viele gekoppelte Gruppen für die exakte Suche (D42); zeilenlos @@ -78,6 +83,9 @@ function build(w, t, esc){ return t('descStrayWarn', {line: w.line}); case 'sizeConflict': return t('sizeConflictWarn', {line: w.line, size: esc(w.size)}); + case 'assigneeOverload': + return t('assigneeOverloadWarn', {tag: esc(w.tag), share: w.share, + stations: w.stations, total: w.totalStations}); case 'cheapApprox': return t('cheapApproxWarn'); case 'sourceLoad': diff --git a/frontend/tests/overload.test.js b/frontend/tests/overload.test.js new file mode 100644 index 0000000..d7fae78 --- /dev/null +++ b/frontend/tests/overload.test.js @@ -0,0 +1,143 @@ +import { describe, it, expect } from 'vitest'; +import { parse } from '../src/parser.js'; +import { computeCheapSet, assigneeLoads, overloadedAssignee } from '../src/model.js'; +import { renderTreeHtml } from '../src/render.js'; +import { formatWarning } from '../src/warnings.js'; + +/* Zuständigen-Engpass (SPEC §7/§9, D71): Trägt eine Person mehr als die Hälfte + der offenen Arbeit des günstigsten Pfads (Marginalkosten-Maß, D69) und gibt + es mindestens zwei Personen mit Last auf der offenen Front, wird gewarnt. + Knoten ohne Tags erben für die Rechnung vom nächsten getaggten Vorfahren; + mehrere Tags teilen sich den Beitrag. Kostenskala: XS=1 S=2 M=3 L=4 XL=5. */ + +const roots = txt => parse(txt).roots; +const plan = txt => { const r = roots(txt); return { r, set: computeCheapSet(r) }; }; +const t = (key, vars) => { + let s = key; + if(vars) for(const k in vars) s += ':' + vars[k]; + return s; +}; + +describe('overloadedAssignee — die Engpass-Entscheidung', () => { + it('eine Person über der Hälfte, eine zweite auf der Front: Warnung', () => { + const { r, set } = plan(`[ ] Plan + - [ ] A (L) @anna + - [ ] B (XS) @ben`); + /* anna 4, ben 1, Wurzel geschätzt L mit Marge 0 ⇒ total 5, anna 80 % */ + expect(overloadedAssignee(r, set)).toEqual( + {tag: 'anna', share: 80, stations: 1, totalStations: 2}); + }); + + it('ein Solo-Plan warnt nie — auch bei 100 %', () => { + const { r, set } = plan(`[ ] Plan + - [ ] A (L) @anna + - [ ] B (S) @anna`); + expect(overloadedAssignee(r, set)).toBeNull(); + }); + + it('genau die Hälfte reicht nicht — die Schwelle ist strikt', () => { + const { r, set } = plan(`[ ] Plan + - [ ] A (L) @anna + - [ ] B (L) @ben`); + expect(overloadedAssignee(r, set)).toBeNull(); + }); + + it('Knoten ohne Tags erben vom nächsten Vorfahren mit Tags', () => { + const { r, set } = plan(`[ ] Plan + - [ ] Paket (L) @anna + - [ ] Teil (M) + - [ ] B (S) @ben`); + /* Teil (3) gehört per Vererbung anna; dazu Paket-Marge 1 ⇒ anna 4 von 6. + Ohne Vererbung wäre anna nur die Marge 1 und niemand über der Hälfte. */ + const o = overloadedAssignee(r, set); + expect(o && o.tag).toBe('anna'); + expect(o.share).toBe(67); + }); + + it('mehrere Tags einer Zeile teilen sich den Beitrag', () => { + const { r, set } = plan(`[ ] Plan + - [ ] A (L) @anna @ben + - [ ] C (S) @carla`); + /* A: je 2 für anna und ben; voll doppelt gezählt läge anna bei 4 von 6 + und würde fälschlich gemeldet. */ + expect(overloadedAssignee(r, set)).toBeNull(); + }); + + it('Erledigtes trägt keine Last — die offene Front zählt', () => { + const { r, set } = plan(`[ ] Plan + - [x] Big (XL) @ben + - [ ] A (S) @anna + - [ ] B (XS) @ben`); + /* bens großer Brocken ist fertig (0); offen sind anna 2 und ben 1 */ + const o = overloadedAssignee(r, set); + expect(o && o.tag).toBe('anna'); + }); + + it('nicht zugewiesene Arbeit verwässert die Anteile, warnt aber nicht', () => { + const { r, set } = plan(`[ ] Plan (XXL) + - [ ] A (S) @anna + - [ ] B (XS) @ben`); + /* Wurzel-Marge 3 gehört niemandem ⇒ anna 2 von 6, unter der Hälfte */ + expect(overloadedAssignee(r, set)).toBeNull(); + }); + + it('leere Pfadmenge (Pfad aus): keine Aussage', () => { + const r = roots(`[ ] Plan + - [ ] A (L) @anna + - [ ] B (XS) @ben`); + expect(overloadedAssignee(r, new Set())).toBeNull(); + }); +}); + +describe('assigneeLoads — das Maß dahinter', () => { + it('Marginalkosten je Person, Summe ist der Pfadpreis', () => { + const { r, set } = plan(`[ ] Plan + - [ ] A (L) @anna + - [ ] B (XS) @ben`); + const { loads, total } = assigneeLoads(r, set); + expect(loads.get('anna')).toBe(4); + expect(loads.get('ben')).toBe(1); + expect(total).toBe(5); + }); + + it('Stationen zählen nur offene Blätter des Pfads', () => { + const { r, set } = plan(`[ ] Plan + - [x] Fertig (S) @anna + - [ ] Offen (S) @anna + - [ ] B (XS) @ben`); + const { stations, totalStations } = assigneeLoads(r, set); + expect(stations.get('anna')).toBe(1); + expect(totalStations).toBe(2); + }); +}); + +describe('Darstellung', () => { + it('die Pille der überlasteten Person wird an offenen Pfad-Knoten markiert', () => { + const { r, set } = plan(`[ ] Plan + - [ ] A (L) @anna + - [x] Done (S) @anna + - [ ] B (XS) @ben`); + const { html } = renderTreeHtml(r, {t, showDiscarded: false, cheapPath: true, + cheapSet: set, overloadTag: 'anna'}); + /* genau eine markierte Pille: am offenen A — nicht am erledigten Done, + nicht an bens Pille */ + expect(html.match(/tag overload/g)).toHaveLength(1); + expect(html).toContain('anna'); + expect(html).toContain('ben'); + }); + + it('ohne overloadTag bleibt alles wie bisher', () => { + const { r, set } = plan(`[ ] Plan + - [ ] A (L) @anna + - [ ] B (XS) @ben`); + const { html } = renderTreeHtml(r, {t, showDiscarded: false, cheapPath: true, + cheapSet: set, overloadTag: null}); + expect(html).not.toContain('overload'); + }); + + it('die Warnung nennt Person, Anteil und Stationen', () => { + const msg = formatWarning( + {type: 'assigneeOverload', tag: 'anna', share: 80, stations: 1, totalStations: 2}, t); + expect(msg).toBe('assigneeOverloadWarn:anna:80:1:2'); + }); +});