diff --git a/Doku/plan.md b/Doku/plan.md index 8443c25..0a96d5c 100644 --- a/Doku/plan.md +++ b/Doku/plan.md @@ -60,6 +60,16 @@ Status: **final** (2026-09-13, abgestimmt) · Basis: [`anforderungen.md`](anford - `SuggestDishes`-Query: Slow 0.5 / Rarität 0.3 / Hotness 0.2 (ADR-006), 5 Vorschläge + „Weitere 5", 1-Klick-Adopt → `CreateDish` mit Referenz - **DoD:** Reorder bleibt nach Reload bestehen; Inspiration klickbar adoptierend +- *Konkrete Umsetzung (M1-Konvention, Domäne DE):* Schritt 1 (Logik): + `MahlzeitReorderUseCase` (neue ID-Reihenfolge → `sortOrder` neu nummerieren, + nur geänderte Positionen updaten, `MahlzeitReorderedEvent`), + `MahlzeitInspirationQuery` (Kandidaten = Namen mit ≥ 1 `gegessen`-Eintrag, + Repräsentant = neuester `gegessen`-Eintrag, Score nach ADR-006 mit + Defaults 0.5/0.3/0.2, `ausschluss`-IDs für „Weitere 5", Sortierung + absteigend nach Score, bei Gleichstand alphabetisch), `MahlzeitAdoptUseCase` + (Quelle → neue `geplant`-Kopie mit `gegessenAm = null`, `MahlzeitAdoptedEvent`). + Schritt 2 (UI): Planungsliste mit SortableJS + `POST /mahlzeiten/reorder`, + Inspiration-View `GET /inspiration` + `POST /mahlzeiten/{id}/adopt`. ### M4 – Härtung & API - `X-Api-Token`-Guard (optional, `.env`) für `GET /api/today` (HA-ready) diff --git a/src/Logic/Essen/Dto/MahlzeitReorderRequest.php b/src/Logic/Essen/Dto/MahlzeitReorderRequest.php new file mode 100644 index 0000000..934ea37 --- /dev/null +++ b/src/Logic/Essen/Dto/MahlzeitReorderRequest.php @@ -0,0 +1,23 @@ + $reihenfolge + */ +final class MahlzeitReorderRequest +{ + /** @param list $reihenfolge */ + public function __construct( + public readonly array $reihenfolge, + ) { + } +} diff --git a/src/Logic/Essen/Dto/MahlzeitVorschlagResponse.php b/src/Logic/Essen/Dto/MahlzeitVorschlagResponse.php new file mode 100644 index 0000000..23842ae --- /dev/null +++ b/src/Logic/Essen/Dto/MahlzeitVorschlagResponse.php @@ -0,0 +1,43 @@ + $wochentage + */ +final class MahlzeitVorschlagResponse +{ + public function __construct( + public readonly int $id, + public readonly string $name, + public readonly string $notiz, + public readonly array $wochentage, + public readonly float $score, + public readonly ?\DateTimeImmutable $gegessenAm, + public readonly int $auftraete, + ) { + } + + public static function fromModel(Mahlzeit $m, float $score, int $auftraete): self + { + return new self( + id: $m->id, + name: $m->name, + notiz: $m->notiz, + wochentage: $m->wochentage, + score: $score, + gegessenAm: $m->gegessenAm, + auftraete: $auftraete, + ); + } +} diff --git a/src/Logic/Essen/Event/MahlzeitAdoptedEvent.php b/src/Logic/Essen/Event/MahlzeitAdoptedEvent.php new file mode 100644 index 0000000..b6649c5 --- /dev/null +++ b/src/Logic/Essen/Event/MahlzeitAdoptedEvent.php @@ -0,0 +1,19 @@ + $reihenfolge + */ +final readonly class MahlzeitReorderedEvent +{ + /** @param list $reihenfolge */ + public function __construct( + public array $reihenfolge, + ) { + } +} diff --git a/src/Logic/Essen/Query/MahlzeitInspirationQuery.php b/src/Logic/Essen/Query/MahlzeitInspirationQuery.php new file mode 100644 index 0000000..6666b1a --- /dev/null +++ b/src/Logic/Essen/Query/MahlzeitInspirationQuery.php @@ -0,0 +1,127 @@ + $ausschluss bereits vorgeschlagene Repräsentanten-IDs + * @return list + */ + public function execute(int $limit = 5, array $ausschluss = []): array + { + $alle = $this->mahlzeiten->list(null); + if ($alle === []) { + return []; + } + + $jetzt = $this->clock->now(); + + /** @var array $perName */ + $perName = []; + foreach ($alle as $m) { + $e = &$perName[$m->name]; + if (!isset($e)) { + $e = ['letzte' => null, 'anzahl' => 0, 'repraesentant' => $m]; + } + $e['anzahl']++; + if ($m->status === MahlzeitStatus::Gegessen && $m->gegessenAm !== null) { + if ($e['letzte'] === null || $m->gegessenAm > $e['letzte']) { + $e['letzte'] = $m->gegessenAm; + $e['repraesentant'] = $m; + } + } + } + unset($e); + + // Nur Gerichte, die im Archiv stehen (>= 1 gegessener Eintrag). + $kandidaten = []; + foreach ($perName as $name => $e) { + if ($e['letzte'] !== null) { + $kandidaten[] = [ + 'name' => $name, + 'letzte' => $e['letzte'], + 'anzahl' => $e['anzahl'], + 'repraesentant' => $e['repraesentant'], + ]; + } + } + if ($kandidaten === []) { + return []; + } + + // Normalisierungs-Maxima. + $maxStale = 0; + $maxAnzahl = 0; + foreach ($kandidaten as $k) { + $stale = $jetzt->getTimestamp() - $k['letzte']->getTimestamp(); + if ($stale > $maxStale) { + $maxStale = $stale; + } + if ($k['anzahl'] > $maxAnzahl) { + $maxAnzahl = $k['anzahl']; + } + } + + $scored = []; + foreach ($kandidaten as $k) { + $stale = $jetzt->getTimestamp() - $k['letzte']->getTimestamp(); + $staleness = $maxStale > 0 ? $stale / $maxStale : 0.0; + $rarity = 1.0 / ($k['anzahl'] + 1); + $popularity = $maxAnzahl > 0 ? $k['anzahl'] / $maxAnzahl : 0.0; + $score = $this->gewichtSlow * $staleness + $this->gewichtRare * $rarity + $this->gewichtHot * $popularity; + $scored[] = ['k' => $k, 'score' => $score]; + } + + // Absteigend nach Score; bei Gleichstand alphabetisch (deterministisch). + usort($scored, static fn (array $a, array $b): int => $b['score'] <=> $a['score'] ?: strcmp($a['k']['name'], $b['k']['name'])); + + $ausschlussSet = array_flip(array_map('intval', $ausschluss)); + $ergebnis = []; + foreach ($scored as $s) { + /** @var \App\Logic\Essen\Model\Mahlzeit $repraesentant */ + $repraesentant = $s['k']['repraesentant']; + if (isset($ausschlussSet[$repraesentant->id])) { + continue; + } + $ergebnis[] = MahlzeitVorschlagResponse::fromModel($repraesentant, $s['score'], (int) $s['k']['anzahl']); + if (\count($ergebnis) >= $limit) { + break; + } + } + + return $ergebnis; + } +} diff --git a/src/Logic/Essen/UseCase/MahlzeitAdoptUseCase.php b/src/Logic/Essen/UseCase/MahlzeitAdoptUseCase.php new file mode 100644 index 0000000..055867c --- /dev/null +++ b/src/Logic/Essen/UseCase/MahlzeitAdoptUseCase.php @@ -0,0 +1,51 @@ +mahlzeiten->find($quellId); + + $req = new MahlzeitCreateRequest( + name: $quelle->name, + notiz: $quelle->notiz, + wochentage: $quelle->wochentage, + ); + $mahlzeit = $this->factory->createFromCreateRequest($req, $this->clock->now()); + + $gespeichert = $this->tx->execute(fn () => $this->mahlzeiten->create($mahlzeit)); + + $this->events->dispatch(new MahlzeitAdoptedEvent(id: $gespeichert->id, name: $gespeichert->name, quellId: $quellId)); + + return MahlzeitResponse::fromModel($gespeichert); + } +} diff --git a/src/Logic/Essen/UseCase/MahlzeitReorderUseCase.php b/src/Logic/Essen/UseCase/MahlzeitReorderUseCase.php new file mode 100644 index 0000000..2a7a7ac --- /dev/null +++ b/src/Logic/Essen/UseCase/MahlzeitReorderUseCase.php @@ -0,0 +1,50 @@ +reihenfolge); + if ($reihenfolge === []) { + return; + } + + $this->tx->execute(function () use ($reihenfolge): void { + foreach ($reihenfolge as $position => $id) { + $sortOrder = $position + 1; + $mahlzeit = $this->mahlzeiten->find($id); + + // Unveränderte Positionen überspringen (keine unnötigen Writes). + if ($mahlzeit->sortOrder !== $sortOrder) { + $this->mahlzeiten->update($mahlzeit->mitSortOrder($sortOrder)); + } + } + }); + + $this->events->dispatch(new MahlzeitReorderedEvent($reihenfolge)); + } +} diff --git a/tests/Unit/Logic/Essen/Query/MahlzeitInspirationQueryTest.php b/tests/Unit/Logic/Essen/Query/MahlzeitInspirationQueryTest.php new file mode 100644 index 0000000..ed47aca --- /dev/null +++ b/tests/Unit/Logic/Essen/Query/MahlzeitInspirationQueryTest.php @@ -0,0 +1,146 @@ +jetzt = new \DateTimeImmutable('2026-09-18 00:00:00'); + } + + private function clock(): ClockInterface + { + $clock = $this->createStub(ClockInterface::class); + $clock->method('now')->willReturn($this->jetzt); + return $clock; + } + + /** @param list $mahlzeiten */ + private function manager(array $mahlzeiten): MahlzeitManagerInterface + { + $manager = $this->createMock(MahlzeitManagerInterface::class); + $manager->expects(self::once())->method('list')->with(null)->willReturn($mahlzeiten); + return $manager; + } + + private function gegessen(int $id, string $name, \DateTimeImmutable $gegessenAm): Mahlzeit + { + return new Mahlzeit($id, $name, '', [Wochentag::Mo], 1, MahlzeitStatus::Gegessen, $this->jetzt, $this->jetzt, $gegessenAm); + } + + private function geplant(int $id, string $name): Mahlzeit + { + return new Mahlzeit($id, $name, '', [Wochentag::Mo], 1, MahlzeitStatus::Geplant, $this->jetzt, $this->jetzt, null); + } + + public function testExecuteLeereListeLiefertKeineVorschlaege(): void + { + $query = new MahlzeitInspirationQuery($this->manager([]), $this->clock()); + + self::assertSame([], $query->execute()); + } + + public function testExecuteOhneArchivEintraegeKeineVorschlaege(): void + { + // Nur `geplant` – kein Gericht im Archiv. + $query = new MahlzeitInspirationQuery($this->manager([ + $this->geplant(1, 'Bolognese'), + $this->geplant(2, 'Curry'), + ]), $this->clock()); + + self::assertSame([], $query->execute()); + } + + public function testExecuteSortiertAbsteigendNachScoreUndWaehltRepraesentanten(): void + { + // "Bolognese": gegessen vor 10 Tagen (id 1) + 1 geplanter Eintrag → auftraete 2, + // repraesentant = id 1. + // "Curry": gegessen vor 3 Tagen (id 3), auftraete 1. + $query = new MahlzeitInspirationQuery($this->manager([ + $this->gegessen(1, 'Bolognese', new \DateTimeImmutable('2026-09-08 00:00:00')), + $this->geplant(2, 'Bolognese'), + $this->gegessen(3, 'Curry', new \DateTimeImmutable('2026-09-15 00:00:00')), + ]), $this->clock()); + + $vorschlaege = $query->execute(); + + self::assertCount(2, $vorschlaege); + self::assertSame(1, $vorschlaege[0]->id); + self::assertSame('Bolognese', $vorschlaege[0]->name); + self::assertSame(2, $vorschlaege[0]->auftraete); + self::assertSame(3, $vorschlaege[1]->id); + self::assertSame('Curry', $vorschlaege[1]->name); + self::assertSame(1, $vorschlaege[1]->auftraete); + // Staleness dominiert: das länger nicht gekochte Gericht hat den höheren Score. + self::assertGreaterThan($vorschlaege[1]->score, $vorschlaege[0]->score); + } + + public function testExecuteWaehltNeuestenGegessenenEintragAlsRepraesentanten(): void + { + $query = new MahlzeitInspirationQuery($this->manager([ + $this->gegessen(1, 'Bolognese', new \DateTimeImmutable('2026-09-01 00:00:00')), + $this->gegessen(4, 'Bolognese', new \DateTimeImmutable('2026-09-08 00:00:00')), + ]), $this->clock()); + + $vorschlaege = $query->execute(); + + self::assertCount(1, $vorschlaege); + self::assertSame(4, $vorschlaege[0]->id); + self::assertEquals(new \DateTimeImmutable('2026-09-08 00:00:00'), $vorschlaege[0]->gegessenAm); + self::assertSame(2, $vorschlaege[0]->auftraete); + } + + public function testExecuteAusschlussSpruengtBereitsVorgeschlageneGerichte(): void + { + $query = new MahlzeitInspirationQuery($this->manager([ + $this->gegessen(1, 'Bolognese', new \DateTimeImmutable('2026-09-08 00:00:00')), + $this->gegessen(3, 'Curry', new \DateTimeImmutable('2026-09-15 00:00:00')), + ]), $this->clock()); + + // "Weitere 5": Bolognese (id 1) wurde bereits vorgeschlagen. + $vorschlaege = $query->execute(5, [1]); + + self::assertCount(1, $vorschlaege); + self::assertSame('Curry', $vorschlaege[0]->name); + } + + public function testExecuteBemaessErgebnisAnLimit(): void + { + $query = new MahlzeitInspirationQuery($this->manager([ + $this->gegessen(1, 'Bolognese', new \DateTimeImmutable('2026-09-08 00:00:00')), + $this->gegessen(2, 'Curry', new \DateTimeImmutable('2026-09-12 00:00:00')), + $this->gegessen(3, 'Pasta', new \DateTimeImmutable('2026-09-15 00:00:00')), + ]), $this->clock()); + + $vorschlaege = $query->execute(2); + + self::assertCount(2, $vorschlaege); + self::assertSame(['Bolognese', 'Curry'], array_map(static fn ($v) => $v->name, $vorschlaege)); + } + + public function testExecuteGleichstandEntscheidetAlphabetisch(): void + { + // Beide Gerichte: gleiche Pause, gleiche Auftritte → Score-Gleichstand. + $query = new MahlzeitInspirationQuery($this->manager([ + $this->gegessen(1, 'Zwiebelsuppe', new \DateTimeImmutable('2026-09-13 00:00:00')), + $this->gegessen(2, 'Apfelkuchen', new \DateTimeImmutable('2026-09-13 00:00:00')), + ]), $this->clock()); + + $vorschlaege = $query->execute(); + + self::assertSame(['Apfelkuchen', 'Zwiebelsuppe'], array_map(static fn ($v) => $v->name, $vorschlaege)); + } +} diff --git a/tests/Unit/Logic/Essen/UseCase/MahlzeitAdoptUseCaseTest.php b/tests/Unit/Logic/Essen/UseCase/MahlzeitAdoptUseCaseTest.php new file mode 100644 index 0000000..da277ff --- /dev/null +++ b/tests/Unit/Logic/Essen/UseCase/MahlzeitAdoptUseCaseTest.php @@ -0,0 +1,90 @@ +createStub(TransactionManagerInterface::class); + $tx->method('execute')->willReturnCallback(static fn (\Closure $work): mixed => $work()); + return $tx; + } + + private function clock(): ClockInterface + { + $clock = $this->createStub(ClockInterface::class); + $clock->method('now')->willReturn(new \DateTimeImmutable('2026-09-18 12:00:00')); + return $clock; + } + + public function testExecuteKopiertQuelleAlsNeuenGeplantEintrag(): void + { + $jetzt = new \DateTimeImmutable('2026-09-18 12:00:00'); + $quelle = new Mahlzeit(7, 'Bolognese', 'mit Parmesan', [Wochentag::Mo], 3, MahlzeitStatus::Gegessen, $jetzt, $jetzt, $jetzt); + + $manager = $this->createMock(MahlzeitManagerInterface::class); + $manager->expects(self::once())->method('find')->with(7)->willReturn($quelle); + $manager->expects(self::once())->method('create')->willReturnCallback(function (Mahlzeit $m) { + // Neue, geplante Kopie: gleiche Inhalte, aber ohne gegessenAm und ohne ID. + self::assertSame('Bolognese', $m->name); + self::assertSame('mit Parmesan', $m->notiz); + self::assertSame([Wochentag::Mo], $m->wochentage); + self::assertSame(MahlzeitStatus::Geplant, $m->status); + self::assertNull($m->gegessenAm); + self::assertSame(0, $m->sortOrder); + self::assertSame(0, $m->id); + // Persistenz vergibt die neue ID. + return $m->mitId(42); + }); + + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects(self::once())->method('dispatch')->with( + self::equalTo(new MahlzeitAdoptedEvent(id: 42, name: 'Bolognese', quellId: 7)), + ); + + $useCase = new MahlzeitAdoptUseCase($this->tx(), $manager, new MahlzeitFactory(), $events, $this->clock()); + + $response = $useCase->execute(7); + + self::assertSame(42, $response->id); + self::assertSame('Bolognese', $response->name); + self::assertSame(MahlzeitStatus::Geplant, $response->status); + self::assertNull($response->gegessenAm); + } + + public function testExecuteUnbekannteQuelleWirft404UndLegtNichtsAn(): void + { + $manager = $this->createMock(MahlzeitManagerInterface::class); + $manager->method('find')->willThrowException(new MahlzeitNotFoundException(99)); + $manager->expects(self::never())->method('create'); + + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects(self::never())->method('dispatch'); + + $useCase = new MahlzeitAdoptUseCase($this->tx(), $manager, new MahlzeitFactory(), $events, $this->clock()); + + $this->expectException(MahlzeitNotFoundException::class); + + try { + $useCase->execute(99); + } finally { + // Erwartete Exception oben. + } + } +} diff --git a/tests/Unit/Logic/Essen/UseCase/MahlzeitReorderUseCaseTest.php b/tests/Unit/Logic/Essen/UseCase/MahlzeitReorderUseCaseTest.php new file mode 100644 index 0000000..c133ee2 --- /dev/null +++ b/tests/Unit/Logic/Essen/UseCase/MahlzeitReorderUseCaseTest.php @@ -0,0 +1,107 @@ +createStub(TransactionManagerInterface::class); + $tx->method('execute')->willReturnCallback(static fn (\Closure $work): mixed => $work()); + return $tx; + } + + private function mahlzeit(int $id, int $sortOrder): Mahlzeit + { + $jetzt = new \DateTimeImmutable('2026-09-18 12:00:00'); + return new Mahlzeit($id, 'Gericht' . $id, '', [], $sortOrder, MahlzeitStatus::Geplant, $jetzt, $jetzt, null); + } + + public function testExecuteRenommertSortOrderUndDispatchtEvent(): void + { + $manager = $this->createMock(MahlzeitManagerInterface::class); + $manager->method('find')->willReturnMap([ + [2, $this->mahlzeit(2, 2)], + [1, $this->mahlzeit(1, 1)], + [3, $this->mahlzeit(3, 3)], + ]); + + $gespeicherte = []; + $manager->expects(self::exactly(2))->method('update')->willReturnCallback(function (Mahlzeit $m) use (&$gespeicherte) { + $gespeicherte[$m->id] = $m->sortOrder; + return $m; + }); + + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects(self::once())->method('dispatch')->with( + self::equalTo(new MahlzeitReorderedEvent([2, 1, 3])), + ); + + $useCase = new MahlzeitReorderUseCase($this->tx(), $manager, $events); + $useCase->execute(new MahlzeitReorderRequest([2, 1, 3])); + + // id 2 → Position 1, id 1 → Position 2; id 3 (Position 3) bleibt unverändert. + self::assertSame([2 => 1, 1 => 2], $gespeicherte); + } + + public function testExecuteSpruengtBereitsKorrektePosition(): void + { + $manager = $this->createMock(MahlzeitManagerInterface::class); + $manager->method('find')->willReturnMap([ + [1, $this->mahlzeit(1, 1)], + [2, $this->mahlzeit(2, 2)], + ]); + $manager->expects(self::never())->method('update'); + + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects(self::once())->method('dispatch'); + + $useCase = new MahlzeitReorderUseCase($this->tx(), $manager, $events); + $useCase->execute(new MahlzeitReorderRequest([1, 2])); + } + + public function testExecuteLeereListeIstNoOp(): void + { + $manager = $this->createMock(MahlzeitManagerInterface::class); + $manager->expects(self::never())->method('find'); + $manager->expects(self::never())->method('update'); + + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects(self::never())->method('dispatch'); + + $useCase = new MahlzeitReorderUseCase($this->tx(), $manager, $events); + $useCase->execute(new MahlzeitReorderRequest([])); + } + + public function testExecuteUnbekannteIdWirft404(): void + { + $manager = $this->createStub(MahlzeitManagerInterface::class); + $manager->method('find')->willThrowException(new MahlzeitNotFoundException(99)); + + $events = $this->createMock(EventDispatcherInterface::class); + $events->expects(self::never())->method('dispatch'); + + $useCase = new MahlzeitReorderUseCase($this->tx(), $manager, $events); + + $this->expectException(MahlzeitNotFoundException::class); + + try { + $useCase->execute(new MahlzeitReorderRequest([99])); + } finally { + // Erwartete Exception oben. + } + } +}