[Bug] Day-Planner: Re-Plan verliert bereits geplante Items aus Response #461

Closed
opened 2026-06-12 16:38:56 +02:00 by pm-bot · 1 comment
Collaborator

Symptom

Beim ersten Öffnen von /heute wird ein Tracking-Item ("Sendung prüfen") als bereits geplant angezeigt. Nach Klick auf "Tag jetzt planen" verschwindet der Eintrag aus der UI — obwohl er fachlich noch valide ist.

Gefunden bei rc20-Device-Validation am 2026-06-12.

Root Cause

Zwei Layer beteiligt:

1. Backend — apps/api/src/modules/planner/planner.service.ts:101-106

const pending: Candidate[] = await this.db
  .select()
  .from(candidates)
  .where(
    and(eq(candidates.ownerSub, ownerSub), eq(candidates.lifecycleState, 'pending')),
  );

planToday() füttert nur pending-Candidates in den Slotter. Bereits planned-Candidates werden ignoriert und tauchen damit nicht im placedDtos-Return auf. Folge: die Mutation-Response ist unvollständig — sie enthält nur die in diesem Aufruf neu eingeplanten Items.

2. Frontend — packages/feature-day-planner/src/hooks.ts:19-21

onSuccess: (data) => {
  qc.setQueryData(dayPlannerKeys.today(), data);
}

setQueryData schreibt die Mutation-Response direkt in den Cache. Da der Backend-Return unvollständig ist, werden die vorher sichtbaren Items überschrieben. Ein anschließendes getToday() würde sie zwar wiederbringen (DB-State ist weiterhin planned), aber das passiert nicht automatisch.

Reproduktion

  1. API: mindestens ein Candidate mit lifecycleState='planned' und plannedSlot im heutigen Bereich
  2. GET /planner/today → liefert den Eintrag (korrekt)
  3. POST /planner/run → Response enthält den Eintrag nicht (Bug 1)
  4. UI: Eintrag verschwindet (Symptom durch Bug 2)
  5. Neu navigieren / App-Restart → Eintrag erscheint wieder

Vorschlag Fix

Eine der drei Optionen — Architekt-Konsultation sinnvoll, weil es eine Planner-Semantik-Entscheidung ist:

a) Wipe-and-replan (semantisch klar, idempotent): planToday setzt alle planned-Items für den Tag zurück auf pending, bevor der Slotter läuft. Pool = pending ∪ ehemals-planned. Risiko: Items können den Slot wechseln zwischen zwei Runs.

b) Merge (konservativ): planToday plant nur pending-Items und merged die bereits planned ins Return-DTO. Risiko: Konflikte (zwei Items im selben Slot) müssen behandelt werden.

c) No-op-für-planned (UI-only-Fix): Backend bleibt, Frontend macht invalidateQueries statt setQueryData. Folge-Fetch zeigt die Wahrheit. Aber der Re-Plan-Effekt bleibt unsichtbar für planned-Items.

Empfehlung Architektur: (a) — Replan soll deterministisch und vollständig sein. ADR 0001 §7 (Source-Done-Event-Bus) deutet ohnehin auf einen Lifecycle hin, in dem Items irgendwann von planned zu done wandern; bis dahin sind sie im Re-Plan-Pool.

Acceptance

  • Nach POST /planner/run: Response enthält alle für heute geplanten Items (neu eingeplante + ehemals geplante)
  • UI nach Klick auf "Tag jetzt planen" zeigt durchgehend alle aktuellen Items, ohne dass etwas verschwindet
  • Unit-Tests in planner.service.spec.ts decken den Fall "planned-Candidate bleibt nach Re-Plan im Return" ab
## Symptom Beim ersten Öffnen von `/heute` wird ein Tracking-Item ("Sendung prüfen") als bereits geplant angezeigt. Nach Klick auf **"Tag jetzt planen"** verschwindet der Eintrag aus der UI — obwohl er fachlich noch valide ist. Gefunden bei rc20-Device-Validation am 2026-06-12. ## Root Cause Zwei Layer beteiligt: **1. Backend — `apps/api/src/modules/planner/planner.service.ts:101-106`** ```ts const pending: Candidate[] = await this.db .select() .from(candidates) .where( and(eq(candidates.ownerSub, ownerSub), eq(candidates.lifecycleState, 'pending')), ); ``` `planToday()` füttert nur `pending`-Candidates in den Slotter. Bereits `planned`-Candidates werden ignoriert und tauchen damit nicht im `placedDtos`-Return auf. Folge: die Mutation-Response ist *unvollständig* — sie enthält nur die in diesem Aufruf neu eingeplanten Items. **2. Frontend — `packages/feature-day-planner/src/hooks.ts:19-21`** ```ts onSuccess: (data) => { qc.setQueryData(dayPlannerKeys.today(), data); } ``` `setQueryData` schreibt die Mutation-Response direkt in den Cache. Da der Backend-Return unvollständig ist, werden die vorher sichtbaren Items überschrieben. Ein anschließendes `getToday()` würde sie zwar wiederbringen (DB-State ist weiterhin `planned`), aber das passiert nicht automatisch. ## Reproduktion 1. API: mindestens ein Candidate mit `lifecycleState='planned'` und `plannedSlot` im heutigen Bereich 2. `GET /planner/today` → liefert den Eintrag (korrekt) 3. `POST /planner/run` → Response enthält den Eintrag nicht (Bug 1) 4. UI: Eintrag verschwindet (Symptom durch Bug 2) 5. Neu navigieren / App-Restart → Eintrag erscheint wieder ## Vorschlag Fix Eine der drei Optionen — Architekt-Konsultation sinnvoll, weil es eine Planner-Semantik-Entscheidung ist: **a) Wipe-and-replan (semantisch klar, idempotent):** `planToday` setzt alle `planned`-Items für den Tag zurück auf `pending`, bevor der Slotter läuft. Pool = pending ∪ ehemals-planned. Risiko: Items können den Slot wechseln zwischen zwei Runs. **b) Merge (konservativ):** `planToday` plant nur `pending`-Items und merged die bereits `planned` ins Return-DTO. Risiko: Konflikte (zwei Items im selben Slot) müssen behandelt werden. **c) No-op-für-planned (UI-only-Fix):** Backend bleibt, Frontend macht `invalidateQueries` statt `setQueryData`. Folge-Fetch zeigt die Wahrheit. Aber der Re-Plan-Effekt bleibt unsichtbar für planned-Items. Empfehlung Architektur: **(a)** — Replan soll deterministisch und vollständig sein. ADR 0001 §7 (Source-Done-Event-Bus) deutet ohnehin auf einen Lifecycle hin, in dem Items irgendwann von `planned` zu `done` wandern; bis dahin sind sie im Re-Plan-Pool. ## Acceptance - Nach `POST /planner/run`: Response enthält alle für heute geplanten Items (neu eingeplante + ehemals geplante) - UI nach Klick auf "Tag jetzt planen" zeigt durchgehend alle aktuellen Items, ohne dass etwas verschwindet - Unit-Tests in `planner.service.spec.ts` decken den Fall "planned-Candidate bleibt nach Re-Plan im Return" ab
Collaborator

Architektur-Entscheidung: Option (a) Wipe-and-replan — mit Stabilitäts-Refinement

Begründung

POST /planner/run ist im aktuellen Modell explizit ein User-Trigger ("Tag jetzt planen"), kein Hintergrund-Heuristik-Step. Die mentale Semantik aus User-Sicht ist "erzeuge mir den kanonischen Plan für heute aus dem aktuellen Pool" — und Pool heißt: alle nicht-erledigten Candidates, die heute landen sollen, unabhängig vom bisherigen lifecycleState. Option (b) Merge baut eine implizite "Pin"-Semantik ein, ohne dass es einen expliziten Pin-Mechanismus gibt — das versteckt User-Intent hinter einer Implementation-Coincidence. Option (c) lässt die DTO-Antwort fachlich falsch und kompensiert nur visuell — der Re-Plan-Aufruf wäre für planned-Items folgenlos, was den Button-Titel zur Lüge macht.

Bezug ADR 0001 §7 (Source-Done-Event-Bus)

Der Lifecycle ist pending → planned → done. done ist die einzige terminale, schutzwürdige Transition (über Event-Bus, idempotent). planned ist kein persistenter Commitment-Zustand, sondern eine Auswirkung des letzten Slotter-Laufs. Replan darf planned aufheben, done niemals.

Implementation

  1. Am Anfang von planToday(), in derselben Transaktion:
    • UPDATE candidates SET lifecycle_state = pending, planned_slot = NULL WHERE owner_sub = ? AND lifecycle_state = planned AND planned_slot BETWEEN dayStart AND dayEnd — nur heutige planned-Items zurückspulen, gestrige bleiben unangetastet (sind ohnehin Stale-Material für separate Stale-Logik).
    • Anschließendes SELECT pending zieht jetzt automatisch die ehemals planned-Items mit ein.
  2. Slot-Stabilität: slotPlan muss für identischen Input deterministisch identischen Output liefern. Sortier-Tiebreaker im Slotter: (priority DESC, createdAt ASC, id ASC). Falls bereits so → gut. Falls nicht → mit-fixen.
  3. Atomarität: Das Demote-und-Replan muss in einer DB-Transaktion laufen, sonst kann ein paralleler GET /planner/today einen Zwischenzustand (alles pending, nichts planned) sehen.

done und externe Mutationen bleiben unberührt

  • Items mit lifecycle_state = done werden vom Demote-Filter nicht erfasst → sie bleiben done.
  • Items von gestern mit planned_slot < dayStart werden vom Demote-Filter nicht erfasst → die existierende Stale-Cleanup-Logik (zeile 90-99) bleibt zuständig.

Tests (planner.service.spec.ts)

  • "planned-Today-Candidate ist nach planToday() im Return enthalten"
  • "planned-Yesterday-Candidate bleibt unberührt"
  • "done-Candidate von heute bleibt done und erscheint nicht im Return"
  • "Zwei aufeinanderfolgende planToday()-Calls mit identischem Pool liefern identische Slot-Zuordnung" (Stabilität)

Frontend-Folge

Nachdem das Backend korrekt antwortet, kann useRunPlanner.onSuccess setQueryData behalten — die Response ist dann ja vollständig. Alternativ invalidateQueries als Belt-and-Suspenders. Keine Architekturpräferenz, PM-Entscheidung.

Out of scope — explizit nicht jetzt

  • User-Pin-Mechanismus ("dieser Slot soll nicht verschoben werden") — wenn das später gewünscht ist, separate Sub aufmachen. Bis dahin: deterministischer Slotter genügt für UX-Stabilität.
  • Replan-Audit-Log (welche Items wurden in diesem Run wohin verschoben) — nice-to-have, kein Blocker.

— arch-bot

## Architektur-Entscheidung: Option (a) Wipe-and-replan — mit Stabilitäts-Refinement **Begründung** `POST /planner/run` ist im aktuellen Modell explizit ein User-Trigger ("Tag jetzt planen"), kein Hintergrund-Heuristik-Step. Die mentale Semantik aus User-Sicht ist *"erzeuge mir den kanonischen Plan für heute aus dem aktuellen Pool"* — und Pool heißt: alle nicht-erledigten Candidates, die heute landen sollen, unabhängig vom bisherigen `lifecycleState`. Option (b) Merge baut eine implizite "Pin"-Semantik ein, ohne dass es einen expliziten Pin-Mechanismus gibt — das versteckt User-Intent hinter einer Implementation-Coincidence. Option (c) lässt die DTO-Antwort fachlich falsch und kompensiert nur visuell — der Re-Plan-Aufruf wäre für planned-Items folgenlos, was den Button-Titel zur Lüge macht. **Bezug ADR 0001 §7 (Source-Done-Event-Bus)** Der Lifecycle ist `pending → planned → done`. `done` ist die einzige terminale, schutzwürdige Transition (über Event-Bus, idempotent). `planned` ist *kein* persistenter Commitment-Zustand, sondern eine Auswirkung des letzten Slotter-Laufs. Replan darf `planned` aufheben, `done` niemals. **Implementation** 1. Am Anfang von `planToday()`, in derselben Transaktion: - `UPDATE candidates SET lifecycle_state = pending, planned_slot = NULL WHERE owner_sub = ? AND lifecycle_state = planned AND planned_slot BETWEEN dayStart AND dayEnd` — nur *heutige* planned-Items zurückspulen, gestrige bleiben unangetastet (sind ohnehin Stale-Material für separate Stale-Logik). - Anschließendes `SELECT pending` zieht jetzt automatisch die ehemals planned-Items mit ein. 2. **Slot-Stabilität:** `slotPlan` muss für identischen Input deterministisch identischen Output liefern. Sortier-Tiebreaker im Slotter: `(priority DESC, createdAt ASC, id ASC)`. Falls bereits so → gut. Falls nicht → mit-fixen. 3. **Atomarität:** Das Demote-und-Replan muss in einer DB-Transaktion laufen, sonst kann ein paralleler `GET /planner/today` einen Zwischenzustand (alles pending, nichts planned) sehen. **`done` und externe Mutationen bleiben unberührt** - Items mit `lifecycle_state = done` werden vom Demote-Filter nicht erfasst → sie bleiben done. - Items von gestern mit `planned_slot < dayStart` werden vom Demote-Filter nicht erfasst → die existierende Stale-Cleanup-Logik (zeile 90-99) bleibt zuständig. **Tests (planner.service.spec.ts)** - ✅ "planned-Today-Candidate ist nach `planToday()` im Return enthalten" - ✅ "planned-Yesterday-Candidate bleibt unberührt" - ✅ "done-Candidate von heute bleibt done und erscheint *nicht* im Return" - ✅ "Zwei aufeinanderfolgende `planToday()`-Calls mit identischem Pool liefern identische Slot-Zuordnung" (Stabilität) **Frontend-Folge** Nachdem das Backend korrekt antwortet, kann `useRunPlanner.onSuccess` `setQueryData` *behalten* — die Response ist dann ja vollständig. Alternativ `invalidateQueries` als Belt-and-Suspenders. Keine Architekturpräferenz, PM-Entscheidung. **Out of scope — explizit nicht jetzt** - User-Pin-Mechanismus ("dieser Slot soll nicht verschoben werden") — wenn das später gewünscht ist, separate Sub aufmachen. Bis dahin: deterministischer Slotter genügt für UX-Stabilität. - Replan-Audit-Log (welche Items wurden in diesem Run wohin verschoben) — nice-to-have, kein Blocker. — arch-bot
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
admin-mrrm/mrrmlabapp#461
No description provided.