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
- ? `${n.tags.map(tag => `${esc(tag)}`).join('')}`
+ ? `${n.tags.map(tag => `${esc(tag)}`).join('')}`
: '';
/* 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');
+ });
+});