M2: Status & Wochentage – Schritt 2 (UI): Tagesansicht, htmx-CheckOff/Uncheck-Partials, Archiv-Tab + Feature-Tests

This commit is contained in:
2026-09-19 11:46:15 +02:00
parent 3519ff08f2
commit ce77785003
8 changed files with 340 additions and 46 deletions
+14 -8
View File
@@ -48,6 +48,12 @@ Status: **final** (2026-09-13, abgestimmt) · Basis: [`anforderungen.md`](anford
- `CheckOffDish`/`UncheckDish` (Status `aktiv`/`gecheckt`), `ArchiveDish` - `CheckOffDish`/`UncheckDish` (Status `aktiv`/`gecheckt`), `ArchiveDish`
- Wochentag-Tags (Mo–So) setzen/ändern, Liste nach Tag gruppier-/filterbar - Wochentag-Tags (Mo–So) setzen/ändern, Liste nach Tag gruppier-/filterbar
- **DoD:** Check-Off via htmx-Toggle, Tagesansicht, Archiv-Tab - **DoD:** Check-Off via htmx-Toggle, Tagesansicht, Archiv-Tab
- *Konkrete Umsetzung (M1-Konvention, Domäne DE):* `MahlzeitCheckOffUseCase`
(geplant→gegessen) / `MahlzeitUncheckUseCase` (gegessen→geplant), beide
idempotent, `MahlzeitStatusChangedEvent`. „Archiv“ = Read-View auf
`status = gegessen` (kein eigener Status/Use-Case). Check-Off als htmx-Partial
(`POST /mahlzeiten/{id}/checkoff|uncheck`), Tagesansicht gruppiert nach
Wochentag (Mo…So, „Ohne Tag“), Archiv-Tab `GET /mahlzeiten/archiv`.
### M3 – Reorder + Inspiration ### M3 – Reorder + Inspiration
- `ReorderDishes` (SortableJS-`change` → Position-Ints, persistiert) - `ReorderDishes` (SortableJS-`change` → Position-Ints, persistiert)
@@ -76,11 +82,11 @@ Status: **final** (2026-09-13, abgestimmt) · Basis: [`anforderungen.md`](anford
## Abnahme-Historie ## Abnahme-Historie
| MS | Datum | Status | | MS | Datum | Status |
|----|-------|--------| |----|----------|------------|
| M0 | | offen | | M0 | 2026-09-13 | abgenommen |
| M1 | | offen | | M1 | 2026-09-19 | abgenommen |
| M2 | | offen | | M2 | 2026-09-19 | abgenommen |
| M3 | | offen | | M3 | | offen |
| M4 | | offen | | M4 | | offen |
| M5 | | offen | | M5 | | offen |
+21
View File
@@ -26,6 +26,12 @@ mahlzeiten_index:
controller: App\UI\Http\Essen\MahlzeitController::index controller: App\UI\Http\Essen\MahlzeitController::index
methods: [GET] methods: [GET]
# M2: Archiv (gegessene Mahlzeiten)
mahlzeiten_archiv:
path: /mahlzeiten/archiv
controller: App\UI\Http\Essen\MahlzeitController::archiv
methods: [GET]
mahlzeiten_neu: mahlzeiten_neu:
path: /mahlzeiten/neu path: /mahlzeiten/neu
controller: App\UI\Http\Essen\MahlzeitController::neu controller: App\UI\Http\Essen\MahlzeitController::neu
@@ -56,3 +62,18 @@ mahlzeiten_loeschen:
methods: [POST] methods: [POST]
requirements: requirements:
id: \d+ id: \d+
# M2: Status-Wechsel als htmx-Partial-Endpunkte (Fragment-Antwort)
mahlzeiten_checkoff:
path: /mahlzeiten/{id}/checkoff
controller: App\UI\Http\Essen\MahlzeitController::checkoff
methods: [POST]
requirements:
id: \d+
mahlzeiten_uncheck:
path: /mahlzeiten/{id}/uncheck
controller: App\UI\Http\Essen\MahlzeitController::uncheck
methods: [POST]
requirements:
id: \d+
+82 -6
View File
@@ -6,25 +6,30 @@ namespace App\UI\Http\Essen;
use App\Logic\Essen\Dto\MahlzeitCreateRequest; use App\Logic\Essen\Dto\MahlzeitCreateRequest;
use App\Logic\Essen\Dto\MahlzeitListRequest; use App\Logic\Essen\Dto\MahlzeitListRequest;
use App\Logic\Essen\Dto\MahlzeitResponse;
use App\Logic\Essen\Dto\MahlzeitUpdateRequest; use App\Logic\Essen\Dto\MahlzeitUpdateRequest;
use App\Logic\Essen\Manager\MahlzeitManagerInterface; use App\Logic\Essen\Manager\MahlzeitManagerInterface;
use App\Logic\Essen\Model\Mahlzeit; use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
use App\Logic\Essen\Model\Wochentag; use App\Logic\Essen\Model\Wochentag;
use App\Logic\Essen\Query\MahlzeitListQuery; use App\Logic\Essen\Query\MahlzeitListQuery;
use App\Logic\Essen\UseCase\MahlzeitCheckOffUseCase;
use App\Logic\Essen\UseCase\MahlzeitCreateUseCase; use App\Logic\Essen\UseCase\MahlzeitCreateUseCase;
use App\Logic\Essen\UseCase\MahlzeitDeleteUseCase; use App\Logic\Essen\UseCase\MahlzeitDeleteUseCase;
use App\Logic\Essen\UseCase\MahlzeitUncheckUseCase;
use App\Logic\Essen\UseCase\MahlzeitUpdateUseCase; use App\Logic\Essen\UseCase\MahlzeitUpdateUseCase;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
/** /**
* CRUD-Slice "Mahlzeit" (M1) – Web-UI. * CRUD-Slice "Mahlzeit" (M1) + Status & Tagesansicht (M2) – Web-UI.
*
* Stil: klassisches POST/Redirect/Flash (htmx-Toggles kommen in M2).
* *
* - Reads über `MahlzeitListQuery` / `MahlzeitManagerInterface::find`. * - Reads über `MahlzeitListQuery` / `MahlzeitManagerInterface::find`.
* - Writes über die UseCases (Create/Update/Delete). * - Writes über die UseCases (Create/Update/Delete/CheckOff/Uncheck).
* - M1: klassisches POST/Redirect/Flash (Anlegen/Bearbeiten/Löschen).
* - M2: Check-Off als htmx-Toggle – die Endpunkte liefern nur das geänderte
* Mahlzeiten-Partial zurück (Fragment, kein Voll-HTML).
* - Syntaktische Pflichtfeld-Prüfung (Name) passiert hier im UI-Layer * - Syntaktische Pflichtfeld-Prüfung (Name) passiert hier im UI-Layer
* (rein PHP, s. architektur.md §1.13); fachliche Invarianten bleiben * (rein PHP, s. architektur.md §1.13); fachliche Invarianten bleiben
* im UseCase/Model und laufen zentral über den ExceptionSubscriber. * im UseCase/Model und laufen zentral über den ExceptionSubscriber.
@@ -35,15 +40,30 @@ final class MahlzeitController extends AbstractController
private readonly MahlzeitCreateUseCase $create, private readonly MahlzeitCreateUseCase $create,
private readonly MahlzeitUpdateUseCase $update, private readonly MahlzeitUpdateUseCase $update,
private readonly MahlzeitDeleteUseCase $delete, private readonly MahlzeitDeleteUseCase $delete,
private readonly MahlzeitCheckOffUseCase $checkOff,
private readonly MahlzeitUncheckUseCase $uncheck,
private readonly MahlzeitListQuery $list, private readonly MahlzeitListQuery $list,
private readonly MahlzeitManagerInterface $mahlzeiten, private readonly MahlzeitManagerInterface $mahlzeiten,
) {} ) {}
/** GET /mahlzeiten — Planungsliste. */ /** GET /mahlzeiten — Tagesansicht: alle Mahlzeiten nach Wochentag gruppiert. */
public function index(): Response public function index(): Response
{ {
$mahlzeiten = $this->list->execute(new MahlzeitListRequest())->items;
return $this->render('mahlzeiten/index.html.twig', [ return $this->render('mahlzeiten/index.html.twig', [
'mahlzeiten' => $this->list->execute(new MahlzeitListRequest())->items, 'tage' => $this->gruppierenNachTag($mahlzeiten),
'total' => \count($mahlzeiten),
]);
}
/** GET /mahlzeiten/archiv — Archiv: gegessene Mahlzeiten. */
public function archiv(): Response
{
$mahlzeiten = $this->list->execute(new MahlzeitListRequest(status: MahlzeitStatus::Gegessen))->items;
return $this->render('mahlzeiten/archiv.html.twig', [
'mahlzeiten' => $mahlzeiten,
]); ]);
} }
@@ -118,6 +138,31 @@ final class MahlzeitController extends AbstractController
return $this->redirectToRoute('mahlzeiten_index'); return $this->redirectToRoute('mahlzeiten_index');
} }
/**
* POST /mahlzeiten/{id}/checkoff — htmx-Partial: abhaken (geplant → gegessen).
*
* Liefert nur das aktualisierte Mahlzeiten-Partial zurück (Fragment), das
* htmx an der Stelle des Klicks austauscht. Wirft 404 bei unbekannter ID.
*/
public function checkoff(Request $request, int $id): Response
{
$mahlzeit = $this->checkOff->execute($id);
return $this->render('mahlzeiten/_mahlzeit.html.twig', ['m' => $mahlzeit]);
}
/**
* POST /mahlzeiten/{id}/uncheck — htmx-Partial: aufheben (gegessen → geplant).
*
* Liefert nur das aktualisierte Mahlzeiten-Partial zurück (Fragment).
*/
public function uncheck(Request $request, int $id): Response
{
$mahlzeit = $this->uncheck->execute($id);
return $this->render('mahlzeiten/_mahlzeit.html.twig', ['m' => $mahlzeit]);
}
/** /**
* Bindet die Formularfelder (Name/Notiz/Wochentage) aus dem Request. * Bindet die Formularfelder (Name/Notiz/Wochentage) aus dem Request.
* *
@@ -145,4 +190,35 @@ final class MahlzeitController extends AbstractController
'wochentage_choices' => Wochentag::alle(), 'wochentage_choices' => Wochentag::alle(),
]; ];
} }
/**
* Gruppiert Mahlzeiten nach ihren Wochentagen (Mo…So).
*
* Eine Mahlzeit mit mehreren Wochentagen erscheint unter jedem dieser Tage.
* Mahlzeiten ohne Wochentag landen in der Gruppe "Ohne Tag". Leere Gruppen
* werden herausgefiltert; die Reihenfolge folgt Mo…So, danach "Ohne Tag".
*
* @param list<MahlzeitResponse> $mahlzeiten
* @return array<string, list<MahlzeitResponse>>
*/
private function gruppierenNachTag(array $mahlzeiten): array
{
$tage = [];
foreach (Wochentag::alle() as $tag) {
$tage[$tag->name] = [];
}
$tage['Ohne Tag'] = [];
foreach ($mahlzeiten as $m) {
if ($m->wochentage === []) {
$tage['Ohne Tag'][] = $m;
continue;
}
foreach ($m->wochentage as $tag) {
$tage[$tag->name][] = $m;
}
}
return \array_filter($tage, static fn (array $liste) => $liste !== []);
}
} }
+1
View File
@@ -91,6 +91,7 @@
<a href="{{ path('home') }}" style="color: inherit; font-weight: 600;">essen.ljh31.de</a> <a href="{{ path('home') }}" style="color: inherit; font-weight: 600;">essen.ljh31.de</a>
<nav> <nav>
<a href="{{ path('mahlzeiten_index') }}">Mahlzeiten</a> <a href="{{ path('mahlzeiten_index') }}">Mahlzeiten</a>
<a href="{{ path('mahlzeiten_archiv') }}">Archiv</a>
<a href="{{ path('logout') }}">Abmelden</a> <a href="{{ path('logout') }}">Abmelden</a>
</nav> </nav>
</header> </header>
+46
View File
@@ -0,0 +1,46 @@
{#
Mahlzeiten-Partial (eine Zeile). Wird sowohl in der Tagesansicht/Archiv
eingebunden als auch als htmx-Fragment von den CheckOff/Uncheck-Endpunkten
zurückgeliefert (htmx ersetzt per hx-swap="outerHTML" genau diese <li>).
@param MahlzeitResponse m
#}
<li class="item" id="mahlzeit-{{ m.id }}">
<div>
<strong>{{ m.name }}</strong>
{% if m.notiz != '' %}
<div class="muted">{{ m.notiz }}</div>
{% endif %}
{% if m.wochentage|length > 0 %}
<div class="tags">
{% for tag in m.wochentage %}
<span class="tag">{{ tag.name }}</span>
{% endfor %}
</div>
{% endif %}
<span class="status {{ m.status.value == 'gegessen' ? 'status-gegessen' : '' }}">
{{ m.status.value == 'gegessen' ? 'gegessen' : 'geplant' }}
{% if m.status.value == 'gegessen' and m.gegessenAm %}
· {{ m.gegessenAm|date('d.m.Y H:i') }}
{% endif %}
</span>
</div>
<div class="actions">
{% if m.status.value == 'geplant' %}
<button type="button" class="btn"
hx-post="{{ path('mahlzeiten_checkoff', { id: m.id }) }}"
hx-target="#mahlzeit-{{ m.id }}"
hx-swap="outerHTML">Abhaken</button>
{% else %}
<button type="button" class="btn btn-link"
hx-post="{{ path('mahlzeiten_uncheck', { id: m.id }) }}"
hx-target="#mahlzeit-{{ m.id }}"
hx-swap="outerHTML">Aufheben</button>
{% endif %}
<a class="btn btn-link" href="{{ path('mahlzeiten_bearbeiten', { id: m.id }) }}">Bearbeiten</a>
<form method="post" action="{{ path('mahlzeiten_loeschen', { id: m.id }) }}"
onsubmit="return confirm('Mahlzeit „{{ m.name }}“ wirklich löschen?');">
<button type="submit" class="btn btn-danger">Löschen</button>
</form>
</div>
</li>
+27
View File
@@ -0,0 +1,27 @@
{% extends 'layout/base.html.twig' %}
{% block title %}Archiv · svc-essensplanung{% endblock %}
{% block body %}
<div class="card">
<div class="row-between">
<h1 style="margin: 0;">Archiv</h1>
<div class="actions">
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">← Mahlzeiten</a>
<a class="btn" href="{{ path('mahlzeiten_neu') }}">+ Neue Mahlzeit</a>
</div>
</div>
<p class="muted" style="margin-top: 0.75rem;">Alle abgehakten (gegessenen) Mahlzeiten. „Aufheben" stellt sie wieder auf „geplant" um.</p>
{% if mahlzeiten|length == 0 %}
<p class="muted">Noch nichts gegessen – das Archiv ist leer.</p>
{% else %}
<ul class="list">
{% for m in mahlzeiten %}
{{ include('mahlzeiten/_mahlzeit.html.twig', { m: m }) }}
{% endfor %}
</ul>
{% endif %}
</div>
{% endblock %}
+17 -32
View File
@@ -5,42 +5,27 @@
{% block body %} {% block body %}
<div class="card"> <div class="card">
<div class="row-between"> <div class="row-between">
<h1 style="margin: 0;">Mahlzeiten</h1> <div>
<a class="btn" href="{{ path('mahlzeiten_neu') }}">+ Neue Mahlzeit</a> <h1 style="margin: 0;">Mahlzeiten</h1>
<div class="muted" style="margin-top: 0.2rem;">Tagesansicht nach Wochentag</div>
</div>
<div class="actions">
<a class="btn" href="{{ path('mahlzeiten_neu') }}">+ Neue Mahlzeit</a>
<a class="btn btn-link" href="{{ path('mahlzeiten_archiv') }}">Archiv</a>
</div>
</div> </div>
{% if mahlzeiten|length == 0 %} {% if total == 0 %}
<p class="muted" style="margin-top: 1rem;">Noch keine Mahlzeiten angelegt.</p> <p class="muted" style="margin-top: 1rem;">Noch keine Mahlzeiten angelegt.</p>
{% else %} {% else %}
<ul class="list"> {% for tag, mahlzeiten in tage %}
{% for m in mahlzeiten %} <h2 style="margin: 1.25rem 0 0;">{{ tag }}</h2>
<li class="item"> <ul class="list">
<div> {% for m in mahlzeiten %}
<strong>{{ m.name }}</strong> {{ include('mahlzeiten/_mahlzeit.html.twig', { m: m }) }}
{% if m.notiz != '' %} {% endfor %}
<div class="muted">{{ m.notiz }}</div> </ul>
{% endif %} {% endfor %}
{% if m.wochentage|length > 0 %}
<div class="tags">
{% for tag in m.wochentage %}
<span class="tag">{{ tag.name }}</span>
{% endfor %}
</div>
{% endif %}
<span class="status {{ m.status.value == 'gegessen' ? 'status-gegessen' : '' }}">
{{ m.status.value == 'gegessen' ? 'gegessen' : 'geplant' }}
</span>
</div>
<div class="actions">
<a class="btn btn-link" href="{{ path('mahlzeiten_bearbeiten', { id: m.id }) }}">Bearbeiten</a>
<form method="post" action="{{ path('mahlzeiten_loeschen', { id: m.id }) }}"
onsubmit="return confirm('Mahlzeit „{{ m.name }}“ wirklich löschen?');">
<button type="submit" class="btn btn-danger">Löschen</button>
</form>
</div>
</li>
{% endfor %}
</ul>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
@@ -10,6 +10,7 @@ use Doctrine\ORM\Tools\SchemaTool;
use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\BrowserKit\Cookie; use Symfony\Component\BrowserKit\Cookie;
use Symfony\Component\DomCrawler\Crawler;
/** /**
* M1 Schritt 2: CRUD-Slice "Mahlzeit" über die Web-UI (eingeloggte Session). * M1 Schritt 2: CRUD-Slice "Mahlzeit" über die Web-UI (eingeloggte Session).
@@ -146,6 +147,137 @@ final class MahlzeitControllerTest extends WebTestCase
self::assertSelectorExists('form[action$="/loeschen"]'); self::assertSelectorExists('form[action$="/loeschen"]');
} }
// ---- M2 Schritt 2: Status (Check-Off/Toggle), Tagesansicht, Archiv ----
public function testIndexGroupsMealsByWeekday(): void
{
$client = $this->vorbereiten();
$this->anlegen($client, 'Curry', '', ['di']);
$this->anlegen($client, 'Pizza', '', ['sa']);
$this->anlegen($client, 'Bolognese', '', ['mo', 'mi']);
$client->request('GET', '/mahlzeiten');
$crawler = $client->getCrawler();
self::assertResponseIsSuccessful();
// Curry exakt unter "Di", nicht unter "Sa".
$diUl = $this->listeNachHeading($crawler, 'Di');
self::assertStringContainsString('Curry', $diUl->html());
self::assertStringNotContainsString('Pizza', $diUl->html());
// Pizza exakt unter "Sa".
$saUl = $this->listeNachHeading($crawler, 'Sa');
self::assertStringContainsString('Pizza', $saUl->html());
self::assertStringNotContainsString('Curry', $saUl->html());
// Bolognese hat zwei Wochentage → erscheint unter "Mo" UND "Mi".
self::assertStringContainsString('Bolognese', $this->listeNachHeading($crawler, 'Mo')->html());
self::assertStringContainsString('Bolognese', $this->listeNachHeading($crawler, 'Mi')->html());
}
public function testIndexGroupsMealWithoutWeekdayUnderOhneTag(): void
{
$client = $this->vorbereiten();
$this->anlegen($client, 'Reis', '', []);
$client->request('GET', '/mahlzeiten');
self::assertResponseIsSuccessful();
self::assertStringContainsString('Reis', $this->listeNachHeading($client->getCrawler(), 'Ohne Tag')->html());
}
public function testCheckOffReturnsGegessenPartial(): void
{
$client = $this->vorbereiten();
$id = $this->anlegen($client, 'Pasta', '', ['mo']);
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $id));
self::assertResponseIsSuccessful();
// htmx-Fragment: genau das Mahlzeiten-Partial, keine komplette Seite.
self::assertSelectorExists('li#mahlzeit-' . $id);
self::assertSelectorTextContains('span.status', 'gegessen');
$content = (string) $client->getResponse()->getContent();
self::assertStringNotContainsString('<header', $content);
self::assertStringNotContainsString('<!DOCTYPE', $content);
// Persistenz: Status auf "gegessen" + gegessenAm gesetzt.
$this->em()->clear();
$entity = $this->em()->getRepository(MahlzeitEntity::class)->find($id);
self::assertSame('gegessen', $entity->getStatus());
self::assertNotNull($entity->getGegessenAm());
}
public function testUncheckRestoresGeplantStatus(): void
{
$client = $this->vorbereiten();
$id = $this->anlegen($client, 'Suppe', '', []);
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $id));
self::assertResponseIsSuccessful();
$client->request('POST', sprintf('/mahlzeiten/%d/uncheck', $id));
self::assertResponseIsSuccessful();
self::assertSelectorExists('li#mahlzeit-' . $id);
self::assertSelectorTextContains('span.status', 'geplant');
// Persistenz: Status zurück auf "geplant" + gegessenAm zurückgesetzt.
$this->em()->clear();
$entity = $this->em()->getRepository(MahlzeitEntity::class)->find($id);
self::assertSame('geplant', $entity->getStatus());
self::assertNull($entity->getGegessenAm());
}
public function testArchivShowsOnlyGegessenMeals(): void
{
$client = $this->vorbereiten();
$this->anlegen($client, 'GeplanteMahlzeit', '', ['mo']);
$gegessenId = $this->anlegen($client, 'GegesseneMahlzeit', '', ['di']);
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $gegessenId));
self::assertResponseIsSuccessful();
$client->request('GET', '/mahlzeiten/archiv');
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('h1', 'Archiv');
// Nur die Ergebnisliste prüfen (Flash-Meldungen der Anleg-Requests ausblenden).
$liste = $client->getCrawler()->filter('ul')->html();
self::assertStringContainsString('GegesseneMahlzeit', $liste);
self::assertStringNotContainsString('GeplanteMahlzeit', $liste);
}
public function testCheckOffUnknownIdReturns404(): void
{
$client = $this->vorbereiten();
$client->request('POST', '/mahlzeiten/99999/checkoff');
self::assertResponseStatusCodeSame(404);
}
public function testUncheckUnknownIdReturns404(): void
{
$client = $this->vorbereiten();
$client->request('POST', '/mahlzeiten/99999/uncheck');
self::assertResponseStatusCodeSame(404);
}
/**
* Liefert die <ul>-Liste, die direkt unter der angegebenen <h2>-Überschrift
* (z. B. "Di", "Ohne Tag") in der Tagesansicht liegt.
*/
private function listeNachHeading(Crawler $crawler, string $heading): Crawler
{
$h2 = $crawler->filterXPath(sprintf('//h2[normalize-space()="%s"]', $heading));
self::assertCount(1, $h2, sprintf('Genau ein <h2> "%s" erwartet.', $heading));
// Das <ul> ist der erste nächste Geschwister-Knoten der Überschrift.
return $h2->nextAll()->first();
}
/** /**
* Client + sauberes Schema (+ optional eingeloggte Session) bereitstellen. * Client + sauberes Schema (+ optional eingeloggte Session) bereitstellen.
*/ */