wip: update docs

This commit is contained in:
Björn Fromme
2026-03-16 11:59:10 +01:00
parent bd5c6359fe
commit 552dd029fa
6 changed files with 2161 additions and 0 deletions
+525
View File
@@ -0,0 +1,525 @@
# Age-Based Field Visibility with Enhanced UX Implementation Plan
## Overview
Extend the existing conditional field system to hide fields when date of birth is not provided and filter options based on age groups, with comprehensive UX improvements to guide users through the progressive disclosure process.
## Architecture Analysis
**Existing Infrastructure to Leverage:**
- `AgeRangeCondition` already handles age calculations and returns `false` when `dateOfBirth` is null
- Field state providers can hide fields using `'hidden'` state conditions
- `CompositeCondition` allows complex condition combinations
- Field options providers support dynamic choice filtering
- Form events already trigger rebuilds when form data changes
## Implementation Strategy: Hybrid Approach with Enhanced UX
### 1. Create New Condition Classes
**A. DateOfBirthProvidedCondition**
- Simple condition that checks if participant has `dateOfBirth` set
- Returns `true` only when date of birth exists
- Used to show/hide entire fields
```php
class DateOfBirthProvidedCondition implements FieldConditionInterface
{
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
{
$participant = $bookingDto->getParticipant($participantIndex);
return null !== $participant && null !== $participant->dateOfBirth;
}
public function getDependentFields(): array
{
return ['dateOfBirth'];
}
}
```
**B. Extend AgeRangeCondition Usage**
- Leverage existing age calculation logic
- Create specific age range instances for different field requirements
### 2. Extend CreateFieldStateProvider
**Add Field Visibility Rules:**
```php
protected function registerFieldStateConditions(): void
{
// Existing rental condition logic...
// Hide age-restricted fields when no date of birth provided
$this->fieldStateConditions['courses'] = [
'hidden' => CompositeCondition::not(new DateOfBirthProvidedCondition()),
];
$this->fieldStateConditions['additionalServices'] = [
'hidden' => CompositeCondition::not(new DateOfBirthProvidedCondition()),
];
// Hide fields for specific age groups (example: alcohol services)
$this->fieldStateConditions['alcoholicBeverages'] = [
'hidden' => CompositeCondition::or(
CompositeCondition::not(new DateOfBirthProvidedCondition()),
CompositeCondition::not(new AgeRangeCondition(18, null))
),
];
// Youth-specific fields (example: under 16 only)
$this->fieldStateConditions['youthActivities'] = [
'hidden' => CompositeCondition::or(
CompositeCondition::not(new DateOfBirthProvidedCondition()),
CompositeCondition::not(new AgeRangeCondition(null, 15))
),
];
}
```
### 3. Enhance ParticipantFieldOptionsProvider
**Add Age-Aware Filtering:**
```php
protected function registerFieldOptionProviders(): void
{
// Existing providers...
// Enhanced courses provider with age filtering
$this->fieldOptionProviders['courses'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
'label' => 'Kurse',
'multiple' => true,
'expanded' => true,
'required' => false,
'choices' => $this->filterChoicesByAge(
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_COURSES),
$bookingDto,
$participantIndex
),
'choice_label' => 'label',
];
}
private function filterChoicesByAge(array $services, BookingDtoInterface $bookingDto, int $participantIndex): array
{
$participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant || null === $participant->dateOfBirth) {
return []; // No services available without age
}
$age = $this->calculateAge($participant->dateOfBirth);
return array_filter($services, function ($service) use ($age) {
return $this->isServiceAvailableForAge($service, $age);
});
}
private function isServiceAvailableForAge($service, int $age): bool
{
// Check service age restrictions (could be metadata on Service model)
if (isset($service->minAge) && $age < $service->minAge) {
return false;
}
if (isset($service->maxAge) && $age > $service->maxAge) {
return false;
}
return true;
}
```
### 4. UX Enhancement System
**A. Age Requirement Hints Provider**
```php
class ParticipantFieldHintsProvider
{
public function getFieldHints(BookingDtoInterface $bookingDto, int $participantIndex): array
{
$hints = [];
$participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant || null === $participant->dateOfBirth) {
$hints[] = [
'type' => 'info',
'message' => 'Geben Sie Ihr Geburtsdatum an, um alle verfügbaren Optionen zu sehen.',
'icon' => 'birthday-cake',
'fields_affected' => $this->getAgeRestrictedFields(),
'action' => 'focus_date_field'
];
} else {
$age = $this->calculateAge($participant->dateOfBirth);
$restrictedFields = $this->getRestrictedFieldsForAge($age);
if (!empty($restrictedFields)) {
$hints[] = [
'type' => 'warning',
'message' => sprintf('Einige Optionen sind für Ihr Alter (%d Jahre) nicht verfügbar.', $age),
'icon' => 'info-circle',
'fields_affected' => $restrictedFields
];
}
}
return $hints;
}
}
```
**B. Field Placeholder System**
```php
// Template helper for placeholder cards
public function renderFieldPlaceholder(string $fieldName, array $hint): string
{
return sprintf('
<div class="field-placeholder" data-field="%s">
<div class="placeholder-content">
<i class="icon-%s"></i>
<h4>%s verfügbar nach Altersangabe</h4>
<p>%s</p>
<button type="button" class="btn btn-sm btn-outline-primary" data-action="focus-date">
Geburtsdatum angeben
</button>
</div>
</div>
', $fieldName, $hint['icon'], ucfirst($fieldName), $hint['message']);
}
```
### 5. Smart Form Guidance
**A. Enhanced Date of Birth Field**
```php
// Add to BookingCreateParticipantType
->add('dateOfBirth', BirthdayType::class, [
'label' => 'Geburtsdatum',
'html5' => true,
'widget' => 'single_text',
'input' => 'datetime_immutable',
'attr' => [
'class' => 'age-dependent-trigger',
'data-controller' => 'age-dependent-fields',
'data-age-dependent-fields-participant-index-value' => $participantIndex,
'placeholder' => 'TT.MM.JJJJ - Für altersgerechte Optionen erforderlich'
],
'help' => 'Benötigt für die Anzeige aller verfügbaren Kurse und Services'
])
```
**B. Field Counter Display**
```javascript
// Stimulus controller: age-dependent-fields
connect() {
this.updateFieldCounter();
this.showPlaceholders();
}
updateFieldCounter() {
const hiddenFieldCount = this.getHiddenFieldCount();
if (hiddenFieldCount > 0) {
this.showFieldCounter(hiddenFieldCount);
}
}
showFieldCounter(count) {
const counter = this.element.querySelector('.field-counter');
if (counter) {
counter.textContent = `👤 ${count} weitere Optionen verfügbar nach Altersangabe`;
counter.classList.add('visible');
}
}
```
### 6. HTMX Integration Enhancements
**A. Smooth Transitions**
```html
<!-- Add to form template -->
<div data-controller="field-transitions">
<div class="field-group" data-field-transitions-target="container">
<!-- Dynamic fields will be inserted here -->
</div>
</div>
```
```javascript
// Stimulus controller: field-transitions
showField(fieldElement) {
fieldElement.style.opacity = '0';
fieldElement.style.transform = 'translateY(-10px)';
// Animate in
fieldElement.addEventListener('transitionend', () => {
fieldElement.classList.add('field-visible');
}, { once: true });
requestAnimationFrame(() => {
fieldElement.style.opacity = '1';
fieldElement.style.transform = 'translateY(0)';
});
}
```
### 7. Update hasValidFieldOptions Method
**Enhanced Validation Logic:**
```php
private function hasValidFieldOptions(array $fieldOptions): bool
{
// Existing validation...
// Check if choices array exists and is not empty
if (false === empty($fieldOptions['choices'])) {
return true;
}
// Check if choice_loader exists and is not null
if (isset($fieldOptions['choice_loader'])) {
return true;
}
// NEW: Check if field has age restrictions that prevent display
if (isset($fieldOptions['age_restricted']) && true === $fieldOptions['age_restricted']) {
return false; // Field should not be added if age-restricted
}
// Check for other valid choice sources
if (isset($fieldOptions['choice_list'])) {
return true;
}
return false;
}
```
### 8. Template Enhancements
**A. Add UX Components to Form Template**
```twig
{# templates/booking/create/step2_participant.html.twig #}
<div class="participant-form" data-controller="age-dependent-fields participant-hints">
<!-- Personal Data Section -->
<div class="form-section">
<h3>Persönliche Daten</h3>
<!-- Date of Birth with enhanced styling -->
<div class="form-group date-of-birth-group"
data-age-dependent-fields-target="dateField">
{{ form_row(form.dateOfBirth) }}
<div class="field-counter" data-age-dependent-fields-target="counter"></div>
</div>
<!-- Other personal fields -->
{{ form_row(form.firstName) }}
{{ form_row(form.lastName) }}
<!-- ... -->
</div>
<!-- Dynamic Sections with Placeholders -->
<div class="form-section dynamic-fields"
data-age-dependent-fields-target="dynamicContainer">
<!-- Hints Display Area -->
<div class="field-hints" data-participant-hints-target="container">
<!-- Hints will be inserted here -->
</div>
<!-- Placeholders for age-dependent fields -->
<div class="field-placeholder" data-field="courses"
data-age-dependent-fields-target="placeholder">
<div class="placeholder-content">
<i class="icon-graduation-cap"></i>
<h4>Kurse</h4>
<p>Verfügbar nach Angabe des Geburtsdatums</p>
<button type="button" class="btn btn-sm btn-outline-primary"
data-action="click->age-dependent-fields#focusDateField">
Geburtsdatum angeben
</button>
</div>
</div>
<!-- Dynamic fields will replace placeholders -->
<div class="dynamic-field-container" data-age-dependent-fields-target="fieldsContainer">
<!-- Form fields added dynamically -->
</div>
</div>
</div>
```
## Implementation Status
### ✅ COMPLETED PHASE 1: XML Parsing & Age Constraints
1. **✅ Extended Service Model** - Added age constraint properties (`ageFrom`, `ageTo`, `birthYearFrom`, `birthYearTo`, `ageConstraintType`)
2. **✅ Created AgeConstraintResult DTO** - Structured data for parsed age constraints
3. **✅ Created AgeConstraintParserInterface** - Extensible parser contract for future constraint types
4. **✅ Implemented BirthYearConstraintParser** - Parses `JG:YYYY-YYYY` format constraints from XML
5. **✅ Created AgeConstraintParserRegistry** - Manages multiple parsers, handles semicolon-separated constraints
6. **✅ Enhanced TravelParser** - Integrated age constraint parsing for both absolute age and extensible formats
7. **✅ Successfully tested XML parsing** - Confirmed correct extraction of age constraints from real BPN data
### ✅ COMPLETED PHASE 2: Form Integration & Age-Based Filtering
8. **✅ Created DateOfBirthProvidedCondition** - Checks if participant has provided birth date
9. **✅ Created ServiceAgeEvaluator** - Evaluates service availability for participant age
10. **✅ Enhanced CreateFieldStateProvider** - Added field visibility rules to hide age-dependent fields
11. **✅ Enhanced ParticipantFieldOptionsProvider** - Integrated age-aware service filtering
12. **✅ Enhanced hasValidFieldOptions method** - Handles empty choices from age filtering
### ✅ RESOLVED: Age Filtering Implementation Complete
**Resolution:** Age-based service filtering is now working correctly - services with age constraints are properly filtered based on participant birth date.
### ✅ COMPLETED PHASE 3: Form Field Management & Room-Based Fields
13. **✅ Age filtering system** - Age-based service filtering working correctly
14. **✅ Service configuration** - ServiceAgeEvaluator instantiated directly (no DI needed)
15. **✅ Skipass field implementation** - Added single-selection skipass field with age/date validation
16. **✅ Service selection bug fixes** - Fixed form submission issues with service ID/object handling
17. **✅ Room-based conditional field system** - Added remarksRoom field for specific room types
18. **✅ Field visibility improvements** - Fields no longer just hidden but excluded from form entirely
19. **✅ Dynamic field type system** - Simplified field configuration with type mapping
20. **✅ Room code normalization** - Case-insensitive room code matching (mbz/MBZ/Mbz)
### 📋 REMAINING IMPLEMENTATION STEPS
21. **⏳ Field placeholder system** - Add explanatory content for missing age
22. **⏳ Enhanced date of birth field** - Smart guidance styling
23. **⏳ HTMX transitions** - Smooth field additions
24. **⏳ Stimulus controllers** - (`age-dependent-fields`, `field-transitions`, `participant-hints`)
25. **⏳ Template enhancements** - UX improvements and placeholder system
26. **⏳ Service model validation** - Ensure all constraint types properly handled
27. **⏳ Comprehensive testing** - UX flows and edge cases
### ✅ NEW FEATURES ADDED
#### Skipass Field System
- **Field Type**: Single-selection radio buttons (mandatory when available)
- **Age Constraints**: Automatic filtering via existing ServiceAgeEvaluator
- **Date Validation**: Travel date range validation for service availability
- **Form Integration**: Proper ID/object handling with choice_value configuration
- **Files Modified**:
- `ParticipantFieldOptionsProvider.php` - Added skipass field provider
- `ParticipantSkiPassFieldHandler.php` - Created field handler
- `ParticipantDto.php` - Changed from array to single Service object
- `BookingEditDto.php` - Conversion logic for existing bookings
- `BookingCreateParticipantType.php` - Added to dynamic fields
- `services.yaml` - Registered handler
#### Room-Based Conditional Fields
- **remarksRoom Field**: Textarea field for room-specific comments
- **Conditional Logic**: Only appears when room with code 'mbz' is selected
- **Case-Insensitive**: Room code matching normalized to lowercase
- **Form Integration**: Uses TextareaType with XSS protection
- **Files Created**:
- `RoomSelectionCondition.php` - Flexible room-based condition
- `ParticipantRemarksRoomFieldHandler.php` - Field processing
- **Files Modified**:
- `CreateFieldStateProvider.php` - Added room selection condition
- `ParticipantDto.php` - Added remarksRoom property
#### Improved Form Field Management
- **Problem**: Fields were added to form but hidden with CSS
- **Solution**: Fields now excluded from form entirely when conditions not met
- **Benefits**: Cleaner HTML, better performance, no hidden field clutter
- **Logic**: Check field state conditions BEFORE adding fields to form
- **Choice Field Optimization**: Only validate choices for ChoiceType fields
- **Field Type System**: Simple field name → form type mapping
#### Service Selection Bug Fixes & HTMX Improvements
- **HTMX Trigger Issue**: HTMX requests were not triggered for service fields
- **Root Cause**: HTMX attributes placed on container elements instead of individual form inputs
- **Solution**: Moved HTMX triggers to individual checkbox/radio inputs for expanded choice fields
- **Affected Fields**: board, skipass, courses, additionalServices, rentals
- **Result**: Real-time dynamic updates now work reliably for all service fields
- **Field Handler Data Issue**: Field handlers were storing service IDs instead of Service objects
- **Root Cause**: Form submission processing returned IDs rather than full Service entities
- **Solution**: Updated all field handlers to retrieve and store complete Service objects
- **Impact**: Pricing calculator now has access to service price data
- **Affected Handlers**: ParticipantBoardFieldHandler, ParticipantSkipassFieldHandler, etc.
- **Form Submission Reset Issue**: Service selections were lost on form submission
- **Root Cause**: Inconsistent choice_value configuration between service types
- **Solution**: Added 'choice_value' => 'id' to all service field providers
- **Affected Fields**: courses, additionalServices, board, rentals, skipass
- **Result**: Consistent form submission behavior across all service types
**Legend:** ✅ Completed | 🔄 In Progress | 🔍 Investigating | ⏳ Pending
## Key Benefits
### Technical Benefits
- **Leverages existing architecture** - no major refactoring needed
- **Consistent patterns** - follows current field state and options provider patterns
- **Dynamic updates** - automatically works with existing HTMX integration
- **Flexible configuration** - easy to add new age-based rules
- **Performance optimized** - fields not computed when not visible
### UX Benefits
- **Clear Expectations** - Users know what to expect after providing age
- **Progressive Disclosure** - Information revealed at the right time
- **Visual Continuity** - Placeholders maintain form layout consistency
- **Contextual Help** - Specific guidance for different age scenarios
- **Smooth Interactions** - Animated transitions for dynamic changes
- **Accessibility** - Screen reader friendly field announcements
- **Mobile Optimized** - Touch-friendly interactions and responsive design
## UX Components to Add
1. **Field Hint Cards** - Informational cards explaining missing fields
2. **Age Requirement Badges** - Visual indicators on age-dependent sections
3. **Progress Indicators** - Show completion status including hidden fields
4. **Interactive Placeholders** - Clickable areas that focus date field
5. **Transition Animations** - Smooth field appearance/disappearance
6. **Contextual Tooltips** - Explain specific age requirements
7. **Smart Form Validation** - Guide users to complete prerequisites first
## Testing Scenarios
### Functional Testing
1. **Initial Load** - Proper placeholders and guidance shown when no date of birth
2. **Progressive Disclosure** - Fields appear correctly as age is entered
3. **Age Restrictions** - Correct fields hidden/shown based on age ranges
4. **Dynamic Updates** - HTMX updates work seamlessly with field state changes
5. **Form Validation** - Proper validation with conditional field visibility
6. **Field Dependencies** - Complex conditions work correctly
### UX Testing
1. **Visual Feedback** - Clear indication of why fields are missing
2. **Interaction Flow** - Smooth user journey through form completion
3. **Accessibility** - Screen readers announce field changes properly
4. **Mobile Experience** - Touch interactions work on mobile devices
5. **Performance** - No lag during dynamic field updates
6. **Error Handling** - Graceful handling of invalid dates and edge cases
## Configuration Examples
### Service Age Restrictions
```php
// Example: Add age restrictions to Service model or metadata
$alcoholicBeverages = [
'minAge' => 18,
'description' => 'Nur für Erwachsene verfügbar'
];
$youthActivities = [
'maxAge' => 15,
'description' => 'Nur für Jugendliche unter 16'
];
$seniorServices = [
'minAge' => 65,
'description' => 'Spezielle Services für Senioren'
];
```
### Field Age Requirements
```php
// Configuration for which fields require age verification
private const AGE_RESTRICTED_FIELDS = [
'courses' => ['requireAge' => true, 'reason' => 'Altersgerechte Kursauswahl'],
'additionalServices' => ['requireAge' => true, 'reason' => 'Altersspezifische Services'],
'alcoholicBeverages' => ['minAge' => 18, 'reason' => 'Jugendschutz'],
'youthActivities' => ['maxAge' => 15, 'reason' => 'Nur für Jugendliche'],
'seniorServices' => ['minAge' => 65, 'reason' => 'Seniorenspezifisch'],
];
```
This comprehensive plan provides both robust technical implementation and excellent user experience for age-based field management, building seamlessly on the existing sophisticated form architecture.
@@ -0,0 +1,641 @@
# Age Constraints Model Extension Plan
## Current Situation Analysis
**XML Data Contains Two Age Constraint Formats:**
1. **Absolute Age**: `<altervon>6</altervon><alterbis>14</alterbis>` (current age 6-14)
2. **Birth Year Ranges**: `<hinweis_stamm>JG:2007-2009</hinweis_stamm>` (birth years 2007-2009)
**Future Considerations:**
- `hinweis_stamm` may contain additional constraint types beyond `JG:` (birth year)
- Need extensible parsing system for future constraint formats
- Maintain English naming conventions throughout
**Current State:**
- `Service` model has `ageFrom`/`ageTo` properties but they're not populated
- `TravelParser` doesn't parse age-related XML nodes
- Form processing doesn't consider age constraints
## Required Changes
### 1. Extend Service Model with Extensible Age Constraints
**Add New Properties for Flexible Age Constraints:**
```php
// Add to Service class (src/BusProNet/Model/Service.php)
#[Groups(['api:single', 'api:list'])]
public ?int $birthYearFrom = null;
#[Groups(['api:single', 'api:list'])]
public ?int $birthYearTo = null;
#[Groups(['api:single', 'api:list'])]
public ?string $ageConstraintType = null; // 'absolute_age', 'birth_year', 'mixed'
#[Groups(['api:single'])]
public ?array $ageConstraintMetadata = null; // Extensible metadata for future constraint types
#[Groups(['api:single'])]
public ?string $rawAgeConstraintData = null; // Store original XML data for debugging/future parsing
```
### 2. Create Extensible Age Constraint Parser System
**A. Create Age Constraint Parser Interface:**
```php
// src/BusProNet/XmlParser/Contract/AgeConstraintParserInterface.php
interface AgeConstraintParserInterface
{
public function canParse(string $constraintData): bool;
public function parse(string $constraintData): AgeConstraintResult;
public function getConstraintType(): string;
}
```
**B. Create Age Constraint Result DTO:**
```php
// src/BusProNet/XmlParser/Model/AgeConstraintResult.php
class AgeConstraintResult
{
public function __construct(
public readonly string $type,
public readonly ?int $ageFrom = null,
public readonly ?int $ageTo = null,
public readonly ?int $birthYearFrom = null,
public readonly ?int $birthYearTo = null,
public readonly array $metadata = [],
public readonly ?string $rawData = null
) {}
public function hasAgeConstraints(): bool
{
return null !== $this->ageFrom || null !== $this->ageTo;
}
public function hasBirthYearConstraints(): bool
{
return null !== $this->birthYearFrom || null !== $this->birthYearTo;
}
public function isEmpty(): bool
{
return !$this->hasAgeConstraints() && !$this->hasBirthYearConstraints();
}
}
```
**C. Create Birth Year Constraint Parser:**
```php
// src/BusProNet/XmlParser/AgeConstraint/BirthYearConstraintParser.php
class BirthYearConstraintParser implements AgeConstraintParserInterface
{
private const BIRTH_YEAR_PREFIX = 'JG:';
public function canParse(string $constraintData): bool
{
return str_starts_with($constraintData, self::BIRTH_YEAR_PREFIX);
}
public function parse(string $constraintData): AgeConstraintResult
{
if (!$this->canParse($constraintData)) {
throw new InvalidArgumentException('Cannot parse constraint data: ' . $constraintData);
}
$yearData = substr($constraintData, strlen(self::BIRTH_YEAR_PREFIX));
// Parse range format "2007-2009"
if (str_contains($yearData, '-')) {
[$fromYear, $toYear] = explode('-', $yearData, 2);
return new AgeConstraintResult(
type: 'birth_year',
birthYearFrom: (int) trim($fromYear),
birthYearTo: (int) trim($toYear),
metadata: [
'range_type' => 'birth_year_range',
'original_format' => $yearData
],
rawData: $constraintData
);
}
// Parse single year format "2007"
$year = (int) trim($yearData);
return new AgeConstraintResult(
type: 'birth_year',
birthYearFrom: $year,
birthYearTo: $year,
metadata: [
'range_type' => 'birth_year_single',
'original_format' => $yearData
],
rawData: $constraintData
);
}
public function getConstraintType(): string
{
return 'birth_year';
}
}
```
**D. Create Age Constraint Parser Registry:**
```php
// src/BusProNet/XmlParser/AgeConstraint/AgeConstraintParserRegistry.php
class AgeConstraintParserRegistry
{
/** @var AgeConstraintParserInterface[] */
private array $parsers = [];
public function __construct()
{
// Register built-in parsers
$this->addParser(new BirthYearConstraintParser());
}
public function addParser(AgeConstraintParserInterface $parser): void
{
$this->parsers[] = $parser;
}
public function parseConstraints(string $constraintData): AgeConstraintResult
{
// Try multiple constraint types (semicolon-separated, e.g., 'JG:2007-2009;GL:5-8')
$constraints = array_map('trim', explode(';', $constraintData));
$results = [];
foreach ($constraints as $constraint) {
if (empty($constraint)) {
continue;
}
foreach ($this->parsers as $parser) {
if ($parser->canParse($constraint)) {
$results[] = $parser->parse($constraint);
break; // First matching parser wins
}
}
}
// Merge results if multiple constraints found
return $this->mergeConstraintResults($results, $constraintData);
}
private function mergeConstraintResults(array $results, string $rawData): AgeConstraintResult
{
if (empty($results)) {
return new AgeConstraintResult(type: 'unknown', rawData: $rawData);
}
if (count($results) === 1) {
return $results[0];
}
// Merge multiple constraint results
$type = 'mixed';
$ageFrom = null;
$ageTo = null;
$birthYearFrom = null;
$birthYearTo = null;
$metadata = ['merged_from' => []];
foreach ($results as $result) {
$ageFrom = $this->mergeMinValue($ageFrom, $result->ageFrom);
$ageTo = $this->mergeMaxValue($ageTo, $result->ageTo);
$birthYearFrom = $this->mergeMinValue($birthYearFrom, $result->birthYearFrom);
$birthYearTo = $this->mergeMaxValue($birthYearTo, $result->birthYearTo);
$metadata['merged_from'][] = $result->type;
}
return new AgeConstraintResult(
type: $type,
ageFrom: $ageFrom,
ageTo: $ageTo,
birthYearFrom: $birthYearFrom,
birthYearTo: $birthYearTo,
metadata: $metadata,
rawData: $rawData
);
}
private function mergeMinValue(?int $current, ?int $new): ?int
{
if (null === $current) return $new;
if (null === $new) return $current;
return max($current, $new); // Most restrictive minimum
}
private function mergeMaxValue(?int $current, ?int $new): ?int
{
if (null === $current) return $new;
if (null === $new) return $current;
return min($current, $new); // Most restrictive maximum
}
}
```
### 3. Enhance TravelParser with Extensible Constraint Parsing
**Add Age Constraint Parsing to Service Methods:**
```php
// Add to TravelParser class
private AgeConstraintParserRegistry $ageConstraintRegistry;
public function __construct()
{
$this->ageConstraintRegistry = new AgeConstraintParserRegistry();
// Future: inject via DI for custom parsers
}
// Update getAdditionalServices() method:
private function parseServiceAgeConstraints(Crawler $serviceNode, Service $service): void
{
// Parse absolute age constraints (altervon/alterbis)
$ageFrom = $this->getIntOrNullValue($serviceNode->filterXPath('.//altervon'));
$ageTo = $this->getIntOrNullValue($serviceNode->filterXPath('.//alterbis'));
// Parse extensible constraint data (hinweis_stamm -> ageConstraintData)
$constraintData = $this->getStringOrNullValue($serviceNode->filterXPath('.//hinweis_stamm'));
$constraintResult = null;
if (null !== $constraintData && !empty(trim($constraintData))) {
$constraintResult = $this->ageConstraintRegistry->parseConstraints($constraintData);
}
// Apply absolute age constraints
if (null !== $ageFrom || null !== $ageTo) {
$service->ageFrom = $ageFrom;
$service->ageTo = $ageTo;
if (null !== $constraintResult && !$constraintResult->isEmpty()) {
// Mixed constraints scenario
$service->ageConstraintType = 'mixed';
$service->birthYearFrom = $constraintResult->birthYearFrom;
$service->birthYearTo = $constraintResult->birthYearTo;
$service->ageConstraintMetadata = array_merge(
$constraintResult->metadata,
['has_absolute_age' => true, 'has_birth_year' => true]
);
} else {
$service->ageConstraintType = 'absolute_age';
}
} elseif (null !== $constraintResult && !$constraintResult->isEmpty()) {
// Only constraint data (birth year, etc.)
$service->ageConstraintType = $constraintResult->type;
$service->birthYearFrom = $constraintResult->birthYearFrom;
$service->birthYearTo = $constraintResult->birthYearTo;
$service->ageConstraintMetadata = $constraintResult->metadata;
}
// Always store raw data for debugging/future parsing
if (null !== $constraintData) {
$service->rawAgeConstraintData = $constraintData;
}
}
// Update getAdditionalServices() method:
public function getAdditionalServices(Crawler $node): array
{
$additionalServices = [];
$node->each(function (Crawler $serviceNode) use (&$additionalServices) {
$serviceId = (int) $serviceNode->attr('idbuspro');
$service = new Service();
$service->source = Constants::SOURCE_TRAVEL;
$service->category = Constants::CATEGORY_ADDITIONAL;
$service->id = $serviceId;
$service->subType = $serviceNode->attr('unterart');
$service->mandatory = $this->stringToBool($serviceNode->attr('pflicht'));
$service->dateFrom = $this->stringToDate($serviceNode->attr('termin'));
$service->dateTo = $this->stringToDate($serviceNode->attr('bis'));
$service->label = $this->getStringOrNullValue($serviceNode->filterXPath('.//text'));
$service->price = $this->stringToFloat($this->getStringOrNullValue($serviceNode->filterXPath('.//preis')));
$service->status = $this->getStringOrNullValue($serviceNode->filterXPath('.//status'));
// Parse age constraints
$this->parseServiceAgeConstraints($serviceNode, $service);
$additionalServices[$serviceId] = $service;
});
return $additionalServices;
}
```
### 4. Create Extensible Age Evaluation System
**A. Enhanced Age Evaluation Interface:**
```php
// src/Form/Service/Contract/AgeEvaluatorInterface.php
interface AgeEvaluatorInterface
{
public function canEvaluate(Service $service): bool;
public function isServiceAvailableForParticipant(Service $service, BookingDtoInterface $bookingDto, int $participantIndex): bool;
public function getConstraintDescription(Service $service): string;
}
```
**B. Create Service Age Evaluator:**
```php
// src/Form/Service/AgeEvaluator/ServiceAgeEvaluator.php
class ServiceAgeEvaluator implements AgeEvaluatorInterface
{
public function canEvaluate(Service $service): bool
{
return null !== $service->ageConstraintType;
}
public function isServiceAvailableForParticipant(Service $service, BookingDtoInterface $bookingDto, int $participantIndex): bool
{
$participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant || null === $participant->dateOfBirth) {
return false; // Cannot evaluate without birth date
}
return match($service->ageConstraintType) {
'absolute_age' => $this->evaluateAbsoluteAge($service, $participant->dateOfBirth),
'birth_year' => $this->evaluateBirthYear($service, $participant->dateOfBirth),
'mixed' => $this->evaluateAbsoluteAge($service, $participant->dateOfBirth)
&& $this->evaluateBirthYear($service, $participant->dateOfBirth),
default => true // No constraints or unknown type
};
}
private function evaluateAbsoluteAge(Service $service, \DateTimeImmutable $dateOfBirth): bool
{
$age = $this->calculateAge($dateOfBirth);
if (null !== $service->ageFrom && $age < $service->ageFrom) {
return false;
}
if (null !== $service->ageTo && $age > $service->ageTo) {
return false;
}
return true;
}
private function evaluateBirthYear(Service $service, \DateTimeImmutable $dateOfBirth): bool
{
$birthYear = (int) $dateOfBirth->format('Y');
if (null !== $service->birthYearFrom && $birthYear < $service->birthYearFrom) {
return false;
}
if (null !== $service->birthYearTo && $birthYear > $service->birthYearTo) {
return false;
}
return true;
}
private function calculateAge(\DateTimeImmutable $dateOfBirth): int
{
$today = new \DateTimeImmutable();
return (int) $dateOfBirth->diff($today)->y;
}
public function getConstraintDescription(Service $service): string
{
return match($service->ageConstraintType) {
'absolute_age' => $this->getAbsoluteAgeDescription($service),
'birth_year' => $this->getBirthYearDescription($service),
'mixed' => sprintf('%s and %s',
$this->getAbsoluteAgeDescription($service),
$this->getBirthYearDescription($service)),
default => 'No age restrictions'
};
}
private function getAbsoluteAgeDescription(Service $service): string
{
if (null !== $service->ageFrom && null !== $service->ageTo) {
return sprintf('Ages %d-%d', $service->ageFrom, $service->ageTo);
}
if (null !== $service->ageFrom) {
return sprintf('Age %d+', $service->ageFrom);
}
if (null !== $service->ageTo) {
return sprintf('Age up to %d', $service->ageTo);
}
return '';
}
private function getBirthYearDescription(Service $service): string
{
if (null !== $service->birthYearFrom && null !== $service->birthYearTo) {
if ($service->birthYearFrom === $service->birthYearTo) {
return sprintf('Born in %d', $service->birthYearFrom);
}
return sprintf('Born %d-%d', $service->birthYearFrom, $service->birthYearTo);
}
if (null !== $service->birthYearFrom) {
return sprintf('Born %d or later', $service->birthYearFrom);
}
if (null !== $service->birthYearTo) {
return sprintf('Born up to %d', $service->birthYearTo);
}
return '';
}
}
```
### 5. Enhanced Form Field Options Provider
**Update with Age-Aware Service Filtering:**
```php
// Add to ParticipantFieldOptionsProvider (simplified approach)
// ServiceAgeEvaluator is instantiated directly when needed
protected function registerFieldOptionProviders(): void
{
// Enhanced field providers with age-aware filtering
$this->fieldOptionProviders['courses'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
'label' => 'Kurse',
'multiple' => true,
'expanded' => true,
'required' => false,
'choices' => $this->filterServicesByAgeConstraints(
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_COURSES),
$bookingDto,
$participantIndex
),
'choice_label' => 'label',
];
// Similar updates for additionalServices, rentals, board, etc.
}
private function filterServicesByAgeConstraints(array $services, BookingDtoInterface $bookingDto, int $participantIndex): array
{
$participant = $bookingDto->getParticipant($participantIndex);
// If no birth date provided, return empty array (handled by DateOfBirthProvidedCondition)
if (null === $participant || null === $participant->dateOfBirth) {
return [];
}
return array_filter($services, function (Service $service) use ($bookingDto, $participantIndex) {
// No age constraints = available to all
$ageEvaluator = new ServiceAgeEvaluator();
if (!$ageEvaluator->canEvaluate($service)) {
return true;
}
return $ageEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex);
});
}
```
### 6. Future Extension Examples
**A. Example: Adding Grade Level Constraints (GL:5-8)**
```php
class GradeLevelConstraintParser implements AgeConstraintParserInterface
{
private const GRADE_PREFIX = 'GL:';
public function canParse(string $constraintData): bool
{
return str_starts_with($constraintData, self::GRADE_PREFIX);
}
public function parse(string $constraintData): AgeConstraintResult
{
$gradeData = substr($constraintData, strlen(self::GRADE_PREFIX));
if (str_contains($gradeData, '-')) {
[$fromGrade, $toGrade] = explode('-', $gradeData, 2);
return new AgeConstraintResult(
type: 'grade_level',
metadata: [
'grade_from' => (int) trim($fromGrade),
'grade_to' => (int) trim($toGrade),
'constraint_type' => 'grade_range'
],
rawData: $constraintData
);
}
// Single grade
return new AgeConstraintResult(
type: 'grade_level',
metadata: [
'grade' => (int) trim($gradeData),
'constraint_type' => 'grade_single'
],
rawData: $constraintData
);
}
public function getConstraintType(): string
{
return 'grade_level';
}
}
// Register in registry constructor:
$this->addParser(new GradeLevelConstraintParser());
```
**B. Example: Complex Mixed Constraints (JG:2007-2009;GL:5-8)**
- Registry automatically handles semicolon-separated constraints
- Merges results into mixed constraint type
- Evaluator can handle multiple constraint types
### 7. Add Helper Methods
**Add to AbstractParser:**
```php
protected function getIntOrNullValue(Crawler $node): ?int
{
$value = $this->getStringOrNullValue($node);
if (null === $value || '' === trim($value)) {
return null;
}
return (int) $value;
}
```
## Implementation Order
1. **Create extensible constraint parser system** (interfaces, registry, birth year parser)
2. **Extend Service model** with new age constraint properties
3. **Update TravelParser** with extensible constraint parsing
4. **Create age evaluator system** for service filtering
5. **Update field options provider** with age-aware filtering
6. **Add comprehensive tests** for parsing and evaluation
7. **Add helper methods** to parser base class
8. **Update documentation** with extensible patterns
## Key Benefits
### Technical Benefits
- **Fully extensible** - easy to add new constraint types (grade level, membership status, etc.)
- **Backward compatible** - existing absolute age constraints continue working
- **English naming** - all properties and methods use clear English names
- **Robust parsing** - handles malformed data gracefully
- **Debuggable** - stores raw constraint data for troubleshooting
- **Testable** - clear separation of parsing, evaluation, and filtering concerns
### Future Extensibility
- **Plugin architecture** - new constraint parsers can be added via DI
- **Mixed constraints** - supports multiple constraint types per service
- **Metadata storage** - extensible metadata for complex constraint types
- **Version resilient** - unknown constraint types don't break existing functionality
### Business Benefits
- **Accurate service filtering** - services only shown to eligible participants
- **Clear constraint communication** - descriptive messages for age restrictions
- **Flexible business rules** - supports complex eligibility scenarios
## Edge Cases Handled
- **Invalid constraint formats** - graceful handling with fallback to 'unknown' type
- **Mixed constraint scenarios** - services with both absolute age and birth year requirements
- **Empty/null constraint data** - treated as no constraints (available to all)
- **Future constraint types** - unknown parsers don't break existing functionality
- **Malformed date ranges** - validation and error handling in parsers
- **Single vs range values** - supports both `JG:2007` and `JG:2007-2009` formats
## Testing Strategy
### Unit Tests
- **Constraint parsing** for all supported formats and edge cases
- **Age evaluation** for different constraint types and participant scenarios
- **Service filtering** with mixed constraint types
- **Registry behavior** with multiple parsers and constraint merging
### Integration Tests
- **XML parsing** with real BPN export data containing age constraints
- **Form field generation** with age-restricted services
- **HTMX updates** when birth date changes affect service availability
- **End-to-end booking flow** with age-restricted services
## Related Completed Improvements
**✅ Form Processing System Enhancements** (complementary to age constraints):
- **Service Field HTMX Integration**: Fixed HTMX triggers for service fields (board, skipass, courses, etc.) to enable real-time updates for age-based field filtering
- **Field Handler Data Storage**: Updated all service field handlers to store complete Service objects instead of IDs, enabling access to age constraint data
- **Service Label Formatting**: Implemented smart service label formatting with pricing integration and quantity display
- **Pricing Integration**: Service selections now properly integrate with pricing calculations, supporting age-restricted service pricing
These improvements provide the foundation for implementing age constraint filtering once the XML parsing and model extensions described in this plan are completed.
This plan provides a robust, extensible foundation for handling current age constraints while being prepared for future constraint types that may emerge from the XML data.
+168
View File
@@ -0,0 +1,168 @@
# Documentation Updates - September 2, 2025
## Overview
This document summarizes the comprehensive documentation updates made to reflect the booking flow debugging and enhancements completed during the HTMX Service Selection Bug Investigation.
## Updated Documentation Files
### 1. AGE_BASED_FIELDS_PLAN.md
**Section Updated**: Service Selection Bug Fixes & HTMX Improvements (Lines 425-442)
**Key Changes**:
- **HTMX Trigger Issue**: Added detailed explanation of HTMX attribute placement fix
- Root cause: HTMX attributes on container elements instead of individual inputs
- Solution: Moved HTMX triggers to individual checkbox/radio inputs for expanded choice fields
- Impact: Real-time dynamic updates now work reliably for all service fields
- **Field Handler Data Issue**: Documented the Service object storage improvement
- Root cause: Field handlers storing service IDs instead of complete Service objects
- Solution: Updated all field handlers to retrieve and store complete Service entities
- Impact: Pricing calculator now has access to service price data
- **Form Submission Reset Issue**: Clarified the choice_value configuration fix
- Root cause: Inconsistent choice_value configuration between service types
- Solution: Added 'choice_value' => 'id' to all service field providers
- Result: Consistent form submission behavior across all service types
### 2. FORM_PROCESSING.md
**Section Updated**: Field Handlers Architecture (Lines 235-251)
**Key Additions**:
- **Data Storage Strategy**: Added explanation of why Service objects are stored instead of IDs
- Enables pricing calculations to access service price data
- Eliminates need for additional database lookups during price calculation
- Provides immediate access to all service metadata
**Section Updated**: HTMX Dynamic Updates (Lines 349-372)
**Key Additions**:
- **Service Field HTMX Integration**: Comprehensive explanation of expanded choice field HTMX handling
- Issue identification: Container-level HTMX attributes don't work for checkboxes/radios
- Solution implementation: Individual input-level HTMX triggers
- Code examples showing correct vs incorrect attribute placement
- Result: Real-time updates work reliably for all service selections
### 3. PRICING_DISPLAY_IMPLEMENTATION.md
**Status**: Completely rewritten to reflect completed implementation
**Major Updates**:
- **Implementation Status**: Changed from "In Progress" to "✅ Implementation completed successfully"
- **Service Label Formatting**: Updated to reflect actual implementation with smart zero-price handling
- Zero-priced services display without price suffix
- Priced services show with formatted German pricing
- Consistent quantity display (1x, 2x) for all services
- **Unified Summary Component**: Documented the single cohesive sticky sidebar approach
- Eliminated separate pricing summary template
- Integrated pricing into main booking summary
- Improved UX with unified layout
- **Service Layer Integration**: Updated with actual implemented methods
- `calculateServiceTotal()` method with bug fix documentation
- `getServiceGroups()` method with corrected key usage (groupTotal vs totalPrice)
- **Controller Integration**: Documented completed pricing integration
- Step 1 and Step 2 controller enhancements
- HTMX service field integration fixes
- **Technical Considerations**: Updated HTMX integration section
- Fixed service field triggers for expanded choice types
- Real-time pricing updates working reliably
- Improved reliability documentation
### 4. FIELD_STATE_SYSTEM.md
**Section Added**: Service Field HTMX Integration Improvements (Lines 181-216)
**New Content**:
- **Critical Fix Documentation**: Detailed explanation of expanded choice field HTMX handling
- **Issue Resolution**: Clear before/after comparison of HTMX attribute placement
- **Implementation Example**: Code snippets showing correct `attr` vs incorrect `row_attr` usage
- **Benefits Documentation**: List of improvements from the fix
- **Affected Field Types**: Comprehensive list of service fields that benefited from the fix
### 5. AGE_CONSTRAINTS_MODEL_EXTENSION_PLAN.md
**Section Added**: Related Completed Improvements (Lines 631-642)
**New Content**:
- **Complementary Enhancements**: Documented how the completed form processing improvements support future age constraints implementation
- **Service Field HTMX Integration**: Reference to completed fixes
- **Field Handler Data Storage**: How Service object storage supports age constraint data access
- **Service Label Formatting**: Integration with age-restricted service pricing
- **Foundation Documentation**: How these improvements prepare for age constraint filtering
## Summary of Issues Resolved
### 🐞 HTMX Service Selection Issues
- **Problem**: HTMX requests not triggered for service fields
- **Root Cause**: Incorrect HTMX attribute placement on containers
- **Solution**: Individual input-level HTMX triggers for expanded choice fields
- **Documentation**: Updated in FORM_PROCESSING.md, FIELD_STATE_SYSTEM.md, AGE_BASED_FIELDS_PLAN.md
### 🧠 Field Handler Logic
- **Problem**: Service IDs stored instead of Service objects
- **Impact**: Pricing calculator couldn't access service price data
- **Solution**: Updated all field handlers to store complete Service entities
- **Documentation**: Updated in FORM_PROCESSING.md, AGE_BASED_FIELDS_PLAN.md
### 💰 Pricing Calculation Bug
- **Problem**: Selected services not included in total calculation
- **Root Cause**: Incorrect array key usage in `calculateServiceTotal()`
- **Solution**: Fixed to use 'groupTotal' instead of 'totalPrice'
- **Documentation**: Detailed in PRICING_DISPLAY_IMPLEMENTATION.md
### 💡 UX & UI Enhancements
- **Improvement**: Unified booking summary with integrated pricing
- **Changes**: Eliminated separate pricing sidebar, improved layout
- **Service Labels**: Smart zero-price handling and consistent quantity display
- **Documentation**: Comprehensive updates in PRICING_DISPLAY_IMPLEMENTATION.md
### 🧼 Code Quality Fixes
- **Issue**: Linter warnings on dynamic property access
- **Solution**: Proper type annotations and parameter typing
- **Documentation**: Noted in implementation completion status
## Benefits Achieved
### Technical Benefits
- ✅ Reliable HTMX dynamic updates for all service fields
- ✅ Consistent data flow from form submission to pricing calculation
- ✅ Clean separation of concerns with Service object storage
- ✅ Improved code quality with proper type annotations
### UX Benefits
- ✅ Real-time pricing updates for all service selections
- ✅ Unified, sticky booking summary with clear pricing breakdown
- ✅ Smart service label formatting (no €0,00 for free services)
- ✅ Consistent quantity display across all services
### Documentation Benefits
- ✅ Accurate reflection of current system behavior
- ✅ Clear troubleshooting information for similar issues
- ✅ Complete implementation status tracking
- ✅ Foundation documentation for future enhancements
## Next Steps
### 🔧 Future Enhancements (Optional)
- Add contextual tooltips to service options
- Implement Stimulus controllers for smooth field transitions
- Add placeholder cards for age-restricted fields
- Enhance pricing with discounts, tax breakdowns, or multi-currency support
### 📚 Documentation Maintenance
- Ensure future updates to service field logic are reflected in updated docs
- Maintain accuracy of implementation status as system evolves
- Update troubleshooting sections based on any new issues discovered
---
**Documentation Update Date**: September 2, 2025
**Updated By**: Claude Code Assistant
**Status**: ✅ All critical documentation updates completed
**Impact**: Documentation now accurately reflects current system behavior and resolved issues
+35
View File
@@ -180,6 +180,41 @@ The system supports real-time field state updates:
3. HTMX can update field states without full page refresh
4. Dependency tracking ensures only affected fields are updated
### Service Field HTMX Integration Improvements
**Critical Fix for Expanded Choice Fields**: Service fields (board, skipass, courses, etc.) use expanded choice types (checkboxes/radios) which required special HTMX trigger handling:
**Issue Resolved**: HTMX attributes were originally placed on container elements (`row_attr`) which don't capture individual input changes for expanded choice fields.
**Solution Implemented**: HTMX triggers must be placed directly on each individual checkbox/radio input via the `attr` configuration:
```php
// Correct implementation for service fields
'attr' => [
'hx-post' => $this->urlGenerator->generate('booking_create_step_2_refresh'),
'hx-target' => '#booking-summary',
'hx-trigger' => 'change',
],
// Incorrect approach (doesn't work for expanded choices)
// 'row_attr' => ['hx-post' => '...']
```
**Benefits of the Fix**:
- Real-time updates now work reliably for all service field selections
- Service pricing calculations update immediately when selections change
- Booking summary reflects service changes without manual form submission
- Consistent HTMX behavior across all form field types
**Affected Field Types**:
- Board/meal selection fields
- Ski pass selection fields
- Course selection fields
- Additional services selection fields
- Rental equipment selection fields
This improvement ensures that the conditional field state system works seamlessly with HTMX for all field types, providing users with immediate feedback on their selections.
## Extending the System
### Custom Conditions
+518
View File
@@ -0,0 +1,518 @@
# Form Processing System Documentation
## Overview
The MyEP Next Booking application implements a sophisticated form processing system designed to handle complex multi-step booking workflows with dynamic participant forms and conditional field logic. The system integrates with the Bus Pro Net (BPN) XML API for travel management and provides real-time field updates through HTMX integration.
## Architecture Components
### 1. Multi-Step Booking Flow
The booking process consists of three main steps:
1. **Step 1 (`CreateStep1Controller`)**: Room selection with quantities and dates
2. **Step 2 (`CreateStep2Controller`)**: Participant details with conditional fields
3. **Step 3**: Final confirmation and submission to BPN API
### 2. Data Transfer Objects (DTOs)
#### BookingCreateDto (`src/Form/Model/BookingCreateDto.php`)
- Main container for the entire booking process
- Contains travel data, hotel ID, room selections, and participants
- Implements `BookingDtoInterface` for polymorphic handling
```php
class BookingCreateDto implements BookingDtoInterface
{
public int $currentStep = 1;
public array $roomSelections = []; // RoomSelectionDto[]
public array $participants = []; // ParticipantDto[]
public Travel $travel;
public int $hotelId;
}
```
#### ParticipantDto (`src/Form/Model/ParticipantDto.php`)
- Individual participant data container
- Includes personal data, body dimensions, and service selections
- Custom validation for body dimensions when rental services are selected
```php
class ParticipantDto
{
// Personal data
public ?string $firstName = null;
public ?string $lastName = null;
public ?\DateTimeImmutable $dateOfBirth = null;
public ?string $email = null;
// Body dimensions (conditional)
public ?string $height = null;
public ?string $weight = null;
public ?string $shoeSize = null;
// Service selections
public ?int $assignedRoomId = null;
public array $courses = [];
public array $additionalServices = [];
public array $rentals = [];
// ... other service arrays
}
```
### 3. Dynamic Field Options System
#### ParticipantFieldOptionsProvider (`src/Form/Service/ParticipantFieldOptionsProvider.php`)
Central registry for dynamic field configurations using a provider pattern with lazy evaluation.
**Registered Field Providers:**
- **`assignedRoomId`**: Context-aware room selection
- Shows only available rooms for the participant
- Excludes rooms already assigned to other participants
- Respects room capacity and booking constraints
- **`courses`**: Available courses from travel data
- Multiple selection with checkboxes
- Populated from `travel.additionalServices` with `TOKEN_COURSES` subtype
- **`additionalServices`**: Additional services with mandatory logic
- Mandatory services are pre-selected and readonly
- Choice attributes include visual indicators for mandatory items
- **`board`**: Board/meal options
- Multiple selection from travel data
- Populated from `TOKEN_BOARD` subtype services
- **`rentals`**: Rental equipment options
- Date-filtered rental services
- Triggers body dimension requirements when selected
- Populated from `TOKEN_RENTALS` subtype services
**Provider Pattern Implementation:**
```php
protected function registerFieldOptionProviders(): void
{
$this->fieldOptionProviders['fieldName'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
'label' => 'Field Label',
'choices' => $this->generateChoicesFor($bookingDto, $participantIndex),
// ... other Symfony form options
];
}
```
### 4. Conditional Field State System
#### Field State Providers
**CreateFieldStateProvider (`src/Form/Service/CreateFieldStateProvider.php`)**
- Manages field states for the booking creation workflow
- Currently implements body dimension requirements for rental services
**Field State Types:**
- `readonly`: Field is visible but not editable
- `disabled`: Field interaction is disabled
- `required`: Field becomes mandatory
- `hidden`: Field is not displayed
**Current Implementation:**
```php
protected function registerFieldStateConditions(): void
{
$rentalCondition = new RentalSelectionCondition();
// Body dimensions become required when rentals are selected
$this->fieldStateConditions['height'] = ['required' => $rentalCondition];
$this->fieldStateConditions['weight'] = ['required' => $rentalCondition];
$this->fieldStateConditions['shoeSize'] = ['required' => $rentalCondition];
}
```
#### Field Conditions
**Available Condition Types:**
1. **`RentalSelectionCondition`**: Evaluates rental service selections
2. **`AgeRangeCondition`**: Age-based conditions
3. **`FieldValueCondition`**: Field interdependency conditions (equals, in, empty, etc.)
4. **`CompositeCondition`**: Complex logic with AND/OR/NOT operators
5. **`ApplicantCondition`**: Applicant-specific conditions
6. **`MutabilityCondition`**: Mutability-based conditions
**Condition Interface:**
```php
interface FieldConditionInterface
{
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool;
public function getDependentFields(): array;
public function getDescription(): string;
}
```
### 5. Participant Field Processing
#### ParticipantFieldHandlerRegistry (`src/Form/Service/ParticipantFieldHandlerRegistry.php`)
Manages field handlers in dependency order using topological sorting to ensure proper processing sequence.
**Key Features:**
- Dependency resolution using Kahn's algorithm
- Circular dependency detection
- Support for both simple and complex handler instantiation
- Batch processing of all participants
**Processing Flow:**
```php
public function processFieldsAndSync(array $submittedData, BookingDtoInterface $bookingDto): array
{
// Process all field handlers to clean the DTO
$this->processFields($submittedData, $bookingDto);
// Synchronize submitted data with the cleaned DTO state
return $this->syncSubmittedDataWithDto($submittedData, $bookingDto);
}
public function processFields(array $submittedData, BookingDtoInterface $bookingDto): void
{
foreach ($submittedData['participants'] as $participantIndex => $participantData) {
foreach ($this->getSortedHandlers() as $handlerName) {
$handler = $this->handlers[$handlerName];
if ($handler->shouldProcess($participantData, $participantIndex)) {
$handler->processField($participantData, $bookingDto, $participantIndex);
}
}
}
}
```
**Data Synchronization:**
The registry includes a critical synchronization feature to maintain consistency between DTO state and form submitted data:
```php
private function syncSubmittedDataWithDto(array $submittedData, BookingDtoInterface $bookingDto): array
{
// Updates submitted data to match cleaned DTO state
// Converts DTO objects back to form-expected formats
// Ensures form rendering shows valid selections only
}
```
This prevents validation errors when field handlers remove invalid selections from DTOs but the original submitted data still contains those invalid choices.
#### Field Handlers
**AbstractParticipantFieldHandler (`src/Form/Service/Abstract/AbstractParticipantFieldHandler.php`)**
Base class providing common functionality:
- Default dependency resolution
- Safe participant data access
- Field value extraction utilities
- Value normalization methods
**Concrete Implementations:**
1. **`ParticipantDateOfBirthFieldHandler`**: Processes date of birth field
- Converts submitted date strings to `DateTimeImmutable` objects
- Normalizes various date formats
- No dependencies (foundation field for age-based logic)
2. **`ParticipantAssignedRoomFieldHandler`**: Processes room assignments
- Converts form strings to integers
- Handles empty selections as null values
- No dependencies (base field)
3. **Service-Based Handlers** (age-aware filtering):
- **`ParticipantAdditionalServicesFieldHandler`**: Additional services filtering
- **`ParticipantCoursesFieldHandler`**: Course selections filtering
- **`ParticipantBoardFieldHandler`**: Board/meal options filtering
- **`ParticipantRentalsFieldHandler`**: Rental equipment filtering
All service handlers share these characteristics:
- Depend on `dateOfBirth` field (processed first)
- Filter selections based on age constraints
- Instantiate `ServiceAgeEvaluator` directly when needed
- Remove invalid selections to prevent form validation errors
- **Store complete Service objects** in ParticipantDto (not just IDs) for pricing calculations
**Key Architecture Decisions**:
1. **Service Evaluator Instantiation**: Service handlers instantiate `ServiceAgeEvaluator` directly rather than using dependency injection because:
- `ServiceAgeEvaluator` has no dependencies itself
- Handlers are registered as simple class names in service configuration
- Avoids complex service wiring for lightweight utility classes
- Maintains clean separation between handlers and evaluator logic
2. **Data Storage Strategy**: Service handlers store complete Service objects in ParticipantDto rather than just IDs because:
- Pricing calculations require access to service price data
- Eliminates need for additional database lookups during price calculation
- Provides immediate access to all service metadata (labels, descriptions, etc.)
- Maintains data consistency throughout the booking flow
**Handler Interface:**
```php
interface ParticipantFieldHandlerInterface
{
public function getFieldName(): string;
public function getDependencies(): array;
public function shouldProcess(array $submittedData, int $participantIndex): bool;
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void;
}
```
### 6. Form Type Integration
#### BookingCreateStep2Type (`src/Form/BookingCreateStep2Type.php`)
Main form type for participant data collection with event-driven processing.
**Form Events:**
- **`PRE_SET_DATA`**: Initial form setup with participants collection
- **`PRE_SUBMIT`**: Dynamic field updates and DTO synchronization
**Event Processing:**
```php
public function onPreSubmit(FormEvent $event): void
{
$form = $event->getForm();
$submittedData = $event->getData();
$bookingDto = $form->getData();
// Process field handlers and synchronize submitted data with cleaned DTO state
$cleanedSubmittedData = $this->participantFieldHandlerRegistry->processFieldsAndSync($submittedData, $bookingDto);
$event->setData($cleanedSubmittedData);
// Rebuild the 'participants' field with the updated DTO
$this->addParticipantsField($form);
}
```
#### BookingCreateParticipantType (`src/Form/BookingCreateParticipantType.php`)
Individual participant form with dynamic field management.
**Static Fields:**
- Personal data (name, email, birth date, etc.)
- Body dimensions (embedded `BodyDimensionsType`)
- Contact information
**Dynamic Fields:**
- Room assignment (`assignedRoomId`)
- Service selections (courses, additional services, board, rentals)
**Dynamic State Application:**
```php
private function applyFieldStates(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): void
{
$allFieldStates = $this->fieldStateProvider->getAllFieldStates($bookingDto, $participantIndex, $formData);
foreach ($allFieldStates as $fieldName => $fieldState) {
// Apply state modifications to form fields
// Handle nested body dimension fields specially
}
}
```
## Form Processing Pipeline
### 1. Initial Form Rendering
1. **Controller** creates `BookingCreateDto` with travel data
2. **Step2Type** `PRE_SET_DATA` event fires:
- Adds participants collection field
- Each participant triggers `BookingCreateParticipantType` creation
3. **ParticipantType** `PRE_SET_DATA` event fires:
- Adds dynamic fields using `FieldOptionsProvider`
- Applies initial field states using `FieldStateProvider`
4. **Form rendered** with proper field options and states
### 2. Form Submission Processing
1. **Form submission** received by controller
2. **Step2Type** `PRE_SUBMIT` event fires:
- `ParticipantFieldHandlerRegistry` processes all submitted data to update DTOs
- Registry synchronizes submitted data with cleaned DTO state
- Event data updated with cleaned submitted data
- Form rebuilt with updated DTO state
3. **ParticipantType** `PRE_SUBMIT` event fires:
- Field states recalculated based on submitted data
- Form fields updated with new states
4. **Validation** runs on updated DTO with cleaned data
5. **Controller** handles successful submission or re-renders with errors
### 3. HTMX Dynamic Updates
For real-time field updates without full form submission:
1. **HTMX request** sent with partial form data
2. **Same pipeline** executes as form submission
3. **Partial response** returned with updated field states
4. **Frontend** updates only changed form sections
**Service Field HTMX Integration:**
Service fields (board, skipass, courses, etc.) use expanded choice types (checkboxes/radios) which require special HTMX trigger handling:
- **Issue**: HTMX attributes on container elements don't capture individual input changes
- **Solution**: HTMX triggers must be placed on each individual checkbox/radio input
- **Implementation**: Field handlers ensure `hx-post`, `hx-target`, and `hx-trigger` attributes are applied to each choice input
- **Result**: Real-time updates work reliably for all service selections
**HTMX Attribute Placement:**
```php
// Correct: Individual input triggers
'attr' => [
'hx-post' => $this->urlGenerator->generate('booking_create_step_2_refresh'),
'hx-target' => '#booking-summary',
'hx-trigger' => 'change',
],
// Incorrect: Container-level triggers (doesn't work for expanded choices)
// 'row_attr' => ['hx-post' => '...']
```
## Validation System
### DTO-Level Validation
**ParticipantDto Validation:**
- Symfony validation constraints on properties
- Custom callback validation for body dimensions when rentals selected
```php
#[Assert\Callback('validateBodyDimensionsForRentals', groups: ['booking_create_step_2'])]
public function validateBodyDimensionsForRentals(ExecutionContextInterface $context): void
{
if (!empty($this->rentals)) {
// Validate height, weight, shoeSize are provided
}
}
```
### Form-Level Validation
**Validation Groups:**
- `booking_create_step_2`: Step 2 specific validations
- `booking_edit`: Edit workflow validations
## Extension Points
### Adding New Dynamic Fields
1. **Register field options** in `ParticipantFieldOptionsProvider`:
```php
$this->fieldOptionProviders['newField'] = fn($bookingDto, $participantIndex) => [
'label' => 'New Field Label',
'choices' => $this->generateChoicesFor($bookingDto, $participantIndex),
];
```
2. **Add to dynamic fields list** in `BookingCreateParticipantType`:
```php
$dynamicFields = ['assignedRoomId', 'courses', 'newField']; // Add 'newField'
```
### Adding New Field Conditions
1. **Implement condition class**:
```php
class NewCondition implements FieldConditionInterface
{
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
{
// Condition logic
}
}
```
2. **Register in field state provider**:
```php
$this->fieldStateConditions['fieldName'] = [
'required' => new NewCondition(),
];
```
### Adding New Field Handlers
1. **Implement handler class**:
```php
class NewFieldHandler extends AbstractParticipantFieldHandler
{
public function getFieldName(): string { return 'newField'; }
public function getDependencies(): array { return ['dependentField']; }
public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void
{
// Processing logic
}
}
```
2. **Register in service configuration** (services.yaml or through DI)
## Integration with BPN API
The form system is designed to prepare data for submission to the Bus Pro Net XML API:
1. **Field handlers** transform form data into BPN-compatible format
2. **Service selections** map to BPN service IDs
3. **Room assignments** align with BPN room availability
4. **Validation rules** ensure data meets BPN requirements
## Performance Considerations
### Optimization Strategies
1. **Lazy evaluation** in field option providers
2. **Caching** in field state providers
3. **Dependency sorting** cached until handlers change
4. **Minimal form rebuilding** only when necessary
### Memory Management
- DTOs use typed properties to minimize memory footprint
- Field handlers process data in-place where possible
- Form events only rebuild changed portions
## Security Considerations
### XSS Protection
- Custom `XssCleanTransformer` applied to text inputs
- `clean_xss: true` option on relevant form fields
### Data Validation
- Strict type declarations throughout
- Yoda conditions for safety
- Explicit validation constraints on all user inputs
## Testing Strategy
### Unit Testing Focus Areas
1. **Field option providers** with various travel data scenarios
2. **Field conditions** with different participant states
3. **Field handlers** with edge cases and dependencies
4. **Validation logic** for body dimensions and rental services
### Integration Testing
1. **Form submission workflows** end-to-end
2. **HTMX dynamic updates** with state changes
3. **Multi-participant scenarios** with interdependencies
## Future Enhancements
### Planned Features
1. **Step 3 implementation** for booking confirmation
2. **Additional field conditions** for complex business rules
3. **Enhanced validation** for service compatibility
4. **Performance optimizations** for large participant counts
### Architectural Improvements
1. **Event system** for field state change notifications
2. **Caching layer** for expensive field option calculations
3. **Async processing** for complex form submissions
4. **Enhanced error handling** with user-friendly messages
+274
View File
@@ -0,0 +1,274 @@
# Pricing Display Implementation Plan
## Overview
Implement comprehensive pricing display functionality that shows costs both inline in form options and in a detailed sidebar summary with real-time updates via HTMX.
## Goals
1. **Inline pricing** in form options (rooms, services) so users see costs while selecting
2. **Sidebar pricing summary** with itemized breakdown and grand total
3. **Real-time updates** via existing HTMX integration
4. **Professional UX** with clear pricing integrated into the booking flow
## Implementation Strategy
### 1. Core Pricing Service (`src/Service/BookingPriceCalculatorService.php`)
**Purpose**: Central service for all pricing calculations
**Key Methods**:
- `calculateRoomPricing(BookingCreateDto $bookingDto): array`
- `calculateServicePricing(BookingCreateDto $bookingDto): array`
- `calculateGrandTotal(BookingCreateDto $bookingDto): float`
- `getPricingBreakdown(BookingCreateDto $bookingDto): array`
**Room Pricing Logic**:
```php
// For each selected room: quantity × room.price
foreach ($bookingDto->getSelectedRooms() as $roomSelection) {
$room = $bookingDto->travel->rooms[$roomSelection->roomId];
$totalPrice = $roomSelection->quantity * $room->price;
}
```
**Service Pricing Logic**:
```php
// For each participant's selected services
foreach ($bookingDto->getParticipants() as $participant) {
// Handle different service types (single vs multiple selection)
$serviceTotal += $participant->skiPass?->price ?? 0;
$serviceTotal += array_sum(array_map(fn($s) => $s->price, $participant->courses));
// etc.
}
```
### 2. Enhanced Form Field Options with Pricing ✅ COMPLETED
#### A. Room Selection Forms ✅ COMPLETED
**File**: `src/Form/RoomSelectType.php`
**Enhancement**: Choice labels include pricing information
```php
'choice_label' => function (Room $room) {
$priceText = $room->price ? sprintf(' (€%.2f pro Nacht)', $room->price) : '';
return $room->label . $priceText;
}
```
#### B. Service Selection Forms ✅ COMPLETED
**File**: `src/Form/Service/ParticipantFieldOptionsProvider.php`
**Enhancement**: Service labels include pricing via `formatServiceLabelWithPrice()` method
- Zero-priced services display without price suffix (e.g., "Vollpension" instead of "Vollpension (€0,00)")
- Priced services show with formatted price (e.g., "Halbpension (€45,00)")
- All services consistently show quantity prefix (e.g., "1x", "2x")
**Service Label Formatting Logic**:
```php
private function formatServiceLabelWithPrice(Service $service): string
{
$label = $service->label;
// Add price only if service has a cost
if ($service->price > 0) {
$label .= sprintf(' (€%.2f)', $service->price);
}
return $label;
}
```
**Affected Service Types**:
- Courses: `"Skikurs Anfänger (€25,00)"`
- Additional Services: `"Versicherung (€15,00)"`
- Ski Pass: `"5-Tage Skipass (€120,00)"`
- Rentals: `"Ski-Set (€30,00)"`
- Board: `"Halbpension (€45,00)"` or `"Vollpension"` (if €0,00)
### 3. Enhanced Unified Booking Summary ✅ COMPLETED
#### A. Unified Summary Component ✅ COMPLETED
**File**: `templates/booking/_summary.html.twig`
The pricing summary has been integrated into the main booking summary, creating a single cohesive sticky sidebar that displays:
**Travel Information Section**:
- Travel details (destination, dates, duration)
- Room selections with quantities
- Participant count summary
**Pricing Summary Section** (integrated):
- Room pricing breakdown with quantities (e.g., "2x Doppelzimmer")
- Service selections by participant with pricing
- Grand total calculation
**Key UX Improvements**:
- Single sticky summary box (eliminated separate pricing sidebar)
- Clean, unified layout with consistent typography
- Service labels show quantity and pricing appropriately
- Zero-priced services display without price suffix
- Real-time updates via HTMX for all pricing changes
#### B. Deprecated Separate Pricing Template ✅ COMPLETED
**File**: `templates/booking/_pricing_summary.html.twig` - REMOVED
The separate pricing summary template was removed in favor of the integrated approach within the main summary template for better UX.
### 4. Service Layer Integration ✅ COMPLETED
#### A. Enhanced BookingService ✅ COMPLETED
**File**: `src/Service/BookingService.php`
**Implemented Pricing Methods**:
```php
public function calculateServiceTotal(BookingCreateDto $bookingDto): float
{
// Groups services by type and calculates total pricing
$serviceGroups = $this->getServiceGroups($bookingDto);
return array_sum(array_column($serviceGroups, 'groupTotal'));
}
private function getServiceGroups(BookingCreateDto $bookingDto): array
{
// Groups selected services and calculates totals for pricing display
// Fixed bug: Uses 'groupTotal' instead of incorrect 'totalPrice' key
}
```
**Key Bug Fix**: The `calculateServiceTotal()` method was corrected to use `groupTotal` instead of `totalPrice` from the grouped array structure, ensuring selected services are properly included in the grand total calculation.
### 5. Controller Integration ✅ COMPLETED
#### A. Step 1 Controller Updates ✅ COMPLETED
**File**: `src/Controller/Booking/CreateStep1Controller.php`
**Enhancement**: Integrated pricing calculations into existing summary methods
```php
// In index() method - pricing data included in summary
$summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto);
// Pricing calculated and passed to template
// In roomSummary() method - HTMX endpoint includes pricing updates
$summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto);
return $this->render('booking/_summary.html.twig', [
// existing data with integrated pricing
]);
```
#### B. Step 2 Controller Updates ✅ COMPLETED
**File**: `src/Controller/Booking/CreateStep2Controller.php`
**Enhancement**: Service pricing calculations integrated into form refresh
```php
// In refresh() method - service selections update pricing in real-time
$summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto);
// Service total calculation includes selected service pricing
```
#### C. HTMX Service Field Integration ✅ COMPLETED
**Critical Fix**: Service field HTMX triggers were moved from container elements to individual form inputs (checkboxes/radios) to ensure real-time updates work properly for expanded choice fields.
### 6. Error Handling & Edge Cases ✅ COMPLETED
**Price Data Handling** ✅ IMPLEMENTED:
- Zero prices handled gracefully (services show without price suffix)
- All prices rounded to 2 decimal places
- German number formatting implemented (comma as decimal separator)
- Null price handling via conditional display logic
**Service Quantity Logic** ✅ IMPLEMENTED:
- Single services (skiPass): price × 1 per participant
- Multiple services (courses, rentals): sum of all selected service prices per participant
- Service quantity consistently displayed (1x, 2x) in labels
- Field handlers store complete Service objects (not just IDs) for pricing access
**Field Handler Data Fix** ✅ COMPLETED:
- All service field handlers updated to store Service objects instead of service IDs
- Enables pricing calculator to access service price data
- Resolves issue where selected services were ignored in total calculations
## Implementation Progress
### ✅ Completed
- [x] Created implementation plan documentation
- [x] Enhanced BookingService with pricing calculation methods (`calculateServiceTotal`, `getServiceGroups`)
- [x] Updated ParticipantFieldOptionsProvider to add prices to service labels with smart formatting
- [x] Updated RoomSelectType to add prices to room labels
- [x] Integrated pricing into main summary template (unified approach)
- [x] Updated controllers to include pricing data in summary calculations
- [x] Fixed HTMX integration for service field real-time updates
- [x] Fixed field handler data storage (Service objects vs IDs)
- [x] Fixed pricing calculation bug (groupTotal vs totalPrice key)
- [x] Implemented smart service label formatting (zero-price handling)
- [x] Added consistent quantity display for all services
- [x] Completed comprehensive testing of pricing display and HTMX integration
### ✅ Additional Improvements Completed
- [x] Unified booking summary layout (eliminated separate pricing sidebar)
- [x] Enhanced UX with sticky summary positioning
- [x] Fixed service field HTMX triggers for expanded choice types
- [x] Resolved linter warnings with proper type annotations
- [x] Streamlined service label formatting logic
## Technical Considerations
### Performance
- All pricing calculations done in-memory (no database queries)
- Calculations triggered only on form changes via HTMX
- Efficient array operations for service aggregation
### HTMX Integration ✅ COMPLETED
- Uses existing `#booking-summary` target for seamless updates
- **Fixed service field triggers**: HTMX attributes moved to individual form inputs for expanded choice fields
- Real-time pricing updates work reliably for all service selections
- Maintains current real-time update behavior with improved reliability
### Styling
- CSS classes for pricing components:
- `.pricing-summary` - Overall container
- `.pricing-section` - Room/service sections
- `.pricing-item` - Individual line items
- `.pricing-total` - Grand total display
### Accessibility
- Proper semantic HTML structure
- ARIA labels for pricing information
- Screen reader friendly number formatting
## Testing Strategy
### Unit Tests
- Test pricing calculations with various room/service combinations
- Test edge cases (null prices, zero quantities)
- Test German number formatting
### Integration Tests
- Test HTMX updates with pricing changes
- Test form submission with pricing data
- Test step navigation with pricing persistence
### Manual Testing Scenarios
1. Select rooms and verify inline pricing appears
2. Change room quantities and verify total updates
3. Add/remove services and verify pricing updates
4. Navigate between steps and verify pricing persistence
5. Test with services that have null/zero prices
## Future Enhancements
### Potential Additions
- **Discounts/Promotions**: Add discount calculation logic
- **Currency Selection**: Support multiple currencies
- **Price History**: Track pricing changes during booking session
- **Export Pricing**: Add pricing breakdown to booking confirmations
- **Tax Calculations**: Add VAT/tax breakdown if needed
### Performance Optimizations
- Cache frequently calculated pricing data
- Optimize service aggregation algorithms
- Consider lazy loading for complex pricing scenarios
---
**Last Updated**: 2025-09-02
**Status**: ✅ Implementation completed successfully
**Completed Features**: Inline pricing, unified summary, HTMX integration, service field fixes, and comprehensive UX improvements