diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0446470..aaf8ee7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -17,6 +17,11 @@ the git history of `docs/examples/werkbaum.werkbaum`. A day can therefore carry a link without having a note (someone forgot to write one) — but never the reverse. +## 2026-08-28 + +- The create buttons in the node window are recut: one "create story" button always opens the dialog with project choice and sub-package checkboxes — unchecking covers the story-only case +- A "create task" button appears where an ancestor node already carries a story: the task lands in that story without any dialog — project and story follow from the tree, only an error gets a surface + ## 2026-08-27 - Where a ticket and the plan disagree, the node window says so and offers both directions as explicit buttons — nothing happens by itself diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 8ce54b2..fe56727 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -8331,3 +8331,58 @@ Wer i18n-Texte mit Anführungszeichen schreibt, nimmt die typografischen (`„…“`, `«…»`, `“…”`) und prüft die Datei einmal mit `npx esbuild src/app.js --outfile=…` — das ist die schnellste ehrliche Syntaxprobe für eine Datei, die kein Test anfasst. + +**Nachtrag 9 — die Anlage-Knöpfe neu geschnitten: ein Story-Knopf mit +Dialog, ein dialogfreier Task-Knopf (2026-08-28).** Nutzerwunsch nach dem +ersten Arbeiten mit der Anlage: statt „Story anlegen"/„Story + Tasks +anlegen" **ein** „Story anlegen" (mit dem Häkchen-Dialog über die +Teilpakete, wie bisher bei der zweiten Aktion) und daneben **„Task +anlegen"** — nur dort, wo ein übergeordneter Knoten schon eine Story trägt, +denn in der wird die Task angelegt; „für Task-Anlegen braucht man dann auch +keine Auswahl". Die Entscheidungen des Baus: + +- **Der eine Story-Knopf trägt den Dialog immer** — wer nur die Story will, + wählt die Häkchen ab (an einem Blatt gibt es ohnehin keine). Der frühere + Story-only-Knopf entfällt ersatzlos; ein Knopf, dessen ganze Aussage „wie + der andere, nur ohne Häkchen" ist, war einer zu viel. +- **„Task anlegen" hängt am nächsten Vorfahren mit `#US-…`-Ref** + (`storyAncestor`, headless in taiga.js). Vorfahren mit **Task**-Ref werden + übersprungen: Taiga-Tasks haben keine Subtasks (D91), die Task eines + Task-Knotens gehört in die Story darüber. +- **Ohne Dialog, weil es nichts zu wählen gibt:** Das Projekt folgt aus dem + geerbten `&taiga.`, die Story aus dem Baum. Das weicht bewusst von + der Haupttext-Festlegung „das Projekt wird bei jeder Anlage gefragt" ab — + hier hätte die Frage genau eine richtige Antwort, und eine Frage mit einer + Antwort ist keine. Der Story-Dialog fragt unverändert. +- **Der Knopf entfällt, wo ein `&taiga.*` zwischen Story und Knoten den Slug + übersteuert:** Die Ref an so einer Zeile würde später gegen das **falsche** + Projekt aufgelöst (Fenster, Öffnen, Status) — lieber kein Knopf als eine + Adresse, die lügt. +- **Drei Umläufe, kein Backend-Umbau:** Mitgliederliste (die Projekt-Id zum + Slug — der Lese-Endpunkt trägt keine), Story-Detail per `by_ref` (die + Story-Id, das `id`-Feld liefert Nachtrag 6 schon), `POST /tasks`. +- **Der Erfolg zeigt sich als Ref an der Zeile** (der Neubau ist die + Rückmeldung, wie beim Falten); nur ein **Fehler** bekommt eine Fläche — + ein kleiner Dialog mit Taigas Meldung (Ok-Knopf verborgen, Abbrechen + schließt), kein `window.alert` (D22-Lehre). Ein Slug außerhalb der eigenen + Projekte meldet sich mit eigenem Text (`taigaTaskNoProject`). +- i18n: `taigaTaskBtn`/`taigaTaskNoProject` neu ×9, `taigaTasksBtn` entfällt. + +**Nachgemessen** Ende-zu-Ende im Browser gegen das lokale Backend mit +Taiga-Stub, alle vier Knopf-Konstellationen: Story + Task unter einem +Story-Vorfahren; nur Story bei übersteuerndem Slug; nur Story ohne +Story-Vorfahren; keine Anlage-Knöpfe an einer Ref (dort Stand + Öffnen wie +gehabt). Der Task-Klick schreibt `#T-201` an die Zeile, ohne Dialog; +Stub-Mitschnitt: `projects` → `by_slug` → `by_ref` → `POST /tasks +{project: 7, user_story: 1234}`. Der Story-Knopf auf einem Knoten mit +Kindern öffnet den Dialog mit Projekt-Vorbelegung und beiden Häkchen; der +Fremd-Slug-Fehlerpfad zeigt den Dialog mit Taigas Meldung und lässt die +Zeile unangetastet. 602 Tests (6 neue für `storyAncestor`); Gegenprobe per +Mutation: ohne das Überspringen der Task-Refs fällt genau der danach +benannte Test. + +**Werkzeug-Notiz:** Die JavaScript-Ausführung des Prüf-Panes lebt in einer +**isolierten Welt** — ein überschriebenes `window.confirm` erreicht die +Seite nicht (Klicks schon: das DOM ist geteilt, die JS-Objekte nicht). Eine +Rückfrage lässt sich dort also nicht wegstubben; Aufräumen, das durch eine +Rückfrage führt, geht stattdessen über die Ablage selbst (D83-Schema). diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index 7185259..f02a37f 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -211,6 +211,7 @@ - [^] #trk.create.project: The project comes from the inherited tag (S) :#not.tag.project - [^] #trk.create.story: A "create story" action in the node window (S) :#trk.create.login - [^] #trk.create.tasks: Sub-packages become tasks, picked in a dialog (M) :#trk.create.story + - [x] #trk.create.task: A dialog-free "create task" under a story ancestor (S) :#trk.create.tasks,#trk.resolve.read - [^] #trk.create.ref: The ref lands in the line beside the id (S) :#trk.create.story - [^] #trk.open: Open a ticket ref in Taiga (S) :#trk.create.ref,#not.tag.project - [-] #trk.plugin: A plugin inside Taiga itself (L) %% legacy frontend, self-hosted only @@ -1192,10 +1193,18 @@ created. A node that already carries a ticket ref is left alone. #trk.create.tasks - The second action also creates the direct sub-packages as tasks under the + The story dialog also creates the direct sub-packages as tasks under the story, chosen in a checkbox dialog: preselected are required and optional children plus the realized alternatives of a |/= group; discarded ones - never appear, finished ones start unchecked. + never appear, finished ones start unchecked. Unchecking everything covers + the story-only case, so there is just one create-story button. + +#trk.create.task + Where an ancestor node already carries a story ref, the node window offers + "create task": the task lands in that story, without any dialog — project + and story follow from the tree. Ancestors with a task ref are skipped + (Taiga tasks have no subtasks), and the button is absent where a &taiga + tag in between would resolve the new ref against the wrong project. #trk.create.project The dialog's project choice is prefilled from the nearest (&taiga.slug) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index e678538..2f550c0 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -656,17 +656,20 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der `render()`. Die letzten drei sind beim Bauen zuerst vergessen worden. - **Taiga-Ticket-Anlage (D91):** Die entscheidbaren Regeln liegen headless in `taiga.js` (`ticketRefOf` = Idempotenz-Marker, `taskCandidates` = - Dialog-Vorbelegung, `appendToken` = Token vor `%%` und vor der - Fortsetzungsmarke ` \`); die `taiga.*`-Vererbung rechnet `taigaSlugs()` in - model.js. app.js verdrahtet: `refreshTaiga()` fragt je Basis EINMAL - `GET /info` (`taiga`-Flag; Aufruf aus Boot und `followActiveDoc`), - `appendTaigaActions()` hängt die zwei Knöpfe ins Knoten-Fenster (nur ohne - vorhandene Ref), `taigaCreate()` führt Login → Projekt-Dialog → - Anlegen → Ref-Rückschreiben. Geschrieben wird über `replaceTextUndoable` - (Fokus/Undo/Schreibmarke, D53) — nie `src.value =`. Das Token liegt als - `werkbaum-taiga` im localStorage (nur Token, nie ein Passwort); ein 401 - löscht es und fragt neu. Fehler erscheinen IM Dialog, nicht als - `window.alert` (in manchen Kontexten unterdrückt, D22-Lehre). + Dialog-Vorbelegung, `storyAncestor` = der nächste Vorfahr mit Story-Ref, + `appendToken` = Token vor `%%` und vor der Fortsetzungsmarke ` \`); die + `taiga.*`-Vererbung rechnet `taigaSlugs()` in model.js. app.js verdrahtet: + `refreshTaiga()` fragt je Basis EINMAL `GET /info` (`taiga`-Flag; Aufruf + aus Boot und `followActiveDoc`), `appendTaigaActions()` hängt die Knöpfe + ins Knoten-Fenster (nur ohne vorhandene Ref): `taigaCreate()` = Login → + Projekt-Dialog samt Häkchen → Anlegen → Ref-Rückschreiben; `taigaTask()` + (D91-Nachtrag 9) legt unter einem Story-Vorfahren dialogfrei eine Task an + — nur wenn der geerbte Slug am Knoten der des Vorfahren ist, sonst löste + die Ref später gegen das falsche Projekt auf. Geschrieben wird über + `replaceTextUndoable` (Fokus/Undo/Schreibmarke, D53) — nie `src.value =`. + Das Token liegt als `werkbaum-taiga` im localStorage (nur Token, nie ein + Passwort); ein 401 löscht es und fragt neu. Fehler erscheinen IM Dialog, + nicht als `window.alert` (in manchen Kontexten unterdrückt, D22-Lehre). - **Ticket-Stand lesen (D91-Nachtrag 6):** `ticketApiPath()` baut den Proxy-Pfad (getrennte by_ref-Endpunkte je Typ, Slug kodiert), `mapTaigaStatus()` bildet Taigas Statusnamen auf `STATUS_BY_CODE` ab — diff --git a/frontend/src/app.js b/frontend/src/app.js index 4eb56cb..f632e24 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -1,7 +1,7 @@ import './style.css'; import { parse, setFoldMark, setStatusBox, expandShortIds, shortIdClosed } from './parser.js'; import { computeCheapPlan, overloadedAssignee, assigneeLoads, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, presetFoldSet, personFoldSet, allTags, lineTargets, taigaSlugs } from './model.js'; -import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath } from './taiga.js'; +import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath, storyAncestor } from './taiga.js'; import { esc, renderTreeHtml, TIP_RULE } from './render.js'; import { formatWarning, warningText } from './warnings.js'; import * as live from './live.js'; @@ -1718,13 +1718,15 @@ document.addEventListener('keydown', e => { if(e.key === 'Escape'){ closeNodeTip document.querySelector('.diagram').addEventListener('scroll', closeNodeTip, {passive: true}); /* ---------- Taiga-Ticket-Anlage (SPEC §11, D91) ---------- - Zwei Aktionen im Knoten-Fenster — „Story anlegen" und „Story + Teilpakete - als Tasks" —, über den Backend-Proxy (D91: die Taiga-Adresse ist - Server-Konfiguration, das Token bleibt im Browser). Die Aktionen erscheinen - nur, wo `GET /info` das Feature meldet (`taiga`, Lebendprobe-Muster wie - beim Teilen, D81-Nachtrag), und nur an Knoten OHNE Ticket-Referenz — die - Ref an der Zeile ist der Idempotenz-Marker (D91-Nachtrag 2). Die Regeln - (Vorbelegung, Token-Anhängen) liegen headless in taiga.js. */ + Zwei Aktionen im Knoten-Fenster — „Story anlegen" (mit dem Häkchen-Dialog + über die Teilpakete) und, unter einem Vorfahren mit Story, „Task anlegen" + ohne jeden Dialog (D91-Nachtrag 9) —, über den Backend-Proxy (D91: die + Taiga-Adresse ist Server-Konfiguration, das Token bleibt im Browser). Die + Aktionen erscheinen nur, wo `GET /info` das Feature meldet (`taiga`, + Lebendprobe-Muster wie beim Teilen, D81-Nachtrag), und nur an Knoten OHNE + Ticket-Referenz — die Ref an der Zeile ist der Idempotenz-Marker + (D91-Nachtrag 2). Die Regeln (Vorbelegung, Story-Vorfahr, Token-Anhängen) + liegen headless in taiga.js. */ const LS_TAIGA = 'werkbaum-taiga'; let taigaBase = null, taigaOn = false, taigaWeb = null; const taigaProbeCache = new Map(); /* Basis -> Promise<{on, web}> (je Basis EIN /info) */ @@ -1836,15 +1838,22 @@ function appendTaigaActions(el){ } return; } - const mk = (label, mitTasks) => { + const mk = (label, action) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'nodetip-taigabtn'; b.tabIndex = -1; b.textContent = label; - b.addEventListener('click', () => { closeNodeTip(); taigaCreate(line, mitTasks); }); + b.addEventListener('click', () => { closeNodeTip(); action(line); }); return b; }; - wrap.appendChild(mk(t('taigaStoryBtn'), false)); - if(taskCandidates(node).some(c => !c.exists)) wrap.appendChild(mk(t('taigaTasksBtn'), true)); + wrap.appendChild(mk(t('taigaStoryBtn'), taigaCreate)); + /* „Task anlegen" nur unter einem Vorfahren mit Story (D91-Nachtrag 9) — + und nur, wenn dessen Projekt-Slug auch am Knoten gilt: Übersteuerte ein + `&taiga.*` dazwischen, trüge die Zeile nach dem Anlegen eine Ref, die + gegen das FALSCHE Projekt aufgelöst würde. */ + const anc = storyAncestor(roots, node); + const slugs = taigaSlugs(roots); + if(anc && slugs.get(node) && slugs.get(node) === slugs.get(anc.node)) + wrap.appendChild(mk(t('taigaTaskBtn'), taigaTask)); nodeTipBody.appendChild(wrap); } @@ -2188,24 +2197,25 @@ function writeLine(lineNo, mapper){ } /* Der Anlege-Dialog (D91): Projekt wählen — vorbelegt aus dem geerbten - `&taiga.` (D91-Nachtrag 3) —, bei „Story + Tasks" dazu der - Häkchen-Dialog über die direkten Kinder. Nach dem Anlegen landet die Ref - als Token an der Zeile (`#US-…`/`#T-…`); die ERSTE Anlage in einem - Teilbaum ohne Zuordnung schreibt zusätzlich das Projekt-Schlagwort zurück - — beides in einem Undo-Schritt je Zeile. + `&taiga.` (D91-Nachtrag 3) — samt Häkchen-Dialog über die direkten + Kinder (D91-Nachtrag 9: der eine Story-Knopf trägt beides; wer nur die + Story will, wählt ab). Nach dem Anlegen landet die Ref als Token an der + Zeile (`#US-…`/`#T-…`); die ERSTE Anlage in einem Teilbaum ohne Zuordnung + schreibt zusätzlich das Projekt-Schlagwort zurück — beides in einem + Undo-Schritt je Zeile. Scheitert eine Task, bleibt der Dialog mit dem Fehler offen; die Story und schon angelegte Tasks werden beim erneuten Anlegen NICHT wiederholt (`story`/`b.done` halten fest, was bezahlt ist — und die geschriebenen Refs sind ohnehin der Idempotenz-Marker). */ -async function taigaCreate(line, mitTasks){ +async function taigaCreate(line){ const info = nodeAndRootsByLine(line); const node = info.node; if(!node || ticketRefOf(node)) return; const session = await taigaEnsureSession(); if(!session) return; const inherited = taigaSlugs(info.roots).get(node) || null; - const cands = mitTasks ? taskCandidates(node).filter(c => !c.exists) : []; + const cands = taskCandidates(node).filter(c => !c.exists); const dlg = taigaModal(t('taigaProjectTitle'), t('taigaCreateDo')); const subject = document.createElement('p'); @@ -2290,6 +2300,42 @@ async function taigaCreate(line, mitTasks){ }); } +/* „Task anlegen" (D91-Nachtrag 9): Der Knoten wird eine Task in der Story + des nächsten Vorfahren mit `#US-…`-Ref — OHNE Dialog: Das Projekt folgt + aus dem geerbten `&taiga.`, die Story aus dem Baum, es gibt nichts + zu wählen. Der Erfolg zeigt sich als Ref an der Zeile (der Neubau ist die + Rückmeldung, wie beim Falten); nur ein FEHLER bekommt eine Fläche — ein + kleiner Dialog mit Taigas Meldung, kein window.alert (D22-Lehre). */ +async function taigaTask(line){ + const info = nodeAndRootsByLine(line); + const node = info.node; + if(!node || ticketRefOf(node)) return; + const anc = storyAncestor(info.roots, node); + const slug = taigaSlugs(info.roots).get(node) || null; + if(!anc || !slug) return; + const session = await taigaEnsureSession(); + if(!session) return; + try{ + /* Die Projekt-Id zum Slug aus der Mitgliederliste (dieselbe, die der + Story-Dialog zeigt) — die Task-Anlage braucht sie, der Story-Detail + trägt sie nicht. Die Story-Id liefert der Lese-Endpunkt (Nachtrag 6). */ + const projects = await taigaProjects(session); + const projekt = projects.find(p => p.slug === slug); + if(!projekt) throw {body: {detail: t('taigaTaskNoProject', {slug})}}; + const story = await taigaFetch(ticketApiPath(anc.ref, slug), null, session.token); + const task = await taigaFetch('/tasks', {method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({project: projekt.id, subject: node.label, + userStory: story.id})}, session.token); + writeLineTokens(node.line, [refToken('T', task.ref)]); + }catch(err){ + const dlg = taigaModal(t('taigaTaskBtn'), t('taigaCancel')); + dlg.ok.hidden = true; + dlg.fail(taigaErrText(err)); + dlg.cancel.addEventListener('click', () => dlg.close()); + } +} + /* Welcher Knoten gehört zu einer Textzeile? Zuerst der Knoten, DER auf dieser Zeile steht; sonst der Knoten, dessen **Beschreibung** hier steht (SPEC §9): Eine `"`-Zeile und die Zeilen eines `---`-Blocks tragen keinen eigenen @@ -3004,7 +3050,7 @@ const I18N = { acHint:"{n} ID-Vorschläge – ↑/↓ wählt, Enter übernimmt", tipClose:"Schließen", tipOpenLink:"Link öffnen", - taigaStoryBtn:"Taiga: Story anlegen", taigaTasksBtn:"Taiga: Story + Tasks anlegen", taigaLoginTitle:"Bei Taiga anmelden", taigaUser:"Benutzername", taigaPass:"Passwort", taigaLoginDo:"Anmelden", taigaCancel:"Abbrechen", taigaProjectTitle:"Taiga-Ticket anlegen", taigaProject:"Projekt", taigaTasksPick:"Teilpakete als Tasks anlegen:", taigaCreateDo:"Anlegen", taigaError:"Fehlgeschlagen: {error}", taigaOpenBtn:"{ref} in Taiga öffnen", taigaTicketDiff:"weicht vom Plan ab:", taigaTicketNoBox:"ohne Statusbox", taigaTicketPush:"nach Taiga schreiben", taigaTicketPull:"aus Taiga übernehmen", taigaTicketWriting:"wird geschrieben …", taigaTicketNoMap:"Dieser Zustand hat in Taiga keine Entsprechung — geschrieben wird nichts.", taigaTicketNoColumn:"Die Spalte „{name}“ gibt es in diesem Projekt nicht.", taigaTicketFetch:"Stand holen", taigaTicketReload:"Stand neu holen", taigaTicketLoading:"Stand wird geholt …", taigaTicketAssignee:"Zuständig: {name}", + taigaStoryBtn:"Taiga: Story anlegen", taigaTaskBtn:"Taiga: Task anlegen", taigaTaskNoProject:"Kein Zugriff auf das Projekt „{slug}“.", taigaLoginTitle:"Bei Taiga anmelden", taigaUser:"Benutzername", taigaPass:"Passwort", taigaLoginDo:"Anmelden", taigaCancel:"Abbrechen", taigaProjectTitle:"Taiga-Ticket anlegen", taigaProject:"Projekt", taigaTasksPick:"Teilpakete als Tasks anlegen:", taigaCreateDo:"Anlegen", taigaError:"Fehlgeschlagen: {error}", taigaOpenBtn:"{ref} in Taiga öffnen", taigaTicketDiff:"weicht vom Plan ab:", taigaTicketNoBox:"ohne Statusbox", taigaTicketPush:"nach Taiga schreiben", taigaTicketPull:"aus Taiga übernehmen", taigaTicketWriting:"wird geschrieben …", taigaTicketNoMap:"Dieser Zustand hat in Taiga keine Entsprechung — geschrieben wird nichts.", taigaTicketNoColumn:"Die Spalte „{name}“ gibt es in diesem Projekt nicht.", taigaTicketFetch:"Stand holen", taigaTicketReload:"Stand neu holen", taigaTicketLoading:"Stand wird geholt …", taigaTicketAssignee:"Zuständig: {name}", liveLoadWarn:"Server-Dokument nicht geladen: {url} ({error}). Läuft das Backend, und ist die Adresse eine Dokument-Adresse (…/documents/<uuid>)?", liveStaleWarn:"Deine Änderung war nicht mehr anwendbar ({error}) — der Stand wurde einmal frisch geholt.", liveConflictText:"Jemand hat dieselben Zeilen geändert. Wessen Fassung soll gelten?", @@ -3141,7 +3187,7 @@ const I18N = { acHint:"{n} id suggestions – ↑/↓ to choose, Enter to insert", tipClose:"Close", tipOpenLink:"Open link", - taigaStoryBtn:"Taiga: create story", taigaTasksBtn:"Taiga: create story + tasks", taigaLoginTitle:"Log in to Taiga", taigaUser:"Username", taigaPass:"Password", taigaLoginDo:"Log in", taigaCancel:"Cancel", taigaProjectTitle:"Create Taiga ticket", taigaProject:"Project", taigaTasksPick:"Create sub-packages as tasks:", taigaCreateDo:"Create", taigaError:"Failed: {error}", taigaOpenBtn:"Open {ref} in Taiga", taigaTicketDiff:"differs from the plan:", taigaTicketNoBox:"no status box", taigaTicketPush:"write to Taiga", taigaTicketPull:"take from Taiga", taigaTicketWriting:"writing …", taigaTicketNoMap:"This state has no counterpart in Taiga — nothing is written.", taigaTicketNoColumn:"There is no column “{name}” in this project.", taigaTicketFetch:"Fetch state", taigaTicketReload:"Fetch again", taigaTicketLoading:"Fetching state …", taigaTicketAssignee:"Assigned to: {name}", + taigaStoryBtn:"Taiga: create story", taigaTaskBtn:"Taiga: create task", taigaTaskNoProject:"No access to project “{slug}”.", taigaLoginTitle:"Log in to Taiga", taigaUser:"Username", taigaPass:"Password", taigaLoginDo:"Log in", taigaCancel:"Cancel", taigaProjectTitle:"Create Taiga ticket", taigaProject:"Project", taigaTasksPick:"Create sub-packages as tasks:", taigaCreateDo:"Create", taigaError:"Failed: {error}", taigaOpenBtn:"Open {ref} in Taiga", taigaTicketDiff:"differs from the plan:", taigaTicketNoBox:"no status box", taigaTicketPush:"write to Taiga", taigaTicketPull:"take from Taiga", taigaTicketWriting:"writing …", taigaTicketNoMap:"This state has no counterpart in Taiga — nothing is written.", taigaTicketNoColumn:"There is no column “{name}” in this project.", taigaTicketFetch:"Fetch state", taigaTicketReload:"Fetch again", taigaTicketLoading:"Fetching state …", taigaTicketAssignee:"Assigned to: {name}", liveLoadWarn:"Server document not loaded: {url} ({error}). Is the backend running, and is the address a document address (…/documents/<uuid>)?", liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.", liveConflictText:"Someone changed the same lines. Whose version should win?", @@ -3277,7 +3323,7 @@ const I18N = { acHint:"{n} sugerencias de ID – ↑/↓ elige, Intro inserta", tipClose:"Cerrar", tipOpenLink:"Abrir enlace", - taigaStoryBtn:"Taiga: crear historia", taigaTasksBtn:"Taiga: crear historia + tareas", taigaLoginTitle:"Iniciar sesión en Taiga", taigaUser:"Usuario", taigaPass:"Contraseña", taigaLoginDo:"Iniciar sesión", taigaCancel:"Cancelar", taigaProjectTitle:"Crear ticket de Taiga", taigaProject:"Proyecto", taigaTasksPick:"Crear subpaquetes como tareas:", taigaCreateDo:"Crear", taigaError:"Error: {error}", taigaOpenBtn:"Abrir {ref} en Taiga", taigaTicketDiff:"difiere del plan:", taigaTicketNoBox:"sin casilla de estado", taigaTicketPush:"escribir en Taiga", taigaTicketPull:"tomar de Taiga", taigaTicketWriting:"escribiendo …", taigaTicketNoMap:"Este estado no tiene equivalente en Taiga: no se escribe nada.", taigaTicketNoColumn:"En este proyecto no existe la columna «{name}».", taigaTicketFetch:"Consultar estado", taigaTicketReload:"Volver a consultar", taigaTicketLoading:"Consultando estado …", taigaTicketAssignee:"Asignado a: {name}", + taigaStoryBtn:"Taiga: crear historia", taigaTaskBtn:"Taiga: crear tarea", taigaTaskNoProject:"Sin acceso al proyecto «{slug}».", taigaLoginTitle:"Iniciar sesión en Taiga", taigaUser:"Usuario", taigaPass:"Contraseña", taigaLoginDo:"Iniciar sesión", taigaCancel:"Cancelar", taigaProjectTitle:"Crear ticket de Taiga", taigaProject:"Proyecto", taigaTasksPick:"Crear subpaquetes como tareas:", taigaCreateDo:"Crear", taigaError:"Error: {error}", taigaOpenBtn:"Abrir {ref} en Taiga", taigaTicketDiff:"difiere del plan:", taigaTicketNoBox:"sin casilla de estado", taigaTicketPush:"escribir en Taiga", taigaTicketPull:"tomar de Taiga", taigaTicketWriting:"escribiendo …", taigaTicketNoMap:"Este estado no tiene equivalente en Taiga: no se escribe nada.", taigaTicketNoColumn:"En este proyecto no existe la columna «{name}».", taigaTicketFetch:"Consultar estado", taigaTicketReload:"Volver a consultar", taigaTicketLoading:"Consultando estado …", taigaTicketAssignee:"Asignado a: {name}", liveLoadWarn:"Documento del servidor no cargado: {url} ({error}). ¿Está el backend en marcha y es la dirección la de un documento (…/documents/<uuid>)?", liveStaleWarn:"Tu cambio ya no era aplicable ({error}): se volvió a cargar el estado una vez.", liveConflictText:"Alguien cambió las mismas líneas. ¿Qué versión debe prevalecer?", @@ -3413,7 +3459,7 @@ const I18N = { acHint:"{n} suggestions d'ID – ↑/↓ pour choisir, Entrée pour insérer", tipClose:"Fermer", tipOpenLink:"Ouvrir le lien", - taigaStoryBtn:"Taiga : créer la story", taigaTasksBtn:"Taiga : créer story + tâches", taigaLoginTitle:"Se connecter à Taiga", taigaUser:"Nom d'utilisateur", taigaPass:"Mot de passe", taigaLoginDo:"Se connecter", taigaCancel:"Annuler", taigaProjectTitle:"Créer un ticket Taiga", taigaProject:"Projet", taigaTasksPick:"Créer les sous-lots comme tâches :", taigaCreateDo:"Créer", taigaError:"Échec : {error}", taigaOpenBtn:"Ouvrir {ref} dans Taiga", taigaTicketDiff:"diffère du plan :", taigaTicketNoBox:"sans case d'état", taigaTicketPush:"écrire dans Taiga", taigaTicketPull:"reprendre de Taiga", taigaTicketWriting:"écriture …", taigaTicketNoMap:"Cet état n'a pas d'équivalent dans Taiga — rien n'est écrit.", taigaTicketNoColumn:"La colonne « {name} » n'existe pas dans ce projet.", taigaTicketFetch:"Relever l'état", taigaTicketReload:"Relever à nouveau", taigaTicketLoading:"Relevé de l'état …", taigaTicketAssignee:"Assigné à : {name}", + taigaStoryBtn:"Taiga : créer la story", taigaTaskBtn:"Taiga : créer une tâche", taigaTaskNoProject:"Pas d'accès au projet « {slug} ».", taigaLoginTitle:"Se connecter à Taiga", taigaUser:"Nom d'utilisateur", taigaPass:"Mot de passe", taigaLoginDo:"Se connecter", taigaCancel:"Annuler", taigaProjectTitle:"Créer un ticket Taiga", taigaProject:"Projet", taigaTasksPick:"Créer les sous-lots comme tâches :", taigaCreateDo:"Créer", taigaError:"Échec : {error}", taigaOpenBtn:"Ouvrir {ref} dans Taiga", taigaTicketDiff:"diffère du plan :", taigaTicketNoBox:"sans case d'état", taigaTicketPush:"écrire dans Taiga", taigaTicketPull:"reprendre de Taiga", taigaTicketWriting:"écriture …", taigaTicketNoMap:"Cet état n'a pas d'équivalent dans Taiga — rien n'est écrit.", taigaTicketNoColumn:"La colonne « {name} » n'existe pas dans ce projet.", taigaTicketFetch:"Relever l'état", taigaTicketReload:"Relever à nouveau", taigaTicketLoading:"Relevé de l'état …", taigaTicketAssignee:"Assigné à : {name}", liveLoadWarn:"Document du serveur non chargé : {url} ({error}). Le backend tourne-t-il, et l'adresse est-elle celle d'un document (…/documents/<uuid>) ?", liveStaleWarn:"Ta modification n'était plus applicable ({error}) — l'état a été rechargé une fois.", liveConflictText:"Quelqu'un a modifié les mêmes lignes. Quelle version doit l'emporter ?", @@ -3549,7 +3595,7 @@ const I18N = { acHint:"{n} podpowiedzi ID – ↑/↓ wybiera, Enter wstawia", tipClose:"Zamknij", tipOpenLink:"Otwórz link", - taigaStoryBtn:"Taiga: utwórz historyjkę", taigaTasksBtn:"Taiga: historyjka + zadania", taigaLoginTitle:"Zaloguj się do Taigi", taigaUser:"Nazwa użytkownika", taigaPass:"Hasło", taigaLoginDo:"Zaloguj", taigaCancel:"Anuluj", taigaProjectTitle:"Utwórz zgłoszenie w Taidze", taigaProject:"Projekt", taigaTasksPick:"Utwórz podpakiety jako zadania:", taigaCreateDo:"Utwórz", taigaError:"Niepowodzenie: {error}", taigaOpenBtn:"Otwórz {ref} w Taidze", taigaTicketDiff:"różni się od planu:", taigaTicketNoBox:"bez pola statusu", taigaTicketPush:"zapisz w Taidze", taigaTicketPull:"pobierz z Taigi", taigaTicketWriting:"zapisywanie …", taigaTicketNoMap:"Ten stan nie ma odpowiednika w Taidze — nic nie zostanie zapisane.", taigaTicketNoColumn:"W tym projekcie nie ma kolumny „{name}”.", taigaTicketFetch:"Pobierz stan", taigaTicketReload:"Pobierz ponownie", taigaTicketLoading:"Pobieranie stanu …", taigaTicketAssignee:"Przypisane do: {name}", + taigaStoryBtn:"Taiga: utwórz historyjkę", taigaTaskBtn:"Taiga: utwórz zadanie", taigaTaskNoProject:"Brak dostępu do projektu „{slug}”.", taigaLoginTitle:"Zaloguj się do Taigi", taigaUser:"Nazwa użytkownika", taigaPass:"Hasło", taigaLoginDo:"Zaloguj", taigaCancel:"Anuluj", taigaProjectTitle:"Utwórz zgłoszenie w Taidze", taigaProject:"Projekt", taigaTasksPick:"Utwórz podpakiety jako zadania:", taigaCreateDo:"Utwórz", taigaError:"Niepowodzenie: {error}", taigaOpenBtn:"Otwórz {ref} w Taidze", taigaTicketDiff:"różni się od planu:", taigaTicketNoBox:"bez pola statusu", taigaTicketPush:"zapisz w Taidze", taigaTicketPull:"pobierz z Taigi", taigaTicketWriting:"zapisywanie …", taigaTicketNoMap:"Ten stan nie ma odpowiednika w Taidze — nic nie zostanie zapisane.", taigaTicketNoColumn:"W tym projekcie nie ma kolumny „{name}”.", taigaTicketFetch:"Pobierz stan", taigaTicketReload:"Pobierz ponownie", taigaTicketLoading:"Pobieranie stanu …", taigaTicketAssignee:"Przypisane do: {name}", liveLoadWarn:"Nie wczytano dokumentu z serwera: {url} ({error}). Czy backend działa i czy adres wskazuje dokument (…/documents/<uuid>)?", liveStaleWarn:"Twoja zmiana nie dała się już zastosować ({error}) — stan pobrano raz od nowa.", liveConflictText:"Ktoś zmienił te same wiersze. Która wersja ma obowiązywać?", @@ -3685,7 +3731,7 @@ const I18N = { acHint:"{n} подсказок ID – ↑/↓ выбирает, Enter вставляет", tipClose:"Закрыть", tipOpenLink:"Открыть ссылку", - taigaStoryBtn:"Taiga: создать историю", taigaTasksBtn:"Taiga: история + задачи", taigaLoginTitle:"Вход в Taiga", taigaUser:"Имя пользователя", taigaPass:"Пароль", taigaLoginDo:"Войти", taigaCancel:"Отмена", taigaProjectTitle:"Создать тикет в Taiga", taigaProject:"Проект", taigaTasksPick:"Создать подпакеты как задачи:", taigaCreateDo:"Создать", taigaError:"Не удалось: {error}", taigaOpenBtn:"Открыть {ref} в Taiga", taigaTicketDiff:"расходится с планом:", taigaTicketNoBox:"без статуса", taigaTicketPush:"записать в Taiga", taigaTicketPull:"взять из Taiga", taigaTicketWriting:"запись …", taigaTicketNoMap:"У этого состояния нет соответствия в Taiga — ничего не записано.", taigaTicketNoColumn:"В этом проекте нет колонки «{name}».", taigaTicketFetch:"Получить статус", taigaTicketReload:"Обновить статус", taigaTicketLoading:"Получение статуса …", taigaTicketAssignee:"Назначено: {name}", + taigaStoryBtn:"Taiga: создать историю", taigaTaskBtn:"Taiga: создать задачу", taigaTaskNoProject:"Нет доступа к проекту «{slug}».", taigaLoginTitle:"Вход в Taiga", taigaUser:"Имя пользователя", taigaPass:"Пароль", taigaLoginDo:"Войти", taigaCancel:"Отмена", taigaProjectTitle:"Создать тикет в Taiga", taigaProject:"Проект", taigaTasksPick:"Создать подпакеты как задачи:", taigaCreateDo:"Создать", taigaError:"Не удалось: {error}", taigaOpenBtn:"Открыть {ref} в Taiga", taigaTicketDiff:"расходится с планом:", taigaTicketNoBox:"без статуса", taigaTicketPush:"записать в Taiga", taigaTicketPull:"взять из Taiga", taigaTicketWriting:"запись …", taigaTicketNoMap:"У этого состояния нет соответствия в Taiga — ничего не записано.", taigaTicketNoColumn:"В этом проекте нет колонки «{name}».", taigaTicketFetch:"Получить статус", taigaTicketReload:"Обновить статус", taigaTicketLoading:"Получение статуса …", taigaTicketAssignee:"Назначено: {name}", liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/<uuid>)?", liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.", liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?", @@ -3821,7 +3867,7 @@ const I18N = { acHint:"{n} आईडी सुझाव – ↑/↓ से चुनें, Enter से डालें", tipClose:"बंद करें", tipOpenLink:"लिंक खोलें", - taigaStoryBtn:"Taiga: स्टोरी बनाएँ", taigaTasksBtn:"Taiga: स्टोरी + टास्क बनाएँ", taigaLoginTitle:"Taiga में साइन इन करें", taigaUser:"उपयोगकर्ता नाम", taigaPass:"पासवर्ड", taigaLoginDo:"साइन इन", taigaCancel:"रद्द करें", taigaProjectTitle:"Taiga टिकट बनाएँ", taigaProject:"प्रोजेक्ट", taigaTasksPick:"उप-पैकेज टास्क के रूप में बनाएँ:", taigaCreateDo:"बनाएँ", taigaError:"विफल: {error}", taigaOpenBtn:"Taiga में {ref} खोलें", taigaTicketDiff:"योजना से भिन्न:", taigaTicketNoBox:"स्थिति-बॉक्स नहीं", taigaTicketPush:"Taiga में लिखें", taigaTicketPull:"Taiga से लें", taigaTicketWriting:"लिखा जा रहा है …", taigaTicketNoMap:"इस स्थिति का Taiga में कोई समकक्ष नहीं है — कुछ नहीं लिखा जाता।", taigaTicketNoColumn:"इस प्रोजेक्ट में “{name}” कॉलम नहीं है।", taigaTicketFetch:"स्थिति लाएँ", taigaTicketReload:"फिर से लाएँ", taigaTicketLoading:"स्थिति लाई जा रही है …", taigaTicketAssignee:"ज़िम्मेदार: {name}", + taigaStoryBtn:"Taiga: स्टोरी बनाएँ", taigaTaskBtn:"Taiga: टास्क बनाएँ", taigaTaskNoProject:"प्रोजेक्ट “{slug}” तक पहुँच नहीं है।", taigaLoginTitle:"Taiga में साइन इन करें", taigaUser:"उपयोगकर्ता नाम", taigaPass:"पासवर्ड", taigaLoginDo:"साइन इन", taigaCancel:"रद्द करें", taigaProjectTitle:"Taiga टिकट बनाएँ", taigaProject:"प्रोजेक्ट", taigaTasksPick:"उप-पैकेज टास्क के रूप में बनाएँ:", taigaCreateDo:"बनाएँ", taigaError:"विफल: {error}", taigaOpenBtn:"Taiga में {ref} खोलें", taigaTicketDiff:"योजना से भिन्न:", taigaTicketNoBox:"स्थिति-बॉक्स नहीं", taigaTicketPush:"Taiga में लिखें", taigaTicketPull:"Taiga से लें", taigaTicketWriting:"लिखा जा रहा है …", taigaTicketNoMap:"इस स्थिति का Taiga में कोई समकक्ष नहीं है — कुछ नहीं लिखा जाता।", taigaTicketNoColumn:"इस प्रोजेक्ट में “{name}” कॉलम नहीं है।", taigaTicketFetch:"स्थिति लाएँ", taigaTicketReload:"फिर से लाएँ", taigaTicketLoading:"स्थिति लाई जा रही है …", taigaTicketAssignee:"ज़िम्मेदार: {name}", liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/<uuid>)?", liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।", liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?", @@ -3968,7 +4014,7 @@ const I18N = { acHint:"{n} 个 ID 建议 – ↑/↓ 选择,Enter 插入", tipClose:"关闭", tipOpenLink:"打开链接", - taigaStoryBtn:"Taiga:创建故事", taigaTasksBtn:"Taiga:创建故事和任务", taigaLoginTitle:"登录 Taiga", taigaUser:"用户名", taigaPass:"密码", taigaLoginDo:"登录", taigaCancel:"取消", taigaProjectTitle:"创建 Taiga 工单", taigaProject:"项目", taigaTasksPick:"将子包创建为任务:", taigaCreateDo:"创建", taigaError:"失败:{error}", taigaOpenBtn:"在 Taiga 中打开 {ref}", taigaTicketDiff:"与计划不一致:", taigaTicketNoBox:"没有状态框", taigaTicketPush:"写入 Taiga", taigaTicketPull:"从 Taiga 取用", taigaTicketWriting:"正在写入 …", taigaTicketNoMap:"该状态在 Taiga 中没有对应项 — 不会写入任何内容。", taigaTicketNoColumn:"本项目中没有「{name}」这一列。", taigaTicketFetch:"获取状态", taigaTicketReload:"重新获取", taigaTicketLoading:"正在获取状态 …", taigaTicketAssignee:"负责人:{name}", + taigaStoryBtn:"Taiga:创建故事", taigaTaskBtn:"Taiga:创建任务", taigaTaskNoProject:"无法访问项目「{slug}」。", taigaLoginTitle:"登录 Taiga", taigaUser:"用户名", taigaPass:"密码", taigaLoginDo:"登录", taigaCancel:"取消", taigaProjectTitle:"创建 Taiga 工单", taigaProject:"项目", taigaTasksPick:"将子包创建为任务:", taigaCreateDo:"创建", taigaError:"失败:{error}", taigaOpenBtn:"在 Taiga 中打开 {ref}", taigaTicketDiff:"与计划不一致:", taigaTicketNoBox:"没有状态框", taigaTicketPush:"写入 Taiga", taigaTicketPull:"从 Taiga 取用", taigaTicketWriting:"正在写入 …", taigaTicketNoMap:"该状态在 Taiga 中没有对应项 — 不会写入任何内容。", taigaTicketNoColumn:"本项目中没有「{name}」这一列。", taigaTicketFetch:"获取状态", taigaTicketReload:"重新获取", taigaTicketLoading:"正在获取状态 …", taigaTicketAssignee:"负责人:{name}", liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/<uuid>)吗?", liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。", liveConflictText:"有人改动了同样的行。以谁的版本为准?", @@ -4104,7 +4150,7 @@ const I18N = { acHint:"ID候補 {n} 件 – ↑/↓で選択、Enterで挿入", tipClose:"閉じる", tipOpenLink:"リンクを開く", - taigaStoryBtn:"Taiga: ストーリーを作成", taigaTasksBtn:"Taiga: ストーリー+タスクを作成", taigaLoginTitle:"Taiga にログイン", taigaUser:"ユーザー名", taigaPass:"パスワード", taigaLoginDo:"ログイン", taigaCancel:"キャンセル", taigaProjectTitle:"Taiga チケットを作成", taigaProject:"プロジェクト", taigaTasksPick:"サブパッケージをタスクとして作成:", taigaCreateDo:"作成", taigaError:"失敗: {error}", taigaOpenBtn:"Taiga で {ref} を開く", taigaTicketDiff:"計画と食い違い:", taigaTicketNoBox:"ステータス欄なし", taigaTicketPush:"Taiga に書く", taigaTicketPull:"Taiga から取る", taigaTicketWriting:"書き込み中 …", taigaTicketNoMap:"この状態に対応する列が Taiga にありません — 何も書き込みません。", taigaTicketNoColumn:"このプロジェクトに「{name}」列はありません。", taigaTicketFetch:"状態を取得", taigaTicketReload:"再取得", taigaTicketLoading:"状態を取得中 …", taigaTicketAssignee:"担当: {name}", + taigaStoryBtn:"Taiga: ストーリーを作成", taigaTaskBtn:"Taiga: タスクを作成", taigaTaskNoProject:"プロジェクト「{slug}」にアクセスできません。", taigaLoginTitle:"Taiga にログイン", taigaUser:"ユーザー名", taigaPass:"パスワード", taigaLoginDo:"ログイン", taigaCancel:"キャンセル", taigaProjectTitle:"Taiga チケットを作成", taigaProject:"プロジェクト", taigaTasksPick:"サブパッケージをタスクとして作成:", taigaCreateDo:"作成", taigaError:"失敗: {error}", taigaOpenBtn:"Taiga で {ref} を開く", taigaTicketDiff:"計画と食い違い:", taigaTicketNoBox:"ステータス欄なし", taigaTicketPush:"Taiga に書く", taigaTicketPull:"Taiga から取る", taigaTicketWriting:"書き込み中 …", taigaTicketNoMap:"この状態に対応する列が Taiga にありません — 何も書き込みません。", taigaTicketNoColumn:"このプロジェクトに「{name}」列はありません。", taigaTicketFetch:"状態を取得", taigaTicketReload:"再取得", taigaTicketLoading:"状態を取得中 …", taigaTicketAssignee:"担当: {name}", liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/<uuid>)ですか。", liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。", liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。", diff --git a/frontend/src/taiga.js b/frontend/src/taiga.js index 5797ed5..b953837 100644 --- a/frontend/src/taiga.js +++ b/frontend/src/taiga.js @@ -150,6 +150,31 @@ export function ticketRefAt(text, caret){ return null; } +/* Der nächste Vorfahr mit einer STORY-Ref (D91-Nachtrag 9): Unter so einem + Knoten wird ein Teilpaket direkt als Task angelegt — in dessen Story, ohne + eigenen Dialog (Projekt und Story folgen aus dem Baum, es gibt nichts zu + wählen). Vorfahren mit Task-Ref (`T-…`) werden übersprungen: Taiga-Tasks + haben keine Subtasks, die Task eines Task-Knotens gehört in die Story + darüber. null, wenn es keinen gibt oder der Knoten nicht im Baum steht. */ +export function storyAncestor(roots, node){ + let result = null, hit = false; + (function w(ns, chain){ + for(const n of ns){ + if(hit) return; + if(n === node){ + hit = true; + for(let i = chain.length - 1; i >= 0; i--){ + const r = ticketRefOf(chain[i]); + if(r && r.startsWith('US-')){ result = {node: chain[i], ref: r}; return; } + } + return; + } + w(n.children || [], chain.concat(n)); + } + })(roots, []); + return result; +} + /* Das Token, das an die Zeile geschrieben wird. */ export function refToken(kind, ref){ return '#' + kind + '-' + ref; } export function slugToken(slug){ return '&taiga.' + slug; } diff --git a/frontend/tests/taiga.test.js b/frontend/tests/taiga.test.js index 9ddfb84..c2e4a16 100644 --- a/frontend/tests/taiga.test.js +++ b/frontend/tests/taiga.test.js @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { parse } from '../src/parser.js'; import { taigaSlugs } from '../src/model.js'; -import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, refParts, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath } from '../src/taiga.js'; +import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, refParts, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath, storyAncestor } from '../src/taiga.js'; import { setStatusBox } from '../src/parser.js'; /* Schlagworte `&tag` (SPEC §1, D91): Extraktion im Parser und die @@ -147,6 +147,47 @@ describe('taskCandidates — Vorbelegung des Häkchen-Dialogs (D91)', () => { }); }); +describe('storyAncestor — der nächste Vorfahr mit Story-Ref (D91-Nachtrag 9)', () => { + const find = (text, label) => { + const { roots } = parse(text); + let node = null; + (function w(ns){ for(const n of ns){ if(n.label === label) node = n; w(n.children); } })(roots); + return {roots, node}; + }; + + it('findet den nächsten Vorfahren mit US-Ref', () => { + const {roots, node} = find('- A #US-1\n - B #US-2\n - C', 'C'); + const anc = storyAncestor(roots, node); + expect(anc.ref).toBe('US-2'); + expect(anc.node.label).toBe('B'); + }); + + it('überspringt Vorfahren mit Task-Ref — die Task gehört in die Story darüber', () => { + const {roots, node} = find('- A #US-1\n - B #T-7\n - C', 'C'); + expect(storyAncestor(roots, node).ref).toBe('US-1'); + }); + + it('null ohne Story-Vorfahren — auch wenn der Knoten selbst eine Ref trägt', () => { + const {roots, node} = find('- A\n - B #US-9', 'B'); + expect(storyAncestor(roots, node)).toBe(null); + }); + + it('null am Wurzelknoten', () => { + const {roots, node} = find('- A #US-1', 'A'); + expect(storyAncestor(roots, node)).toBe(null); + }); + + it('null für einen Knoten, der nicht im Baum steht', () => { + const {roots} = find('- A #US-1\n - B', 'B'); + expect(storyAncestor(roots, {label: 'fremd', children: []})).toBe(null); + }); + + it('die Ref darf auch die Knoten-ID selbst sein (D60)', () => { + const {roots, node} = find('- #US-42\n - Kind', 'Kind'); + expect(storyAncestor(roots, node).ref).toBe('US-42'); + }); +}); + describe('appendToken — Token ans sichtbare Zeilenende (D91)', () => { it('hängt hinter den Inhalt an', () => { expect(appendToken(' - [ ] Backend (M)', '#US-123')).toBe(' - [ ] Backend (M) #US-123');