[Sub] Day-Planner — Todos werden zu Candidates (UX-Lücke aus rc21) #472

Closed
opened 2026-06-13 19:48:05 +02:00 by pm-bot · 1 comment
Collaborator

Kontext

In der rc21-Device-Validation (2026-06-13) ist aufgefallen: Der Day-Planner Empty-State (#463) bietet die Action „Manuelle Aufgabe erstellen" → leitet zu /todo (Todo-Liste). Aber Todos sind aktuell eine Insellösung — sie werden nicht zu Candidates und tauchen nie auf /heute auf.

Ein User-Test mit „Todo für heute anlegen" → „Tag jetzt planen" zeigte: Todo erscheint nicht auf /heute. Damit war die Empty-State-CTA eine UX-Lüge und Tests 2+3 der Device-Validation nicht ausführbar (keine Möglichkeit, das eigene Planning zu seedan).

Sub zu Epic #360.

Anforderung (vom Stakeholder)

„Todos sollen auch als Candidate für heute genutzt werden können, sonst wird Todo zur Insellösung."

Das bedeutet: Wer eine Todo mit Fälligkeit (oder Erinnerung) für heute anlegt, soll sie auch auf /heute sehen — als Candidate, der vom Planner einen Slot bekommt.

Scope

Backend:

  • Todos mit dueDate = heute (oder ein vergleichbares Feld) werden als Candidate-Source behandelt
  • Vermutlich neue Source-Enum-Variante (source: 'todo') oder Reuse von manual
  • lists-Modul bekommt Hook/Event/Subscription: Item-create mit Datum=heute → Candidate-create
  • Lifecycle-Bezug: Wenn der Todo erledigt wird, soll der Candidate auf done gehen (siehe auch ADR 0001 §7 Source-Done-Event-Bus — die andere Richtung). Spiegelbild davon hier ist relevant.

Frontend:

  • Empty-State-CTA „Manuelle Aufgabe erstellen" → Ziel hat jetzt echte Wirkung
  • Eventuell: Todo-Erstellungs-Form bekommt explizit ein „Heute planen"-Toggle oder Datum-Feld

Architektur-Question — sollte mit arch-bot konsultiert werden:

Drei Implementierungs-Optionen:

(a) Sync-Source-Pattern: Todo-Create-Event triggert Candidate-Insert. Zwei Datenmodelle, in Sync gehalten via Event-Bus. Spiegelbild zum Source-Done-Event-Bus aus ADR 0001 §7.

(b) Read-Through: Day-Planner-Service liest direkt aus todos-Tabelle zusätzlich zu candidates und mergt. Kein zweites Modell, aber gekoppelt.

(c) Unification: Todos werden zu Candidates fusioniert — gleiche Tabelle, Source-Diskriminator. Größere Migration, sauberster End-State.

Out of scope

  • Habits-Spawn-Flow (existiert, separat)
  • Manueller Candidate-Create-Flow ohne Todo-Brücke (z.B. FAB direkt auf /heute) — kann separates Sub sein wenn überhaupt nötig

Acceptance

  • User legt eine Todo mit Fälligkeit=heute an (z.B. via /todo oder über die Empty-State-CTA)
  • Auf /heute → „Tag jetzt planen" → Todo erscheint als Item mit Slot
  • Erledigt-Markierung in /todo oder /heute propagiert in beide Richtungen
  • Empty-State-CTA-Versprechen aus #463 wird eingelöst

Meta — Prozess-Lesson aus dem Vorfall

Habe als Memory gespeichert (feedback_verify_cta_destinations.md): vor jedem CTA / Empty-State end-to-end-Datenfluss verifizieren statt nur Route-Existenz. Verhindert Wiederholungen.

## Kontext In der rc21-Device-Validation (2026-06-13) ist aufgefallen: Der Day-Planner Empty-State (#463) bietet die Action „Manuelle Aufgabe erstellen" → leitet zu `/todo` (Todo-Liste). Aber Todos sind aktuell eine **Insellösung** — sie werden nicht zu Candidates und tauchen nie auf `/heute` auf. Ein User-Test mit „Todo für heute anlegen" → „Tag jetzt planen" zeigte: Todo erscheint nicht auf `/heute`. Damit war die Empty-State-CTA eine UX-Lüge und Tests 2+3 der Device-Validation nicht ausführbar (keine Möglichkeit, das eigene Planning zu seedan). Sub zu Epic #360. ## Anforderung (vom Stakeholder) > „Todos sollen auch als Candidate für heute genutzt werden können, sonst wird Todo zur Insellösung." Das bedeutet: Wer eine Todo mit Fälligkeit (oder Erinnerung) für heute anlegt, soll sie auch auf `/heute` sehen — als Candidate, der vom Planner einen Slot bekommt. ## Scope **Backend:** - Todos mit `dueDate = heute` (oder ein vergleichbares Feld) werden als Candidate-Source behandelt - Vermutlich neue Source-Enum-Variante (`source: 'todo'`) oder Reuse von `manual` - `lists`-Modul bekommt Hook/Event/Subscription: Item-create mit Datum=heute → Candidate-create - Lifecycle-Bezug: Wenn der Todo erledigt wird, soll der Candidate auf `done` gehen (siehe auch ADR 0001 §7 Source-Done-Event-Bus — die andere Richtung). Spiegelbild davon hier ist relevant. **Frontend:** - Empty-State-CTA „Manuelle Aufgabe erstellen" → Ziel hat jetzt echte Wirkung - Eventuell: Todo-Erstellungs-Form bekommt explizit ein „Heute planen"-Toggle oder Datum-Feld **Architektur-Question — sollte mit arch-bot konsultiert werden:** Drei Implementierungs-Optionen: (a) **Sync-Source-Pattern:** Todo-Create-Event triggert Candidate-Insert. Zwei Datenmodelle, in Sync gehalten via Event-Bus. Spiegelbild zum Source-Done-Event-Bus aus ADR 0001 §7. (b) **Read-Through:** Day-Planner-Service liest direkt aus `todos`-Tabelle zusätzlich zu `candidates` und mergt. Kein zweites Modell, aber gekoppelt. (c) **Unification:** Todos werden zu Candidates fusioniert — gleiche Tabelle, Source-Diskriminator. Größere Migration, sauberster End-State. ## Out of scope - Habits-Spawn-Flow (existiert, separat) - Manueller Candidate-Create-Flow ohne Todo-Brücke (z.B. FAB direkt auf /heute) — kann separates Sub sein wenn überhaupt nötig ## Acceptance - User legt eine Todo mit Fälligkeit=heute an (z.B. via `/todo` oder über die Empty-State-CTA) - Auf `/heute` → „Tag jetzt planen" → Todo erscheint als Item mit Slot - Erledigt-Markierung in `/todo` oder `/heute` propagiert in beide Richtungen - Empty-State-CTA-Versprechen aus #463 wird eingelöst ## Meta — Prozess-Lesson aus dem Vorfall Habe als Memory gespeichert (feedback_verify_cta_destinations.md): vor jedem CTA / Empty-State end-to-end-Datenfluss verifizieren statt nur Route-Existenz. Verhindert Wiederholungen.
Collaborator

Architektur-Entscheidung: Option (a) Sync-Source-Pattern — via Todo-Candidate-Writer im bestehenden Stil

Kernbeobachtung — der Codebase hat das Pattern bereits

apps/api/src/modules/<source>/<source>-candidate-writer.service.ts existiert dreimal:

  • mail-candidate-writer.service.ts — Mail-Ingest schreibt Candidates
  • tracking-candidate-writer.service.ts — Tracking-Updates schreiben Candidates
  • habit-candidate-writer.service.ts — Habit-Spawn-Cron schreibt Candidates

Alle drei ingesten in candidates-Tabelle, jeder mit eigenem source-Diskriminator. Das ist Option (a) in der Praxis, einfach mit synchronem Direkt-Call statt Event-Bus — und es funktioniert seit Monaten. Eine vierte Source einzuziehen, ist additiv, kein neuer Pattern.

Optionen (b) Read-Through und (c) Unification würden das etablierte Pattern brechen:

  • (b) Read-Through würde Day-Planner an lists-Schema koppeln. Calendar nutzt zwar Read-Through, aber Calendar hat keine Lifecycle (pending → planned → done) — Events sind statisch im DB-State, Candidates nicht. Todos brauchen Lifecycle. Wenn man Lifecycle für Read-Through-Items nachbaut, landet man bei einem Pseudo-Candidate-Schema in der lists-Tabelle — kein Gewinn gegenüber (a).
  • (c) Unification ist ein Big-Bang-Migration. Würde lists-todos in candidates-Schema überführen, lässt aber shopping + notes ungelöst und macht aus einer kleinen UX-Lücke ein Plattform-Projekt. Nein.

Entscheidung: (a), neuer todo-candidate-writer.service.ts

Konkret:

  1. Source-Enum erweitern: candidates.source bekommt 'todo'-Variante (eigene Diskriminator-Wert, nicht 'manual' recyceln — 'manual' bleibt reserviert für direkte Candidate-Creates ohne List-Anker, falls je benötigt). Drizzle-Migration N: pgEnum-Variante hinzufügen — additiv, backward-compatible.

  2. Neuer Service TodoCandidateWriter in apps/api/src/modules/lists/ (nicht in candidates/ — Source-Adapter gehört zur Source, nicht zum Plan-Workspace; siehe mail/tracking/habit-Layout). Methoden:

    • upsertFromTodo(listItem) — schreibt/aktualisiert eine Candidate-Zeile mit sourceRef = listItem.id, mappt:
      • titlelistItem.data.title
      • prioritylistItem.data.priority ?? 'normal'
      • latestAtendOfDay(listItem.data.dueDate) oder null wenn kein dueDate
      • lifecycleStatelistItem.data.done ? 'done' : 'pending'
      • source'todo'
    • removeForTodo(listItemId) — markiert Candidate als obsolete (nicht hart löschen, damit Audit-Spur erhalten bleibt)
  3. Hook in ListsServicecreateItem / updateItem / removeItem rufen den Writer synchron nach dem Write auf, in derselben DB-Transaktion. Nicht via Event-Bus. Grund: die in-process Direkt-Call-Variante ist im Codebase bereits etabliert, einfacher zu testen, keine Outbox-Komplexität.

  4. Trigger-Bedingung — nur Todos mit expliziter Planung-Absicht werden gespiegelt, nicht alle:

    • Erst-Wahl: listItem.data.dueDate ist gesetzt UND done = false → Candidate. Heißt: ein Todo wird erst „aktiv plan-würdig" wenn der User ihm ein Datum gibt. Das deckt sich mit User-Intent („ich will das an einem konkreten Tag erledigen") und vermeidet, dass Hunderte alter Einkaufslisten-Todos plötzlich als Candidates erscheinen.
    • Wenn dueDate später entfernt wird → removeForTodo (Candidate → obsolete).
  5. Backward-Propagation (Todo-Done aus /heute): Das ist der Spiegel von ADR 0001 §7 Source-Done-Event-Bus. Bis dieser Event-Bus existiert, hardcoded-Fallback im Planner-Service: bei markCandidateDone mit source = 'todo' direkt ListsService.markTodoDone(sourceRef) aufrufen. Nach Event-Bus-Implementierung weicht das in den Bus.

Was das mit #464 (Mark-Done) macht

Wenn #464 vor diesem Sub kommt, muss der Mark-Done-Endpoint die Source-Diskriminierung schon können — sprich, der Hardcoded-Fallback aus Punkt 5 wird Teil von #464. Falls #472 zuerst kommt: #464 baut nur den /heute-UI-Teil und ruft direkt den Mark-Done-Endpoint auf, der dann je nach Source verzweigt.

Mapping-Edge-Cases

  • Todo-Title-Update nach Candidate-Erzeugung: Writer rerunnt bei jedem updateItem → Candidate-Title aktualisiert. (Mail/Tracking haben dieses Verhalten nicht — wenn der User dort später edits, drifted Title. Bei Todos können wir besser sein, weil der User selbst editiert.)
  • dueDate in der Vergangenheit: Candidate erhält latestAt < dayStart → bei nächstem planToday() wird Candidate vom existierenden Stale-Cleanup auf obsolete gesetzt. Lifecycle bleibt sauber.
  • Liste gelöscht (Cascade auf list_items): DB-CASCADE läuft, aber Candidates sind nicht via FK an list_items gehängt (sourceRef ist ein text-Feld). Lists-Service muss vor Cascade-Delete explizit removeForTodo für alle Items aufrufen. Das ist ein Bestehender-Code-Smell auch für mail/tracking — nicht hier neu zu lösen.

Out of scope für dieses Sub — sollte separate Issues sein

  • Source-Create-Event-Bus als generisches Pattern: aktuell unnötig, Direkt-Call funktioniert. Wenn irgendwann externe Quellen (n8n, Webhooks, fremde Apps) Candidates spawnen sollen, dann lohnt sich der Bus. Bis dahin YAGNI.
  • Manueller Candidate-Create ohne Todo-Anker (z.B. FAB direkt auf /heute, der einen source='manual'-Candidate erzeugt): kann ein eigenes kleines Sub sein, falls sich rausstellt dass das Todo-mit-dueDate-Mapping zu viele Klicks ist. Erstmal: das Todo-Mapping ist die UX, die der User explizit wollte („sonst wird Todo zur Insellösung").
  • Shopping/Notes als Candidates: Andere ListType-Werte bleiben Insel, das ist OK. Shopping ist ein einkaufslistiger Workflow, Notes sind statisch — beide haben keine „heute erledigen"-Semantik.

Tests (TDD)

  • todo-candidate-writer.service.spec.ts: upsert-/remove-Branches, Mapping-Korrektheit, dueDate-Trigger-Bedingung
  • lists.service.spec.ts: createItem/updateItem rufen Writer auf
  • Integration planner.int-spec.ts: Todo mit dueDate=heute landet nach planToday auf /heute
  • Integration: Todo done = true → Candidate done; Candidate done von /heute → Todo done (Backward via Hardcode-Fallback bis §7)

Acceptance

  • User legt Todo mit dueDate=heute an (via /todo) → /heute → „Tag jetzt planen" → Todo erscheint mit Slot ✓
  • Empty-State-CTA aus #463 wird eingelöst ✓
  • Lifecycle in beide Richtungen synchron (mit Hardcode-Fallback bis §7-Bus) ✓
  • Migration ist forward-only / backward-compatible (Enum-Variante additiv) ✓

— arch-bot

## Architektur-Entscheidung: Option (a) Sync-Source-Pattern — via Todo-Candidate-Writer im bestehenden Stil **Kernbeobachtung — der Codebase hat das Pattern bereits** `apps/api/src/modules/<source>/<source>-candidate-writer.service.ts` existiert dreimal: - `mail-candidate-writer.service.ts` — Mail-Ingest schreibt Candidates - `tracking-candidate-writer.service.ts` — Tracking-Updates schreiben Candidates - `habit-candidate-writer.service.ts` — Habit-Spawn-Cron schreibt Candidates Alle drei ingesten in `candidates`-Tabelle, jeder mit eigenem `source`-Diskriminator. Das ist Option (a) in der Praxis, einfach mit synchronem Direkt-Call statt Event-Bus — und es funktioniert seit Monaten. Eine vierte Source einzuziehen, ist *additiv*, kein neuer Pattern. Optionen (b) Read-Through und (c) Unification würden das etablierte Pattern brechen: - **(b) Read-Through** würde Day-Planner an `lists`-Schema koppeln. Calendar nutzt zwar Read-Through, aber Calendar hat keine Lifecycle (`pending → planned → done`) — Events sind statisch im DB-State, Candidates nicht. Todos brauchen Lifecycle. Wenn man Lifecycle für Read-Through-Items nachbaut, landet man bei einem Pseudo-Candidate-Schema in der lists-Tabelle — kein Gewinn gegenüber (a). - **(c) Unification** ist ein Big-Bang-Migration. Würde lists-todos in candidates-Schema überführen, lässt aber shopping + notes ungelöst und macht aus einer kleinen UX-Lücke ein Plattform-Projekt. Nein. **Entscheidung: (a), neuer `todo-candidate-writer.service.ts`** Konkret: 1. **Source-Enum erweitern:** `candidates.source` bekommt `'todo'`-Variante (eigene Diskriminator-Wert, nicht `'manual'` recyceln — `'manual'` bleibt reserviert für direkte Candidate-Creates ohne List-Anker, falls je benötigt). Drizzle-Migration N: pgEnum-Variante hinzufügen — additiv, backward-compatible. 2. **Neuer Service `TodoCandidateWriter`** in `apps/api/src/modules/lists/` (nicht in `candidates/` — Source-Adapter gehört zur Source, nicht zum Plan-Workspace; siehe mail/tracking/habit-Layout). Methoden: - `upsertFromTodo(listItem)` — schreibt/aktualisiert eine Candidate-Zeile mit `sourceRef = listItem.id`, mappt: - `title` ← `listItem.data.title` - `priority` ← `listItem.data.priority ?? 'normal'` - `latestAt` ← `endOfDay(listItem.data.dueDate)` oder `null` wenn kein dueDate - `lifecycleState` ← `listItem.data.done ? 'done' : 'pending'` - `source` ← `'todo'` - `removeForTodo(listItemId)` — markiert Candidate als `obsolete` (nicht hart löschen, damit Audit-Spur erhalten bleibt) 3. **Hook in `ListsService`** — `createItem` / `updateItem` / `removeItem` rufen den Writer synchron nach dem Write auf, in derselben DB-Transaktion. Nicht via Event-Bus. Grund: die in-process Direkt-Call-Variante ist im Codebase bereits etabliert, einfacher zu testen, keine Outbox-Komplexität. 4. **Trigger-Bedingung** — nur Todos mit *expliziter Planung-Absicht* werden gespiegelt, nicht alle: - **Erst-Wahl:** `listItem.data.dueDate` ist gesetzt UND `done = false` → Candidate. Heißt: ein Todo wird erst „aktiv plan-würdig" wenn der User ihm ein Datum gibt. Das deckt sich mit User-Intent („ich will das an einem konkreten Tag erledigen") und vermeidet, dass Hunderte alter Einkaufslisten-Todos plötzlich als Candidates erscheinen. - Wenn `dueDate` später entfernt wird → `removeForTodo` (Candidate → obsolete). 5. **Backward-Propagation (Todo-Done aus /heute):** Das ist der Spiegel von ADR 0001 §7 Source-Done-Event-Bus. Bis dieser Event-Bus existiert, hardcoded-Fallback im Planner-Service: bei `markCandidateDone` mit `source = 'todo'` direkt `ListsService.markTodoDone(sourceRef)` aufrufen. Nach Event-Bus-Implementierung weicht das in den Bus. **Was das mit #464 (Mark-Done) macht** Wenn #464 vor diesem Sub kommt, muss der Mark-Done-Endpoint die Source-Diskriminierung schon können — sprich, der Hardcoded-Fallback aus Punkt 5 wird Teil von #464. Falls #472 zuerst kommt: #464 baut nur den /heute-UI-Teil und ruft direkt den Mark-Done-Endpoint auf, der dann je nach Source verzweigt. **Mapping-Edge-Cases** - **Todo-Title-Update nach Candidate-Erzeugung:** Writer rerunnt bei jedem `updateItem` → Candidate-Title aktualisiert. (Mail/Tracking haben dieses Verhalten *nicht* — wenn der User dort später edits, drifted Title. Bei Todos können wir besser sein, weil der User selbst editiert.) - **dueDate in der Vergangenheit:** Candidate erhält `latestAt < dayStart` → bei nächstem `planToday()` wird Candidate vom existierenden Stale-Cleanup auf `obsolete` gesetzt. Lifecycle bleibt sauber. - **Liste gelöscht (Cascade auf list_items):** DB-CASCADE läuft, aber Candidates sind nicht via FK an list_items gehängt (sourceRef ist ein text-Feld). Lists-Service muss vor Cascade-Delete explizit `removeForTodo` für alle Items aufrufen. Das ist ein Bestehender-Code-Smell auch für mail/tracking — nicht hier neu zu lösen. **Out of scope für dieses Sub — sollte separate Issues sein** - **Source-Create-Event-Bus** als generisches Pattern: aktuell unnötig, Direkt-Call funktioniert. Wenn irgendwann externe Quellen (n8n, Webhooks, fremde Apps) Candidates spawnen sollen, dann lohnt sich der Bus. Bis dahin YAGNI. - **Manueller Candidate-Create ohne Todo-Anker** (z.B. FAB direkt auf /heute, der einen `source='manual'`-Candidate erzeugt): kann ein eigenes kleines Sub sein, falls sich rausstellt dass das Todo-mit-dueDate-Mapping zu viele Klicks ist. Erstmal: das Todo-Mapping ist die UX, die der User explizit wollte („sonst wird Todo zur Insellösung"). - **Shopping/Notes als Candidates:** Andere ListType-Werte bleiben Insel, das ist OK. Shopping ist ein einkaufslistiger Workflow, Notes sind statisch — beide haben keine „heute erledigen"-Semantik. **Tests (TDD)** - `todo-candidate-writer.service.spec.ts`: upsert-/remove-Branches, Mapping-Korrektheit, dueDate-Trigger-Bedingung - `lists.service.spec.ts`: createItem/updateItem rufen Writer auf - Integration `planner.int-spec.ts`: Todo mit dueDate=heute landet nach planToday auf /heute - Integration: Todo done = true → Candidate done; Candidate done von /heute → Todo done (Backward via Hardcode-Fallback bis §7) **Acceptance** - User legt Todo mit dueDate=heute an (via /todo) → /heute → „Tag jetzt planen" → Todo erscheint mit Slot ✓ - Empty-State-CTA aus #463 wird eingelöst ✓ - Lifecycle in beide Richtungen synchron (mit Hardcode-Fallback bis §7-Bus) ✓ - Migration ist forward-only / backward-compatible (Enum-Variante additiv) ✓ — 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#472
No description provided.