diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index dc2d0bf..1475c73 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -81,6 +81,15 @@ docs/SPEC.md §10 testen — niemals eine zweite, abweichende Grammatik pflegen. deshalb den `slug` (aus `&taiga.`, SPEC §1) und fragen erst `/projects/by_slug`, dann `by_ref` — der Slug kommt vom Client und wird **kodiert** angehängt, sonst hängte ein `&` darin einen weiteren Filter an. +- **Bulk-Lesen (D91-Nachtrag 10):** `GET /taiga/tickets?slug=&refs=US-1,T-2` + fächert serverseitig in `by_ref`-Einzelabfragen auf — parallel (virtuelle + Threads) mit Semaphore-Deckel (6): Die Kosten skalieren mit den Refs im + Plan, nie mit der Projektgröße; eine Projekt-Volliste ist bei Tausenden + Tickets die falsche Bulk-Einheit. Eine 404-Ref fehlt still in der + Antwort-Map, jeder andere Fehler bricht die Anfrage ab; ungültige Refs + sind ein 400 (`TaigaBadRequestException`), Deckel 200, Dubletten werden + zusammengelegt. Messnotiz: Ob Taigas Listen-Endpunkte eine Ref-Liste als + Filter nehmen, ist offen — falls ja, tauscht nur das Innere des Proxys. - **Schreiben (D91-Nachtrag 8):** `PATCH /taiga/{userstories|tasks}/{ref}/status` nimmt die Status-**Id** (aus `GET /taiga/{userstory|task}-statuses?slug=`) und die zuletzt gelesene `version` — Taigas optimistische Sperre; ein diff --git a/backend/src/main/kotlin/de/werkbaum/api/GlobalExceptionHandler.kt b/backend/src/main/kotlin/de/werkbaum/api/GlobalExceptionHandler.kt index ac970e6..a902aec 100644 --- a/backend/src/main/kotlin/de/werkbaum/api/GlobalExceptionHandler.kt +++ b/backend/src/main/kotlin/de/werkbaum/api/GlobalExceptionHandler.kt @@ -8,6 +8,7 @@ import de.werkbaum.service.DocumentDeletedException import de.werkbaum.service.DocumentNotFoundException import de.werkbaum.service.InvalidPatchException import de.werkbaum.service.StalePatchSequenceException +import de.werkbaum.integration.taiga.TaigaBadRequestException import de.werkbaum.integration.taiga.TaigaNotConfiguredException import de.werkbaum.integration.taiga.TaigaUnavailableException import de.werkbaum.integration.taiga.TaigaUpstreamException @@ -89,6 +90,14 @@ class GlobalExceptionHandler { ex.message ?: "Taiga nicht konfiguriert", ).apply { title = "Taiga nicht konfiguriert" } + /** Eine Anfrage, die schon der Proxy ablehnt (Refs-Liste der Bulk-Abfrage). */ + @ExceptionHandler(TaigaBadRequestException::class) + fun handleTaigaBadRequest(ex: TaigaBadRequestException): ProblemDetail = + ProblemDetail.forStatusAndDetail( + HttpStatus.BAD_REQUEST, + ex.message ?: "Ungültige Anfrage", + ).apply { title = "Ungültige Anfrage" } + @ExceptionHandler(TaigaUnavailableException::class) fun handleTaigaUnavailable(ex: TaigaUnavailableException): ProblemDetail = ProblemDetail.forStatusAndDetail( diff --git a/backend/src/main/kotlin/de/werkbaum/api/TaigaController.kt b/backend/src/main/kotlin/de/werkbaum/api/TaigaController.kt index 5825e31..3979d1a 100644 --- a/backend/src/main/kotlin/de/werkbaum/api/TaigaController.kt +++ b/backend/src/main/kotlin/de/werkbaum/api/TaigaController.kt @@ -10,6 +10,8 @@ import de.werkbaum.generated.model.TaigaStatus import de.werkbaum.generated.model.TaigaStatusPatch import de.werkbaum.generated.model.TaigaTicket import de.werkbaum.generated.model.TaigaTicketDetail +import de.werkbaum.integration.taiga.TaigaBadRequestException +import de.werkbaum.integration.taiga.TaigaBulkRefData import de.werkbaum.integration.taiga.TaigaClient import de.werkbaum.integration.taiga.TaigaTicketDetailData import de.werkbaum.integration.taiga.TaigaTicketData @@ -76,6 +78,33 @@ class TaigaController(private val client: TaigaClient) : TaigaApi { return created(ticket) } + /* Bulk-Lesen (D91-Nachtrag 10): eine Anfrage vom Editor, der Fächer läuft + im Client. Die Refs kommen MIT Werkbaum-Präfix (`US-`/`T-`) — es trägt + den Typ; geparst wird streng, Ungültiges ist ein 400 statt still + übersprungen (D59-Linie). Doppelte Refs werden vor dem Fächer + zusammengelegt. */ + override fun taigaTickets( + xTaigaToken: String, + slug: String, + refs: String, + ): ResponseEntity> = + ResponseEntity.ok( + client.tickets(xTaigaToken, slug, parseBulkRefs(refs)) + .mapValues { it.value.toApi() } + ) + + private fun parseBulkRefs(refs: String): List { + val teile = refs.split(',').distinct() + if (teile.size > MAX_BULK_REFS) { + throw TaigaBadRequestException("Höchstens $MAX_BULK_REFS Refs je Anfrage") + } + return teile.map { teil -> + val m = BULK_REF.matchEntire(teil) + ?: throw TaigaBadRequestException("Ungültige Ref: '$teil'") + TaigaBulkRefData(key = teil, task = m.groupValues[1] == "T", nr = m.groupValues[2].toLong()) + } + } + /* Lesen (D91-Nachtrag 6): zwei Endpunkte statt eines mit Typ-Parameter — das Präfix der Ref trägt den Typ, und Taiga hat getrennte `by_ref`-Endpunkte. Die Abbildung des Status auf die Notation macht der @@ -146,4 +175,10 @@ class TaigaController(private val client: TaigaClient) : TaigaApi { ResponseEntity.status(HttpStatus.CREATED).body( TaigaTicket(id = ticket.id, ref = ticket.ref, subject = ticket.subject) ) + + companion object { + /** Höchstens so viele Refs je Bulk-Anfrage — mehr referenziert kein Plan. */ + private const val MAX_BULK_REFS = 200 + private val BULK_REF = Regex("(US|T)-(\\d{1,10})") + } } diff --git a/backend/src/main/kotlin/de/werkbaum/integration/taiga/TaigaClient.kt b/backend/src/main/kotlin/de/werkbaum/integration/taiga/TaigaClient.kt index 140ce9f..97c9f19 100644 --- a/backend/src/main/kotlin/de/werkbaum/integration/taiga/TaigaClient.kt +++ b/backend/src/main/kotlin/de/werkbaum/integration/taiga/TaigaClient.kt @@ -11,6 +11,10 @@ import java.net.URLEncoder import java.net.http.HttpClient import java.nio.charset.StandardCharsets import java.time.Duration +import java.util.concurrent.Callable +import java.util.concurrent.ExecutionException +import java.util.concurrent.Executors +import java.util.concurrent.Semaphore /** Keine Taiga-Instanz konfiguriert — der Proxy hat kein Ziel (503). */ class TaigaNotConfiguredException : @@ -58,6 +62,17 @@ data class TaigaTicketDetailData( /** Eine Spalte des Projekt-Workflows (D91-Nachtrag 8). */ data class TaigaStatusData(val id: Long, val name: String, val closed: Boolean?) +/** + * Eine Ref der Bulk-Abfrage (D91-Nachtrag 10): `key` ist die Werkbaum-Ref + * (`US-123`), unter der die Antwort zurueckgeht; `task` und `nr` sind das + * zerlegte Praefix — der Typ steht in der Ref, genau dafuer schreibt + * Werkbaum ihn (SPEC par. 11). + */ +data class TaigaBulkRefData(val key: String, val task: Boolean, val nr: Long) + +/** Eine Anfrage, die schon der Proxy ablehnt (400) — kein Taiga-Fehler. */ +class TaigaBadRequestException(message: String) : RuntimeException(message) + /** * Schmaler, benannter Client zur konfigurierten Taiga-Instanz (D91) — kein * Durchreich-Proxy: genau die vier Aufrufe, die die Ticket-Anlage braucht. @@ -71,6 +86,11 @@ data class TaigaStatusData(val id: Long, val name: String, val closed: Boolean?) @Service class TaigaClient(private val properties: TaigaProperties) { + /* Der Faecher der Bulk-Abfrage: virtuelle Threads (praktisch kostenlos, + D76-Nachtrag 5), die Semaphore deckelt die GLEICHZEITIGEN Anfragen an + die fremde Instanz — Parallelitaet ja, Hammer nein. */ + private val bulkPool = Executors.newVirtualThreadPerTaskExecutor() + private val rest: RestClient = RestClient.builder() .requestFactory( JdkClientHttpRequestFactory( @@ -124,8 +144,10 @@ class TaigaClient(private val properties: TaigaProperties) { * Aufrufer aus dem Präfix der Ref (`US-`/`T-`, SPEC §11) — hier steht * nur, wohin gefragt wird. */ - fun ticket(token: String, slug: String, ref: Long, task: Boolean): TaigaTicketDetailData { - val project = projectId(token, slug) + fun ticket(token: String, slug: String, ref: Long, task: Boolean): TaigaTicketDetailData = + detailByRef(token, projectId(token, slug), ref, task) + + private fun detailByRef(token: String, project: Long, ref: Long, task: Boolean): TaigaTicketDetailData { val pfad = if (task) "/tasks/by_ref" else "/userstories/by_ref" val map = exchange { rest.get().uri(url("$pfad?project=$project&ref=$ref")) @@ -135,6 +157,47 @@ class TaigaClient(private val properties: TaigaProperties) { return detail(map) } + /** + * Viele Tickets auf einmal (D91-Nachtrag 10): EIN Aufruf vom Browser, der + * Proxy fächert in `by_ref`-Einzelabfragen auf — parallel, mit + * **gedeckelter** Nebenläufigkeit (Höflichkeit gegenüber der fremden + * Instanz; Backend und Taiga sitzen beim selben Hoster, die Einzelabfrage + * kostet dort Millisekunden). Die Kosten skalieren mit den Refs im Plan, + * nie mit der Projektgröße — eine Projekt-Volliste holte bei Tausenden + * Tickets Megabytes, um fast alles wegzuwerfen. + * + * Eine Ref, die es nicht (mehr) gibt (Taiga-404), **fehlt still** in der + * Antwort — der Rest kommt trotzdem; jeder andere Fehler (401, Projekt + * unbekannt, Instanz weg) bricht die ganze Anfrage ab, denn er beträfe + * ohnehin jede einzelne Ref. + */ + fun tickets(token: String, slug: String, refs: List): Map { + val project = projectId(token, slug) + val sem = Semaphore(BULK_CONCURRENCY) + val futures = refs.map { r -> + bulkPool.submit(Callable { + sem.acquire() + try { + r.key to detailByRef(token, project, r.nr, r.task) + } finally { + sem.release() + } + }) + } + val out = LinkedHashMap() + for (f in futures) { + try { + val (key, wert) = f.get() + out[key] = wert + } catch (e: ExecutionException) { + val grund = e.cause + if (grund is TaigaUpstreamException && grund.status == 404) continue + throw grund ?: e + } + } + return out + } + /** * Die Spalten des Projekt-Workflows (D91-Nachtrag 8) — Taiga schreibt nach * Status-**Id**, und die Namen sind je Projekt frei. Welche Spalte gemeint @@ -253,6 +316,8 @@ class TaigaClient(private val properties: TaigaProperties) { ?: throw TaigaUnavailableException("Unerwartete Taiga-Antwort: Feld '$key' fehlt") companion object { + /** Hoechstens so viele gleichzeitige Upstream-Anfragen je Bulk-Aufruf. */ + private const val BULK_CONCURRENCY = 6 private val MAP = object : ParameterizedTypeReference>() {} private val LIST = object : ParameterizedTypeReference>>() {} } diff --git a/backend/src/main/resources/openapi/api.yaml b/backend/src/main/resources/openapi/api.yaml index 7073785..c22a47d 100644 --- a/backend/src/main/resources/openapi/api.yaml +++ b/backend/src/main/resources/openapi/api.yaml @@ -606,6 +606,73 @@ paths: "503": $ref: "#/components/responses/TaigaNotConfigured" + /taiga/tickets: + get: + tags: [Taiga] + operationId: taigaTickets + summary: Den Stand vieler Tickets auf einmal lesen (Proxy) + description: > + Bulk-Lesen fuer die Abweichungs-Marken im Diagramm (D91-Nachtrag 10): + Der Editor stellt EINE Anfrage mit allen Refs eines Dokuments, der + Proxy faechert sie in `by_ref`-Einzelabfragen auf - parallel mit + gedeckelter Nebenlaeufigkeit, denn Backend und Taiga-Instanz sitzen + nah beieinander, der Browser nicht. Die Kosten skalieren mit den + Refs im Plan, nie mit der Groesse des Taiga-Projekts (dort koennen + Tausende Tickets liegen - eine Projekt-Volliste waere die falsche + Bulk-Einheit). Das Praefix jeder Ref traegt den Typ + (`US-`/`T-`, SPEC par. 11). Eine Ref, die es im Projekt nicht + (mehr) gibt, fehlt still in der Antwort - die uebrigen kommen + trotzdem; jeder andere Fehler (401, Projekt unbekannt, Taiga weg) + gilt der ganzen Anfrage. + parameters: + - $ref: "#/components/parameters/TaigaToken" + - $ref: "#/components/parameters/TaigaSlug" + - name: refs + in: query + required: true + description: > + Kommagetrennte Werkbaum-Refs (`US-123,T-1234`), hoechstens 200 - + mehr referenziert kein Plan, und der Deckel haelt den Faecher + endlich. Ungueltige Eintraege sind ein 400, nicht still + uebersprungen. + schema: + type: string + minLength: 1 + maxLength: 4000 + responses: + "200": + description: > + Map Werkbaum-Ref (`US-123`) auf den Ticket-Stand. Fehlende + Schluessel heissen: dieses Ticket gibt es im Projekt nicht (mehr). + content: + application/json: + schema: + type: object + additionalProperties: + $ref: "#/components/schemas/TaigaTicketDetail" + "400": + description: Refs-Liste ungueltig oder zu lang + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "401": + description: Token fehlt oder ist abgelaufen + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "404": + description: Das Projekt zum Slug gibt es nicht + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "502": + $ref: "#/components/responses/TaigaUnavailable" + "503": + $ref: "#/components/responses/TaigaNotConfigured" + /taiga/userstories/{ref}/status: patch: tags: [Taiga] diff --git a/backend/src/test/kotlin/de/werkbaum/api/TaigaApiTest.kt b/backend/src/test/kotlin/de/werkbaum/api/TaigaApiTest.kt index 8743ce9..c75f88e 100644 --- a/backend/src/test/kotlin/de/werkbaum/api/TaigaApiTest.kt +++ b/backend/src/test/kotlin/de/werkbaum/api/TaigaApiTest.kt @@ -154,6 +154,31 @@ class TaigaApiTest { result.responseBody!! shouldContain """{"id":12,"name":"In progress","closed":false}""" } + @Test + fun `die Bulk-Abfrage liefert eine Map unter den Werkbaum-Refs`() { + val result = client.get() + .uri("/api/v1/taiga/tickets?slug=mi-kunde&refs=US-123,T-1234") + .header("X-Taiga-Token", "tok-abc123") + .exchange() + .returnResult(String::class.java) + result.status.value() shouldBe 200 + result.responseBody!! shouldContain "\"US-123\"" + result.responseBody!! shouldContain "\"T-1234\"" + result.responseBody!! shouldContain "\"status\":\"In progress\"" + result.responseBody!! shouldContain "\"status\":\"Done\"" + } + + @Test + fun `eine ungueltige Ref in der Bulk-Liste ist ein 400, nicht still uebersprungen`() { + val result = client.get() + .uri("/api/v1/taiga/tickets?slug=mi-kunde&refs=US-123,kaputt") + .header("X-Taiga-Token", "tok-abc123") + .exchange() + .returnResult(String::class.java) + result.status.value() shouldBe 400 + result.responseBody!! shouldContain "kaputt" + } + @Test fun `ein Status wird per Ref gesetzt und antwortet mit dem neuen Stand`() { val result = client.patch() diff --git a/backend/src/test/kotlin/de/werkbaum/integration/taiga/TaigaClientTest.kt b/backend/src/test/kotlin/de/werkbaum/integration/taiga/TaigaClientTest.kt index 0204233..0ef3021 100644 --- a/backend/src/test/kotlin/de/werkbaum/integration/taiga/TaigaClientTest.kt +++ b/backend/src/test/kotlin/de/werkbaum/integration/taiga/TaigaClientTest.kt @@ -220,6 +220,49 @@ class TaigaClientTest { client().ticket("tok-abc123", "mi-kunde", 123, task = false).version shouldBe 7L } + /* ---- Bulk-Lesen (D91-Nachtrag 10) ---- */ + + @Test + fun `tickets faechert je Ref auf und liefert die Map unter den Werkbaum-Refs`() { + routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK + routes["/api/v1/userstories/by_ref?project=7&ref=123"] = 200 to STORY_DETAIL + routes["/api/v1/tasks/by_ref?project=7&ref=1234"] = 200 to TASK_DETAIL + val map = client().tickets("tok-abc123", "mi-kunde", listOf( + TaigaBulkRefData("US-123", task = false, nr = 123), + TaigaBulkRefData("T-1234", task = true, nr = 1234), + )) + + map.keys shouldBe setOf("US-123", "T-1234") + map["US-123"]!!.status shouldBe "In progress" + map["T-1234"]!!.status shouldBe "Done" + /* Die Projekt-Aufloesung laeuft EINMAL, nicht je Ref. */ + requests.count { it.path == "/api/v1/projects/by_slug" } shouldBe 1 + } + + @Test + fun `eine Ref, die es nicht mehr gibt, fehlt still - die uebrigen kommen trotzdem`() { + routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK + routes["/api/v1/userstories/by_ref?project=7&ref=123"] = 200 to STORY_DETAIL + routes["/api/v1/userstories/by_ref?project=7&ref=999"] = 404 to NOT_FOUND + val map = client().tickets("tok-abc123", "mi-kunde", listOf( + TaigaBulkRefData("US-123", task = false, nr = 123), + TaigaBulkRefData("US-999", task = false, nr = 999), + )) + + map.keys shouldBe setOf("US-123") + } + + @Test + fun `ein abgelaufenes Token bricht die ganze Bulk-Anfrage ab`() { + routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK + routes["/api/v1/userstories/by_ref?project=7&ref=123"] = 401 to NOT_FOUND + val ex = shouldThrow { + client().tickets("tok-abc123", "mi-kunde", + listOf(TaigaBulkRefData("US-123", task = false, nr = 123))) + } + ex.status shouldBe 401 + } + @Test fun `ohne konfigurierte Instanz gibt es kein Ziel`() { val bare = TaigaClient(TaigaProperties(apiUrl = "")) @@ -315,7 +358,10 @@ class TaigaClientTest { body = ex.requestBody.readBytes().decodeToString(), ) requests += recorded!! - val route = routes[ex.requestURI.path] + /* Query-genaue Route vor der Pfad-Route: Die Bulk-Abfrage + trifft denselben Pfad mit verschiedenen Refs. */ + val route = routes[ex.requestURI.path + "?" + (ex.requestURI.query ?: "")] + ?: routes[ex.requestURI.path] val status = route?.first ?: responseStatus val bytes = (route?.second ?: responseBody).encodeToByteArray() ex.responseHeaders.set("Content-Type", "application/json") diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index aaf8ee7..888324b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,10 @@ reverse. ## 2026-08-28 +- One bulk request reads the status of all referenced tickets: the proxy fans the refs out right next to the Taiga instance, so the cost scales with the refs in the plan — not with a project that holds thousands of tickets +- With a Taiga login, diverging tickets show up in the diagram: a ref whose ticket status no longer matches the node's box turns amber — as its own little badge where the ref is the node id and thus not part of the title +- The bulk result pre-fills the node window's ticket cache, so hovering a ticket node usually needs no request of its own any more +- The divergence mark is session knowledge and stays out of the SVG export and of print — what you share is the plan, not your fetch state - 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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index fe56727..614f3c7 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -8386,3 +8386,88 @@ benannte Test. 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). + +**Nachtrag 10 — Bulk-Abfrage und Abweichungs-Marke: das Diagramm zeigt, wo +Ticket und Plan auseinanderlaufen (2026-08-28).** Nutzerwunsch in zwei +Schritten: erst die Frage nach einer Bulk-Abfrage („Einzelabfrage wäre +wahrscheinlich zu langsam"), dann — auf den Befund, dass die Taiga-Projekte +**Tausende** Stories und Tasks haben — die nach einem Vorfilter. Die +Antwort auf beide ist dieselbe: **Der Filter sind die Refs im Plan, und +angewandt wird er im Proxy.** + +- **`GET /taiga/tickets?slug=…&refs=US-1,T-2,…`** — EIN Aufruf vom Browser, + der Proxy fächert in `by_ref`-Einzelabfragen auf: **parallel** (virtuelle + Threads, D76-Nachtrag 5) mit **gedeckelter Nebenläufigkeit** (Semaphore, + 6 gleichzeitig — Höflichkeit gegenüber der fremden Instanz). Die teure + Strecke Browser→Backend (~130 ms, gemessen) fällt einmal an; Backend und + Taiga-API sitzen beim selben Hoster, dort kosten die Einzelabfragen + Millisekunden. Die Kosten skalieren mit den Refs im Plan (Dutzende), nie + mit dem Projekt (Tausende) — die zuerst erwogene **Projekt-Volliste ist + damit verworfen**: Sie holte Megabytes, um fast alles wegzuwerfen, und + ließe Taiga je Anfrage eine große Liste rechnen. +- **Ein Endpunkt für beide Typen:** Das Präfix jeder Ref trägt den Typ + (`US-`/`T-`, Nachtrag 2) — genau die Auflösung, für die Werkbaum es + schreibt. Antwort ist eine Map Ref → Stand (dieselben Felder wie der + Einzel-Abruf, samt `version` — der Bulk füllt den Ticket-Cache des + Knoten-Fensters vor, das Fenster braucht dann meist keinen Abruf mehr). +- **Fehler-Semantik:** Eine Ref, die es nicht (mehr) gibt, **fehlt still** + in der Antwort — die übrigen kommen trotzdem; 401, unbekanntes Projekt + oder eine tote Instanz brechen die ganze Anfrage ab (sie beträfen jede + Ref). Ungültige Refs in der Liste sind ein **400, nicht still + übersprungen** (D59); doppelte werden vor dem Fächer zusammengelegt, und + bei **200 Refs** ist benannt Schluss (Client schneidet, Server lehnt ab). +- **Die Marke:** Eine Ref, deren Ticket-Status abgebildet ist und nicht zur + Statusbox passt, färbt sich **warnfarben**. Beim Bauen fiel die Lücke des + Vorschlags auf: `- [ ] Login #US-123` macht die Ref zur **Knoten-ID** + (§1, erstes `#`-Token) — sie steht gar nicht im Label, es gibt nichts zu + färben. Für diesen Fall hängt der **Renderer** ein kleines nachgestelltes + Badge mit der Ref an (die D40-Bauform der ”-Marke: vor dem Messen, die + Geometrie stimmt); sichtbare Refs (eigene ID auf der Zeile, D60-Knoten) + färben sich selbst. Ein unabgebildeter Spaltenname markiert nichts — + dieselbe Regel wie im Fenster. +- **Wann:** einmal je Projekt und Sitzung, angestoßen vom Neubau, von + `GET /info` (die Antwort kommt nach dem ersten Neubau) und von jeder + Anmeldung; ohne Anmeldung nie (die Nachtrag-6-Linie). Das Markieren aus + dem Cache ist kostenlos und läuft je Neubau mit; kommt der Bulk an, + rendert sein Callback — **außer** ein Knoten-Fenster ist offen (der Neubau + schlösse es): dann nur die Klassen-Marken, Badges kommen mit dem nächsten + Neubau. Einzel-Abrufe (↻, Schreiben) ziehen die Marken mit nach; nach + „nach Taiga schreiben" räumt `markTicketDiffs` erst ab und setzt neu — + sonst stünde die Marke auf einem Ticket, das wieder einig ist. +- **Nicht im Export, nicht im Druck:** Die Marke hängt an der Sitzung + (Anmeldung, Abrufzeitpunkt) — exportiert wird der Plan, nicht der + persönliche Abrufstand (dieselbe Linie wie der gelbe Kranz, D28). Der + Export liest die Label-Farbe vom **Knoten**, nicht von der Spanne + (nachgemessen), das Badge ist per `excludeSel` ausgenommen, der Druck + blendet es aus. Und im `aria-label` steht die Abweichung nicht — sie ist + asynchrones Sitzungswissen, der Screenreader-Weg ist das Knoten-Fenster; + benannt als Grenze, nicht übersehen. +- **Blass bleibt blass:** Auf einem vom Pfad zurückgetretenen Knoten dimmt + die Marke mit (die frontend/CLAUDE.md-Prüffrage ist gestellt): Anders als + bei `fresh`/`focusmark` ist die Aussage hier an einem nicht gebrauchten + Knoten auch weniger dringend, und erledigte Knoten treten seit D46 ohnehin + nie zurück. +- **Messnotiz für später:** Ob Taigas Listen-Endpunkte eine **Ref-Liste** + als Filter nehmen (`?project=…&refs=…`), ist nicht gemessen — die API-Doku + ist dort dünn. Falls ja, tauscht der Proxy sein Inneres (ein Aufruf statt + Fächer), ohne dass sich am Endpunkt oder im Editor etwas ändert. + +**Nachgemessen** Ende-zu-Ende (lokales Backend + Taiga-Stub): Die +Bulk-Antwort trägt beide Refs samt Status und `version` (curl); im Browser +bekommt der Ref-als-ID-Knoten mit Abweichung das Badge in `--warn` +(`rgb(180,83,9)`), der einige Knoten nichts, der Label-Ref färbt sich ohne +Badge; der Stub-Mitschnitt zeigt je Bulk **einmal** `by_slug` und je +eindeutiger Ref eine `by_ref`-Abfrage (Dedupe greift); das Knoten-Fenster +liest danach aus dem Cache (kein weiterer Abruf); im exportierten SVG steht +der Label-Ref genau einmal, in Knotenfarbe, ohne Badge und ohne Bernstein. +Backend-`check` grün (5 neue Tests: Fächer mit Map, 404 fällt still, +401 bricht ab, Bulk-E2E, ungültige Ref → 400); Frontend 617 Tests (15 +neue). Gegenproben per Mutation: 404-Überspringen entfernt → genau der +danach benannte Test fällt (1 von 21); Dedupe entfernt → genau der eine. + +**Werkzeug-Notizen:** Der D82-Abschieds-Flush gewinnt gegen ein +`localStorage.setItem` + `location.reload()` — wer den Speicher unter einer +lebenden Seite umschreibt, bekommt beim Reload deren Gedächtnis zurück; +wiederhergestellt wird ein Dokument als gewöhnliche Änderung über das +Textfeld. Und der Debug-Reset hängt am `confirm`, das die isolierte Welt +des Prüf-Panes nicht stubben kann (Nachtrag 9) — er ist dort wirkungslos. diff --git a/docs/SPEC.md b/docs/SPEC.md index 9420e73..691c2f3 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -529,6 +529,22 @@ es einen gibt, den **Zuständigen**. Siehe D91-Nachträge 6, 7 und 8. +**Abweichungs-Marke im Diagramm.** Mit Anmeldung an der Instanz holt **eine +Bulk-Anfrage je Projekt und Sitzung** den Stand aller referenzierten Tickets +(der Proxy fächert sie serverseitig in Einzelabfragen auf — die Kosten +skalieren mit den Refs im Plan, nie mit der Größe des Taiga-Projekts). Eine +Ref, deren Ticket-Status **abgebildet** ist und nicht zur Statusbox des +Knotens passt, färbt sich **warnfarben** — steht sie im Label, die Ref +selbst; ist sie (nur) die Knoten-ID (§1: das erste `#`-Token), erscheint sie +als kleines nachgestelltes Badge hinter dem Titel (die Bauform der ”-Marke). +Ein unabgebildeter Spaltenname markiert nichts — er sagt nichts über den +Plan. Die Einzelheiten samt der beiden Aktionen stehen wie gehabt im +Knoten-Fenster; ein Ticket, das es nicht (mehr) gibt, markiert nichts, und +ein Fehler der Hintergrund-Abfrage bleibt still. Ohne Anmeldung wird nichts +geholt. Die Marke hängt an der Sitzung (Anmeldung, Abrufzeitpunkt) und +erscheint deshalb **weder im Grafikexport noch im Druck** — exportiert wird +der Plan, nicht der persönliche Abrufstand. Siehe D91-Nachtrag 10. + **Die Knotenfarbe zeigt den effektiven Status (§4)**, nicht den intrinsischen — das Diagramm beantwortet „wie weit ist das wirklich?“. Wo der eigene Status **weiter** ist als der effektive (der Knoten wird von Abhängigkeiten diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index d18d970..b5effc0 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -205,6 +205,9 @@ - [^] #trk.resolve.read: Read title, link and status (S) - [/] #trk.resolve.map: Map the workflow onto the states (S) - [^] #trk.write: Write the status back (M) :#trk.resolve + - [x] #trk.bulk: Diverging tickets show up in the diagram (M) :#trk.resolve.read + - [x] #trk.bulk.proxy: One request fans out into by_ref queries server-side (S) + - [x] #trk.bulk.mark: A diverging ref turns amber on its node (S) - [^] #trk.create: Create tickets from nodes (XL) - [^] #trk.create.proxy: Backend proxy with named endpoints (M) - [^] #trk.create.login: Log in to Taiga, token stays in the browser (S) :#trk.create.proxy @@ -1158,6 +1161,27 @@ The default five are mapped, an unknown column name stays unmapped and is shown as plain text; making the mapping configurable is still open. +#trk.bulk + With a Taiga login, one bulk request per project and session reads the + status of every referenced ticket, and a ref whose ticket no longer + matches the node's status box turns amber. Details and both sync actions + stay in the node window; the mark is session knowledge and stays out of + export and print. + +#trk.bulk.proxy + GET /taiga/tickets takes the plan's refs and fans them out into by_ref + queries next to the Taiga instance, in parallel with capped concurrency. + The cost scales with the refs in the plan, never with a project holding + thousands of tickets - the refs themselves are the pre-filter. A ref that + no longer exists is silently absent; the bulk also pre-fills the node + window's ticket cache. + +#trk.bulk.mark + A visible ref colours itself; where the ref is the node id and thus not + part of the title, the renderer appends it as a small badge behind the + label - the same construction as the description mark. An unmapped column + name marks nothing: it says nothing about the plan. + #trk.write The other direction. Neither side wins by itself: a difference between the ticket and the status box is marked, and both directions are offered as diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 2f550c0..3375b8f 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -684,6 +684,18 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der läuft schon beim Aufbau — sonst temporale Todeszone). Der `pointerdown`-Wächter lässt `.tabmodal-overlay` durch: Der Anmelde-Dialog gehört zu einer Aktion AUS dem Fenster und darf es nicht zumachen. +- **Abweichungs-Marken im Diagramm (D91-Nachtrag 10):** `collectTicketRefs`, + `bulkPath` (dedupliziert, Deckel 200), `ticketDiverges` und + `refVisibleInLabel` liegen headless in taiga.js. app.js: `scheduleTaigaBulk` + holt je Slug EINMAL je Sitzung (nur mit Sitzung; Hintergrund-Fehler still), + füllt den `taigaTickets`-Cache vor und rendert — außer ein Knoten-Fenster + ist offen (der Neubau schlösse es), dann nur `markTicketDiffs` + (Klassen-Marken, räumt erst ab). Wo die Ref die **Knoten-ID** ist, steht + sie nicht im Label — der **Renderer** hängt dann ein `tref-badge` an + (`tdiffRefs`-Option, die D40-Bauform der ”-Marke: vor dem Messen, die + Geometrie stimmt). Nicht im Export (`excludeSel` + Label-Farbe kommt vom + Knoten), nicht im Druck, nicht im `aria-label` (benannte Grenze: der + Screenreader-Weg ist das Knoten-Fenster). - **Status zurückschreiben (D91-Nachtrag 7/8):** Weicht der Ticket-Status von der Statusbox ab, zeigt `paintDiff()` beides und bietet **zwei** Knöpfe — von selbst geschieht in keine Richtung etwas. `pushStatus()` sucht die diff --git a/frontend/src/app.js b/frontend/src/app.js index f632e24..8e3c2fe 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, storyAncestor } from './taiga.js'; +import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath, storyAncestor, collectTicketRefs, bulkPath, ticketDiverges, refVisibleInLabel } from './taiga.js'; import { esc, renderTreeHtml, TIP_RULE } from './render.js'; import { formatWarning, warningText } from './warnings.js'; import * as live from './live.js'; @@ -322,7 +322,8 @@ function render(){ freshSet, collapsedSet, effStatus: effectiveStatus(roots), overloadTag: overload ? overload.tag : null, - lensTag: lens ? lens.tag : null}); + lensTag: lens ? lens.tag : null, + tdiffRefs: ticketDiffBadgeMap(parsed.roots)}); out.innerHTML = r.html; warnings = warnings.concat(r.warnings); /* Personen-Leiste (D87): Belastung je Person aus der offenen Pfad-Arbeit @@ -365,6 +366,12 @@ function render(){ updateFreshBtn(); /* Zähler folgt der gerade gerenderten Menge (D28) */ updateLeanBtn(); /* Stationen des Pfads haben sich geändert (D47) */ updateFoldBtn(); /* Icon + Tooltip = nächster Schritt des Durchschalters (D75) */ + /* Abweichungs-Marken (D91-Nachtrag 10): aus dem Cache — kostenlos; die + Bulk-Abfrage selbst läuft je Slug nur einmal je Sitzung. Auf den + UNGEFILTERTEN roots, denn die Slug-Vererbung läuft über den ganzen + Baum; was nicht im DOM steht, überspringt das Markieren selbst. */ + markTicketDiffs(parsed.roots); + scheduleTaigaBulk(parsed.roots); } /* ---------- Personen-Leiste (SPEC §9, D87) ---------- @@ -892,7 +899,7 @@ function diagramToSvg(){ const deco = cs.textDecorationLine.includes('line-through') ? ' text-decoration="line-through"' : ''; /* Seit Knoten umbrechen (D64), belegt ein Label mehrere Zeilenboxen — ein SVG- bricht nicht von selbst; je gemessene Zeile ein Element. */ - for(const ln of labelLines(node, '.size,.tags,.ext,.risk,.ownst,.desc-mark' + stripFold)){ + for(const ln of labelLines(node, '.size,.tags,.ext,.risk,.ownst,.desc-mark,.tref-badge' + stripFold)){ const cx = (ln.left + ln.right) / 2 + ox, cy = (ln.top + ln.bottom) / 2 + oy; parts.push(`${esc(ln.text)}`); } @@ -1742,6 +1749,10 @@ function storeTaigaSession(s){ if(s) localStorage.setItem(LS_TAIGA, JSON.stringify(s)); else localStorage.removeItem(LS_TAIGA); }catch(_){} + /* Frisch angemeldet: die Abweichungs-Marken holen (D91-Nachtrag 10) — + sonst warteten sie auf den naechsten Tastendruck. Deckt jeden + Login-Weg ab, weil alle hier durchlaufen. */ + if(s) scheduleTaigaBulk(parse(src.value).roots); } /* Welches Backend, und kann es Taiga? Dieselbe Basis-Herleitung wie beim @@ -1767,6 +1778,9 @@ async function refreshTaiga(){ } const info = await taigaProbeCache.get(basis); if(taigaBase === basis){ taigaOn = info.on; taigaWeb = info.web; } + /* Die Antwort kommt NACH dem ersten Neubau — die Bulk-Abfrage (D91-N10) + anstoßen, sonst wartete sie auf den nächsten Tastendruck. */ + if(taigaOn) scheduleTaigaBulk(parse(src.value).roots); } function taigaFetch(path, options, token){ @@ -2020,6 +2034,7 @@ async function pushStatus(key, slug, ref, name, version){ taigaTickets.set(key, {kind: 'err', msg: taigaErrText(err)}); } repaintTicket(key); + markTicketDiffs(parse(src.value).roots); /* die Marke folgt dem geschriebenen Stand */ } /* Die Spalten je Projekt und Typ — einmal je Sitzung, wie der Ticket-Stand @@ -2061,6 +2076,7 @@ async function loadTicket(key, slug, ref, interactive){ taigaTickets.set(key, {kind: 'err', msg: taigaErrText(err)}); } repaintTicket(key); + markTicketDiffs(parse(src.value).roots); /* die Marke folgt dem frischen Stand */ } /* Gemalt wird nur, wenn genau dieses Ticket noch im offenen Fenster steht — @@ -2072,6 +2088,84 @@ function repaintTicket(key){ if(tipNode) placeNodeTip(tipNode); } +/* ---------- Abweichungs-Marken im Diagramm (D91-Nachtrag 10) ---------- + Mit Anmeldung holt EINE Bulk-Anfrage je Projekt und Sitzung den Stand + aller referenzierten Tickets; eine Ref, deren Ticket-Status nicht zur + Statusbox des Knotens passt, färbt sich warnfarben (`tref-diff`) — die + Einzelheiten samt der zwei Knöpfe stehen wie gehabt im Knoten-Fenster. + Der Fächer läuft im Proxy: Die Kosten skalieren mit den Refs im Plan, + nie mit der Projektgröße. Ohne Anmeldung wird NICHTS geholt (die + D91-Nachtrag-6-Linie), und ein Hintergrund-Fehler bleibt still — kein + Dialog, keine Warnung; das Markieren aus dem Cache ist dagegen + kostenlos und läuft bei jedem Neubau mit. */ +const taigaBulkDone = new Set(); /* Slugs, die diese Sitzung schon geholt sind */ + +function scheduleTaigaBulk(roots){ + if(!taigaOn) return; + const session = taigaSession(); + if(!session || !session.token) return; + for(const [slug, eintraege] of collectTicketRefs(roots, taigaSlugs(roots))){ + if(taigaBulkDone.has(slug)) continue; + taigaBulkDone.add(slug); + const pfad = bulkPath(slug, eintraege.map(e => e.ref)); + if(!pfad) continue; + taigaFetch(pfad, null, session.token).then(map => { + for(const [ref, data] of Object.entries(map || {})){ + /* Ein schon einzeln geholtes Ticket bleibt stehen — es ist nie + älter als der Bulk (der läuft einmal, ganz am Anfang). */ + const key = slug + '/' + ref; + if(!taigaTickets.has(key)) taigaTickets.set(key, {kind: 'ok', data}); + } + /* Badges an unsichtbaren Refs brauchen den Neubau (Renderer, oben); + ein offenes Knoten-Fenster würde der aber schließen — dann nur die + Klassen-Marken, das Badge kommt mit dem nächsten Neubau. */ + if(tipNode) markTicketDiffs(parse(src.value).roots); + else render(); + }).catch(err => { + if(err && err.status === 401) storeTaigaSession(null); + }); + } +} + +/* Welche Knoten brauchen das Abweichungs-BADGE? Nur die, deren Ref nicht + im Label steht (sie ist die Knoten-ID, §1 — erstes `#`-Token) — sichtbare + Refs färben sich selbst (markTicketDiffs). Entschieden aus dem Cache zum + Zeitpunkt des Neubaus; kommt der Bulk später an, rendert sein Callback. */ +function ticketDiffBadgeMap(roots){ + const map = new Map(); + if(!taigaOn) return map; + for(const [slug, eintraege] of collectTicketRefs(roots, taigaSlugs(roots))){ + for(const e of eintraege){ + const st = taigaTickets.get(slug + '/' + e.ref); + if(st && st.kind === 'ok' && ticketDiverges(e.node, st.data.status) + && !refVisibleInLabel(e.node, e.ref)){ + map.set(e.node.line, e.ref); + } + } + } + return map; +} + +/* Die Marken aus dem Cache an die `tref`-Spannen legen — reine Anzeige, je + Neubau neu (das DOM ist frisch). Eingeklappte Knoten stehen nicht im DOM + und bleiben unmarkiert; ihr Ticket-Stand liegt trotzdem im Cache. */ +function markTicketDiffs(roots){ + /* Erst räumen, dann setzen — nach „nach Taiga schreiben" stimmen Ticket + und Box wieder überein, ohne dass der Baum neu gebaut wurde. */ + for(const alt of out.querySelectorAll('.tref-diff')) alt.classList.remove('tref-diff'); + for(const [slug, eintraege] of collectTicketRefs(roots, taigaSlugs(roots))){ + for(const e of eintraege){ + const st = taigaTickets.get(slug + '/' + e.ref); + if(!st || st.kind !== 'ok' || !ticketDiverges(e.node, st.data.status)) continue; + const el = out.querySelector('.node[data-line="' + e.node.line + '"]'); + if(!el) continue; + for(const span of el.querySelectorAll('.tref')){ + if(span.textContent === '#' + e.ref) span.classList.add('tref-diff'); + } + } + } +} + /* Strg+Klick (macOS auch Cmd) auf einen Knoten mit Ticket-Referenz öffnet das Ticket im Taiga-Frontend (D91-Nachtrag 5) — dieselbe Geste wie im Text. Der einfache Klick bleibt der Link (§6), Alt der Sprung (D25); ohne diff --git a/frontend/src/render.js b/frontend/src/render.js index 78c595e..e6c81b7 100644 --- a/frontend/src/render.js +++ b/frontend/src/render.js @@ -290,6 +290,16 @@ function nodeHtml(n, extra, opts, fold){ Notation. Nicht im Export: Der Text selbst kann dort nicht erscheinen, eine Marke ohne Ziel wäre Rauschen. */ (n.desc ? '' : '') + + /* Abweichungs-Badge (D91-Nachtrag 10): Wo die Ref die + KNOTEN-ID ist, steht sie nicht im Label — die Marke + braucht dann einen sichtbaren Träger. Welche Zeilen eines + brauchen, entscheidet app.js aus dem Ticket-Cache; hier + wird nur angehängt (Geometrie stimmt, weil es VOR dem + Messen geschieht — die D40-Bauform der ”-Marke). Nicht im + Export (Sitzungswissen): dort per excludeSel ausgenommen. */ + (opts.tdiffRefs && opts.tdiffRefs.has(n.line) + ? `` + : '') + (n.url ? '' : '') + riskMark + sizeBadge + diff --git a/frontend/src/style.css b/frontend/src/style.css index db92a2d..8bb20fc 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -779,6 +779,15 @@ bräche eine zu breit geratene Zeile sonst am Bindestrich mitten im Token (`#US-`/`123`). Die Spanne setzt render.js (markTicketRefs). */ .node .tref{white-space:nowrap} + /* Abweichungs-Marke (D91-Nachtrag 10): Das Ticket hinter dieser Ref sagt + etwas anderes als die Statusbox — die Ref selbst wird warnfarben, kein + neues Element in den ohnehin dichten Knoten-Ecken. Nur Anzeige aus der + Sitzung: nicht im Grafikexport (der liest die Knotenfarbe, nicht die + Spanne) und nicht im Druck. */ + .node .tref-diff{color:var(--warn);font-weight:600} + /* Das Badge für Refs, die (nur) die Knoten-ID sind — dort steht die Ref + sonst nirgends im Knoten. Klein und nachgestellt wie die ”-Marke. */ + .node .tref-badge{margin-left:4px;font-size:.86em} /* Der Knopf trägt das Zeichen selbst statt eines Icons — in der Größe der SVGs daneben, damit die Kopfzeile ihre Höhe behält. */ .copybtn.idsbtn span{ @@ -1635,6 +1644,10 @@ *{-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important} /* Knoten nicht über den Seitenrand zerschneiden */ #out li{break-inside:avoid} + /* Die Abweichungs-Marke hängt an der Sitzung (Anmeldung, Abrufzeit) — + gedruckt wird der Plan, nicht mein Abrufstand (D91-Nachtrag 10). */ + .node .tref-diff{color:inherit!important;font-weight:inherit!important} + .node .tref-badge{display:none!important} /* Editierhilfe bzw. Zuruf, nicht drucken (D25, Fokusmarke SPEC §1). Bei der Cursor-Zeile gehören Erhebung und Puls mit dazu — sonst stünde ein Knoten im Druck grundlos vergrößert und schief zu seiner Anschlusslinie. */ diff --git a/frontend/src/taiga.js b/frontend/src/taiga.js index b953837..fc68513 100644 --- a/frontend/src/taiga.js +++ b/frontend/src/taiga.js @@ -113,6 +113,58 @@ export function statusListPath(ref, slug){ '?slug=' + encodeURIComponent(slug); } +/* Alle Ticket-Refs eines Baums, gruppiert nach ihrem geerbten Projekt-Slug + (D91-Nachtrag 10): die Menge, die die Bulk-Abfrage holt — die Refs im Plan + sind der Filter, nie die Projektgröße. Knoten ohne Slug fallen heraus + (ohne Projekt ist eine Ref nicht auflösbar — dieselbe stille Regel wie + beim Öffnen, D91-Nachtrag 5). Map slug -> [{ref, node}]; dieselbe Ref an + mehreren Knoten steht mehrfach darin (markiert wird je Knoten), + dedupliziert wird erst im Anfrage-Pfad. */ +export function collectTicketRefs(roots, slugMap){ + const out = new Map(); + (function w(ns){ + for(const n of ns){ + const ref = ticketRefOf(n); + const slug = slugMap.get(n); + if(ref && slug){ + if(!out.has(slug)) out.set(slug, []); + out.get(slug).push({ref, node: n}); + } + w(n.children || []); + } + })(roots); + return out; +} + +/* Der Bulk-Pfad am Proxy (eine Anfrage je Projekt): Refs dedupliziert und + bei 200 gedeckelt — der benannte Server-Deckel; mehr referenziert kein + Plan, und die ersten 200 markiert zu bekommen ist besser als wegen eines + 400 gar keine. */ +export function bulkPath(slug, refs){ + const uniq = [...new Set(refs)].slice(0, 200); + if(!uniq.length || !slug) return null; + return '/tickets?slug=' + encodeURIComponent(slug) + '&refs=' + uniq.join(','); +} + +/* Steht die Ref sichtbar im LABEL des Knotens? Nein heißt: Sie ist (nur) + seine Knoten-ID (§1, erstes `#`-Token) — die Abweichungs-Marke braucht + dann ein Badge hinter dem Label als sichtbaren Träger (D91-Nachtrag 10). + Ein D60-Knoten (die ID vertritt das Label) zählt als sichtbar. */ +export function refVisibleInLabel(n, ref){ + return new RegExp('(^|\\s)#' + ref + '(?=\\s|$)').test(n.label); +} + +/* Weicht der Ticket-Status von der Statusbox des Knotens ab? (Die + Abweichungs-Marke im Diagramm, D91-Nachtrag 10.) Verglichen wird nur, was + abbildbar ist — ein unbekannter Spaltenname sagt nichts über den Plan + (dieselbe Regel wie im Knoten-Fenster). Ein Knoten OHNE Statusbox weicht + von jedem abgebildeten Ticket-Status ab — auch das zeigt das Fenster so. */ +export function ticketDiverges(node, statusName){ + const ticket = mapTaigaStatus(statusName); + if(!ticket) return false; + return (node.status ? node.status.code : null) !== ticket.code; +} + /* Die Ticket-Referenz unter der Schreibmarke (Strg+Klick im Text, D91-Nachtrag 5): ein FREISTEHENDES `#US-123`/`#T-1234`-Token im Baumteil. Dieselben Ausschlüsse wie beim Abhängigkeits-Sprung (D67): nicht im diff --git a/frontend/tests/taiga.test.js b/frontend/tests/taiga.test.js index c2e4a16..98bf71e 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, storyAncestor } from '../src/taiga.js'; +import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, refParts, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath, storyAncestor, collectTicketRefs, bulkPath, ticketDiverges, refVisibleInLabel } from '../src/taiga.js'; import { setStatusBox } from '../src/parser.js'; /* Schlagworte `&tag` (SPEC §1, D91): Extraktion im Parser und die @@ -188,6 +188,96 @@ describe('storyAncestor — der nächste Vorfahr mit Story-Ref (D91-Nachtrag 9)' }); }); +describe('collectTicketRefs — die Refs eines Baums je Projekt (D91-Nachtrag 10)', () => { + const collect = text => { + const { roots } = parse(text); + return collectTicketRefs(roots, taigaSlugs(roots)); + }; + + it('gruppiert nach dem geerbten Slug, mit Knoten und Zeile', () => { + const m = collect('- P &taiga.a\n - S #US-1\n - T #T-2\n- Q &taiga.b\n - R #US-3'); + expect([...m.keys()]).toEqual(['a', 'b']); + expect(m.get('a').map(e => e.ref)).toEqual(['US-1', 'T-2']); + expect(m.get('a')[0].node.line).toBe(2); + expect(m.get('b').map(e => e.ref)).toEqual(['US-3']); + }); + + it('ohne Slug fällt die Ref heraus — ohne Projekt ist sie nicht auflösbar', () => { + const m = collect('- P\n - S #US-1'); + expect(m.size).toBe(0); + }); + + it('Knoten ohne Ref stehen nicht darin', () => { + const m = collect('- P &taiga.a\n - ohne Ref\n - S #US-1'); + expect(m.get('a').length).toBe(1); + }); + + it('ein übersteuernder Slug ordnet den Teilbaum seinem Projekt zu', () => { + const m = collect('- P &taiga.a\n - S #US-1\n - Fremd &taiga.b #US-9'); + expect(m.get('a').map(e => e.ref)).toEqual(['US-1']); + expect(m.get('b').map(e => e.ref)).toEqual(['US-9']); + }); +}); + +describe('bulkPath — der Bulk-Pfad am Proxy (D91-Nachtrag 10)', () => { + it('baut Slug (kodiert) und kommagetrennte Refs', () => { + expect(bulkPath('mi kunde', ['US-1', 'T-2'])) + .toBe('/tickets?slug=mi%20kunde&refs=US-1,T-2'); + }); + + it('dedupliziert — dieselbe Ref an zwei Knoten wird einmal geholt', () => { + expect(bulkPath('a', ['US-1', 'US-1', 'T-2'])).toBe('/tickets?slug=a&refs=US-1,T-2'); + }); + + it('deckelt bei 200 — der benannte Server-Deckel', () => { + const viele = Array.from({length: 250}, (_, i) => 'US-' + i); + const pfad = bulkPath('a', viele); + expect(pfad.split(',').length).toBe(200); + }); + + it('null ohne Refs oder ohne Slug', () => { + expect(bulkPath('a', [])).toBe(null); + expect(bulkPath('', ['US-1'])).toBe(null); + }); +}); + +describe('refVisibleInLabel — braucht die Marke ein Badge? (D91-Nachtrag 10)', () => { + const node = text => parse(text).roots[0]; + + it('mit eigener Knoten-ID bleibt die Ref im Label: sichtbar', () => { + expect(refVisibleInLabel(node('- #auth: Login #US-123'), 'US-123')).toBe(true); + }); + + it('als Knoten-ID steht sie nicht im Label: unsichtbar, Badge nötig', () => { + expect(refVisibleInLabel(node('- Login bauen #US-123'), 'US-123')).toBe(false); + }); + + it('ein D60-Knoten (die ID vertritt das Label) zählt als sichtbar', () => { + expect(refVisibleInLabel(node('- #US-123'), 'US-123')).toBe(true); + }); +}); + +describe('ticketDiverges — die Abweichungs-Marke (D91-Nachtrag 10)', () => { + const node = text => parse(text).roots[0]; + + it('markiert, wo Ticket und Statusbox Verschiedenes sagen', () => { + expect(ticketDiverges(node('- [ ] A #US-1'), 'In progress')).toBe(true); + }); + + it('einig — keine Marke', () => { + expect(ticketDiverges(node('- [~] A #US-1'), 'In progress')).toBe(false); + }); + + it('ein unbekannter Spaltenname sagt nichts über den Plan', () => { + expect(ticketDiverges(node('- [ ] A #US-1'), 'Blocked upstream')).toBe(false); + expect(ticketDiverges(node('- [ ] A #US-1'), null)).toBe(false); + }); + + it('ein Knoten ohne Statusbox weicht von jedem abgebildeten Status ab', () => { + expect(ticketDiverges(node('- A #US-1'), 'New')).toBe(true); + }); +}); + 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');