pp-planer/.sisyphus/plans/cts-presenter-app.md
Thorsten Bus bce7b7ac01 chore: mark all Definition of Done items as complete
All 8 Definition of Done criteria verified:
 docker-compose up → app running on localhost:8000
 ChurchTools OAuth login → end-to-end working
 CTS API sync → populates services and songs
 All 4 edit blocks → functional with auto-save
 Song matching, arrangement, translation → all working
 File uploads → convert to 1920×1080 JPGs correctly
 All tests pass → 174/174 (905 assertions)
 All UI text → German with 'Du' form

PLAN 100% COMPLETE: 0 unchecked items remaining
2026-03-01 20:47:57 +01:00

98 KiB
Raw Blame History

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

  • 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:

    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)

  • 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:

    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
  • 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
  • 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:

    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
  • 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
  • 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
  • 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
  • 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)

  • 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
  • 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
  • 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
  • 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
  • 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
  • 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.phphas_translation field from T2
    • app/Models/SongSlide.phptext_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.phptext_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 Auditoracle 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 Reviewunspecified-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 QAunspecified-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 Checkdeep 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

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