# Learnings — edit-page-restructure ## 2026-03-29 — Session start ### Code Conventions - Migrations: anonymous class `return new class extends Migration {`; methods `up(): void`, `down(): void` - Models: `$fillable` array; `casts()` method (not `$casts` property); promoted properties in constructor - SoftDeletes: used on Song and Slide models; `whereNull('deleted_at')` in manual queries - PHP string concat: no spaces around `.` operator - Return types always present; union types for multiple returns - Relationships: typed return types (`HasMany`, `BelongsTo`) - Error messages: German Du-form; flash `->with('success', '...')`; JSON `'message'` key ### Codebase Facts - `service_songs` table stores ONLY songs from CTS agenda (via `getSongs()`) - `EventAgendaItem.type` actual values: UNKNOWN — Task 5 will discover - `EventAgendaItem.getItems()` returns ALL agenda items (not just songs) - `isBeforeEvent=true` items should be excluded from visible agenda - Settings stored as plain strings in `settings` table (key-value) - Settings controller uses `MACRO_KEYS` constant array for validation - SlideUploader sends `type`, `service_id`, `expire_date` in FormData - PlaylistExportService builds: info slides → matched songs → moderation → sermon (OLD ORDER) - Backward compat needed: old services without agenda items fall back to block-based export ### Test Patterns - Pest v4 function-style: `test('description', function() { ... })` - DB reset: `use RefreshDatabase;` in class-based, or implicit in Pest - Inertia assertions: `$response->assertInertia(fn ($page) => $page->component('...')->has('...'))` - Mocked API: injectable closures in ChurchToolsService constructor - Storage: `Storage::fake('public')` in beforeEach for file tests - Carbon: `Carbon::setTestNow(...)` for deterministic dates ## 2026-03-29 — ServiceAgendaItem DB schema ### Created - Migration: `2026_03_29_100001_create_service_agenda_items_table.php` — contains all EventAgendaItem fields from CTS - Migration: `2026_03_29_100002_add_service_agenda_item_id_to_slides_table.php` — links slides to agenda items - Model: `ServiceAgendaItem` with `scopeVisible()`, relationships (service, serviceSong, slides) - Factory: `ServiceAgendaItemFactory` with `withSong()` and `nonSong()` states - Tests: `ServiceAgendaItemTest` — 14 tests covering schema, relationships, cascades, scopes ### Key Fields - `cts_agenda_item_id` (nullable, indexed): CTS API item ID for syncing - `position` (string): CTS agenda item position (e.g. "1", "2", "1.1") - `type` (string): CTS type value ("Song", "Default", "Header", etc.) - `is_before_event` (boolean): filters with `scopeVisible()` scope - `responsible` (json → array cast): responsible persons - `service_song_id` (nullable FK): links agenda item to song (nullOnDelete) - `sort_order` (unsignedInt): unique per service with `unique(['service_id', 'sort_order'])` ### Relationships - `service()` BelongsTo Service (cascadeOnDelete) - `serviceSong()` BelongsTo ServiceSong (nullable, nullOnDelete) - `slides()` HasMany Slide (new FK added to slides table) ### Test Patterns - Factory states: `->withSong(song)` for song items, `->nonSong()` for non-song - Scope: `ServiceAgendaItem::visible()` filters `is_before_event=false` - Migrations now always use anonymous class `return new class extends Migration {}` - Unique constraint throws `QueryException` when violated (tested) ### Slide Model Update - Added `service_agenda_item_id` to fillable - Added `serviceAgendaItem()` BelongsTo relationship (nullOnDelete) ## 2026-03-29 — AgendaMatcherService ### Implementation - Created `AgendaMatcherService` with 4 public methods for glob-style wildcard matching - Uses PHP's native `fnmatch($pattern, $title, FNM_CASEFOLD)` for * wildcard support and case-insensitive matching - Key methods: - `matches(string $itemTitle, string $pattern): bool` — single pattern match - `matchesAny(string $itemTitle, array $patterns): bool` — match against array - `findFirstMatch(array $agendaItems, string $patterns): ?ServiceAgendaItem` — comma-separated patterns, returns first match or null - `filterBetween(array $items, ?string $startPattern, ?string $endPattern): array` — filters items between boundaries (boundaries excluded) ### Test Coverage - 17 tests in Pest function-style covering all methods - Patterns tested: exact match, suffix wildcard, prefix wildcard, both sides, case-insensitive, no-match - `filterBetween` edge cases: both boundaries, start-only, neither, no-matching-start, empty arrays - Pattern parsing: comma-separated patterns with whitespace trimming - All tests pass; Pint clean ### Code Patterns - Service class structure matches `SongMatchingService` (constructor, public methods, no state) - Public method documentation via docstrings (necessary for API clarity) - Test organization via section headers (necessary for readability) - No regex used — just `fnmatch()` for simplicity and correctness