# Learnings — keyvisual-background-nametag

> Append with `## [TIMESTAMP] Task: {task-id}\n{content}`. NEVER overwrite.

## [2026-05-30] Task: T1 — Parser background-layer
- Parser repo is at `/Users/thorsten/AI/propresenter` (NOT `propresenter-work/php` as plan stated).
- composer.json uses VCS remote `https://git.stadtmission-butzbach.de/public/propresenter-php.git` (NOT a path repo).
- For development: copy changed parser files into `vendor/propresenter/parser/src/` in the app. All future parser changes must also be synced: `cp /Users/thorsten/AI/propresenter/src/CHANGED.php /Users/thorsten/AI/pp-planer/vendor/propresenter/parser/src/CHANGED.php`
- `slideData['background']` contract: `['path' => string, 'format' => 'JPG'|'PNG', 'width' => int, 'height' => int, 'bundleRelative' => bool(opt)]`
- `slideData['imageOnly'] = true` → skips text element entirely.
- Background action is appended BEFORE foreground media in actions array.
- New Slide accessors: `hasBackgroundMedia()`, `getBackgroundMediaUrl()`, `getBackgroundMediaFormat()`
- Parser commit: `582ef85 feat(parser): background-layer + image-only slide support` in `/Users/thorsten/AI/propresenter` repo.
- All 372 parser tests green + 503 app tests green after vendor sync.
- Evidence: `.sisyphus/evidence/task-1-background-layer.txt`, `task-1-image-only.txt`

## Initial Setup
- Parser pkg at /Users/thorsten/AI/propresenter-work/php (PHPUnit 11, #[Test], ref/ fixtures)
- App at /Users/thorsten/AI/pp-planer (Pest v4 + PHPUnit, in-memory sqlite, ddev commands)
- FileConversionService uses Intervention v3 GD, contain(1920,1080,'000000','center')
- COVER new path: cover(1920,1080) center crop. Thumbnails 320x180 reused.
- Parser GAPS: LAYER_TYPE_BACKGROUND unused; no image-only slide; no text styling needed.
- Settings: Setting::get/set key-value; SettingsController AGENDA_KEYS constant; HandleInertiaRequests shares globals.
- ServiceAgendaItem: is_before_event bool, responsible json, sort_order, service_song_id.
- PlaylistExportService: iterates by sort_order; song items->ProExportService; slide items->addSlidesFromCollection.

## [2026-05-30] Task: T1
- Parser repo was at `/Users/thorsten/AI/propresenter` in this workspace (planned `/Users/thorsten/AI/propresenter-work/php` path was absent).
- Implemented `slideData['background'] = ['path' => string, 'format' => string, 'width' => int, 'height' => int]` to emit `ActionType::ACTION_TYPE_MEDIA` on `LayerType::LAYER_TYPE_BACKGROUND`.
- `slideData['imageOnly'] === true` skips text element generation even if `text` is present, producing background-only/image-only slides with 0 text elements.
- Slides may combine background media plus existing foreground `media`; cue action order is slide action, background media, foreground media so background precedes foreground media without breaking slide-element reads.
- `Slide` read accessors now distinguish foreground media from background media via layer type: `hasBackgroundMedia()`, `getBackgroundMediaUrl()`, `getBackgroundMediaFormat()`.

## [2026-05-30] Task: T2
- `FileConversionService::convertImageCover()` now writes JPG slides with `cover(1920, 1080)` and thumbnail generation, returning `filename`, `thumbnail`, `warnings`, `fullCover => true`.
- Existing contain `convertImage()` keeps `contain(1920, 1080, '000000', 'center')` behavior and adds additive metadata `fullCover => false` for downstream export decisions.
- COVER warnings intentionally only include the German upscale/quality warning for sources smaller than 1920×1080, avoiding contain-specific black-bar wording.
- Evidence: `.sisyphus/evidence/task-2-cover-fill.txt`, `.sisyphus/evidence/task-2-contain-regression.txt`.

## [2026-05-30] Task: T3
- Migration filename: `2026_05_10_115900_add_image_fields_to_services_table.php`
- Columns added on `services`: `key_visual_filename`, `background_filename`, `moderator_name`, `preacher_name_override`
- `Service` accessors use `Attribute::get(fn () => $this->... ? '/storage/'.$this->... : null)` for `keyVisualUrl` and `backgroundUrl`
- Added all 4 new fields to `Service::$fillable` so `fill()`/`save()` persists them
- Full suite green after fresh migrate: `ddev exec php artisan test` → 510 passed

## [2026-05-30] Task: T4
- Setting keys added to `SettingsController::AGENDA_KEYS` constant (single source for both index() props and update() Rule::in validation): `current_key_visual`, `current_background`, `namenseinblender_macro_name`, `namenseinblender_macro_uuid`, `namenseinblender_macro_collection_name`, `namenseinblender_macro_collection_uuid`.
- PATCH /settings (route `settings.update`) validates `key` via `Rule::in(self::AGENDA_KEYS)`, `value` nullable string max:500, persists via `Setting::set()`.
- `HandleInertiaRequests::share()` now globally exposes: `namenseinblenderMacro` => {name, uuid, collection_name (default '--MAIN--'), collection_uuid}, plus `currentKeyVisual`, `currentBackground`.
- NOTE: namenseinblenderMacro uses snake_case keys (name/uuid/collection_name/collection_uuid) — differs from existing `macroSettings` which uses camelCase (collectionName/collectionUuid). T10/T14 consumers must use snake_case for namenseinblender.
- Test file `tests/Feature/NamenseinblenderSettingTest.php` (Pest, 4 tests). Inertia prop assertions use `->where('namenseinblenderMacro.name', ...)` dot-path on shared props from any authed page (settings.index).
- 514 app tests green (510 baseline + 4 new). Pint clean.

## [2026-05-31] Task: T5
- `App\Services\ServiceImageResolver` is pure and lazy: it only reads `Service`, `Setting::get(...)`, and `Storage::disk('public')->exists(...)`; it writes nothing.
- Resolution order is per-service filename first, then global setting filename, then `null`; missing files are skipped/fall through at both levels.
- `keyVisualFor()` uses `key_visual_filename` then `current_key_visual`; `backgroundFor()` uses `background_filename` then `current_background`.
- Return contract is the raw storage-relative filename (`slides/abc.jpg`), NOT the Service model `/storage/...` URL accessor output.
- Test file `tests/Feature/ServiceImageResolverTest.php` covers service wins, global fallback, null, missing service-file fallthrough, and background resolution branches.
- Full suite green: `ddev exec php artisan test` → 519 passed, 2627 assertions. Pint clean for resolver + tests.

## [2026-05-31] Task: T6 — ServiceImageController
- New controller `app/Http/Controllers/ServiceImageController.php`: `storeKeyVisual()` + `storeBackground()` both delegate to private `store($request, $service, $column, $settingKey)`.
- Validation: `file` => required|file|mimes:jpg,jpeg,png|max:20480 (20MB); `scope` => required|Rule::in(['service','default']). German messages via 2nd arg to `$request->validate([...], [...])`.
- Conversion: `app(FileConversionService::class)->convertImageCover($request->file('file'))` → stores `$result['filename']` (slides/{uuid}.jpg) into `key_visual_filename`/`background_filename` via `$service->update([...])`.
- scope=default ALSO calls `Setting::set('current_key_visual'|'current_background', $result['filename'])`. scope=service leaves global Setting untouched.
- Old file NOT deleted on replace (protects finalized snapshots). Verified by test.
- Routes (inside auth group): `POST /services/{service}/key-visual` → `services.key-visual.store`; `POST /services/{service}/background` → `services.background.store`.
- Response: `back()->with('success', 'Bild wurde gespeichert.')` (Inertia redirect). Web validation failures redirect 302; tests use `postJson()` to assert the 422 JSON contract.
- GOTCHA: after adding controller, route cache + autoload were stale → `ddev exec composer dump-autoload && ddev exec php artisan optimize:clear`. Also the `use` import edit silently didn't stick first time — verify imports after editing routes/web.php.
- Test file `tests/Feature/ServiceImageControllerTest.php` (6 tests). Helper `makeImageUpload($name,$w,$h)` GD-based (same pattern as SlideControllerTest's makePngUploadForSlide).
- Full suite: 525 passed (519 baseline + 6). Pint clean.
- Evidence: `.sisyphus/evidence/task-6-default-upload.txt`, `task-6-invalid-upload.txt`.

## [2026-05-31] Task: T7 — NameTagResolver
- New service `App\Services\NameTagResolver`: `moderatorFor(Service): ?string`, `preacherFor(Service): ?string`.
- Moderator resolution: trimmed `services.moderator_name` wins; else first visible agenda item (`is_before_event=false`) ordered by `sort_order`, then `id`; responsible names joined by `', '`; no name => `null`.
- Preacher resolution: trimmed `services.preacher_name_override` wins; else trimmed `services.preacher_name`; else first visible non-song sermon agenda item (`service_song_id IS NULL`) responsible names; no name => `null`.
- `responsible` JSON findings: model casts to array; sync test proves associative object shape `{name: 'Max Mustermann'}`; expected multi-person shape is list of objects `[{name: 'Anna Müller'}, {name: 'Tom Klein'}]`. Resolver supports both plus string entries and `firstName`/`lastName` or snake_case fallbacks.
- Sermon detection method: use `Setting::get('agenda_sermon_matching')` comma patterns through `AgendaMatcherService::matchesAny()` (same matcher used by ServiceController/Service finalization). If no setting is configured, fallback checks title/type for `predigt` or `sermon`.
- Tests: `tests/Feature/NameTagResolverTest.php` covers override/fallback/null branches for moderator and preacher. Full suite green: `ddev exec php artisan test` → 532 passed (2659 assertions). Pint clean. Evidence: `.sisyphus/evidence/task-7-moderator.txt`, `.sisyphus/evidence/task-7-preacher.txt`.

## [2026-05-31] Task: T8 — Export background layer
- `ProExportService::buildGroups()` now resolves `ServiceImageResolver::backgroundFor($service)` once per song export and adds `slideData['background']` with `Storage::disk('public')->path(...)`, format JPG, 1920×1080 to every non-full-cover song slide.
- Sermon image exports use the same background contract in `PlaylistExportService` and `ProBundleExportService`; information/moderation slide exports explicitly remain without background.
- Full-cover detection is persisted with new nullable `slides.cover_mode` (`null` legacy/unknown, `false` contain, `true` cover). `SlideController` stores `$result['fullCover']` from image/ZIP conversions; existing contain conversions store `false`.
- Migration timestamp chosen as `2026_05_10_115950_add_cover_mode_to_slides_table.php` so the existing CCLI rollback test still rolls back the CCLI migration with `--step=1`.
- Tests appended to `tests/Feature/ProFileExportTest.php`: song background, null background, sermon full-cover skip, information/moderation exclusion, playlist sermon/information regression.
- Verification: RED targeted test failed before implementation; GREEN targeted `ProFileExportTest` 13 passed / 76 assertions; full suite `ddev exec php artisan test` 537 passed / 2683 assertions; Pint clean. Evidence: `.sisyphus/evidence/task-8-song-background.txt`, `.sisyphus/evidence/task-8-no-background.txt`.

## [2026-05-31] Task: T9 — Keyvisual fallback playlist slides
- `PlaylistExportService` now adds an ephemeral keyvisual fallback presentation only in the agenda export path after song handling and after uploaded agenda slides are considered.
- Eligible fallback items: visible agenda items with `service_song_id === null`, no uploaded/special slides, not a nametag/namenseinblender marker, and `ServiceImageResolver::keyVisualFor($service)` resolves an existing storage file.
- Fallback `.pro` uses parser T1 contract: one group `Keyvisual`, arrangement `normal`, slide data `['imageOnly' => true, 'background' => ['path' => Storage::disk('public')->path($keyvisual), 'format' => 'JPG', 'width' => 1920, 'height' => 1080]]`.
- Generated fallback slides are not persisted; tests assert `Slide::count()` stays unchanged.
- Tests: `tests/Feature/KeyVisualFallbackTest.php` covers fallback creation, song exclusion, uploaded-slide exclusion, and no-keyvisual no-op. Full suite green: `ddev exec php artisan test` → 541 passed / 2699 assertions. Pint clean. Evidence: `.sisyphus/evidence/task-9-fallback.txt`, `.sisyphus/evidence/task-9-no-keyvisual.txt`.

## [2026-05-31] Task: T10 — NameTagSlideBuilder
- New pure service `App\Services\NameTagSlideBuilder` builds ephemeral slideData only; it does not persist slides.
- `build()` gates solely on non-empty `Setting::get('namenseinblender_macro_name')`; no configured macro name returns `null` so callers skip nametag slides.
- Text contract is plain parser text string with exactly two lines: `$name."\n".$title`; convenience methods use `Moderation` and `Predigt`.
- Parser-facing macro uses camelCase keys: `name`, `uuid`, `collectionName`, `collectionUuid`; collection name defaults to `--MAIN--` via `Setting::get('namenseinblender_macro_collection_name', '--MAIN--')`.
- Tests: `tests/Feature/NameTagSlideBuilderTest.php` covers no-macro null, configured macro/text shape, and moderator/preacher titles. Full suite green: `ddev exec php artisan test` → 544 passed / 2703 assertions. Pint clean. Evidence: `.sisyphus/evidence/task-10-nametag-macro.txt`, `.sisyphus/evidence/task-10-no-macro.txt`.

## Task 11: Sermon sequence + moderator injection
- Pint auto-removes same-namespace `use App\Services\X` imports (NameTagResolver/NameTagSlideBuilder are already in App\Services); rely on `app(Class::class)` resolution instead of importing.
- Sermon item WITH slides now ALWAYS gets a Keyvisual-Predigt .pro prepended (macro-independent), then Predigername nametag (only if macro set), then sermon slides. This changed KeyVisualFallbackTest expectation from 1 to 2 embedded .pro files.
- Moderator nametag injected as FIRST playlist entry for the first is_before_event=false agenda item (only if macro set).
- PlaylistArchive::getEntries() + entry->getName() is the way to assert playlist ORDER in tests.
