# CTS Presenter App — Church Service Show Creator ## TL;DR > **Quick Summary**: Greenfield Laravel 11 + Vue 3 + Inertia.js app that syncs church service data from ChurchTools API, lets users prepare services (map songs, upload slides, configure arrangements), manage a song database with translations, and track finalization status. German UI, Docker deployment, TDD. > > **Deliverables**: > - Docker-containerized Laravel+Vue app with ChurchTools OAuth login > - Service list with status tracking (song mapping, slides uploaded, finalized) > - Service edit form with 4 blocks: Information, Moderation, Sermon, Songs > - Song database with arrangement configurator, translation editor, PDF export > - File upload pipeline: images → JPG 1920×1080, PPT → slides, ZIP → extraction > - CTS API sync with "Refresh" button and timestamp in nav bar > > **Estimated Effort**: XL > **Parallel Execution**: YES — 5 waves + final verification > **Critical Path**: T0 → T1 → T4 → T8 → T14/T18 → T24 → FINAL --- ## Context ### Original Request Build a "ChurchService Presenter Software Show Creator" — a tool that reads church service data from ChurchTools API, provides a form to finalize service setup (songs, slides, arrangements), and manages a song database. German UI with "Du" form. All actions immediately persistent (auto-save). ### Interview Summary **Key Discussions**: - **CTS API Client**: Use `5pm-HDH/churchtools-api` PHP package (confirmed) - **Auth**: Only ChurchTools OAuth — no local password login - **Arrangements**: Manually created in app. Later also from .pro import - **Info-Slides**: Dynamic per query (expire_date > service_date), not copied into services - **Translation**: URL to lyrics webpage (scrape) + manual text paste - **Group Colors**: Freely choosable per group (color picker) - **Song Preview**: HTML Modal/Overlay with formatted text - **Download (finalized)**: Placeholder — future ProPresenter show generator tool - **Deployment**: Docker container - **Tests**: TDD strategy **Research Findings**: - ChurchTools REST API at `/api/*` with Swagger docs at each instance - OAuth2 Authorization Code flow, endpoints: `/oauth/authorize`, `/oauth/access_token`, `/oauth/userinfo` - `5pm-HDH/churchtools-api` v2.1: EventRequest, SongRequest, EventAgendaRequest with fluent filtering - Intervention Image v3 for image processing (letterbox to 1920×1080) - LibreOffice headless → PDF → `spatie/pdf-to-image` → JPG for PPT conversion (must be queued) - `vue-draggable-plus` for arrangement drag-and-drop (clone mode, groups repeat) - `@jaxtheprime/vue3-dropzone` for file upload zones (drop/preview/edit modes) - `barryvdh/laravel-dompdf` for PDF generation (no Tailwind in templates — old-school CSS only) - ProPresenter .pro: Pro6=XML, Pro7=Protobuf. Parser deferred. - `@vueuse/core` for `useDebounceFn` (auto-save debouncing) ### Metis Review **Identified Gaps** (addressed): - CTS API may not include song lyrics text → Plan includes Wave 0 spike to verify. Songs get lyrics from manual entry or future .pro import. - `5pm-HDH/churchtools-api` token auth unverified → Spike task verifies `CTConfig::setApiKey()` support - PPT conversion must be async (queued Laravel Job) → Planned as Job with progress indicator - DomPDF cannot render Tailwind → Song PDF template uses old-school CSS with `DejaVu Sans` font - Lyrics URL scraping is fragile → Best-effort HTTP fetch with manual paste as primary fallback - Arrangement Vue keys must use `${group.id}-${index}` not `group.id` (groups repeat) - Docker image needs: PHP, LibreOffice, ImageMagick/Imagick, Node.js --- ## Work Objectives ### Core Objective Build a complete service preparation tool that reads CTS data, enables song mapping/arrangement/translation, handles multi-format slide uploads, and tracks finalization status — all in German with auto-save and Docker deployment. ### Concrete Deliverables - Docker setup (Dockerfile + docker-compose.yml) - ChurchTools OAuth login (only auth method) - CTS API sync service with Refresh button + timestamp - Service list page with status indicators - Service edit form with 4 blocks (Information, Moderation, Sermon, Songs) - Song database page with CRUD, arrangement config, translation editor - File upload pipeline (image/PPT/ZIP processing) - Song preview modal + PDF download - Email notification for unmatched songs - .pro upload/download placeholders (throw NotImplementedException) ### Definition of Done - [ ] `docker-compose up` starts working app on localhost - [ ] Login via ChurchTools OAuth works end-to-end - [ ] CTS API sync populates services and songs - [ ] All 4 edit blocks functional with auto-save - [ ] Song matching, arrangement config, translation all working - [ ] File uploads convert to 1920×1080 JPGs correctly - [ ] All tests pass (`php artisan test`) - [ ] All UI text in German with "Du" form ### Must Have - ChurchTools OAuth as sole login method - READ-ONLY CTS API access (no writes) - Auto-save (every action immediately persistent) - German UI with "Du" form throughout - File upload: JPG 1920×1080 letterbox, PPT→slides, ZIP extraction - Song matching by CCLI ID - Arrangement configurator with drag-and-drop - Info-slides with expire dates shown dynamically in future services - Docker deployment ### Must NOT Have (Guardrails) - NO writes to ChurchTools API — READ ONLY - NO local password login — OAuth only - NO .pro file parser implementation (placeholder/exception only) - NO finalized download generation (placeholder — future tool) - NO upscaling of small images (letterbox with black bars, never stretch) - NO Tailwind CSS in DomPDF templates (use old-school CSS + DejaVu Sans) - NO site-specific lyrics scrapers (best-effort HTTP fetch only) - NO over-engineered abstractions — keep it practical - NO English UI text — everything in German with "Du" --- ## Verification Strategy (MANDATORY) > **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions. ### Test Decision - **Infrastructure exists**: YES (Breeze includes Pest/PHPUnit) - **Automated tests**: TDD — Tests first, then implementation - **Framework**: Pest (Laravel default with Breeze) - **Each task**: RED (failing test) → GREEN (minimal impl) → REFACTOR ### QA Policy Every task MUST include agent-executed QA scenarios. Evidence saved to `.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}`. - **Frontend/UI**: Use Playwright — Navigate, interact, assert DOM, screenshot - **API/Backend**: Use Bash (curl/artisan) — Commands, assertions - **File Processing**: Use Bash — Upload test files, verify output dimensions/format --- ## Execution Strategy ### Parallel Execution Waves ``` Wave 0 (Spike — sequential, blocks everything): └── Task 0: CTS API spike — verify token auth + package compat + API shape Wave 1 (Foundation — 7 parallel tasks): ├── Task 1: Laravel scaffolding + Breeze Vue + Docker [quick] ├── Task 2: Database schema + all migrations [deep] ├── Task 3: ChurchTools OAuth provider (replace Breeze login) [unspecified-high] ├── Task 4: CTS API service + sync command [deep] ├── Task 5: File conversion service (image/PPT/ZIP) [deep] ├── Task 6: Shared Vue components (layout, nav, auto-save) [visual-engineering] ├── Task 7: Email config + Mailable for missing songs [quick] Wave 2 (Core Features — 6 parallel tasks): ├── Task 8: Service list page (backend + frontend) [deep] ├── Task 9: Song model + SongDB CRUD backend [unspecified-high] ├── Task 10: Slide upload component (shared reusable) [visual-engineering] ├── Task 11: Arrangement model + configurator component [deep] ├── Task 12: Song matching service (CCLI ID) [unspecified-high] ├── Task 13: Translation service (URL scrape + manual) [unspecified-high] Wave 3 (Service Edit + Song UI — 6 parallel tasks): ├── Task 14: Service edit page layout + routing [visual-engineering] ├── Task 15: Information block (slides + expire dates) [visual-engineering] ├── Task 16: Moderation block (slides, service-specific) [quick] ├── Task 17: Sermon block (slides, service-specific) [quick] ├── Task 18: Songs block (matching + arrangement + translation) [deep] ├── Task 19: Song preview modal + PDF download [unspecified-high] Wave 4 (Song DB + Finalization — 5 parallel tasks): ├── Task 20: Song DB page (list + search + filters) [visual-engineering] ├── Task 21: Song DB edit popup (metadata + arrangement) [visual-engineering] ├── Task 22: Song DB translate page (two-column editor) [deep] ├── Task 23: Song DB .pro upload + download placeholders [quick] ├── Task 24: Service finalization + status management [unspecified-high] Wave FINAL (Verification — 4 parallel): ├── Task F1: Plan compliance audit [oracle] ├── Task F2: Code quality review [unspecified-high] ├── Task F3: Real manual QA with Playwright [unspecified-high] └── Task F4: Scope fidelity check [deep] Critical Path: T0 → T1 → T4 → T8 → T14/T18 → T24 → FINAL Parallel Speedup: ~65% faster than sequential Max Concurrent: 7 (Wave 1) ``` ### Dependency Matrix | Task | Depends On | Blocks | Wave | |------|-----------|--------|------| | T0 | — | ALL | 0 | | T1 | T0 | T2-T7 | 1 | | T2 | T1 | T8-T13 | 1 | | T3 | T1 | T8 | 1 | | T4 | T1 | T8, T12 | 1 | | T5 | T1 | T10, T15-T17 | 1 | | T6 | T1 | T8, T14-T18, T20 | 1 | | T7 | T1 | T12 | 1 | | T8 | T2, T3, T4, T6 | T14 | 2 | | T9 | T2 | T11, T12, T18-T23 | 2 | | T10 | T2, T5, T6 | T15-T17 | 2 | | T11 | T2, T9 | T18, T21 | 2 | | T12 | T2, T4, T7, T9 | T18 | 2 | | T13 | T2, T9 | T22 | 2 | | T14 | T8, T6 | T15-T19 | 3 | | T15 | T10, T14 | T24 | 3 | | T16 | T10, T14 | T24 | 3 | | T17 | T10, T14 | T24 | 3 | | T18 | T11, T12, T14 | T24 | 3 | | T19 | T9, T11 | — | 3 | | T20 | T9, T6 | — | 4 | | T21 | T9, T11 | — | 4 | | T22 | T13, T9 | — | 4 | | T23 | T9 | — | 4 | | T24 | T15-T18 | FINAL | 4 | | F1-F4 | ALL | — | FINAL | ### Agent Dispatch Summary - **Wave 0**: **1** — T0 → `deep` - **Wave 1**: **7** — T1 → `quick`, T2 → `deep`, T3 → `unspecified-high`, T4 → `deep`, T5 → `deep`, T6 → `visual-engineering`, T7 → `quick` - **Wave 2**: **6** — T8 → `deep`, T9 → `unspecified-high`, T10 → `visual-engineering`, T11 → `deep`, T12 → `unspecified-high`, T13 → `unspecified-high` - **Wave 3**: **6** — T14 → `visual-engineering`, T15 → `visual-engineering`, T16 → `quick`, T17 → `quick`, T18 → `deep`, T19 → `unspecified-high` - **Wave 4**: **5** — T20 → `visual-engineering`, T21 → `visual-engineering`, T22 → `deep`, T23 → `quick`, T24 → `unspecified-high` - **FINAL**: **4** — F1 → `oracle`, F2 → `unspecified-high`, F3 → `unspecified-high`, F4 → `deep` --- ## TODOs > TDD = RED → GREEN → REFACTOR for every task. > EVERY task MUST have: Agent Profile + QA Scenarios. > ALL UI text in German with "Du" form. ### Wave 0: API Spike - [x] 0. CTS API Spike — Verify Token Auth + API Shape **What to do**: - TEST: Write Pest test that creates a mock CTS API response and verifies the sync pipeline - Install `5pm-HDH/churchtools-api` via composer - Verify token-based auth works: `CTConfig::setApiUrl()` + `CTConfig::setApiKey($token)` from `CTS_API_TOKEN` env var - If `setApiKey` doesn't exist, check `authWithLoginToken()` or similar methods in CTConfig - Hit `GET /api/events` with date filter (today+future) and capture response shape - Hit `GET /api/songs/1` (any song) and check if response includes lyrics text, arrangement data - Download OpenAPI spec from `/system/runtime/swagger/openapi.json` and save to `docs/churchtools-openapi.json` for reference - Document findings: which fields exist, what data shapes we get, confirm CCLI field presence on songs - If package doesn't support token auth: document workaround (raw HTTP with `Authorization: Login TOKEN` header) **Must NOT do**: - Do NOT write to any CTS API endpoint - Do NOT commit actual API token to git **Recommended Agent Profile**: - **Category**: `deep` - **Skills**: [] **Parallelization**: - **Can Run In Parallel**: NO - **Parallel Group**: Wave 0 (sequential, blocks everything) - **Blocks**: T1-T24 (all tasks) - **Blocked By**: None **References**: - `5pm-HDH/churchtools-api` docs: https://github.com/5pm-HDH/churchtools-api/blob/master/docs/out/CTConfig.md - `5pm-HDH/churchtools-api` EventAPI: https://github.com/5pm-HDH/churchtools-api/blob/master/docs/out/EventAPI.md - `5pm-HDH/churchtools-api` SongAPI: https://github.com/5pm-HDH/churchtools-api/blob/master/docs/out/SongAPI.md - `.env.example` — contains `CTS_API_TOKEN=XXXXXX` - CTS API docs at instance: `https://INSTANCE.church.tools/api` (Swagger UI) **Acceptance Criteria**: - [ ] `5pm-HDH/churchtools-api` installed and configured with token from `.env` - [ ] `php artisan test --filter=CtsApiSpikeTest` → PASS - [ ] API response shapes documented in `docs/api-response-shapes.md` - [ ] Confirmed: songs have/don't have lyrics text, CCLI field present - [ ] Auth method documented (setApiKey vs authWithLoginToken vs raw HTTP) **QA Scenarios:** ``` Scenario: Verify CTS API authentication Tool: Bash Preconditions: .env has valid CTS_API_TOKEN and CTS_API_URL Steps: 1. Run: php artisan tinker --execute="CTApi\CTConfig::setApiUrl(env('CTS_API_URL')); CTApi\CTConfig::setApiKey(env('CTS_API_TOKEN')); dump(CTApi\Models\Groups\Person\PersonRequest::whoami()->getFirstName());" 2. Assert: output contains a first name string (not null/error) 3. Run: php artisan tinker --execute="dump(CTApi\Models\Events\Event\EventRequest::where('from', now()->format('Y-m-d'))->get()->count());" 4. Assert: output is a number >= 0 Expected Result: Both commands succeed without authentication errors Evidence: .sisyphus/evidence/task-0-api-auth.txt Scenario: Verify song data includes CCLI Tool: Bash Preconditions: CTS API authenticated Steps: 1. Run: php artisan tinker to fetch first song and dump ccli field 2. Assert: song object has getCcli() method that returns a value Expected Result: CCLI field accessible on song model Evidence: .sisyphus/evidence/task-0-song-ccli.txt ``` **Commit**: YES - Message: `chore: verify CTS API token auth and package compatibility` - Files: `composer.json`, `composer.lock`, `docs/api-response-shapes.md` - Pre-commit: `php artisan test --filter=CtsApiSpikeTest` ### Wave 1: Foundation (7 parallel tasks) - [x] 1. Laravel Scaffolding + Breeze Vue + Docker **What to do**: - TEST: Write Pest test that verifies the home route returns Inertia response - Run `laravel new cts --breeze --stack=vue --pest --database=sqlite` (or equivalent composer commands) - Configure `.env` with all needed vars: `CTS_API_URL`, `CTS_API_TOKEN`, `CHURCHTOOLS_URL`, `CHURCHTOOLS_CLIENT_ID`, `CHURCHTOOLS_REDIRECT_URI`, `MAIL_*` - Update `.env.example` with all new vars (placeholders) - Create `Dockerfile` for PHP 8.3 + required extensions (imagick, zip, pdo_sqlite, pdo_mysql) - Install LibreOffice headless + ImageMagick in Docker image - Create `docker-compose.yml` with app + node (for Vite) services - Configure `vite.config.js` for Docker hot-reload - Verify `docker-compose up` starts app successfully - Set app locale to `de` in `config/app.php` - Add `@vueuse/core`, `vue-draggable-plus`, `@jaxtheprime/vue3-dropzone` to package.json **Must NOT do**: - Do NOT keep Breeze default login/register pages (will be replaced in T3) - Do NOT add application-specific routes yet **Recommended Agent Profile**: - **Category**: `quick` - **Skills**: [] **Parallelization**: - **Can Run In Parallel**: NO (first in Wave 1, all others depend on it) - **Parallel Group**: Wave 1 (runs first, others start after) - **Blocks**: T2-T7 - **Blocked By**: T0 **References**: - Laravel 11 docs: https://laravel.com/docs/11.x/installation - Breeze docs: https://laravel.com/docs/11.x/starter-kits#laravel-breeze - Intervention Image v3: `composer require intervention/image` - `spatie/pdf-to-image`: `composer require spatie/pdf-to-image` **Acceptance Criteria**: - [ ] `docker-compose up -d` → containers start without errors - [ ] `http://localhost:8000` returns HTTP response - [ ] `npm run build` completes without errors - [ ] `php artisan test` → default Breeze tests pass - [ ] `.env.example` contains all project-specific vars **QA Scenarios:** ``` Scenario: Docker containers start successfully Tool: Bash Preconditions: Docker installed, docker-compose.yml present Steps: 1. Run: docker-compose up -d 2. Run: docker-compose ps 3. Assert: all containers show 'Up' or 'healthy' status 4. Run: curl -s -o /dev/null -w '%{http_code}' http://localhost:8000 5. Assert: HTTP status is 200 or 302 Expected Result: All containers running, app reachable Evidence: .sisyphus/evidence/task-1-docker-up.txt Scenario: Vite build succeeds Tool: Bash Preconditions: npm dependencies installed Steps: 1. Run: docker-compose exec app npm run build 2. Assert: exit code 0, no errors in output Expected Result: Build completes, assets generated in public/build Evidence: .sisyphus/evidence/task-1-vite-build.txt ``` **Commit**: YES - Message: `feat: scaffold Laravel + Breeze Vue + Docker setup` - Files: entire project scaffolding - Pre-commit: `php artisan test` - [x] 2. Database Schema + All Migrations **What to do**: - TEST: Write Pest test that verifies all tables exist after migration - Create migrations for ALL tables in this order: - `users` (extend: add churchtools_id, avatar, churchtools_groups, churchtools_roles columns) - `services` (cts_event_id, title, date, preacher_name, beamer_tech_name, finalized_at, last_synced_at, cts_data JSON) - `songs` (ccli_id unique nullable, title, author, copyright_text, copyright_year, publisher, has_translation bool, deleted_at soft-delete, last_used_at) - `song_groups` (song_id FK, name, color hex, order int) - `song_slides` (song_group_id FK, order int, text_content, text_content_translated nullable, notes nullable) - `song_arrangements` (song_id FK, name, is_default bool) - `song_arrangement_groups` (song_arrangement_id FK, song_group_id FK, order int) - `service_songs` (service_id FK, song_id FK nullable, song_arrangement_id FK nullable, use_translation bool default false, order int, cts_song_name, cts_ccli_id nullable, matched_at nullable, request_sent_at nullable) - `slides` (type enum[information|moderation|sermon], service_id FK nullable, original_filename, stored_filename, thumbnail_filename, expire_date nullable, uploader_name nullable, uploaded_at, deleted_at soft-delete) - `cts_sync_log` (synced_at, events_count, songs_count, status, error nullable) - Create Eloquent models with relationships for all tables - Add factory classes for testing (Song, Service, SongGroup, etc.) **Must NOT do**: - Do NOT add business logic to models (just relationships and casts) - Do NOT create controllers or routes **Recommended Agent Profile**: - **Category**: `deep` - **Skills**: [] **Parallelization**: - **Can Run In Parallel**: YES (after T1) - **Parallel Group**: Wave 1 (with T3-T7) - **Blocks**: T8-T13 (all Wave 2 tasks) - **Blocked By**: T1 **References**: - ProPresenter schema research: songs → song_groups → song_slides hierarchy - AGENTS.md: service list fields (Title, Preacher, beamer technician, qty of songs) - AGENTS.md: Song block fields (CCLI ID, has Translation, arrangement) - AGENTS.md: Information block (expire date, uploader name) **Acceptance Criteria**: - [ ] `php artisan migrate:fresh` → all migrations run without errors - [ ] `php artisan test --filter=DatabaseSchemaTest` → PASS (all tables exist with correct columns) - [ ] All Eloquent models have correct relationships defined - [ ] Factory classes generate valid test data **QA Scenarios:** ``` Scenario: All migrations run successfully Tool: Bash Steps: 1. Run: php artisan migrate:fresh --force 2. Assert: exit code 0, output shows all migrations ran 3. Run: php artisan tinker --execute="dump(Schema::getTableListing());" 4. Assert: output includes users, services, songs, song_groups, song_slides, song_arrangements, song_arrangement_groups, service_songs, slides, cts_sync_log Expected Result: 10+ tables created Evidence: .sisyphus/evidence/task-2-migrations.txt ``` **Commit**: YES - Message: `feat: add database schema for services, songs, arrangements, slides` - Files: `database/migrations/*.php`, `app/Models/*.php`, `database/factories/*.php` - Pre-commit: `php artisan test` - [x] 3. ChurchTools OAuth Provider (Replace Breeze Login) **What to do**: - TEST: Write Pest test that mocks Socialite driver and verifies user creation from OAuth data - Install `laravel/socialite` - Create `App\Socialite\ChurchToolsProvider` extending `AbstractProvider` with: - `getAuthUrl()` → `{CTS_URL}/oauth/authorize` - `getTokenUrl()` → `{CTS_URL}/oauth/access_token` - `getUserByToken()` → `GET {CTS_URL}/oauth/userinfo` - `mapUserToObject()` → maps id, displayName, email, imageUrl, groups, roles - Register provider in `AppServiceProvider::boot()` via `Socialite::extend('churchtools', ...)` - Add `config/services.php` entry for `churchtools` (url, client_id, client_secret, redirect) - Create `AuthController` with `redirect()` and `callback()` methods - `callback()`: find-or-create User by email, store churchtools_id, avatar, groups, roles - Add routes: `GET /auth/churchtools` (redirect), `GET /auth/churchtools/callback` - Remove Breeze login/register pages, replace with single "Mit ChurchTools anmelden" button - Create simple `Login.vue` page with the OAuth button - Add logout route that clears session and redirects to login - Protect all routes with `auth` middleware except login routes - Update `.env.example` with `CHURCHTOOLS_URL`, `CHURCHTOOLS_CLIENT_ID`, `CHURCHTOOLS_CLIENT_SECRET`, `CHURCHTOOLS_REDIRECT_URI` **Must NOT do**: - Do NOT keep local email/password registration or login - Do NOT store ChurchTools access token (we use our own API token for API calls) **Recommended Agent Profile**: - **Category**: `unspecified-high` - **Skills**: [] **Parallelization**: - **Can Run In Parallel**: YES (after T1) - **Parallel Group**: Wave 1 (with T2, T4-T7) - **Blocks**: T8 - **Blocked By**: T1 **References**: - `devdot/churchtools-oauth2-client` source: https://github.com/devdot/churchtools-oauth2-client — reference for endpoint URLs and user mapping - Laravel Socialite docs: https://laravel.com/docs/11.x/socialite - ChurchTools OAuth flow: `/oauth/authorize` → `/oauth/access_token` → `/oauth/userinfo` - User data shape: `{ id, firstName, lastName, displayName, email, imageUrl, groups, roles }` **Acceptance Criteria**: - [ ] `php artisan test --filter=OAuthTest` → PASS - [ ] Visiting `/` unauthenticated → redirect to login page - [ ] Login page shows only "Mit ChurchTools anmelden" button (no email/password form) - [ ] After OAuth callback → user created in DB, redirected to dashboard - [ ] No Breeze login/register routes remain **QA Scenarios:** ``` Scenario: Unauthenticated user sees login page Tool: Playwright Steps: 1. Navigate to http://localhost:8000/ 2. Assert: page redirects to /login or shows login page 3. Assert: page contains button or link with text 'Mit ChurchTools anmelden' 4. Assert: NO email input field exists on the page 5. Assert: NO password input field exists on the page Expected Result: Clean OAuth-only login page in German Evidence: .sisyphus/evidence/task-3-login-page.png Scenario: OAuth redirect works Tool: Bash Steps: 1. Run: curl -s -o /dev/null -w '%{http_code} %{redirect_url}' http://localhost:8000/auth/churchtools 2. Assert: HTTP 302 redirect to churchtools.church.tools/oauth/authorize Expected Result: Redirect to ChurchTools OAuth authorize endpoint Evidence: .sisyphus/evidence/task-3-oauth-redirect.txt ``` **Commit**: YES - Message: `feat: implement ChurchTools OAuth login via Socialite` - Files: `app/Socialite/ChurchToolsProvider.php`, `app/Http/Controllers/AuthController.php`, `resources/js/Pages/Auth/Login.vue`, routes - Pre-commit: `php artisan test` - [x] 4. CTS API Service + Sync Command **What to do**: - TEST: Write Pest tests that mock API responses and verify sync creates correct DB records - Create `App\Services\ChurchToolsService` that wraps `5pm-HDH/churchtools-api`: - `syncEvents()`: fetch events from today forward, upsert into `services` table - `syncSongs()`: fetch all songs, upsert basic metadata into local reference - `syncAgenda($eventId)`: fetch agenda for event, create/update `service_songs` - `getEventServices($eventId)`: get assigned people (preacher, beamer tech) - Create `App\Console\Commands\SyncChurchToolsCommand` (`php artisan cts:sync`) - Store sync timestamp in `cts_sync_log` table - When syncing songs to services: try to match `cts_ccli_id` to existing `songs.ccli_id` - If matched: set `service_songs.song_id`, set `matched_at` - If not matched: leave `song_id` null (UI will show matching options later) - Create `App\Http\Controllers\SyncController` with `sync()` action for the refresh button - Sync action returns Inertia redirect with flash message: "Daten wurden aktualisiert" or error **Must NOT do**: - Do NOT write/update anything on the CTS API - Do NOT sync historical events (only today + future) - Do NOT delete local songs when they're removed from CTS **Recommended Agent Profile**: - **Category**: `deep` - **Skills**: [] **Parallelization**: - **Can Run In Parallel**: YES (after T1) - **Parallel Group**: Wave 1 (with T2, T3, T5-T7) - **Blocks**: T8, T12 - **Blocked By**: T1 (needs T0 findings for auth method) **References**: - T0 findings: `docs/api-response-shapes.md` — confirmed API response structures - `5pm-HDH/churchtools-api` EventAPI: `EventRequest::where('from', date)->get()` - `5pm-HDH/churchtools-api` SongAPI: `SongRequest::all()`, `$song->getCcli()` - `5pm-HDH/churchtools-api` EventAgendaRequest: `EventAgendaRequest::fromEvent($id)->get()` - AGENTS.md: service list needs title, preacher, beamer tech, song count **Acceptance Criteria**: - [ ] `php artisan test --filter=ChurchToolsSyncTest` → PASS - [ ] `php artisan cts:sync` → populates services and service_songs tables - [ ] Sync log entry created with count and status - [ ] CCLI matching works: matched songs have `song_id` set - [ ] Unmatched songs have `song_id = null`, `cts_ccli_id` preserved **QA Scenarios:** ``` Scenario: Sync command populates database Tool: Bash Steps: 1. Run: php artisan migrate:fresh --force 2. Run: php artisan cts:sync 3. Assert: exit code 0, output shows 'Sync abgeschlossen' 4. Run: php artisan tinker --execute="dump(App\Models\Service::count());" 5. Assert: count > 0 6. Run: php artisan tinker --execute="dump(App\Models\CtsSync Log::latest()->first()->status);" 7. Assert: status is 'success' Expected Result: Services populated from CTS API Evidence: .sisyphus/evidence/task-4-sync-command.txt ``` **Commit**: YES - Message: `feat: add CTS API sync service and artisan command` - Files: `app/Services/ChurchToolsService.php`, `app/Console/Commands/SyncChurchToolsCommand.php`, `app/Http/Controllers/SyncController.php` - Pre-commit: `php artisan test` - [x] 5. File Conversion Service (Image/PPT/ZIP) **What to do**: - TEST: Write Pest tests with sample image (400×300 PNG) and verify output is 1920×1080 JPG - Create `App\Services\FileConversionService` with methods: - `convertImage($file): array` — letterbox to 1920×1080 JPG (black bars, no crop, no upscale-stretch), return `['filename', 'thumbnail']` - `convertPowerPoint($file): array` — LibreOffice headless → PDF → spatie/pdf-to-image → individual JPGs → letterbox each. Return array of slide data. - `processZip($file): array` — extract ZIP, recursively process each file (images, PPTs, nested ZIPs) - `generateThumbnail($path): string` — create 320×180 thumbnail - PPT conversion MUST be a queued Laravel Job (`App\Jobs\ConvertPowerPointJob`) — NOT synchronous - Job dispatches events for progress tracking (`PowerPointConversionProgress`) - Image conversion uses Intervention Image v3: create 1920×1080 canvas → fill black → scale image to fit → place centered - Handle edge cases: portrait images get pillarbox, square images get bars all around - Store converted files in `storage/app/public/slides/` and thumbnails in `storage/app/public/slides/thumbnails/` - Validate: only accept png, jpg, jpeg, ppt, pptx, zip file types **Must NOT do**: - Do NOT upscale small images — always letterbox with black bars - Do NOT crop any part of an image - Do NOT process PPT synchronously (must be queued) **Recommended Agent Profile**: - **Category**: `deep` - **Skills**: [] **Parallelization**: - **Can Run In Parallel**: YES (after T1) - **Parallel Group**: Wave 1 (with T2-T4, T6-T7) - **Blocks**: T10, T15-T17 - **Blocked By**: T1 **References**: - Intervention Image v3 docs: `Image::create(1920, 1080)->fill('000000')`, `$image->scale()`, `$canvas->place($image, 'center')` - `spatie/pdf-to-image` docs: `(new Pdf($path))->setPage($n)->saveImage($out)` - LibreOffice headless: `soffice --headless --convert-to pdf --outdir $dir $input` - Docker: LibreOffice path is `/usr/bin/libreoffice` in container **Acceptance Criteria**: - [ ] `php artisan test --filter=FileConversionTest` → PASS - [ ] 400×300 PNG input → 1920×1080 JPG output with black letterbox - [ ] 1080×1920 portrait PNG → 1920×1080 JPG with black pillarbox - [ ] Thumbnail generated at 320×180 - [ ] PPT conversion dispatches job, job processes to JPGs **QA Scenarios:** ``` Scenario: Image conversion with letterbox Tool: Bash Steps: 1. Create test 400x300 PNG: convert -size 400x300 xc:red /tmp/test.png 2. Run conversion via tinker or test endpoint 3. Assert: output file is JPG 4. Run: identify output.jpg (ImageMagick) 5. Assert: dimensions are exactly 1920x1080 Expected Result: Letterboxed JPG at 1920x1080 Evidence: .sisyphus/evidence/task-5-letterbox.txt Scenario: Portrait image gets pillarbox Tool: Bash Steps: 1. Create test 600x1200 PNG (portrait) 2. Run conversion 3. Assert: output is 1920x1080 with black bars left/right Expected Result: Pillarboxed portrait image Evidence: .sisyphus/evidence/task-5-pillarbox.txt ``` **Commit**: YES - Message: `feat: add file conversion service (image, PPT, ZIP)` - Files: `app/Services/FileConversionService.php`, `app/Jobs/ConvertPowerPointJob.php`, tests - Pre-commit: `php artisan test` - [x] 6. Shared Vue Components (Layout, Nav, Auto-Save) **What to do**: - TEST: Write Pest test that verifies shared Inertia data includes auth user and last_synced_at - Create `AuthenticatedLayout.vue` with: - Top bar showing logged-in user name + avatar (from `$page.props.auth.user`) - "Daten aktualisieren" refresh button that calls `POST /sync` and shows loading spinner - Timestamp "Zuletzt aktualisiert: {date}" from last sync log - Navigation: "Services" link, "Song-Datenbank" link - Logout button/link - Update `HandleInertiaRequests` middleware to share: `auth.user`, `flash`, `last_synced_at`, `app_name` - Create `AutoSaveForm` composable using `@vueuse/core` `useDebounceFn`: - Text inputs: debounce 500ms - Selects/checkboxes: immediate save - Uses Inertia `router.put/post` with `preserveScroll: true, preserveState: true` - Shows subtle save indicator ("Gespeichert" / "Speichert...") - Create shared components: `FlashMessage.vue`, `ConfirmDialog.vue`, `LoadingSpinner.vue` - All text in German with "Du" form **Must NOT do**: - Do NOT create page-specific components here (only shared/reusable) - Do NOT add business logic to layout **Recommended Agent Profile**: - **Category**: `visual-engineering` - **Skills**: [`frontend-ui-ux`] **Parallelization**: - **Can Run In Parallel**: YES (after T1) - **Parallel Group**: Wave 1 (with T2-T5, T7) - **Blocks**: T8, T14-T18, T20 - **Blocked By**: T1 **References**: - Breeze `AuthenticatedLayout.vue` — extend this pattern - `usePage()` from `@inertiajs/vue3` for accessing shared props - `useDebounceFn` from `@vueuse/core` for auto-save debouncing - AGENTS.md: "Button in Top Bar to refresh Data" + "timestamp with latest refresh" + "LoggedIn User visible" **Acceptance Criteria**: - [ ] Layout shows user name and avatar in top bar - [ ] Refresh button triggers sync and updates timestamp - [ ] Navigation links to Services and Song-Datenbank - [ ] Auto-save composable debounces text input at 500ms - [ ] All text in German **QA Scenarios:** ``` Scenario: Top bar shows user info and navigation Tool: Playwright Steps: 1. Login via OAuth (or mock auth state) 2. Assert: top bar contains user display name 3. Assert: top bar contains 'Daten aktualisieren' button 4. Assert: top bar contains 'Zuletzt aktualisiert:' timestamp 5. Assert: navigation contains 'Services' link 6. Assert: navigation contains 'Song-Datenbank' link Expected Result: Complete German top bar with all elements Evidence: .sisyphus/evidence/task-6-topbar.png ``` **Commit**: YES - Message: `feat: create shared Vue layout with nav, user, refresh button` - Files: `resources/js/Layouts/AuthenticatedLayout.vue`, `resources/js/Composables/useAutoSave.js`, shared components - Pre-commit: `php artisan test` - [x] 7. Email Configuration + Missing Song Mailable **What to do**: - TEST: Write Pest test that verifies MissingSongNotification mailable renders correct content - Configure Laravel mail in `config/mail.php` (already done by default, uses MAIL_* env vars) - Add to `.env.example`: `MAIL_MAILER=smtp`, `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM_ADDRESS`, `SONG_REQUEST_EMAIL` (recipient for missing song requests) - Create `App\Mail\MissingSongRequest` Mailable: - Subject: "Song-Anfrage: {songName} (CCLI: {ccliId})" - Body: German text explaining which song is needed, which service it's for, CCLI ID, and link to service in the app - Create `App\Notifications\MissingSongNotification` as alternative (using Mail channel) - Add `SONG_REQUEST_EMAIL` to config/services.php for easy access **Must NOT do**: - Do NOT send emails automatically — only when user clicks "Erstellung anfragen" button - Do NOT include sensitive data in emails **Recommended Agent Profile**: - **Category**: `quick` - **Skills**: [] **Parallelization**: - **Can Run In Parallel**: YES (after T1) - **Parallel Group**: Wave 1 (with T2-T6) - **Blocks**: T12 - **Blocked By**: T1 **References**: - Laravel Mail docs: https://laravel.com/docs/11.x/mail - AGENTS.md: "button 'request creation' which causes an EMAIL to a configured mail address with the song and the CCLI Id" **Acceptance Criteria**: - [ ] `php artisan test --filter=MissingSongMailTest` → PASS - [ ] Mailable renders German text with song name and CCLI ID - [ ] `SONG_REQUEST_EMAIL` configurable via .env **QA Scenarios:** ``` Scenario: Missing song email renders correctly Tool: Bash Steps: 1. Run: php artisan tinker to render MissingSongRequest mailable with test data 2. Assert: subject contains 'Song-Anfrage' 3. Assert: body contains CCLI ID 4. Assert: body is in German Expected Result: Well-formatted German email Evidence: .sisyphus/evidence/task-7-email-render.txt ``` **Commit**: YES - Message: `feat: configure email and missing-song notification mailable` - Files: `app/Mail/MissingSongRequest.php`, `resources/views/mail/missing-song.blade.php` - Pre-commit: `php artisan test` ### Wave 2: Core Features (6 parallel tasks) - [x] 8. Service List Page (Backend + Frontend) **What to do**: - TEST: Write Pest tests for ServiceController index endpoint — verify it returns Inertia page with services, filters by date >= today, includes status counts - Create `App\Http\Controllers\ServiceController` with `index()` method: - Query services where `date >= today`, order by date ascending - Include computed status fields: `songs_mapped` (x/y), `songs_arranged` (x/y), `has_sermon_slides`, `info_slides_count`, `finalized_at` - Return Inertia render `Services/Index` with services collection - Create `resources/js/Pages/Services/Index.vue`: - Table/list showing: Titel, Prediger, Beamer-Techniker, Anzahl Songs, Letzte Änderung, Status - Status indicators: "x/y Songs zugeordnet", "x/y Arrangements geprüft", "Predigtfolien", "Infofolien", "Abgeschlossen am" - Color coding: green checkmark for complete, red/orange for incomplete - Action buttons per service: - If NOT finalized: "Bearbeiten" (link to edit page) + "Abschließen" button - If finalized: "Wieder öffnen" + "Herunterladen" (placeholder — shows coming soon toast) - Wire up Finalize/ReOpen as `POST /services/{id}/finalize` and `POST /services/{id}/reopen` - Auto-refresh list after sync **Must NOT do**: - Do NOT show past services (only today and future) - Do NOT implement download content (placeholder only) - Do NOT inline-edit services from the list page **Recommended Agent Profile**: - **Category**: `deep` - **Skills**: [`frontend-ui-ux`] - `frontend-ui-ux`: Table/list layout with status indicators requires good UI judgment **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 2 (with T9-T13) - **Blocks**: T14 - **Blocked By**: T2, T3, T4, T6 **References**: - `app/Models/Service.php` — Service model with relationships (from T2) - `app/Models/ServiceSong.php` — for song mapping status counts - `app/Models/Slide.php` — for slide counts - `resources/js/Layouts/AuthenticatedLayout.vue` — layout wrapper (from T6) - AGENTS.md lines 29-45: Service list fields and action buttons specification - Pattern: follow Breeze `Dashboard.vue` for Inertia page structure **Acceptance Criteria**: - [ ] `php artisan test --filter=ServiceControllerTest` → PASS - [ ] Service list page renders with correct columns - [ ] Only today + future services shown - [ ] Finalize/ReOpen buttons toggle `finalized_at` field - [ ] Status indicators show correct x/y counts - [ ] All text in German with "Du" form **QA Scenarios:** ``` Scenario: Service list shows upcoming services with status Tool: Playwright Preconditions: Database seeded with 3 future services (1 with mapped songs, 1 without, 1 finalized) Steps: 1. Navigate to http://localhost:8000/services 2. Assert: page contains table/list with 3 service entries 3. Assert: first service row contains 'Prediger' column with preacher name 4. Assert: service with mapped songs shows green indicator or 'x/y' text 5. Assert: finalized service shows 'Wieder öffnen' button 6. Assert: non-finalized service shows 'Bearbeiten' and 'Abschließen' buttons Expected Result: Service list with correct German labels and status indicators Evidence: .sisyphus/evidence/task-8-service-list.png Scenario: Finalize and reopen service Tool: Playwright Preconditions: At least 1 non-finalized service in DB Steps: 1. Navigate to http://localhost:8000/services 2. Click 'Abschließen' button on first non-finalized service 3. Assert: service now shows 'Wieder öffnen' button instead 4. Assert: 'Abgeschlossen am' shows current date 5. Click 'Wieder öffnen' 6. Assert: service returns to non-finalized state with 'Bearbeiten' button Expected Result: Toggle finalization status works both ways Evidence: .sisyphus/evidence/task-8-finalize-toggle.png ``` **Commit**: YES - Message: `feat: add service list page with status indicators and finalization` - Files: `app/Http/Controllers/ServiceController.php`, `resources/js/Pages/Services/Index.vue`, routes - Pre-commit: `php artisan test` - [x] 9. Song Model + SongDB CRUD Backend **What to do**: - TEST: Write Pest tests for SongController — CRUD endpoints, soft delete, search by CCLI, search by name - Create `App\Http\Controllers\SongController` with full resource methods: - `index()`: list all songs (with soft-deleted excluded), searchable by name and CCLI ID, paginated - `store()`: create new song with metadata (title, ccli_id, author, copyright_text) - `show($id)`: return song with groups, slides, arrangements - `update($id)`: update song metadata - `destroy($id)`: soft-delete - Create `App\Http\Requests\SongRequest` for validation (title required, ccli_id unique) - Create `App\Services\SongService` for business logic: - `createDefaultGroups($song)`: create default groups (Strophe 1, Refrain, Bridge, etc.) if none exist - `createDefaultArrangement($song)`: create "Normal" arrangement referencing all groups in order - `duplicateArrangement($arrangement, $name)`: clone arrangement with new name - Add Song model accessors: `last_used_in_service` (computed from service_songs join) - Add SongGroup model: color field with default palette - Create API routes for JSON responses (used by Vue components via fetch/axios) **Must NOT do**: - Do NOT create Vue pages here (only backend API + controller) - Do NOT hard-delete songs (always soft-delete) - Do NOT auto-create songs from CTS API (songs are manually created or imported) **Recommended Agent Profile**: - **Category**: `unspecified-high` - **Skills**: [] **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 2 (with T8, T10-T13) - **Blocks**: T11, T12, T18-T23 - **Blocked By**: T2 **References**: - `app/Models/Song.php`, `app/Models/SongGroup.php`, `app/Models/SongSlide.php` — models from T2 - `app/Models/SongArrangement.php`, `app/Models/SongArrangementGroup.php` — arrangement models from T2 - AGENTS.md lines 89-97: SongDB page specification (CRUD, edit, download, translate, upload) - AGENTS.md lines 66-80: Song block fields (CCLI ID, arrangement, translation, preview/download) **Acceptance Criteria**: - [ ] `php artisan test --filter=SongControllerTest` → PASS - [ ] CRUD endpoints work: create, read, update, soft-delete - [ ] Search by name returns matching songs - [ ] Search by CCLI ID returns matching songs - [ ] Default arrangement "Normal" created with new song - [ ] `last_used_in_service` accessor returns correct date **QA Scenarios:** ``` Scenario: Create and retrieve a song via API Tool: Bash (curl) Preconditions: Authenticated session cookie Steps: 1. POST /api/songs with body: {"title": "Großer Gott wir loben dich", "ccli_id": "12345", "author": "Test"} 2. Assert: HTTP 201, response contains song with id 3. GET /api/songs/{id} 4. Assert: response contains title, ccli_id, groups array, arrangements array 5. Assert: arrangements contains at least one entry named "Normal" Expected Result: Song created with default arrangement Evidence: .sisyphus/evidence/task-9-song-crud.txt Scenario: Search songs by CCLI ID Tool: Bash (curl) Steps: 1. GET /api/songs?search=12345 2. Assert: response contains the song with ccli_id=12345 3. GET /api/songs?search=99999 4. Assert: response is empty array Expected Result: CCLI search works correctly Evidence: .sisyphus/evidence/task-9-song-search.txt ``` **Commit**: YES - Message: `feat: add Song CRUD controller with search and default arrangements` - Files: `app/Http/Controllers/SongController.php`, `app/Services/SongService.php`, `app/Http/Requests/SongRequest.php`, routes - Pre-commit: `php artisan test` - [x] 10. Slide Upload Component (Shared Reusable) **What to do**: - TEST: Write Pest tests for SlideController — upload image, upload ZIP, verify conversion, verify thumbnail generation - Create `App\Http\Controllers\SlideController` with: - `store(Request $request)`: accept file upload, determine type (image/ppt/zip), dispatch conversion - `destroy($id)`: soft-delete slide - `updateExpireDate($id, Request $request)`: update expire_date for info slides - Create reusable Vue component `resources/js/Components/SlideUploader.vue`: - Uses `@jaxtheprime/vue3-dropzone` for drag-and-drop upload area - Big "+" icon / dotted border area for drag-and-drop or click-to-upload - Accepts: png, jpg, jpeg, ppt, pptx, zip - Shows upload progress bar - Props: `type` (information|moderation|sermon), `serviceId` (nullable), `showExpireDate` (bool) - If `showExpireDate=true`: show datepicker for expire date applied to all uploaded files - After upload: emit event to refresh parent's slide list - Create `resources/js/Components/SlideGrid.vue`: - Grid of thumbnails for uploaded slides - Each thumbnail shows: image preview, upload date (muted), uploader name (muted) - If type=information: prominent expire date field with inline datepicker for editing - Delete button (soft-delete with confirmation) - For PPT uploads: show progress indicator while job is processing - Use Inertia `router.post` with FormData for file uploads - Handle PPT async: poll for job completion, then refresh slide grid **Must NOT do**: - Do NOT process PPT synchronously in the request (use job from T5) - Do NOT allow file types outside png/jpg/jpeg/ppt/pptx/zip - Do NOT create page-level components (these are reusable building blocks) **Recommended Agent Profile**: - **Category**: `visual-engineering` - **Skills**: [`frontend-ui-ux`] - `frontend-ui-ux`: Upload area and thumbnail grid need polished UI **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 2 (with T8-T9, T11-T13) - **Blocks**: T15, T16, T17 - **Blocked By**: T2, T5, T6 **References**: - `app/Services/FileConversionService.php` — conversion logic from T5 - `app/Jobs/ConvertPowerPointJob.php` — async PPT processing from T5 - `app/Models/Slide.php` — slide model from T2 - `@jaxtheprime/vue3-dropzone` docs: https://github.com/jaxtheprime/vue3-dropzone - AGENTS.md lines 51-54: "big plus icon/area for drag'n'drop", "thumbnails with upload date, uploader name, expire date" - AGENTS.md lines 82-87: File upload specifications (image→JPG, PPT→slides, ZIP→extract) **Acceptance Criteria**: - [ ] `php artisan test --filter=SlideControllerTest` → PASS - [ ] Image upload → converted to 1920×1080 JPG + thumbnail - [ ] ZIP upload → all contained images processed - [ ] PPT upload → job dispatched, slides created after completion - [ ] SlideUploader component renders drop zone with '+' area - [ ] SlideGrid shows thumbnails with metadata - [ ] Expire date editable inline for information slides **QA Scenarios:** ``` Scenario: Upload image via drop zone Tool: Playwright Preconditions: Authenticated, on a page with SlideUploader component Steps: 1. Locate the upload area (dotted border / '+' icon area) 2. Upload a test 400x300 PNG via file input 3. Assert: progress bar appears and completes 4. Assert: new thumbnail appears in SlideGrid 5. Assert: thumbnail shows upload date and uploader name Expected Result: Image uploaded, converted, and displayed as thumbnail Evidence: .sisyphus/evidence/task-10-slide-upload.png Scenario: Reject invalid file type Tool: Playwright Steps: 1. Try to upload a .txt file 2. Assert: error message shown (e.g. 'Dateityp nicht erlaubt') 3. Assert: no slide created in grid Expected Result: Invalid files rejected with German error message Evidence: .sisyphus/evidence/task-10-invalid-file.png ``` **Commit**: YES - Message: `feat: add reusable slide upload and grid components` - Files: `app/Http/Controllers/SlideController.php`, `resources/js/Components/SlideUploader.vue`, `resources/js/Components/SlideGrid.vue` - Pre-commit: `php artisan test` - [x] 11. Arrangement Model + Configurator Component **What to do**: - TEST: Write Pest tests for ArrangementController — create, clone, update group order, delete - Create `App\Http\Controllers\ArrangementController` with: - `store(Song $song)`: create new arrangement (clone from default order), accept name via request - `clone($id)`: duplicate an existing arrangement with new name - `update($id)`: save reordered groups (accept array of `{song_group_id, order}`) - `destroy($id)`: delete arrangement (prevent deleting last one) - Create `resources/js/Components/ArrangementConfigurator.vue`: - Select dropdown listing all arrangements for this song ("Normal" pre-selected as default) - "Hinzufügen" button → prompt for name, creates new arrangement (clone from default) - "Klonen" button → prompt for name, clones current arrangement - Below select: show the groups of the selected arrangement as colored pills (like `ref/form-song-arangment-config.png`) - Each pill shows group name with its color background - Pool area showing all available groups from the song (source for drag) - Drag-and-drop to reorder pills and add groups from pool - Use `vue-draggable-plus` with clone mode for pool→sequence and sort mode for reordering - CRITICAL: Vue key must be `${group.id}-${index}` not just `group.id` (groups can repeat) - Auto-save on every drag-end via Inertia `router.put` with `preserveScroll: true` - Each group pill should have: color background, name text, remove button (×) - Color picker integration: clicking on a group in the pool lets you change its color (stored in `song_groups.color`) **Must NOT do**: - Do NOT allow deleting the last arrangement of a song - Do NOT sync arrangements from CTS API (manually created only) - Do NOT use `group.id` alone as Vue key (groups repeat in arrangements) **Recommended Agent Profile**: - **Category**: `deep` - **Skills**: [`frontend-ui-ux`] - `frontend-ui-ux`: Complex drag-and-drop interaction with colored pills requires careful UI work **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 2 (with T8-T10, T12-T13) - **Blocks**: T18, T21 - **Blocked By**: T2, T9 **References**: - `ref/form-song-arangment-config.png` — reference UI screenshot showing colored pills, arrangement dropdown, group pool - `vue-draggable-plus` docs: clone mode for pool→sequence drag - `app/Models/SongArrangement.php`, `app/Models/SongArrangementGroup.php` — models from T2 - `app/Models/SongGroup.php` — groups with color field from T2 - AGENTS.md lines 73-77: arrangement select, add/clone buttons, drag-and-drop groups **Acceptance Criteria**: - [ ] `php artisan test --filter=ArrangementControllerTest` → PASS - [ ] Create arrangement: new arrangement appears in select - [ ] Clone arrangement: exact copy with new name - [ ] Drag-and-drop reorder persists on drop-end - [ ] Groups from pool can be dragged into arrangement (clone mode) - [ ] Groups can be removed from arrangement via × button - [ ] Color picker changes group color and persists - [ ] Cannot delete last arrangement (error shown) **QA Scenarios:** ``` Scenario: Create and configure arrangement Tool: Playwright Preconditions: Song exists with 3 groups (Strophe 1, Refrain, Bridge) Steps: 1. Navigate to song arrangement area 2. Click 'Hinzufügen' button 3. Enter name 'Abend-Version' in prompt 4. Assert: new arrangement appears in select dropdown 5. Assert: arrangement shows all groups as colored pills 6. Drag 'Refrain' pill to position after 'Bridge' 7. Assert: order updated — Strophe 1, Bridge, Refrain 8. Reload page 9. Assert: new order persisted Expected Result: Arrangement created and reorderable via drag-and-drop Evidence: .sisyphus/evidence/task-11-arrangement-create.png Scenario: Clone arrangement Tool: Playwright Steps: 1. Select 'Abend-Version' arrangement 2. Click 'Klonen' button 3. Enter name 'Spezial' 4. Assert: new arrangement 'Spezial' appears in dropdown 5. Assert: same group order as cloned source Expected Result: Arrangement cloned successfully Evidence: .sisyphus/evidence/task-11-arrangement-clone.png ``` **Commit**: YES - Message: `feat: add arrangement configurator with drag-and-drop group management` - Files: `app/Http/Controllers/ArrangementController.php`, `resources/js/Components/ArrangementConfigurator.vue`, routes - Pre-commit: `php artisan test` - [x] 12. Song Matching Service (CCLI ID) **What to do**: - TEST: Write Pest tests for song matching — auto-match by CCLI, manual assign, request email - Create `App\Services\SongMatchingService` with: - `autoMatch(ServiceSong $serviceSong)`: look up `songs.ccli_id` matching `service_songs.cts_ccli_id`, set `song_id` and `matched_at` if found - `manualAssign(ServiceSong $serviceSong, Song $song)`: manually assign a song to a service song, set `matched_at` - `requestCreation(ServiceSong $serviceSong)`: send `MissingSongRequest` email (from T7), set `request_sent_at` - Hook auto-matching into `ChurchToolsService::syncAgenda()` (from T4) — after creating/updating service_songs, run `autoMatch` on each - Create `App\Http\Controllers\ServiceSongController` with: - `assignSong($serviceSongId, Request $request)`: manually assign a song → accepts `song_id` - `requestSong($serviceSongId)`: trigger email and set `request_sent_at` - `unassign($serviceSongId)`: remove manual assignment - Frontend will be built in T18 (Songs block) — this task is backend only **Must NOT do**: - Do NOT build the frontend UI here (that's T18) - Do NOT auto-create songs from CTS API data - Do NOT send email automatically on sync — only on explicit user request **Recommended Agent Profile**: - **Category**: `unspecified-high` - **Skills**: [] **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 2 (with T8-T11, T13) - **Blocks**: T18 - **Blocked By**: T2, T4, T7, T9 **References**: - `app/Services/ChurchToolsService.php` — sync pipeline from T4 where matching integrates - `app/Mail/MissingSongRequest.php` — email mailable from T7 - `app/Models/ServiceSong.php` — service_songs model from T2 - `app/Models/Song.php` — songs model from T2 - AGENTS.md lines 67-70: CCLI matching, 'request creation' button, manual select field **Acceptance Criteria**: - [ ] `php artisan test --filter=SongMatchingTest` → PASS - [ ] Auto-match: service song with CCLI 12345 matches song with ccli_id 12345 - [ ] Manual assign: endpoint sets song_id and matched_at - [ ] Request email: sends MissingSongRequest email, sets request_sent_at - [ ] Unmatched songs remain with song_id = null **QA Scenarios:** ``` Scenario: Auto-match song by CCLI ID Tool: Bash Preconditions: Song with ccli_id='12345' in DB, service_song with cts_ccli_id='12345' and song_id=null Steps: 1. Run: php artisan cts:sync (or call matching directly via tinker) 2. Query: ServiceSong where cts_ccli_id='12345' 3. Assert: song_id is now set to the matching Song's id 4. Assert: matched_at is not null Expected Result: Auto-matching links service song to DB song Evidence: .sisyphus/evidence/task-12-auto-match.txt Scenario: Request missing song email Tool: Bash Preconditions: Unmatched service_song exists, MAIL config set to log driver Steps: 1. POST /api/service-songs/{id}/request 2. Assert: HTTP 200 3. Check laravel.log or mail log for 'Song-Anfrage' subject 4. Assert: service_song.request_sent_at is now set Expected Result: Email sent and timestamp recorded Evidence: .sisyphus/evidence/task-12-request-email.txt ``` **Commit**: YES - Message: `feat: add song matching service with CCLI auto-match and request email` - Files: `app/Services/SongMatchingService.php`, `app/Http/Controllers/ServiceSongController.php`, routes - Pre-commit: `php artisan test` - [x] 13. Translation Service (URL Scrape + Manual) **What to do**: - TEST: Write Pest tests for TranslationService — mock HTTP scrape, manual text import, line-count matching - Create `App\Services\TranslationService` with: - `fetchFromUrl(string $url): ?string` — best-effort HTTP GET, extract text content (strip HTML tags), return raw text or null on failure - `importTranslation(Song $song, string $text)`: distribute translated text across slides - For each group's slides: take matching line count from translated text - If original slide has 4 lines → take next 4 lines from translation - Store in `song_slides.text_content_translated` - `markAsTranslated(Song $song)`: set `songs.has_translation = true` - `removeTranslation(Song $song)`: clear all `text_content_translated`, set `has_translation = false` - Create `App\Http\Controllers\TranslationController` with: - `fetchUrl(Request $request)`: accept URL, return scraped text for review before import - `import(Song $song, Request $request)`: accept full text, run `importTranslation` - URL scraping is best-effort only — gracefully handle failures (return null, show German error message) **Must NOT do**: - Do NOT build site-specific scrapers (only generic HTTP fetch + strip tags) - Do NOT auto-save URL fetch result (user reviews text first, then explicitly saves) - Do NOT build the translation editor UI here (that's T22) **Recommended Agent Profile**: - **Category**: `unspecified-high` - **Skills**: [] **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 2 (with T8-T12) - **Blocks**: T22 - **Blocked By**: T2, T9 **References**: - `app/Models/Song.php` — `has_translation` field from T2 - `app/Models/SongSlide.php` — `text_content_translated` field from T2 - AGENTS.md line 96: "allow add a full text or an URL to the Full text ... always the same line qty of text from the original" **Acceptance Criteria**: - [ ] `php artisan test --filter=TranslationServiceTest` → PASS - [ ] URL fetch returns text content or null (no exception) - [ ] Import distributes lines matching original slide line counts - [ ] Song marked as `has_translation = true` after import - [ ] Remove translation clears all translated text and sets flag to false **QA Scenarios:** ``` Scenario: Import translation with line-count matching Tool: Bash Preconditions: Song with 2 groups, group 1 has 2 slides (4 lines, 2 lines), group 2 has 1 slide (4 lines) Steps: 1. Prepare translated text: 10 lines total 2. POST /api/songs/{id}/translation/import with {text: '10 lines...'} 3. Query song slides 4. Assert: slide 1 has 4 translated lines 5. Assert: slide 2 has 2 translated lines 6. Assert: slide 3 has 4 translated lines 7. Assert: song.has_translation = true Expected Result: Translation distributed by slide line counts Evidence: .sisyphus/evidence/task-13-translation-import.txt Scenario: URL fetch failure handled gracefully Tool: Bash Steps: 1. POST /api/translation/fetch-url with {url: 'https://nonexistent.invalid/lyrics'} 2. Assert: HTTP 200 with {text: null, error: 'Konnte Text nicht abrufen'} Expected Result: Graceful failure with German error message Evidence: .sisyphus/evidence/task-13-url-failure.txt ``` **Commit**: YES - Message: `feat: add translation service with URL scrape and line-count distribution` - Files: `app/Services/TranslationService.php`, `app/Http/Controllers/TranslationController.php`, routes - Pre-commit: `php artisan test` ### Wave 3: Service Edit + Song UI (6 parallel tasks) - [ ] 14. Service Edit Page Layout + Routing **What to do**: - TEST: Write Pest test for ServiceController edit/update — verify Inertia render with service data and related models - Add `edit($id)` method to `ServiceController`: - Load service with: service_songs (with song, arrangement), slides (information/moderation/sermon), preacher, beamer tech - Return Inertia render `Services/Edit` - Create `resources/js/Pages/Services/Edit.vue`: - Page title: service title + date - 4 collapsible/tabbed blocks: Information, Moderation, Predigt, Songs - Each block is a child component (from T15-T18) - Auto-save behavior inherited from `useAutoSave` composable (T6) - Back button: navigates to service list - Add route: `GET /services/{id}/edit` - Wire up block components with proper props (service, songs, slides) **Must NOT do**: - Do NOT implement block content here (just the layout/routing shell) - Do NOT add a save button (auto-save only) **Recommended Agent Profile**: - **Category**: `visual-engineering` - **Skills**: [`frontend-ui-ux`] - `frontend-ui-ux`: Page layout with collapsible sections requires good visual structure **Parallelization**: - **Can Run In Parallel**: YES (first in Wave 3, but other Wave 3 tasks can start in parallel) - **Parallel Group**: Wave 3 (with T15-T19) - **Blocks**: T15, T16, T17, T18, T19 - **Blocked By**: T8, T6 **References**: - `app/Http/Controllers/ServiceController.php` — extend with edit method (from T8) - `resources/js/Layouts/AuthenticatedLayout.vue` — layout wrapper (from T6) - `resources/js/Composables/useAutoSave.js` — auto-save composable (from T6) - AGENTS.md lines 40-45: Edit shows form with blocks (information, moderation, sermon, songs) **Acceptance Criteria**: - [ ] `php artisan test --filter=ServiceEditTest` → PASS - [ ] `/services/{id}/edit` renders edit page with service data - [ ] All 4 blocks visible (Information, Moderation, Predigt, Songs) - [ ] Back button navigates to service list - [ ] Page title shows service name and date **QA Scenarios:** ``` Scenario: Service edit page renders with all blocks Tool: Playwright Preconditions: Service with id=1 exists, has songs and slides Steps: 1. Navigate to http://localhost:8000/services/1/edit 2. Assert: page title contains service title 3. Assert: page contains section/tab labeled 'Information' 4. Assert: page contains section/tab labeled 'Moderation' 5. Assert: page contains section/tab labeled 'Predigt' 6. Assert: page contains section/tab labeled 'Songs' 7. Assert: back button/link exists pointing to /services Expected Result: Edit page with all 4 German-labeled blocks Evidence: .sisyphus/evidence/task-14-edit-layout.png ``` **Commit**: YES - Message: `feat: add service edit page layout with 4-block structure` - Files: `resources/js/Pages/Services/Edit.vue`, routes - Pre-commit: `php artisan test` - [ ] 15. Information Block (Slides + Expire Dates) **What to do**: - TEST: Write Pest test that verifies info slides are dynamically queried (expire_date > service_date) - Create `resources/js/Components/Blocks/InformationBlock.vue`: - Uses `SlideGrid` component (from T10) to display information slides - Uses `SlideUploader` component (from T10) with `showExpireDate=true` - Dynamically shows all slides where `type='information' AND expire_date >= service.date` - Each slide shows: thumbnail, upload date (muted), uploader name (muted), **prominent expire date** with inline datepicker - Delete button (soft-delete) on each slide - Editing expire date saves immediately (auto-save) - Create backend support: `SlideController` method to query information slides by service date - Information slides are NOT tied to a specific service — they appear in ALL services where `expire_date >= service.date` **Must NOT do**: - Do NOT copy info slides into services (query dynamically by date) - Do NOT show expired slides **Recommended Agent Profile**: - **Category**: `visual-engineering` - **Skills**: [`frontend-ui-ux`] - `frontend-ui-ux`: Thumbnail grid with prominent expire dates needs polished layout **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 3 (with T14, T16-T19) - **Blocks**: T24 - **Blocked By**: T10, T14 **References**: - `resources/js/Components/SlideUploader.vue`, `resources/js/Components/SlideGrid.vue` — from T10 - `app/Models/Slide.php` — slide model from T2 - AGENTS.md lines 49-54: Information block specification (thumbnails, expire date, uploader, drag-and-drop upload) - AGENTS.md line 54: "automatically show these files to all future services, till the expire date is after the service date" **Acceptance Criteria**: - [ ] Info slides from other services shown if expire_date >= current service date - [ ] Expired slides NOT shown - [ ] Upload new slide with expire date → appears in slide grid - [ ] Change expire date inline → saves immediately - [ ] Delete slide → soft-deleted, disappears from grid **QA Scenarios:** ``` Scenario: Info slides shown dynamically by expire date Tool: Playwright Preconditions: 2 info slides in DB — one expires tomorrow, one expired yesterday. Service date = today. Steps: 1. Navigate to /services/{id}/edit 2. Open Information block 3. Assert: only the non-expired slide is visible (1 thumbnail) 4. Assert: expired slide is NOT shown 5. Assert: visible slide shows expire date prominently Expected Result: Dynamic filtering by expire_date vs service date Evidence: .sisyphus/evidence/task-15-info-dynamic.png Scenario: Upload new info slide with expire date Tool: Playwright Steps: 1. In Information block, select expire date via datepicker (2 weeks from now) 2. Upload a test PNG file 3. Assert: new thumbnail appears in grid 4. Assert: expire date shown matches selected date Expected Result: New info slide uploaded with correct expire date Evidence: .sisyphus/evidence/task-15-info-upload.png ``` **Commit**: YES - Message: `feat: add Information block with dynamic expire-date filtering` - Files: `resources/js/Components/Blocks/InformationBlock.vue` - Pre-commit: `php artisan test` - [ ] 16. Moderation Block (Slides, Service-Specific) **What to do**: - TEST: Write Pest test verifying moderation slides are service-specific (not shared across services) - Create `resources/js/Components/Blocks/ModerationBlock.vue`: - Same UI as Information block BUT: - NO expire date field (no datepicker) - Slides are tied to THIS service only (service_id FK set) - Uses `SlideGrid` component (from T10) with `showExpireDate=false` - Uses `SlideUploader` component (from T10) with `showExpireDate=false` - Upload, view thumbnails, delete — standard slide management - Backend: SlideController already handles this — just pass `type='moderation'` and `service_id` **Must NOT do**: - Do NOT add expire date functionality (that's Information block only) - Do NOT share moderation slides across services **Recommended Agent Profile**: - **Category**: `quick` - **Skills**: [] **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 3 (with T14-T15, T17-T19) - **Blocks**: T24 - **Blocked By**: T10, T14 **References**: - `resources/js/Components/Blocks/InformationBlock.vue` — same pattern but simpler (from T15) - `resources/js/Components/SlideUploader.vue`, `resources/js/Components/SlideGrid.vue` — from T10 - AGENTS.md line 58: "Same features as Block Information but without the datepicker and only relevant for this service" **Acceptance Criteria**: - [ ] Moderation slides belong to specific service (not shared) - [ ] No expire date field visible - [ ] Upload, view, delete works same as information block **QA Scenarios:** ``` Scenario: Moderation slides are service-specific Tool: Playwright Preconditions: Service A has 2 moderation slides. Service B has 0. Steps: 1. Navigate to /services/{A}/edit, open Moderation block 2. Assert: 2 slides visible 3. Navigate to /services/{B}/edit, open Moderation block 4. Assert: 0 slides visible (no cross-service sharing) 5. Assert: no datepicker / expire date field visible Expected Result: Moderation slides scoped to individual service Evidence: .sisyphus/evidence/task-16-moderation-scoped.png ``` **Commit**: YES (group with T17) - Message: `feat: add Moderation and Sermon slide blocks` - Files: `resources/js/Components/Blocks/ModerationBlock.vue` - Pre-commit: `php artisan test` - [ ] 17. Sermon Block (Slides, Service-Specific) **What to do**: - TEST: Write Pest test verifying sermon slides are service-specific - Create `resources/js/Components/Blocks/SermonBlock.vue`: - Identical to Moderation block but with `type='sermon'` - Service-specific slides, no expire date - Uses same `SlideGrid` and `SlideUploader` components - Essentially a thin wrapper passing `type='sermon'` to the shared components **Must NOT do**: - Do NOT add any features beyond what Moderation block has - Do NOT share sermon slides across services **Recommended Agent Profile**: - **Category**: `quick` - **Skills**: [] **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 3 (with T14-T16, T18-T19) - **Blocks**: T24 - **Blocked By**: T10, T14 **References**: - `resources/js/Components/Blocks/ModerationBlock.vue` — identical pattern (from T16) - AGENTS.md line 62: "Same features as Block Moderation" **Acceptance Criteria**: - [ ] Sermon slides are service-specific - [ ] Same upload/view/delete functionality as Moderation block - [ ] type='sermon' correctly set on new slides **QA Scenarios:** ``` Scenario: Sermon block functions identically to Moderation Tool: Playwright Preconditions: Service with no sermon slides Steps: 1. Navigate to /services/{id}/edit, open Predigt block 2. Assert: empty state with upload area 3. Upload a test image 4. Assert: thumbnail appears with upload date and uploader name 5. Assert: no expire date field Expected Result: Sermon block works like Moderation Evidence: .sisyphus/evidence/task-17-sermon-block.png ``` **Commit**: YES (group with T16) - Message: `feat: add Moderation and Sermon slide blocks` - Files: `resources/js/Components/Blocks/SermonBlock.vue` - Pre-commit: `php artisan test` - [ ] 18. Songs Block (Matching + Arrangement + Translation) **What to do**: - TEST: Write Pest tests for the Songs block Inertia endpoint — verify service songs returned with match status, arrangement data - Create `resources/js/Components/Blocks/SongsBlock.vue` — the most complex block: - Show all service songs in order from CTS agenda - Each song row shows: Name, CCLI ID, "Hat Übersetzung" indicator - **If NOT matched** (song_id = null): - Show "Erstellung anfragen" button → calls `POST /api/service-songs/{id}/request` (from T12) - Show searchable select dropdown of all songs from DB (title + CCLI searchable) → calls manual assign (from T12) - **If matched** (song_id set): - If song `has_translation=true`: show checkbox "Übersetzung verwenden" (default: checked), auto-saves - Show `ArrangementConfigurator` component (from T11) for this song in this service context - Default arrangement "Normal" pre-selected - Show "Vorschau" button → opens preview modal (from T19) - Show "PDF herunterladen" button → triggers PDF download (from T19) - Use auto-save for all changes (checkbox toggle, arrangement selection) - Song order matches CTS agenda order (not draggable here) **Must NOT do**: - Do NOT allow reordering songs (order comes from CTS API) - Do NOT allow adding/removing songs (that happens in CTS) - Do NOT build the preview modal here (that's T19) **Recommended Agent Profile**: - **Category**: `deep` - **Skills**: [`frontend-ui-ux`] - `frontend-ui-ux`: Complex conditional UI (matched vs unmatched states) needs careful design **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 3 (with T14-T17, T19) - **Blocks**: T24 - **Blocked By**: T11, T12, T14 **References**: - `resources/js/Components/ArrangementConfigurator.vue` — arrangement component (from T11) - `app/Http/Controllers/ServiceSongController.php` — matching endpoints (from T12) - `app/Models/ServiceSong.php` — service_songs model (from T2) - AGENTS.md lines 64-80: Full Songs block specification (matching, arrangement, preview, download) **Acceptance Criteria**: - [ ] Songs shown in CTS agenda order - [ ] Unmatched songs show 'Erstellung anfragen' button and searchable select - [ ] Matched songs show arrangement configurator - [ ] Translation checkbox appears for translated songs - [ ] Vorschau and PDF buttons visible for songs with arrangements - [ ] All auto-save interactions work **QA Scenarios:** ``` Scenario: Unmatched song shows matching options Tool: Playwright Preconditions: Service with 1 unmatched song (song_id=null, cts_song_name='Amazing Grace') Steps: 1. Navigate to /services/{id}/edit, open Songs block 2. Assert: song row shows 'Amazing Grace' 3. Assert: 'Erstellung anfragen' button visible 4. Assert: searchable select dropdown visible 5. Assert: NO arrangement configurator shown (not matched yet) Expected Result: Unmatched song shows request and manual assign options Evidence: .sisyphus/evidence/task-18-unmatched-song.png Scenario: Matched song shows arrangement and controls Tool: Playwright Preconditions: Service with 1 matched song (has_translation=true, has arrangement 'Normal') Steps: 1. Navigate to /services/{id}/edit, open Songs block 2. Assert: arrangement select shows 'Normal' selected 3. Assert: 'Übersetzung verwenden' checkbox visible and checked 4. Assert: 'Vorschau' button visible 5. Assert: 'PDF herunterladen' button visible Expected Result: Matched song shows full control panel Evidence: .sisyphus/evidence/task-18-matched-song.png ``` **Commit**: YES - Message: `feat: add Songs block with matching UI and arrangement integration` - Files: `resources/js/Components/Blocks/SongsBlock.vue` - Pre-commit: `php artisan test` - [ ] 19. Song Preview Modal + PDF Download **What to do**: - TEST: Write Pest tests for PDF generation endpoint — verify PDF returns correct content type and contains song text - Create `resources/js/Components/SongPreviewModal.vue`: - HTML overlay/modal showing song text in arrangement order - Each group's slides shown sequentially - Group name shown as header with group's color as background - If `use_translation=true`: show original + translated text side by side (or translated below original) - Copyright footer at bottom from song metadata - Close button / click-outside to dismiss - Create `App\Http\Controllers\SongPdfController` with `download($songId, $arrangementId)` method: - Use `barryvdh/laravel-dompdf` to generate PDF - Template: `resources/views/pdf/song.blade.php` - MUST use old-school CSS (no Tailwind classes!) with `DejaVu Sans` font for German umlauts - Layout: song title header, groups with colored headers, slide text, copyright footer - If `use_translation=true`: include translation text - Return PDF download with filename `{song-title}-{arrangement-name}.pdf` - Wire up buttons: "Vorschau" opens modal, "PDF herunterladen" triggers download **Must NOT do**: - Do NOT use Tailwind CSS in the DomPDF Blade template (DomPDF doesn't support it) - Do NOT use web fonts in PDF (use DejaVu Sans which is bundled with DomPDF) **Recommended Agent Profile**: - **Category**: `unspecified-high` - **Skills**: [`frontend-ui-ux`] - `frontend-ui-ux`: Preview modal needs clean, readable formatting **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 3 (with T14-T18) - **Blocks**: — - **Blocked By**: T9, T11 **References**: - `barryvdh/laravel-dompdf` docs: https://github.com/barryvdh/laravel-dompdf - `app/Models/Song.php`, `app/Models/SongGroup.php`, `app/Models/SongSlide.php` — data models from T2 - `app/Models/SongArrangement.php`, `app/Models/SongArrangementGroup.php` — arrangement models from T2 - AGENTS.md line 79: "preview: show text of the song in the order of the arrangement configuration, prominent highlighted which textpart was with group" - AGENTS.md line 80: "download: download the preview as a nice pdf with header/footer and copyright footer" **Acceptance Criteria**: - [ ] Preview modal opens with song text in arrangement order - [ ] Group headers show name with group color - [ ] PDF downloads with correct filename - [ ] PDF renders German umlauts correctly (DejaVu Sans) - [ ] PDF includes copyright footer - [ ] Translation text shown when use_translation=true **QA Scenarios:** ``` Scenario: Preview modal shows formatted song text Tool: Playwright Preconditions: Song with 3 groups (Strophe 1, Refrain, Bridge), arrangement 'Normal' ordering all 3 Steps: 1. Navigate to song's 'Vorschau' button and click it 2. Assert: modal/overlay appears 3. Assert: first section header contains 'Strophe 1' with colored background 4. Assert: slide text content visible below each header 5. Assert: copyright text shown at bottom 6. Click outside modal or close button 7. Assert: modal dismissed Expected Result: Clean formatted preview with colored group headers Evidence: .sisyphus/evidence/task-19-preview-modal.png Scenario: PDF download with German umlauts Tool: Bash (curl) Steps: 1. GET /api/songs/{id}/pdf/{arrangementId} 2. Assert: Content-Type is application/pdf 3. Assert: Content-Disposition contains song title in filename 4. Save to /tmp/song-test.pdf 5. Run: pdftotext /tmp/song-test.pdf - | head -20 6. Assert: output contains song title and group names 7. Assert: German umlauts (äöüß) render correctly (not garbled) Expected Result: Valid PDF with correct text and umlauts Evidence: .sisyphus/evidence/task-19-pdf-download.txt ``` **Commit**: YES - Message: `feat: add song preview modal and PDF download with DomPDF` - Files: `resources/js/Components/SongPreviewModal.vue`, `app/Http/Controllers/SongPdfController.php`, `resources/views/pdf/song.blade.php`, routes - Pre-commit: `php artisan test` ### Wave 4: Song DB + Finalization (5 parallel tasks) - [ ] 20. Song DB Page (List + Search + Filters) **What to do**: - TEST: Write Pest test for SongDB index page — verify Inertia render with paginated songs, search, soft-delete excluded - Create `resources/js/Pages/Songs/Index.vue`: - Table showing all songs: Titel, CCLI-ID, Erstellt, Letzte Änderung, Zuletzt verwendet, Hat Übersetzung - Search bar: search by song name or CCLI ID - Each row has action buttons: "Bearbeiten" (edit), "Löschen" (soft-delete), "Herunterladen" (.pro download), "Übersetzen" (translate) - Upload area at top: drag-and-drop / click for .pro file upload (placeholder from T23) - Soft-deleted songs not shown (can add 'Papierkorb anzeigen' toggle later) - Pagination for large song lists - Add route: `GET /songs` (handled by SongController index from T9) - Wire up to navigation: "Song-Datenbank" link in top bar (from T6) **Must NOT do**: - Do NOT implement .pro parser (placeholder from T23) - Do NOT show soft-deleted songs by default **Recommended Agent Profile**: - **Category**: `visual-engineering` - **Skills**: [`frontend-ui-ux`] - `frontend-ui-ux`: Data table with search, actions, and upload area needs clean design **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 4 (with T21-T24) - **Blocks**: — - **Blocked By**: T9, T6 **References**: - `app/Http/Controllers/SongController.php` — backend CRUD from T9 - `resources/js/Layouts/AuthenticatedLayout.vue` — layout with navigation from T6 - AGENTS.md lines 91-93: "shows all songs from DB with created, last update, ccliID, last_used_in_service" + action buttons **Acceptance Criteria**: - [ ] Song list page renders with correct columns in German - [ ] Search by name returns matching songs - [ ] Search by CCLI ID returns matching songs - [ ] Action buttons visible on each row - [ ] Pagination works for large lists - [ ] Upload area visible at top **QA Scenarios:** ``` Scenario: Song database page with search Tool: Playwright Preconditions: 5 songs in DB, one named 'Großer Gott' Steps: 1. Navigate to http://localhost:8000/songs 2. Assert: table shows 5 song rows 3. Assert: columns include 'Titel', 'CCLI-ID', 'Zuletzt verwendet' 4. Type 'Großer' in search bar 5. Assert: table filters to 1 result containing 'Großer Gott' 6. Assert: row shows 'Bearbeiten', 'Löschen', 'Herunterladen', 'Übersetzen' buttons Expected Result: Searchable song list with German UI Evidence: .sisyphus/evidence/task-20-songdb-list.png ``` **Commit**: YES - Message: `feat: add Song Database page with search and action buttons` - Files: `resources/js/Pages/Songs/Index.vue`, routes - Pre-commit: `php artisan test` - [ ] 21. Song DB Edit Popup (Metadata + Arrangement) **What to do**: - TEST: Write Pest test for song update endpoint — verify metadata update works - Create `resources/js/Components/SongEditModal.vue`: - Modal/popup triggered by "Bearbeiten" button on song list - Fields: Titel (text input), CCLI-ID (text input), Copyright-Text (textarea) - Show any additional metadata available from song model - Below metadata: embed `ArrangementConfigurator` component (from T11) - Auto-save on all field changes (use `useAutoSave` from T6) - Close button / click-outside to dismiss - Wire up to `SongController::update()` for metadata saves **Must NOT do**: - Do NOT allow editing song groups/slides content here (that's for the translate page) - Do NOT duplicate the arrangement logic (reuse component from T11) **Recommended Agent Profile**: - **Category**: `visual-engineering` - **Skills**: [`frontend-ui-ux`] - `frontend-ui-ux`: Modal form with embedded arrangement configurator needs good UX flow **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 4 (with T20, T22-T24) - **Blocks**: — - **Blocked By**: T9, T11 **References**: - `resources/js/Components/ArrangementConfigurator.vue` — arrangement component from T11 - `app/Http/Controllers/SongController.php` — update endpoint from T9 - `resources/js/Composables/useAutoSave.js` — auto-save composable from T6 - AGENTS.md line 94: "edit: shows a popup with Name, CCLI and copyright text and the arrangement configurator" **Acceptance Criteria**: - [ ] Modal opens with song metadata pre-filled - [ ] Editing title auto-saves after debounce - [ ] CCLI ID editable and saves - [ ] Copyright text editable and saves - [ ] Arrangement configurator rendered inside modal - [ ] Modal closes on × or outside click **QA Scenarios:** ``` Scenario: Edit song metadata via popup Tool: Playwright Preconditions: Song 'Amazing Grace' with CCLI 12345 in DB Steps: 1. Navigate to /songs 2. Click 'Bearbeiten' on 'Amazing Grace' row 3. Assert: modal appears with title 'Amazing Grace' pre-filled 4. Assert: CCLI field shows '12345' 5. Change title to 'Amazing Grace (Neu)' 6. Wait 600ms (debounce) 7. Close modal 8. Assert: song list shows updated title 'Amazing Grace (Neu)' Expected Result: Auto-saving metadata edit in modal Evidence: .sisyphus/evidence/task-21-song-edit.png ``` **Commit**: YES - Message: `feat: add Song DB edit popup with metadata and arrangement configurator` - Files: `resources/js/Components/SongEditModal.vue` - Pre-commit: `php artisan test` - [ ] 22. Song DB Translate Page (Two-Column Editor) **What to do**: - TEST: Write Pest test for translation import endpoint — verify line-count distribution - Create `resources/js/Pages/Songs/Translate.vue`: - Page triggered by "Übersetzen" button on song list - Top section: URL input field + "Text abrufen" button OR large textarea for manual paste - After text is provided (via URL fetch or paste): show two-column editor - Two-column editor for EACH slide of EACH group: - Left column: original text (read-only, from `song_slides.text_content`) - Right column: translation text editor (editable, from `song_slides.text_content_translated`) - Each editor area has same line count as original (constrained by original line count) - Group headers between sections (colored, matching group color) - "Speichern" button saves all translations at once - After save: song marked as `has_translation = true` - Add route: `GET /songs/{id}/translate` - Wire up to `TranslationController` (from T13) for URL fetch and import - Use Inertia form for submission **Must NOT do**: - Do NOT allow changing the original text (left column is read-only) - Do NOT allow adding more lines than the original has per slide **Recommended Agent Profile**: - **Category**: `deep` - **Skills**: [`frontend-ui-ux`] - `frontend-ui-ux`: Two-column editor with constrained line counts needs precise UI **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 4 (with T20-T21, T23-T24) - **Blocks**: — - **Blocked By**: T13, T9 **References**: - `app/Services/TranslationService.php` — translation backend from T13 - `app/Http/Controllers/TranslationController.php` — URL fetch and import endpoints from T13 - `app/Models/SongSlide.php` — `text_content` and `text_content_translated` fields from T2 - AGENTS.md line 96: "two columns for every slide of every group. Left the original text, right a texteditor, with the imported text — always the same line qty" **Acceptance Criteria**: - [ ] Translate page shows URL input and paste area - [ ] URL fetch returns text for review - [ ] Two-column editor shows original (read-only) and translation (editable) - [ ] Line count per slide matches original - [ ] Save marks song as `has_translation = true` - [ ] Group headers with colors shown between sections **QA Scenarios:** ``` Scenario: Two-column translation editor Tool: Playwright Preconditions: Song with 2 groups, 3 slides total (4 lines, 2 lines, 4 lines) Steps: 1. Navigate to /songs/{id}/translate 2. Paste 10 lines of translated text into textarea 3. Click import/distribute button 4. Assert: two-column editor appears 5. Assert: left columns show original text (read-only) 6. Assert: right columns show distributed translation text 7. Assert: slide 1 right column has exactly 4 lines 8. Assert: slide 2 right column has exactly 2 lines 9. Assert: slide 3 right column has exactly 4 lines 10. Click 'Speichern' 11. Navigate to /songs 12. Assert: song shows 'Hat Übersetzung' indicator Expected Result: Translation distributed correctly by line count Evidence: .sisyphus/evidence/task-22-translate-editor.png Scenario: URL fetch for lyrics Tool: Playwright Steps: 1. Navigate to /songs/{id}/translate 2. Enter a valid URL in URL field 3. Click 'Text abrufen' 4. Assert: either text appears in textarea OR error message 'Konnte Text nicht abrufen' shown Expected Result: URL fetch attempts text retrieval (best-effort) Evidence: .sisyphus/evidence/task-22-url-fetch.png ``` **Commit**: YES - Message: `feat: add Song DB translation page with two-column editor` - Files: `resources/js/Pages/Songs/Translate.vue`, routes - Pre-commit: `php artisan test` - [ ] 23. Song DB .pro Upload + Download Placeholders **What to do**: - TEST: Write Pest test that verifies .pro upload endpoint throws NotImplementedException, and .pro download endpoint returns a stub file - Create upload endpoint `POST /api/songs/import-pro`: - Accept .pro file(s) or ZIP containing .pro files - Throw `App\Exceptions\ProParserNotImplementedException` with message "Der .pro-Parser wird später implementiert. Bitte warte auf die detaillierte Spezifikation." - Return HTTP 501 with JSON error message - Create download endpoint `GET /api/songs/{id}/download-pro`: - Throw same `ProParserNotImplementedException` with message "Der .pro-Generator wird später implementiert." - Return HTTP 501 - Create custom exception class `App\Exceptions\ProParserNotImplementedException` - Wire up upload area on Song DB page (from T20) to call the upload endpoint — show the error toast when it returns 501 - Wire up "Herunterladen" button on Song DB page to call download endpoint — show error toast **Must NOT do**: - Do NOT implement actual .pro file parsing (placeholder only!) - Do NOT implement actual .pro file generation (placeholder only!) **Recommended Agent Profile**: - **Category**: `quick` - **Skills**: [] **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 4 (with T20-T22, T24) - **Blocks**: — - **Blocked By**: T9 **References**: - AGENTS.md line 23: "parser and generator of song files (.pro) are added later, to just add simple placeholder" - AGENTS.md line 95: "download: download generated .pro file" — placeholder for now - AGENTS.md line 97: "UploadArea for .pro file ... which should be parsed (this module was integrated later, so show an Exception here)" **Acceptance Criteria**: - [ ] `php artisan test --filter=ProPlaceholderTest` → PASS - [ ] POST /api/songs/import-pro → HTTP 501 with German error message - [ ] GET /api/songs/{id}/download-pro → HTTP 501 with German error message - [ ] UI shows toast/alert with German error when clicking upload or download **QA Scenarios:** ``` Scenario: .pro upload returns not-implemented error Tool: Bash (curl) Steps: 1. Create dummy file: echo 'test' > /tmp/test.pro 2. POST /api/songs/import-pro with file upload 3. Assert: HTTP 501 4. Assert: response contains 'später implementiert' Expected Result: Placeholder error returned Evidence: .sisyphus/evidence/task-23-pro-upload.txt Scenario: .pro download returns not-implemented error Tool: Bash (curl) Steps: 1. GET /api/songs/1/download-pro 2. Assert: HTTP 501 3. Assert: response contains 'später implementiert' Expected Result: Placeholder error returned Evidence: .sisyphus/evidence/task-23-pro-download.txt ``` **Commit**: YES - Message: `feat: add .pro file upload/download placeholders (NotImplementedException)` - Files: `app/Exceptions/ProParserNotImplementedException.php`, routes - Pre-commit: `php artisan test` - [ ] 24. Service Finalization + Status Management **What to do**: - TEST: Write Pest tests for finalization — verify status transitions, computed status fields, download placeholder - Review and finalize the finalization logic (partially in T8): - `POST /services/{id}/finalize`: set `finalized_at = now()`, verify all prerequisites met - `POST /services/{id}/reopen`: set `finalized_at = null` - Prerequisite checks before finalization (warn, don't block): - All songs should be matched (warn if not) - All songs should have arrangements selected (warn if not) - At least 1 sermon slide uploaded (warn if not) - If prerequisites not met: show confirmation dialog in German listing missing items - Implement download button behavior: - Show toast/placeholder message: "Die Show-Erstellung wird in einem zukünftigen Update verfügbar sein." - Return HTTP 501 from endpoint - Update service list page (T8) status computation to be fully accurate: - `songs_mapped`: count of service_songs with song_id vs total service_songs - `songs_arranged`: count of service_songs with song_arrangement_id vs total matched - `has_sermon_slides`: count of sermon slides for this service > 0 - `info_slides_count`: count of info slides where expire_date >= service.date - `finalized_at`: timestamp or null - Add computed `isReadyToFinalize` accessor on Service model **Must NOT do**: - Do NOT block finalization on missing prerequisites (warn only) - Do NOT implement actual show download (placeholder only) **Recommended Agent Profile**: - **Category**: `unspecified-high` - **Skills**: [] **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 4 (with T20-T23) - **Blocks**: FINAL - **Blocked By**: T15, T16, T17, T18 **References**: - `app/Http/Controllers/ServiceController.php` — finalize/reopen actions (from T8) - `app/Models/Service.php` — service model from T2 - AGENTS.md lines 36-38: "if finalized: ReOpen and Download", "if NOT finalized: Edit and Finalize" - AGENTS.md line 40: "ReOpen and Finalize just change the status of the service" **Acceptance Criteria**: - [ ] `php artisan test --filter=FinalizationTest` → PASS - [ ] Finalize sets `finalized_at` timestamp - [ ] ReOpen clears `finalized_at` - [ ] Finalize with missing prerequisites shows German warning dialog - [ ] Download button shows placeholder toast - [ ] All status fields compute correctly on service list **QA Scenarios:** ``` Scenario: Finalize service with warnings Tool: Playwright Preconditions: Service with 2 songs — 1 matched, 1 unmatched. No sermon slides. Steps: 1. Navigate to /services 2. Click 'Abschließen' on the service 3. Assert: confirmation dialog appears with warnings: - '1 Song ist noch nicht zugeordnet' - 'Keine Predigtfolien hochgeladen' 4. Confirm finalization 5. Assert: service now shows finalized_at timestamp 6. Assert: 'Wieder öffnen' and 'Herunterladen' buttons shown Expected Result: Finalization with warning but not blocked Evidence: .sisyphus/evidence/task-24-finalize-warnings.png Scenario: Download placeholder Tool: Playwright Preconditions: Service is finalized Steps: 1. Click 'Herunterladen' on finalized service 2. Assert: toast/message appears with 'zukünftigen Update' text Expected Result: Placeholder message shown Evidence: .sisyphus/evidence/task-24-download-placeholder.png ``` **Commit**: YES - Message: `feat: add service finalization with status checks and download placeholder` - Files: `app/Http/Controllers/ServiceController.php` (extend), `app/Models/Service.php` (accessors) - Pre-commit: `php artisan test` --- ## Final Verification Wave (MANDATORY — after ALL implementation tasks) > 4 review agents run in PARALLEL. ALL must APPROVE. Rejection → fix → re-run. - [ ] F1. **Plan Compliance Audit** — `oracle` Read the plan end-to-end. For each "Must Have": verify implementation exists (read file, curl endpoint, run command). For each "Must NOT Have": search codebase for forbidden patterns — reject with file:line if found. Check evidence files exist in .sisyphus/evidence/. Compare deliverables against plan. Verify ALL UI text is German with "Du" form. Output: `Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT` - [ ] F2. **Code Quality Review** — `unspecified-high` Run `php artisan test` + linter. Review all changed files for: `as any`/`@ts-ignore`, empty catches, console.log in prod, commented-out code, unused imports. Check AI slop: excessive comments, over-abstraction, generic names. Verify TDD: test files exist for every feature. Verify no Tailwind in DomPDF templates. Output: `Build [PASS/FAIL] | Tests [N pass/N fail] | Files [N clean/N issues] | VERDICT` - [ ] F3. **Real Manual QA** — `unspecified-high` (+ `playwright` skill) Start from clean state (`docker-compose up`). Execute EVERY QA scenario from EVERY task — follow exact steps, capture evidence. Test cross-task integration. Test edge cases: empty state, invalid input, rapid actions. All in German UI. Save to `.sisyphus/evidence/final-qa/`. Output: `Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT` - [ ] F4. **Scope Fidelity Check** — `deep` For each task: read "What to do", read actual diff. Verify 1:1 match. Check "Must NOT do" compliance. Detect cross-task contamination. Flag unaccounted changes. Verify no CTS API writes. Verify .pro parser is placeholder only. Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | VERDICT` --- ## Commit Strategy - **T0**: `chore: verify CTS API token auth and package compatibility` - **T1**: `feat: scaffold Laravel + Breeze Vue + Docker setup` - **T2**: `feat: add database schema for services, songs, arrangements, slides` - **T3**: `feat: implement ChurchTools OAuth login via Socialite` - **T4**: `feat: add CTS API sync service and artisan command` - **T5**: `feat: add file conversion service (image, PPT, ZIP)` - **T6**: `feat: create shared Vue layout with nav, user, refresh button` - **T7**: `feat: configure email and missing-song notification mailable` - **T8**: `feat: implement service list page with status indicators` - **T9**: `feat: add Song model with CRUD and relationships` - **T10**: `feat: create reusable slide upload component` - **T11**: `feat: add arrangement model and drag-drop configurator` - **T12**: `feat: implement CCLI-based song matching service` - **T13**: `feat: add translation service with URL scraping` - **T14**: `feat: create service edit page layout with block navigation` - **T15**: `feat: implement information block with expire dates` - **T16**: `feat: implement moderation block for service slides` - **T17**: `feat: implement sermon block for service slides` - **T18**: `feat: implement songs block with matching and arrangements` - **T19**: `feat: add song preview modal and PDF download` - **T20**: `feat: implement Song DB page with list and search` - **T21**: `feat: add Song DB edit popup with arrangement config` - **T22**: `feat: implement song translation two-column editor` - **T23**: `feat: add .pro file upload/download placeholders` - **T24**: `feat: implement service finalization and status management` --- ## Success Criteria ### Verification Commands ```bash docker-compose up -d # Expected: all containers healthy docker-compose exec app php artisan test # Expected: all tests pass docker-compose exec app php artisan migrate:status # Expected: all migrations ran curl -I http://localhost:8000 # Expected: 302 redirect to OAuth login ``` ### Final Checklist - [ ] All "Must Have" requirements present and working - [ ] All "Must NOT Have" guardrails respected - [ ] All tests pass (TDD — comprehensive coverage) - [ ] All UI text in German with "Du" form - [ ] Docker deployment works end-to-end - [ ] Auto-save functional on every interactive element - [ ] .pro parser/generator throws NotImplementedException - [ ] Finalized download is placeholder