feat(planner): Planner v1 — echte Slots + Location-Cluster + Calendar-Aware (Phase 7 von #360) #372

Closed
opened 2026-05-20 23:15:02 +02:00 by admin-mrrm · 0 comments
Owner

Ziel

Phase 7 von Epic #360. Planner v1 ist die erste Version mit echten Slots: jedes geplante Candidate bekommt eine konkrete Uhrzeit, Kalender-Events werden als blocked-Slots respektiert, und Candidates mit dem gleichen Location-Hint werden zusammengeplant ("alle Termine bei Lidl in einem Block").

Vorgaenger: Planner v0 (#368) hat alles auf "jetzt" geplant — keine echten Slots. Diese Phase ersetzt diese Heuristik.

Scope

Algorithmus

PlannerService.planToday(ownerSub, now):

  1. Obsolete-Marker wie in v0: pending Candidates mit latestAt < startOfDay(now)obsolete.
  2. Fetch: pending Candidates + Calendar-Events des Tages.
  3. Location-Gruppierung: Candidates mit gleichem locationHints[].name (case-insensitive, erstes Element) landen im gleichen Bucket. Candidates ohne Location landen im __nolocation__-Bucket.
  4. Bucket-Sortierung: nach hoechster Prio im Bucket (high>normal>low), tie-break: min(createdAt) aufsteigend. Habit-Candidates anchoren ihren Bucket — fallen ueber Priority-Sort heraus, kein Sonderfall noetig.
  5. Slot-Assignment: Cursor startet bei dayStart + 09:00. Pro Bucket in sortierter Reihenfolge:
    • Innerhalb Bucket: sortieren nach earliestAt asc nulls last, dann createdAt asc.
    • Pro Candidate:
      • duration = estDurationMin ?? 30
      • slotStart = max(cursor, earliestAt ?? workStart)
      • Falls Kalender-Event in [slotStart, slotStart+duration] → cursor hinter das Event setzen, retry.
      • Falls slotEnd > workEnd (21:00) oder slotEnd > latestAt → Candidate bleibt pending (Overflow), nicht in items.
      • Sonst: plannedSlot = slotStart, lifecycleState = planned. Cursor = slotEnd.

Konstanten (v1 hartkodiert)

  • WORK_START_HOUR = 9 (UTC)
  • WORK_END_HOUR = 21 (UTC)
  • DEFAULT_SLOT_MIN = 30

(Spaeter via env konfigurierbar — Out-of-scope.)

DTO-Anpassung

DayPlanItem.plannedSlot ist jetzt der echte Slot (vorher: immer now). Format unveraendert: ISO-String.

Tests

  • Unit PlannerService.planToday v1:
    • Slots werden sequenziell platziert (9:00, 9:30, 10:00 ...)
    • earliestAt wird respektiert (Slot nicht davor)
    • Kalender-Event blockt — naechster Slot startet nach Event-Ende
    • latestAt-Verletzung → Candidate bleibt pending (nicht in items)
    • Overflow nach 21:00 → Candidate bleibt pending
    • Location-Cluster: Candidates mit gleichem locationHints[0].name werden konsekutiv platziert
  • Integration: Candidates + Calendar-Subscription mit blockierendem Event → /planner/run → Slots ueberspringen das Event

Out of scope (Phase 8+)

  • LLM-Assistenz (#122)
  • Multi-Day-Overflow (Candidates die heute nicht passen, auf morgen schieben)
  • TZ-Aware Working-Hours (aktuell UTC-hart)
  • Dependency-Resolution (dependsOn)
  • Re-Planning waehrend des Tages (Adaptive)

Bezug

  • Epic: #360
  • Phase 5 (Planner v0): #368
  • Phase 6 (Calendar Read): #370
## Ziel Phase 7 von Epic #360. **Planner v1** ist die erste Version mit *echten Slots*: jedes geplante Candidate bekommt eine konkrete Uhrzeit, Kalender-Events werden als blocked-Slots respektiert, und Candidates mit dem gleichen Location-Hint werden zusammengeplant ("alle Termine bei Lidl in einem Block"). Vorgaenger: Planner v0 (#368) hat alles auf "jetzt" geplant — keine echten Slots. Diese Phase ersetzt diese Heuristik. ## Scope ### Algorithmus `PlannerService.planToday(ownerSub, now)`: 1. **Obsolete-Marker** wie in v0: pending Candidates mit `latestAt < startOfDay(now)` → `obsolete`. 2. **Fetch:** pending Candidates + Calendar-Events des Tages. 3. **Location-Gruppierung:** Candidates mit gleichem `locationHints[].name` (case-insensitive, erstes Element) landen im gleichen Bucket. Candidates ohne Location landen im `__nolocation__`-Bucket. 4. **Bucket-Sortierung:** nach hoechster Prio im Bucket (high>normal>low), tie-break: `min(createdAt)` aufsteigend. Habit-Candidates anchoren ihren Bucket — fallen ueber Priority-Sort heraus, kein Sonderfall noetig. 5. **Slot-Assignment:** Cursor startet bei `dayStart + 09:00`. Pro Bucket in sortierter Reihenfolge: - Innerhalb Bucket: sortieren nach `earliestAt asc nulls last`, dann `createdAt asc`. - Pro Candidate: - `duration = estDurationMin ?? 30` - `slotStart = max(cursor, earliestAt ?? workStart)` - Falls Kalender-Event in `[slotStart, slotStart+duration]` → cursor hinter das Event setzen, retry. - Falls `slotEnd > workEnd (21:00)` oder `slotEnd > latestAt` → Candidate bleibt `pending` (Overflow), nicht in `items`. - Sonst: `plannedSlot = slotStart`, `lifecycleState = planned`. Cursor = `slotEnd`. ### Konstanten (v1 hartkodiert) - `WORK_START_HOUR = 9` (UTC) - `WORK_END_HOUR = 21` (UTC) - `DEFAULT_SLOT_MIN = 30` (Spaeter via env konfigurierbar — Out-of-scope.) ### DTO-Anpassung `DayPlanItem.plannedSlot` ist jetzt der **echte Slot** (vorher: immer `now`). Format unveraendert: ISO-String. ### Tests - Unit `PlannerService.planToday` v1: - Slots werden sequenziell platziert (9:00, 9:30, 10:00 ...) - `earliestAt` wird respektiert (Slot nicht davor) - Kalender-Event blockt — naechster Slot startet nach Event-Ende - `latestAt`-Verletzung → Candidate bleibt pending (nicht in items) - Overflow nach 21:00 → Candidate bleibt pending - Location-Cluster: Candidates mit gleichem `locationHints[0].name` werden konsekutiv platziert - Integration: Candidates + Calendar-Subscription mit blockierendem Event → /planner/run → Slots ueberspringen das Event ## Out of scope (Phase 8+) - LLM-Assistenz (#122) - Multi-Day-Overflow (Candidates die heute nicht passen, auf morgen schieben) - TZ-Aware Working-Hours (aktuell UTC-hart) - Dependency-Resolution (`dependsOn`) - Re-Planning waehrend des Tages (Adaptive) ## Bezug - Epic: #360 - Phase 5 (Planner v0): #368 ✅ - Phase 6 (Calendar Read): #370 ✅
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
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#372
No description provided.