Files
myep/REFACTORING_PARTICIPANT_CARDS.md
T

1318 lines
45 KiB
Markdown

# Participant Card View Refactoring Plan
## Overview
Transform create (step 2) and edit flows from accordion-style "all forms at once" to a card overview with lazy-loaded individual participant forms. This addresses UX and performance issues with large groups (50+ participants).
**Status**: Planning Complete - Ready for Implementation
## Goals
- **Performance**: Lazy load forms on demand instead of rendering all at once
- **UX**: Cleaner overview with card-based navigation
- **Scalability**: Handle 50+ participant bookings efficiently
- **Code Reuse**: Maximize shared code between create and edit flows
- **Safe Migration**: Keep old flows intact during development
## Architecture Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| **Form Rendering** | Replace entire content area (Option C) | Best for mobile, prevents concurrency issues |
| **Form Submission** | Explicit "Speichern" button | Not all fields trigger refresh, cleaner UX |
| **Card Content** | Name, room, price only | Minimal, focused information |
| **Validation** | On form submission only | No card-level indicators needed |
| **Card Price Updates** | Full re-render on return to cards | Cards not visible during form editing |
| **Room Assignment** | Keep in participant form | Existing behavior maintained |
| **Migration Strategy** | Separate controllers (-v2 routes) | Safe parallel development, easy comparison |
| **Lazy Loading** | Forms loaded via HTMX on demand | Critical for performance with 50+ participants |
---
## Phase 1: Shared Foundation
### 1.1 Create `ParticipantCardDataService`
**File**: `src/Service/ParticipantCardDataService.php`
**Purpose**: Extract card display data from BookingDto
**Methods**:
```php
/**
* Get card data for a single participant
*
* @return array{name: string, roomName: string, price: string}
*/
public function getCardData(BookingDto $bookingDto, int $index): array
/**
* Get card data for all participants
*
* @return array<int, array{name: string, roomName: string, price: string}>
*/
public function getAllCardsData(BookingDto $bookingDto): array
```
**Implementation Details**:
- **Name**: `$participant->firstName . ' ' . $participant->lastName` or fallback to "Teilnehmer {index+1}"
- **Room**: Look up from `$bookingDto->travel->getRoomById($participant->assignedRoomId)`
- **Price**: Use `BookingPriceCalculatorService::calculateAllParticipantIndividualPrices()`, format as `number_format($price, 2, ',', '.') . ' €'`
**Dependencies**:
- `BookingPriceCalculatorService` (existing)
- Access to Travel model for room lookups
**Test Coverage**:
- Unit test: Verify name fallback for participants without names
- Unit test: Verify price formatting
- Unit test: Verify room name lookup
- Unit test: Handle missing room assignment gracefully
**Todos**:
- [ ] Create service class with DI configuration
- [ ] Implement `getCardData()` method
- [ ] Implement `getAllCardsData()` method
- [ ] Write unit tests
- [ ] Handle edge cases (missing room, missing names)
---
### 1.2 Extend `ParticipantFieldHandlerRegistry`
**File**: `src/Form/Service/ParticipantFieldHandlerRegistry.php`
**New Method**:
```php
/**
* Process field handlers for a single participant
*
* @param array<string, mixed> $participantData Submitted data for one participant
* @param BookingDto $bookingDto The booking DTO to update
* @param int $participantIndex Index of participant to process
*/
public function processFieldsForParticipant(
array $participantData,
BookingDto $bookingDto,
int $participantIndex
): void
```
**Implementation**:
- Extract loop body from existing `processFields()` method
- Apply all handlers to single participant in dependency order
- Reuse existing handler sorting and execution logic
**Todos**:
- [ ] Add `processFieldsForParticipant()` method to registry
- [ ] Extract handler execution logic from loop
- [ ] Maintain dependency order execution
- [ ] Test with existing field handlers
- [ ] Verify field sync logic works for single participant
---
### 1.3 Create `ParticipantFormDto`
**File**: `src/Form/Model/ParticipantFormDto.php`
**Purpose**: Dedicated DTO for editing a single participant with full booking context
**Why This Is Needed**:
The existing `BookingParticipantType` uses `getBookingDtoFromForm()` which traverses up the form tree to find a parent form with `BookingDto` as its data. This DTO provides that context while maintaining clean separation between the booking flow state and individual participant editing.
**Architecture Benefits**:
- ✅ Clean separation: `BookingDto` = full booking flow state, `ParticipantFormDto` = single participant editing context
- ✅ No pollution of BookingDto with editing-specific properties
- ✅ Type-safe and explicit relationships
- ✅ Participant has full booking context for conditional logic
- ✅ Field state providers work unchanged
**Implementation**:
```php
/**
* DTO for editing a single participant within a booking context.
*
* This DTO encapsulates the participant being edited along with the full
* booking context needed for field state evaluation, pricing calculations,
* and conditional logic.
*/
class ParticipantFormDto
{
public function __construct(
public BookingDto $bookingContext,
public ParticipantDto $participant,
) {}
}
```
**Key Points**:
- `bookingContext` provides full booking state for field conditions
- `participant` is the participant being edited
- Participant index available via `$participant->index` (no separate property needed)
**Todos**:
- [ ] Create DTO class in `src/Form/Model/`
- [ ] Add constructor with type hints
- [ ] Write unit tests for DTO instantiation
- [ ] Test with field state providers
---
### 1.4 Create `ParticipantFormType`
**File**: `src/Form/ParticipantFormType.php`
**Purpose**: Form type for editing a single participant with automatic field handler processing
**Implementation**:
```php
class ParticipantFormType extends AbstractType
{
public function __construct(
private readonly ParticipantFieldHandlerRegistry $participantFieldHandlerRegistry,
) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('participant', BookingParticipantType::class, [
'edit_mode' => $options['edit_mode'],
])
->addEventListener(FormEvents::PRE_SUBMIT, [$this, 'onPreSubmit']);
}
/**
* Process field handlers before form binding and validation.
*
* This follows the same pattern as BookingCreateStep2Type - field handlers
* are called during PRE_SUBMIT to clean and validate data before binding.
*/
public function onPreSubmit(FormEvent $event): void
{
$form = $event->getForm();
$submittedData = $event->getData();
/** @var ParticipantFormDto $participantFormDto */
$participantFormDto = $form->getData();
$bookingDto = $participantFormDto->bookingContext;
$participantIndex = $participantFormDto->participant->index;
// Extract participant data from submitted form
$participantSubmittedData = $submittedData['participant'] ?? [];
// Process field handlers for this single participant
$this->participantFieldHandlerRegistry->processFieldsForParticipant(
$participantSubmittedData,
$bookingDto,
$participantIndex
);
// Note: No need to sync data back - the DTO is updated by reference
// and handleRequest() will bind the updated values
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ParticipantFormDto::class,
'edit_mode' => false,
]);
$resolver->setAllowedTypes('edit_mode', 'bool');
}
}
```
**Key Points**:
- Field handlers called automatically in PRE_SUBMIT event (matches existing pattern in `BookingCreateStep2Type`)
- Controller doesn't need to call field handlers manually
- Uses `processFieldsForParticipant()` to process only the current participant
- Root form data is `ParticipantFormDto` which provides `BookingDto` context via `bookingContext` property
**Todos**:
- [ ] Create form type with DI for field handler registry
- [ ] Implement buildForm with participant field
- [ ] Implement onPreSubmit event listener
- [ ] Add proper PHPDoc
- [ ] Test field handler execution
- [ ] Test validation behavior
---
### 1.5 Update `FormTraversalTrait`
**File**: `src/Form/Service/Trait/FormTraversalTrait.php`
**Purpose**: Support both `BookingDto` and `ParticipantFormDto` as root form data
**Modification**:
```php
trait FormTraversalTrait
{
/**
* Gets the BookingDto from the root of the form tree.
*
* Supports both direct BookingDto (used in current flows) and
* ParticipantFormDto wrapper (used in card-based flows).
*
* @param FormInterface $form The form to start traversing from
*
* @return BookingDto|null The booking DTO or null if not found
*/
public function getBookingDtoFromForm(FormInterface $form): ?BookingDto
{
// Traverse up the form tree to get the root form's data
$rootForm = $form;
while ($rootForm->getParent()) {
$rootForm = $rootForm->getParent();
}
$data = $rootForm->getData();
// Direct BookingDto (used in current flows)
if ($data instanceof BookingDto) {
return $data;
}
// ParticipantFormDto wrapper (used in card-based flows)
if ($data instanceof ParticipantFormDto) {
return $data->bookingContext;
}
return null;
}
}
```
**Why This Works**:
- Existing flows use `BookingDto` directly → no changes needed
- New card flows use `ParticipantFormDto` → extracts `bookingContext`
- Field state providers work with both approaches seamlessly
- `BookingParticipantType` remains completely unchanged
**Todos**:
- [ ] Add `ParticipantFormDto` support to trait
- [ ] Add `use` statement for `ParticipantFormDto`
- [ ] Test with existing flows (should work unchanged)
- [ ] Test with new card flows
- [ ] Verify field state providers work correctly
---
### 1.6 Create `ParticipantCardFlowTrait`
**File**: `src/Controller/Booking/ParticipantCardFlowTrait.php`
**Purpose**: Share common controller logic between create and edit flows
**Methods**:
```php
/**
* Load BookingDto from session or throw exception
*/
private function loadBookingDtoOrFail(Request $request, string $mode): BookingDto
/**
* Generate card data for all participants
*/
private function generateAllCardsData(BookingDto $bookingDto): array
/**
* Create form for single participant
*/
private function createParticipantForm(
BookingDto $bookingDto,
int $index,
array $options = []
): FormInterface
/**
* Calculate summary data (pricing, room counts, etc.)
*/
private function calculateSummaryData(BookingDto $bookingDto): array
/**
* Process single participant form refresh
*/
private function handleParticipantRefresh(
Request $request,
BookingDto $bookingDto,
int $index,
string $refreshRouteName
): Response
```
**Todos**:
- [ ] Create trait file
- [ ] Implement `loadBookingDtoOrFail()`
- [ ] Implement `generateAllCardsData()` using `ParticipantCardDataService`
- [ ] Implement `createParticipantForm()`
- [ ] Implement `calculateSummaryData()`
- [ ] Implement `handleParticipantRefresh()` with OOB swap logic
- [ ] Add type hints and PHPDoc
---
## Phase 2: Create Flow (Step 2 Refactored)
### 2.1 Create `CreateStep2RefactoredController`
**File**: `src/Controller/Booking/CreateStep2RefactoredController.php`
**Routes**:
| Method | Route | Name | Purpose |
|--------|-------|------|---------|
| GET/POST | `/bookings/create/participants-v2` | `app_booking_create_step_2_v2_cards` | Display card grid |
| GET/POST | `/bookings/create/participants-v2/{index}` | `app_booking_create_step_2_v2_participant` | Show/submit participant form |
| POST | `/bookings/create/participants-v2/{index}/refresh` | `app_booking_create_step_2_v2_participant_refresh` | HTMX refresh without validation |
**Controller Structure**:
```php
class CreateStep2RefactoredController extends AbstractController
{
use BookingCreateTrait;
use BookingExceptionHandlerTrait;
use HxTrait;
use ParticipantCardFlowTrait;
public function __construct(
private readonly BookingService $bookingService,
private readonly BookingPriceCalculatorService $priceCalculator,
private readonly TravelDataService $travelDataService,
private readonly RoomAssignmentService $roomAssignmentService,
private readonly ParticipantCardDataService $participantCardService,
private readonly ParticipantFieldHandlerRegistry $fieldHandlerRegistry,
) {
}
}
```
**Action: `showCards(Request $request): Response`**
Implementation steps:
1. Load BookingDto from session (reuse `getOrCreateBookingCreateDto()`)
2. Enrich with fresh availability data
3. Validate step access
4. Ensure correct number of participants
5. Auto-assign rooms if needed
6. Preselect mandatory services
7. Save BookingDto to session
8. Generate all cards data via `ParticipantCardDataService`
9. Calculate summary data
10. Render `create_step_2_v2_cards.html.twig`
**Action: `editParticipant(int $index, Request $request): Response`**
Implementation steps:
1. Load BookingDto from session
2. Validate participant index
3. Create form for `$bookingDto->participants[$index]` using `BookingParticipantType`
4. Set HTMX attributes for refresh endpoint
5. `$form->handleRequest($request)`
6. If submitted and valid:
- Extract participant data from submitted form
- Process field handlers via `processFieldsForParticipant()`
- Save BookingDto to session
- HTMX redirect to cards view
7. Calculate summary data for sidebar
8. Render `_participant_form_standalone.html.twig` with form and summary
**Action: `refreshParticipantForm(int $index, Request $request): Response`**
Implementation steps:
1. Load BookingDto from session
2. Enrich with fresh availability data
3. Create form with `validation_groups: false`
4. `$form->handleRequest($request)`
5. Extract participant data from submitted form
6. Process field handlers via `processFieldsForParticipant()`
7. Preselect mandatory services
8. Save BookingDto to session
9. Collect notifications from participant DTO
10. Calculate summary data
11. Render form + sidebar using `htmxOobResponse()` with blocks:
- `participant_form` (target)
- `booking_summary` (OOB swap)
12. Add notifications to HX-Trigger header if present
**Todos**:
- [ ] Create controller class with DI
- [ ] Implement `showCards()` action
- [ ] Implement `editParticipant()` action
- [ ] Implement `refreshParticipantForm()` action
- [ ] Add route annotations
- [ ] Handle edge cases (invalid index, session expiry)
- [ ] Add error handling and logging
---
### 2.2 Templates for Create Flow
**Main Template: `templates/booking/create_step_2_v2_cards.html.twig`**
Structure:
```twig
{% extends 'layout.html.twig' %}
{% block content %}
{% include '_partials/_flashes.html.twig' %}
<h1>Neue Buchung</h1>
<div class="grid grid-cols-3 gap-8">
{# Main content area - cards grid #}
<div id="main-content" class="col-span-2">
<h2>Teilnehmer</h2>
<div id="participant-cards-grid" class="space-y-4">
{% for participant in bookingDto.participants %}
{% include 'booking/_participant_card.html.twig' with {
'cardData': cardsData[loop.index0],
'index': loop.index0,
'mode': 'create'
} %}
{% endfor %}
</div>
<div class="flex justify-between mt-8">
<a href="{{ path('app_booking_create_step_1') }}" class="button bg-button bg-button--secondary">
Zurück
</a>
<button type="submit" class="button bg-button bg-button--secondary">
Weiter
</button>
</div>
</div>
{# Sidebar summary #}
{% block booking_summary %}
<div id="booking-summary">
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingDto,
'participantCount': participantsCount,
'groupedSelectedRooms': groupedSelectedRooms,
'assignmentCounts': assignmentCounts
} %}
</div>
{% endblock %}
</div>
{% endblock %}
```
**Todos**:
- [ ] Create main template file
- [ ] Add grid layout structure
- [ ] Include card loop
- [ ] Add navigation buttons
- [ ] Include sidebar summary
---
**Card Partial: `templates/booking/_participant_card.html.twig`**
Structure:
```twig
{# Compact participant card with name, room, price, and edit button #}
<div id="participant-card-{{ index }}" class="border rounded p-4 flex justify-between items-center">
<div>
<h3 class="font-semibold">{{ cardData.name }}</h3>
<p class="text-sm text-gray-600">{{ cardData.roomName }}</p>
</div>
<div class="flex items-center gap-4">
<span class="font-medium">{{ cardData.price }}</span>
<button type="button"
class="button bg-button bg-button--secondary"
hx-get="{{ path('app_booking_create_step_2_v2_participant', {index: index}) }}"
hx-target="#main-content"
hx-swap="innerHTML">
Bearbeiten
</button>
</div>
</div>
```
**Features**:
- Compact layout
- Name (or "Teilnehmer X" fallback)
- Room name
- Price formatted as "450,00 €"
- "Bearbeiten" button with HTMX attributes
- Target replaces entire `#main-content` area
**Todos**:
- [ ] Create card template
- [ ] Add HTMX attributes for navigation
- [ ] Style with TailwindCSS
- [ ] Test responsive layout
- [ ] Handle long names gracefully
---
**Form Standalone: `templates/booking/_participant_form_standalone.html.twig`**
Structure:
```twig
{# Standalone participant form view (replaces main content area) #}
<div id="participant-form-view">
<h2>{{ participantIndex == 0 ? 'Anmelder:in' : 'Teilnehmer:in ' ~ (participantIndex + 1) }}</h2>
{{ form_start(form, {
'attr': {
'novalidate': 'novalidate',
'hx-post': path('app_booking_create_step_2_v2_participant', {index: participantIndex}),
'hx-target': '#main-content',
'hx-swap': 'innerHTML'
}
}) }}
{% block participant_form %}
<div id="participant-form" class="space-y-4">
{# Personal data section #}
<div class="grid grid-cols-2 gap-4">
{{ form_row(form.firstName) }}
{{ form_row(form.lastName) }}
{{ form_row(form.dateOfBirth, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_create_step_2_v2_participant_refresh', {index: participantIndex}),
'hx-swap': 'none'
}
}) }}
{{ form_row(form.gender) }}
{{ form_row(form.nationality) }}
</div>
{# Contact information #}
<div class="grid grid-cols-2 gap-4">
{{ form_row(form.email) }}
{{ form_row(form.mobile) }}
</div>
{# Address #}
{% if form.address is defined %}
<div class="grid grid-cols-2 gap-4">
{{ form_row(form.address.street) }}
{{ form_row(form.address.postCode) }}
{{ form_row(form.address.city) }}
{{ form_row(form.address.country) }}
</div>
{% endif %}
{# Body dimensions #}
{% if form.bodyDimensions is defined %}
<div class="grid grid-cols-2 gap-4">
{{ form_row(form.bodyDimensions.height) }}
{{ form_row(form.bodyDimensions.shoeSize) }}
{{ form_row(form.bodyDimensions.weight) }}
</div>
{% endif %}
{# Room assignment #}
<div class="grid grid-cols-2 gap-4">
{{ form_row(form.assignedRoomId, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_create_step_2_v2_participant_refresh', {index: participantIndex}),
'hx-swap': 'none'
}
}) }}
{% if form.remarksRoom is defined %}
{{ form_row(form.remarksRoom) }}
{% endif %}
</div>
{# Service selection - same structure as current templates #}
{# Age eligibility checks, conditional field rendering, etc. #}
{# Transportation, insurance, etc. #}
</div>
{% endblock %}
<div class="flex gap-4 mt-8">
<button type="button"
class="button bg-button bg-button--secondary"
hx-get="{{ path('app_booking_create_step_2_v2_cards') }}"
hx-target="#main-content"
hx-swap="innerHTML">
Abbrechen
</button>
<button type="submit" class="button bg-button bg-button--secondary">
Speicheren
</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
</div>
```
**Key Features**:
- Reuses existing form field structure from `create_step_2.html.twig`
- HTMX attributes on fields that trigger refresh
- "Abbrechen" returns to cards (GET request, no save)
- "Speichern" submits form (POST request, validation, save on success)
- Form action posts to same route (Symfony convention)
**Todos**:
- [ ] Create form template
- [ ] Copy form field structure from current template
- [ ] Add HTMX attributes for refresh triggers
- [ ] Add navigation buttons (Abbrechen/Speichern)
- [ ] Test all conditional field logic
- [ ] Ensure age eligibility checks work
- [ ] Verify field state providers work correctly
---
## Phase 3: Edit Flow Refactored
### 3.1 Create `EditRefactoredController`
**File**: `src/Controller/Booking/EditRefactoredController.php`
**Routes**:
| Method | Route | Name | Purpose |
|--------|-------|------|---------|
| GET/POST | `/bookings/{id}/edit-v2` | `app_booking_edit_v2_cards` | Display card grid |
| GET/POST | `/bookings/{id}/edit-v2/participants/{index}` | `app_booking_edit_v2_participant` | Show/submit participant form |
| POST | `/bookings/{id}/edit-v2/participants/{index}/refresh` | `app_booking_edit_v2_participant_refresh` | HTMX refresh |
| POST | `/bookings/{id}/edit-v2/reload` | `app_booking_edit_v2_reload` | Discard changes, reload from API |
**Differences from Create Flow**:
- Load booking data from API on first visit (via `BookingDataTrait`)
- Store in session with `MODE_EDIT`
- Handle canceled participants (status 'S'):
- Show "storniert" badge on card
- Disable "Bearbeiten" button
- Show surcharges info when clicked
- Final submission calls `ApiClient::updateBooking()` instead of navigating to step 3
- Apply mutability constraints via `EditFieldStateProvider`
**Controller Structure**:
```php
class EditRefactoredController extends AbstractController
{
use BookingDataTrait;
use HxTrait;
use ParticipantCardFlowTrait;
public function __construct(
private readonly ApiClient $apiClient,
private readonly BookingDataProcessor $bookingDataProcessor,
private readonly TravelDataService $travelDataService,
private readonly BookingService $bookingService,
private readonly BookingPriceCalculatorService $priceCalculator,
private readonly ParticipantCardDataService $participantCardService,
private readonly ParticipantFieldHandlerRegistry $fieldHandlerRegistry,
private readonly CacheInterface $cache,
private readonly Security $security,
private readonly Crypt $crypt,
private readonly LoggerInterface $logger,
) {
}
}
```
**Action Implementations**: Similar to create flow, with edit-specific logic
**Todos**:
- [ ] Create controller class with DI
- [ ] Implement `showCards()` action (load from API on first visit)
- [ ] Implement `editParticipant()` action (handle canceled participants)
- [ ] Implement `refreshParticipantForm()` action (apply mutability constraints)
- [ ] Implement `reloadFromApi()` action (discard session changes)
- [ ] Add route annotations
- [ ] Handle edit-specific edge cases
- [ ] Test API integration
---
### 3.2 Templates for Edit Flow
**Main Template: `templates/booking/edit_v2_cards.html.twig`**
Similar to create flow, with differences:
- Header shows "Buchung bearbeiten"
- "Reload" button to discard changes
- Different navigation (Zurück to bookings list, Aktualisieren to save)
- Final submit button calls API update
**Card Template**: Reuse `_participant_card.html.twig` with mode parameter
- Add "storniert" badge for canceled participants
- Disable button for canceled participants
**Form Template**: Reuse `_participant_form_standalone.html.twig` with mode parameter
- EditFieldStateProvider applies mutability constraints
- Show applicant address placeholders
**Todos**:
- [ ] Create main template for edit
- [ ] Add reload button with confirmation
- [ ] Modify card template to handle canceled participants
- [ ] Test form template with edit mode
- [ ] Verify mutability constraints applied correctly
---
## Phase 4: Integration & Testing
### 4.1 Functional Testing Checklist
**Card View**:
- [ ] Card grid renders all participants correctly
- [ ] Card names show participant names or "Teilnehmer X" fallback
- [ ] Card room names display correctly
- [ ] Card prices formatted as "450,00 €"
- [ ] "Bearbeiten" button on each card
- [ ] Clicking "Bearbeiten" loads participant form
- [ ] Navigation buttons work (Zurück/Weiter or Zurück/Aktualisieren)
**Participant Form**:
- [ ] Form loads for correct participant
- [ ] All form fields render correctly
- [ ] Field states applied (readonly, disabled, hidden, required)
- [ ] Conditional fields appear/disappear based on data
- [ ] Age eligibility checks work
- [ ] Service fields populated correctly
**HTMX Interactions**:
- [ ] Field changes trigger refresh endpoint
- [ ] Form refreshes with updated data
- [ ] Sidebar summary updates (OOB swap)
- [ ] Notifications display via toast
- [ ] "Abbrechen" returns to cards without saving
- [ ] "Speichern" submits form with validation
- [ ] Valid submission redirects to cards
- [ ] Invalid submission shows validation errors
**Field Handlers**:
- [ ] Date of birth enables age-dependent services
- [ ] Room assignment updates counts
- [ ] Ski pass selection filters rentals by duration
- [ ] Rental selection shows insurance checkbox
- [ ] Bulk insurance updates dependent participants
- [ ] Transportation selection shows/hides pickup/parking
- [ ] License plate field appears with parking selection
- [ ] Insurance auto-reassignment on price changes
- [ ] Rental clearing when ski pass changes
**Data Consistency**:
- [ ] Session DTO updated correctly after form save
- [ ] Session DTO updated correctly after refresh
- [ ] Card data matches DTO after returning from form
- [ ] Prices recalculated correctly
- [ ] Room assignments preserved
- [ ] Service selections preserved
**Edit Flow Specific**:
- [ ] Booking data loads from API on first visit
- [ ] Session stores data correctly (MODE_EDIT)
- [ ] Canceled participants show badge
- [ ] Canceled participants have disabled button
- [ ] Mutability constraints applied
- [ ] Final submission calls API correctly
- [ ] Reload discards session changes
---
### 4.2 Performance Testing
**Metrics to Measure**:
- [ ] Card grid load time with 50 participants: < 1 second
- [ ] Participant form load time: < 200ms (lazy loading benefit)
- [ ] Field refresh response time: < 500ms
- [ ] Memory usage with 50 participants: reasonable
- [ ] No N+1 queries in card data generation
- [ ] Session size doesn't grow excessively
**Performance Tests**:
- [ ] Create booking with 2-3 participants (baseline)
- [ ] Create booking with 10 participants
- [ ] Create booking with 25 participants
- [ ] Create booking with 50 participants
- [ ] Create booking with 100 participants (stress test)
- [ ] Measure database queries per action
- [ ] Measure memory usage per action
- [ ] Profile with Symfony profiler
---
### 4.3 Comparison Testing
**Compare Old vs New Flow**:
- [ ] Session DTO structure identical
- [ ] Final booking API payload identical
- [ ] Pricing calculations identical
- [ ] Field handler behavior identical
- [ ] Validation rules identical
- [ ] Service selection logic identical
- [ ] Room assignment logic identical
- [ ] Insurance matching logic identical
**Test Scenarios**:
- [ ] Simple booking (2 adults, no services)
- [ ] Family booking (2 adults, 2 children, services)
- [ ] Complex booking (mixed ages, all services, insurance)
- [ ] Large group (50 participants)
- [ ] Edge cases (age limits, mandatory services, mutability)
---
## Phase 5: Code Sharing Summary
### Shared Components (100%)
**Services**:
- `ParticipantCardDataService` (new)
- `BookingService` (existing)
- `BookingPriceCalculatorService` (existing)
- `TravelDataService` (existing)
- `RoomAssignmentService` (existing)
- `ParticipantFieldHandlerRegistry` (existing, extended)
- All field handlers (existing)
**Form Types**:
- `BookingParticipantType` (existing)
- `AddressType`, `BodyDimensionsType`, etc. (existing)
- All field types (existing)
**Templates**:
- `_participant_card.html.twig` (new, shared)
- `_participant_form_standalone.html.twig` (new, shared)
- `_summary.html.twig` (existing, shared)
**Traits**:
- `ParticipantCardFlowTrait` (new, shared)
- `HxTrait` (existing, shared)
### Controller-Specific (~20%)
**Create Flow**:
- `CreateStep2RefactoredController` routes
- Initial step validation
- Navigation to step 3 on success
- Template: `create_step_2_v2_cards.html.twig`
**Edit Flow**:
- `EditRefactoredController` routes
- Initial API data loading
- API update on success
- Canceled participant handling
- Reload functionality
- Template: `edit_v2_cards.html.twig`
---
## Technical Details
### Form Handling Pattern
Standard Symfony pattern - single action for both GET and POST:
```php
public function editParticipant(int $index, Request $request): Response
{
$bookingDto = $this->loadBookingDtoOrFail($request, BookingDto::MODE_CREATE);
$form = $this->createParticipantForm($bookingDto, $index, [
'validation_groups' => ['booking_create_step_2'],
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Extract submitted data
$submittedData = $request->request->all();
$participantData = $submittedData['booking_participant'] ?? [];
// Process field handlers for this participant
$this->fieldHandlerRegistry->processFieldsForParticipant(
$participantData,
$bookingDto,
$index
);
// Save to session
$this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
// Redirect back to cards
return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_2_v2_cards'));
}
// Render form (GET or invalid POST)
return $this->render('booking/_participant_form_standalone.html.twig', [
'form' => $form,
'participantIndex' => $index,
'bookingDto' => $bookingDto,
// ... summary data
]);
}
```
### Card Data Structure
```php
[
'name' => 'Max Mustermann', // or "Teilnehmer 1"
'roomName' => 'Doppelzimmer',
'price' => '450,00 €'
]
```
### HTMX Flow Diagram
```
┌─────────────────────────────────────────────┐
│ CARDS VIEW (GET /participants-v2) │
│ │
│ [Teilnehmer 1 | Doppelzimmer | 450,00 €] │
│ [Teilnehmer 2 | Einzelzimmer | 520,00 €] │
│ [Teilnehmer 3 | Doppelzimmer | 450,00 €] │
│ │
│ [Zurück] [Weiter] │
└─────────────────────────────────────────────┘
│ Click "Bearbeiten"
│ hx-get="/participants-v2/0"
│ hx-target="#main-content"
┌─────────────────────────────────────────────┐
│ FORM VIEW (GET /participants-v2/0) │
│ │
│ Teilnehmer 1 │
│ │
│ [Vorname] [Nachname] │
│ [Geburtsdatum] [Geschlecht] │
│ ... │
│ [Skipass ▼] ◄────────────┐ │
│ [Kurse ▼] │ │
│ │ Field change │
│ [Abbrechen] [Speichern] │ POST refresh │
└───────────────────────────┼─────────────────┘
│ hx-post="/participants-v2/0/refresh"
│ validation_groups: false
│ Returns: form + sidebar OOB swap
┌─────────────────────────────────────────────┐
│ FORM VIEW (refreshed) │
│ - Form updated with new field states │
│ - Sidebar summary updated (OOB) │
│ - Notifications shown via toast │
└─────────────────────────────────────────────┘
│ Click "Speichern"
│ POST /participants-v2/0
│ validation_groups: ['booking_create_step_2']
┌──────┴──────┐
│ Validation │
└──────┬──────┘
┌───────┴────────┐
│ │
Valid Invalid
│ │
▼ ▼
HX-Redirect Return form
to cards with errors
┌─────────────────────────────────────────────┐
│ CARDS VIEW (all re-rendered) │
│ │
│ [Max Mustermann | Doppelzimmer | 580,00 €] │ ← Updated!
│ [Teilnehmer 2 | Einzelzimmer | 520,00 €] │
│ [Teilnehmer 3 | Doppelzimmer | 450,00 €] │
└─────────────────────────────────────────────┘
```
### Session Flow
1. **Cards View**: Load DTO from session, render cards
2. **Form View**: Load DTO from session, create form for participant[index]
3. **Form Refresh**: Load DTO, update with field changes (no validation), save to session
4. **Form Submit**: Load DTO, validate, run handlers, save to session, redirect
5. **Back to Cards**: Load DTO from session (with all changes), render cards
**Key**: DTO is single source of truth in session, updated incrementally
---
## Implementation Sequence
### Week 1: Foundation & Create Flow
**Day 1-2**: Foundation
- [ ] Create `ParticipantCardDataService` with tests
- [ ] Add `processFieldsForParticipant()` to registry with tests
- [ ] Create `ParticipantCardFlowTrait`
**Day 3**: Templates
- [ ] Create `_participant_card.html.twig`
- [ ] Create `_participant_form_standalone.html.twig`
- [ ] Create `create_step_2_v2_cards.html.twig`
**Day 4-5**: Create Flow Controller
- [ ] Implement `CreateStep2RefactoredController`
- [ ] All three routes/actions
- [ ] Test with small bookings (2-3 participants)
- [ ] Test field refresh logic
- [ ] Test validation
### Week 2: Edit Flow & Testing
**Day 6-7**: Edit Flow
- [ ] Implement `EditRefactoredController`
- [ ] Reuse trait methods
- [ ] Handle canceled participants
- [ ] Create edit templates
- [ ] Test API integration
**Day 8-9**: Integration Testing
- [ ] Compare outputs (old vs new flows)
- [ ] Test all field handlers
- [ ] Test bulk insurance
- [ ] Test edge cases
- [ ] Performance test with 50+ participants
**Day 10**: Cleanup & Documentation
- [ ] Code review
- [ ] Update documentation
- [ ] Test both flows side by side
- [ ] Mark as ready for production consideration
---
## Migration Strategy
### Current State (Before Refactoring)
- Old routes: `/bookings/create/participants` and `/bookings/{id}/edit`
- Old controllers: `CreateStep2Controller`, `EditController`
- Old templates: `create_step_2.html.twig`, `edit.html.twig`
### Development State (During Refactoring)
- **Old routes remain functional and unchanged**
- New routes: `/bookings/create/participants-v2` and `/bookings/{id}/edit-v2`
- New controllers: `CreateStep2RefactoredController`, `EditRefactoredController`
- New templates: `create_step_2_v2_cards.html.twig`, `edit_v2_cards.html.twig`
- Both flows coexist - **critical for testing and comparison**
### Transition State (Switching to New Flow)
**Phase 1: Testing & Validation (During Development)**
- [ ] Deploy both flows to production (both accessible via their routes)
- [ ] Test NEW flow manually in production environment
- [ ] OLD routes remain the default in all navigation
- [ ] NEW routes accessible directly for testing (e.g., via direct URL)
- [ ] Compare bookings created through both flows
- [ ] Verify session DTO structure is identical
- [ ] Confirm pricing calculations match exactly
**Phase 2: Navigation Switch (Single Deployment)**
- [ ] Update all navigation links to point to NEW routes
- [ ] Update internal redirects to use NEW routes
- [ ] OLD routes remain accessible (direct URL access works)
- [ ] Deploy navigation changes
- [ ] Monitor error rates and booking success rates closely
- [ ] **If issues found**: Deploy rollback (revert navigation to OLD routes)
**Phase 3: Stabilization (2-4 weeks)**
- [ ] Monitor NEW flow in production with all traffic
- [ ] OLD routes still accessible but deprecated
- [ ] No new features added to OLD flow
- [ ] Document any issues found and resolved
**Phase 4: Cleanup (After Confidence Established)**
- [ ] Remove OLD controllers (`CreateStep2Controller`, `EditController`)
- [ ] Remove OLD templates (`create_step_2.html.twig`, `edit.html.twig`)
- [ ] Remove OLD routes from routing configuration
- [ ] (Optional) Remove `-v2` suffix from route names and paths
### Critical Transition Checklist
**Before Switching Navigation**:
- [ ] All functional tests pass for NEW flow
- [ ] Performance tests show acceptable load times
- [ ] Comparison tests confirm identical DTO structure
- [ ] Field handlers produce identical results
- [ ] Pricing calculations match exactly
- [ ] Session compatibility verified
- [ ] HTMX refresh logic tested extensively
- [ ] Bulk insurance functionality verified
- [ ] All age-dependent service filtering works
- [ ] Transportation service conditional logic works
- [ ] Insurance auto-reassignment tested
- [ ] Edit flow API integration verified
- [ ] Large booking tests (50+ participants) pass
**Session Compatibility**:
- ✅ Both flows use identical `BookingDto` structure
- ✅ Session keys are mode-specific (`MODE_CREATE`, `MODE_EDIT`)
- ✅ Users can switch between OLD and NEW flows without losing data
- ✅ No session migration needed
**Rollback Plan**:
If critical issues discovered:
1. **Immediate**: Deploy navigation changes to revert links to OLD routes
2. **Investigation**: Analyze logs, compare booking data, identify root cause
3. **Fix**: Apply fixes to NEW flow controllers/templates
4. **Retest**: Verify fixes in development environment
5. **Redeploy**: Update navigation to NEW routes again
### Monitoring During Transition
**Key Metrics to Track**:
- [ ] Booking creation success rate (OLD vs NEW)
- [ ] Average page load time for card grid
- [ ] Average form load time (lazy loading)
- [ ] Field refresh response times
- [ ] Validation error rates
- [ ] Session expiry rates
- [ ] Browser console errors
- [ ] Server error logs
- [ ] Database query counts
- [ ] Memory usage per request
**Logging Strategy**:
Add structured logging to NEW flow controllers:
```php
$this->logger->info('Refactored flow: Card view loaded', [
'participant_count' => count($bookingDto->participants),
'load_time_ms' => $loadTime,
'user_id' => $user->getId(),
]);
$this->logger->info('Refactored flow: Participant form saved', [
'participant_index' => $index,
'had_validation_errors' => !$form->isValid(),
'field_handler_execution_time_ms' => $handlerTime,
]);
```
**Comparison Analysis**:
Since bookings are created via BPN API (not database), compare OLD vs NEW flows by:
- Analyzing application logs for booking creation success/failure rates
- Comparing API response times between OLD and NEW flows
- Monitoring Symfony profiler data for both flows
- Tracking error rates in application logs
### Post-Transition Cleanup
After successful transition (2+ months with no issues):
**Step 1: Remove OLD Controllers and Templates**
- [ ] Delete `CreateStep2Controller.php`
- [ ] Delete `EditController.php`
- [ ] Delete `create_step_2.html.twig`
- [ ] Delete `edit.html.twig`
- [ ] Remove OLD routes from routing configuration
**Step 2: (Optional) Rename Routes**
- [ ] Rename `app_booking_create_step_2_v2_cards``app_booking_create_step_2`
- [ ] Rename `app_booking_create_step_2_v2_participant``app_booking_create_step_2_participant`
- [ ] Rename `app_booking_edit_v2_cards``app_booking_edit`
- [ ] Update all navigation links and redirects
- [ ] Test thoroughly after rename
**Step 3: Verify Navigation**
- [ ] Ensure all links point to correct routes (after cleanup)
- [ ] Check for any hardcoded OLD route references
- [ ] Test navigation flow end-to-end
**Step 4: Documentation Updates**
- [ ] Update developer documentation
- [ ] Remove references to "refactored" or "v2" terminology
- [ ] Update onboarding guides for new developers
### Common Pitfalls to Avoid
**❌ DON'T**:
- Switch navigation to NEW flow without thorough testing in production first
- Remove OLD controllers/routes immediately after navigation switch
- Ignore session compatibility between flows
- Switch without monitoring tools in place
- Forget to update ALL navigation links (easy to miss some)
**✅ DO**:
- Test NEW flow extensively via direct URL access before navigation switch
- Keep OLD flow accessible for weeks/months as safety net
- Monitor metrics closely after navigation switch
- Have rollback plan ready (revert navigation links)
- Document all navigation link locations before switch
---
---
## Success Criteria
### Performance
- [ ] Card grid loads in <1 second with 50 participants
- [ ] Form loads instantly (lazy loading)
- [ ] Field refresh responds in <500ms
- [ ] Memory usage reasonable with large bookings
### Functionality
- [ ] All field handlers work identically to old flow
- [ ] Validation rules applied correctly
- [ ] Session DTO structure matches old flow
- [ ] Final booking creation/update produces identical results
### Code Quality
- [ ] 80%+ code reuse between create and edit flows
- [ ] Full test coverage for new services
- [ ] Clean separation of concerns
- [ ] Documented and maintainable
---
## Notes & Considerations
### Why No OOB Swaps for Cards?
When editing a participant form, the cards view is not rendered - the entire #main-content is replaced with the form. When returning to cards (via "Speichern" or "Abbrechen"), the entire cards view is re-rendered fresh from the session DTO. Therefore, no need for OOB swaps of individual card prices.
### Why Lazy Loading?
With 50+ participants, rendering all forms at once (current accordion approach) causes:
- Long initial page load time
- High memory usage
- Heavy DOM with hundreds of form fields
Lazy loading loads only the form for the participant being edited, dramatically improving performance.
### Why Separate Controllers?
Keeps old implementation intact for:
- Comparison testing during development
- Fallback if issues discovered
- Easier code review (clear diff between old and new)
- Lower risk deployment
### Field Handler Registry Change
Adding `processFieldsForParticipant()` allows processing a single participant's data instead of looping through all participants. This is necessary because the new flow processes participants individually, not all at once.
---
## Open Questions
- [ ] Should we add a progress indicator showing X/Y participants completed?
- [ ] Should we add keyboard shortcuts (e.g., Ctrl+S to save, Esc to cancel)?
- [ ] Should we add a "Save & Next" button to quickly move through participants?
- [ ] Should we store completion state per participant to show visual progress?
---
## References
- Current Implementation: `src/Controller/Booking/CreateStep2Controller.php`
- Current Template: `templates/booking/create_step_2.html.twig`
- Field Handlers: `src/Form/Service/`
- HTMX Trait: `src/Htmx/HxTrait.php`