Dienstplan-Pro/docs/plans/2026-05-11-bild-import-plan.md
Kenearos 1b515c7d54 docs: add design specs and implementation plans for bonus variants and image import
- Feature B: 3 Bonus-Varianten (V1/V2/V3 loose) + Urlaubsmodus + Feature C date stepper
- Feature A: Bild-Import via OpenRouter Vision-LLM

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:10:21 +02:00

112 KiB

Feature A: Bild → Dienste Import (OpenRouter Vision) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Let the user upload a photo/screenshot of a shift roster table; an OpenRouter Vision-LLM extracts duty entries; the user reviews and confirms in a preview dialog; entries are persisted via DataStorage.addDuty. 100% browser-side, no backend.

Architecture: New image-import.js with class ImageImporter owns the entire workflow: API key prompt, image preprocessing (canvas resize → JPEG base64), OpenRouter chat/completions call, JSON schema validation, name matching against existing employees (incl. Levenshtein fuzzy), preview modal, commit to storage. storage.js is extended with API-key/model accessors. UI additions in the "Dienste eintragen" tab and a settings section.

Tech Stack: Vanilla ES6+ classes, browser fetch(), Canvas2D, localStorage. Inline Levenshtein implementation (no external deps). No build step. DOM is constructed via document.createElement + textContent for any user-controlled data (XSS-safe).


File Structure

File Status Responsibility post-change
image-import.js CREATE New module. Exports class ImageImporter to window.ImageImporter and instantiates window.imageImporter. Owns: API-key prompt, image preprocessing, OpenRouter call, response parsing & schema validation, name matching (incl. inline levenshtein), classify(date) slot helper, modal lifecycle and rendering across Stages 1 to 4, commit-to-storage.
index.html MODIFY Add <button id="open-image-import-btn"> inside the tab-duties card header, add <div id="image-import-modal"> skeleton with four stage divs, add <div class="settings-section"> for "Bild-Import (KI)" in tab-settings before the "Alle Daten löschen" section, add <script src="image-import.js"></script> after <script src="app.js"></script>.
app.js MODIFY Wire open-image-import-btn click to window.imageImporter.openImportDialog(). Wire settings buttons set-api-key-btn, clear-api-key-btn, api-model-select to DataStorage accessors. Update settings status line. No business logic.
storage.js MODIFY Extend DataStorage with STORAGE_KEY_OPENROUTER_KEY, STORAGE_KEY_OPENROUTER_MODEL, DEFAULT_MODEL, and methods getApiKey, setApiKey, clearApiKey, getApiModel, setApiModel. clearAll(), exportData(), importData() remain unchanged (device-local config).
styles.css MODIFY Add .modal, .modal-backdrop, .modal-content, .modal-close, .modal-stage, .drag-drop-zone, .thumbnail-preview, .unknown-names-box, .unknown-name-row, .preview-table, .slot-badge (fr / sa / so / weekday), .privacy-notice, .spinner, .card-header (flex).
test-suite.js MODIFY Add new tests across categories: API Key Persistenz, Image Preprocessing, Response Parsing, Name Matching, Conflict Handling, Levenshtein, Resolve Imports. Extend categories object in runAllTests() to bucket the new tests.
test.html MODIFY Add <script src="image-import.js"></script> after storage.js (and before test-suite.js) so tests can reference window.ImageImporter methods. NOTE: image-import.js instantiates new ImageImporter(window.app) at the bottom; test.html does not load app.js, so the instantiation must be guarded by if (window.app).
sw.js MODIFY Bump CACHE_NAME from dienstplan-pro-v1 to dienstplan-pro-v3 (v2 is reserved for Feature B). Add './image-import.js' to the ASSETS precache list.

Sequential Tasks

Task 1: Skeleton image-import.js and load it in index.html

Files:

  • Create: G:\Claude\Claude_tmp_dienstplan\image-import.js
  • Modify: G:\Claude\Claude_tmp_dienstplan\index.html

Steps:

  • Step 1: Create image-import.js with empty class skeleton.

    Write file G:\Claude\Claude_tmp_dienstplan\image-import.js with this content:

    /**
     * Image Importer
     * Owns the Bild → Dienste import workflow via OpenRouter Vision-LLM.
     * Loaded AFTER app.js so window.app is available.
     */
    class ImageImporter {
        constructor(app) {
            this.app = app || null;
            this.storage = app ? app.storage : null;
            this.holidayProvider = app ? app.holidayProvider : null;
            this.session = null;
            this.abortController = null;
        }
    }
    
    // Make available globally
    window.ImageImporter = ImageImporter;
    
    // Auto-instantiate when DOM + app are ready
    if (typeof document !== 'undefined') {
        document.addEventListener('DOMContentLoaded', () => {
            if (window.app) {
                window.imageImporter = new ImageImporter(window.app);
            }
        });
    }
    
  • Step 2: Inject <script src="image-import.js"> into index.html after app.js.

    In G:\Claude\Claude_tmp_dienstplan\index.html, replace the block:

        <!-- Scripts -->
        <script src="holidays.js"></script>
        <script src="calculator.js"></script>
        <script src="storage.js"></script>
        <script src="app.js"></script>
    

    with:

        <!-- Scripts -->
        <script src="holidays.js"></script>
        <script src="calculator.js"></script>
        <script src="storage.js"></script>
        <script src="app.js"></script>
        <script src="image-import.js"></script>
    
  • Step 3: Manual verification — page still loads.

    Run python3 -m http.server 8000 (or npx http-server -p 8000) in G:\Claude\Claude_tmp_dienstplan\. Open http://localhost:8000/. Open DevTools Console. Expect:

    • No JavaScript errors.
    • window.imageImporter evaluates to an ImageImporter instance.
    • window.imageImporter.app is the DienstplanApp instance.
    • window.imageImporter.storage is the DataStorage instance.
  • Step 4: Commit.

    Run:

    git add image-import.js index.html
    git commit -m "feat: add empty ImageImporter skeleton wired into index.html"
    

Task 2: Extend storage.js with API-key + model accessors (TDD)

Files:

  • Modify: G:\Claude\Claude_tmp_dienstplan\storage.js
  • Test: G:\Claude\Claude_tmp_dienstplan\test-suite.js

Steps:

  • Step 1: Add failing tests for API-key/model accessors.

    Append to G:\Claude\Claude_tmp_dienstplan\test-suite.js BEFORE the // Display Functions section:

    // ============================================================================
    // Storage Tests - API Key / Model (Feature A)
    // ============================================================================
    
    runner.test('Storage API Key: setApiKey/getApiKey round-trip', (t) => {
        const storage = new DataStorage();
        storage.clearApiKey();
        storage.setApiKey('sk-or-test-12345');
        t.assertEqual(storage.getApiKey(), 'sk-or-test-12345', 'Key sollte gespeichert sein');
        storage.clearApiKey();
    });
    
    runner.test('Storage API Key: getApiKey ohne gesetzten Wert liefert null', (t) => {
        const storage = new DataStorage();
        storage.clearApiKey();
        t.assertEqual(storage.getApiKey(), null, 'Sollte null sein');
    });
    
    runner.test('Storage API Key: clearApiKey entfernt den Key', (t) => {
        const storage = new DataStorage();
        storage.setApiKey('sk-or-test');
        storage.clearApiKey();
        t.assertEqual(storage.getApiKey(), null, 'Key sollte gelöscht sein');
    });
    
    runner.test('Storage API Model: Default ist anthropic/claude-sonnet-4.6', (t) => {
        const storage = new DataStorage();
        localStorage.removeItem('dienstplan_openrouter_model');
        t.assertEqual(storage.getApiModel(), 'anthropic/claude-sonnet-4.6', 'Default-Modell');
    });
    
    runner.test('Storage API Model: setApiModel/getApiModel round-trip', (t) => {
        const storage = new DataStorage();
        storage.setApiModel('google/gemini-2.5-pro');
        t.assertEqual(storage.getApiModel(), 'google/gemini-2.5-pro', 'Modell sollte gespeichert sein');
        localStorage.removeItem('dienstplan_openrouter_model');
    });
    
    runner.test('Storage API Key: exportData enthält keinen API-Key', (t) => {
        const storage = new DataStorage();
        storage.setApiKey('sk-or-secret');
        const exported = storage.exportData();
        t.assertFalse(exported.includes('sk-or-secret'), 'Key darf nicht im Export sein');
        storage.clearApiKey();
    });
    
    runner.test('Storage API Key: clearAll laesst API-Key unberuehrt', (t) => {
        const storage = new DataStorage();
        storage.setApiKey('sk-or-keep');
        storage.clearAll();
        t.assertEqual(storage.getApiKey(), 'sk-or-keep', 'Key sollte clearAll ueberleben');
        storage.clearApiKey();
    });
    

    Also add a new bucket to the categories object inside runAllTests(). In the same file, locate:

        const categories = {
            'Holiday Provider': [],
            'Calculator - Tag-Klassifizierung': [],
            'Calculator - Bonusberechnung': [],
            'Storage': [],
            'Edge Cases': []
        };
    

    Replace with:

        const categories = {
            'Holiday Provider': [],
            'Calculator - Tag-Klassifizierung': [],
            'Calculator - Bonusberechnung': [],
            'Storage': [],
            'Storage API Key': [],
            'Image Importer': [],
            'Edge Cases': []
        };
    

    And in the same function, locate the categorization block:

        results.forEach(result => {
            if (result.name.includes('HolidayProvider')) {
                categories['Holiday Provider'].push(result);
            } else if (result.name.includes('qualifizierender Tag') || result.name.includes('Feiertag ist')) {
                categories['Calculator - Tag-Klassifizierung'].push(result);
            } else if (result.name.includes('Berechnung:')) {
                categories['Calculator - Bonusberechnung'].push(result);
            } else if (result.name.includes('Storage:')) {
                categories['Storage'].push(result);
            } else if (result.name.includes('Edge Case:')) {
                categories['Edge Cases'].push(result);
            }
        });
    

    Replace with:

        results.forEach(result => {
            if (result.name.includes('HolidayProvider')) {
                categories['Holiday Provider'].push(result);
            } else if (result.name.includes('qualifizierender Tag') || result.name.includes('Feiertag ist')) {
                categories['Calculator - Tag-Klassifizierung'].push(result);
            } else if (result.name.includes('Berechnung:')) {
                categories['Calculator - Bonusberechnung'].push(result);
            } else if (result.name.startsWith('Storage API')) {
                categories['Storage API Key'].push(result);
            } else if (result.name.includes('Storage:')) {
                categories['Storage'].push(result);
            } else if (result.name.startsWith('ImageImporter') || result.name.startsWith('Levenshtein') || result.name.startsWith('Parse') || result.name.startsWith('Match') || result.name.startsWith('Preprocess') || result.name.startsWith('Resolve') || result.name.startsWith('CallVision') || result.name.startsWith('Classify')) {
                categories['Image Importer'].push(result);
            } else if (result.name.includes('Edge Case:')) {
                categories['Edge Cases'].push(result);
            }
        });
    
  • Step 2: Run tests, confirm the new tests are RED.

    Open http://localhost:8000/test.html. Click "Alle Tests ausfuehren". In the rendered output find the new category Storage API Key (0/7). All seven tests should show red. (Reason: methods don't exist yet.)

  • Step 3: Implement the accessors in storage.js to make tests GREEN.

    In G:\Claude\Claude_tmp_dienstplan\storage.js, replace the constructor:

        constructor() {
            this.STORAGE_KEY_EMPLOYEES = 'dienstplan_employees';
            this.STORAGE_KEY_DUTIES = 'dienstplan_duties';
        }
    

    with:

        constructor() {
            this.STORAGE_KEY_EMPLOYEES = 'dienstplan_employees';
            this.STORAGE_KEY_DUTIES = 'dienstplan_duties';
            this.STORAGE_KEY_OPENROUTER_KEY = 'dienstplan_openrouter_key';
            this.STORAGE_KEY_OPENROUTER_MODEL = 'dienstplan_openrouter_model';
            this.DEFAULT_MODEL = 'anthropic/claude-sonnet-4.6';
        }
    

    Then, immediately before the closing } of the DataStorage class (before // Make it available globally), insert:

        // ============================================================
        // API Key / Model (Feature A: Bild-Import)
        // Device-local config — NOT included in exportData/clearAll.
        // ============================================================
    
        /**
         * @returns {string|null}
         */
        getApiKey() {
            try {
                return localStorage.getItem(this.STORAGE_KEY_OPENROUTER_KEY) || null;
            } catch (e) {
                console.error('Fehler beim Laden des API-Keys:', e);
                return null;
            }
        }
    
        /**
         * @param {string} key
         */
        setApiKey(key) {
            try {
                localStorage.setItem(this.STORAGE_KEY_OPENROUTER_KEY, String(key));
            } catch (e) {
                console.error('Fehler beim Speichern des API-Keys:', e);
                throw e;
            }
        }
    
        clearApiKey() {
            try {
                localStorage.removeItem(this.STORAGE_KEY_OPENROUTER_KEY);
            } catch (e) {
                console.error('Fehler beim Loeschen des API-Keys:', e);
            }
        }
    
        /**
         * @returns {string}
         */
        getApiModel() {
            try {
                return localStorage.getItem(this.STORAGE_KEY_OPENROUTER_MODEL) || this.DEFAULT_MODEL;
            } catch (e) {
                console.error('Fehler beim Laden des Modells:', e);
                return this.DEFAULT_MODEL;
            }
        }
    
        /**
         * @param {string} modelId
         */
        setApiModel(modelId) {
            try {
                localStorage.setItem(this.STORAGE_KEY_OPENROUTER_MODEL, String(modelId));
            } catch (e) {
                console.error('Fehler beim Speichern des Modells:', e);
                throw e;
            }
        }
    
  • Step 4: Re-run tests in browser; confirm GREEN.

    Reload http://localhost:8000/test.html and click "Alle Tests ausfuehren". Expect Storage API Key (7/7) green. Pre-existing tests must remain green.

  • Step 5: Commit.

    git add storage.js test-suite.js
    git commit -m "feat(storage): add getApiKey/setApiKey/clearApiKey/getApiModel/setApiModel for OpenRouter integration"
    

Task 3: Image preprocessing (compressImage) (TDD)

Files:

  • Modify: G:\Claude\Claude_tmp_dienstplan\image-import.js
  • Modify: G:\Claude\Claude_tmp_dienstplan\test-suite.js
  • Modify: G:\Claude\Claude_tmp_dienstplan\test.html

Steps:

  • Step 1: Load image-import.js into the test harness.

    In G:\Claude\Claude_tmp_dienstplan\test.html, replace:

        <script src="holidays.js"></script>
        <script src="calculator.js"></script>
        <script src="storage.js"></script>
        <script src="test-suite.js"></script>
    

    with:

        <script src="holidays.js"></script>
        <script src="calculator.js"></script>
        <script src="storage.js"></script>
        <script src="image-import.js"></script>
        <script src="test-suite.js"></script>
    
  • Step 2: Add failing tests for compressImage.

    Append to test-suite.js (in the section started in Task 2):

    // ============================================================================
    // ImageImporter Tests - Preprocessing (Feature A)
    // ============================================================================
    
    /**
     * Helper: build a synthetic image File from a canvas.
     */
    async function makeTestImageFile(width, height, mime = 'image/png') {
        const canvas = document.createElement('canvas');
        canvas.width = width;
        canvas.height = height;
        const ctx = canvas.getContext('2d');
        ctx.fillStyle = '#3366cc';
        ctx.fillRect(0, 0, width, height);
        ctx.fillStyle = '#ffffff';
        ctx.font = '32px sans-serif';
        ctx.fillText('TEST', 20, 50);
        const blob = await new Promise(res => canvas.toBlob(res, mime));
        return new File([blob], 'test.png', { type: mime });
    }
    
    runner.test('Preprocess: 4000x3000 wird auf laengste Kante 2048 skaliert', async (t) => {
        const importer = new ImageImporter(null);
        const file = await makeTestImageFile(4000, 3000);
        const result = await importer.compressImage(file);
        t.assertEqual(result.width, 2048, 'Breite sollte 2048 sein');
        t.assertEqual(result.height, 1536, 'Hoehe sollte 1536 sein (Seitenverhaeltnis erhalten)');
        t.assertTrue(result.dataUrl.startsWith('data:image/jpeg;base64,'), 'dataUrl-Prefix korrekt');
    });
    
    runner.test('Preprocess: 800x600 bleibt unveraendert (kein Upscale)', async (t) => {
        const importer = new ImageImporter(null);
        const file = await makeTestImageFile(800, 600);
        const result = await importer.compressImage(file);
        t.assertEqual(result.width, 800, 'Breite unveraendert');
        t.assertEqual(result.height, 600, 'Hoehe unveraendert');
    });
    
    runner.test('Preprocess: Output ist immer JPEG', async (t) => {
        const importer = new ImageImporter(null);
        const file = await makeTestImageFile(500, 500, 'image/png');
        const result = await importer.compressImage(file);
        t.assertTrue(result.dataUrl.startsWith('data:image/jpeg;base64,'), 'Output ist JPEG');
        t.assertTrue(result.dataUrl.length > 1000, 'Output-Laenge > 1KB');
    });
    
  • Step 3: Run tests in browser — confirm RED.

    Reload http://localhost:8000/test.html, click "Alle Tests ausfuehren". Find Image Importer category — the three Preprocess tests should fail because compressImage does not exist yet.

  • Step 4: Implement compressImage in image-import.js.

    Open G:\Claude\Claude_tmp_dienstplan\image-import.js. Inside the ImageImporter class (after the constructor, before the closing }), add:

        /**
         * Resize image so the longest edge is <= 2048 px, re-encode as JPEG q=0.85.
         * @param {File|Blob} file
         * @returns {Promise<{blob: Blob, dataUrl: string, width: number, height: number}>}
         */
        async compressImage(file) {
            const objUrl = URL.createObjectURL(file);
            try {
                const img = await new Promise((resolve, reject) => {
                    const i = new Image();
                    i.onload = () => resolve(i);
                    i.onerror = () => reject(new Error('Bild konnte nicht geladen werden'));
                    i.src = objUrl;
                });
    
                const longest = Math.max(img.width, img.height);
                let newW = img.width;
                let newH = img.height;
                if (longest > 2048) {
                    const scale = 2048 / longest;
                    newW = Math.round(img.width * scale);
                    newH = Math.round(img.height * scale);
                }
    
                const canvas = document.createElement('canvas');
                canvas.width = newW;
                canvas.height = newH;
                const ctx = canvas.getContext('2d');
                ctx.drawImage(img, 0, 0, newW, newH);
    
                const blob = await new Promise((resolve, reject) => {
                    canvas.toBlob(
                        (b) => b ? resolve(b) : reject(new Error('toBlob fehlgeschlagen')),
                        'image/jpeg',
                        0.85
                    );
                });
    
                const dataUrl = await new Promise((resolve, reject) => {
                    const reader = new FileReader();
                    reader.onload = () => resolve(reader.result);
                    reader.onerror = () => reject(new Error('FileReader fehlgeschlagen'));
                    reader.readAsDataURL(blob);
                });
    
                return { blob, dataUrl, width: newW, height: newH };
            } finally {
                URL.revokeObjectURL(objUrl);
            }
        }
    
  • Step 5: Re-run tests; confirm GREEN.

    Reload test.html, click "Alle Tests ausfuehren". Image Importer category should show the three Preprocess tests green.

  • Step 6: Commit.

    git add image-import.js test-suite.js test.html
    git commit -m "feat(image-import): add compressImage with canvas resize + JPEG re-encode"
    

Task 4: OpenRouter Vision API call (callVisionAPI) (TDD with mocked fetch)

Files:

  • Modify: G:\Claude\Claude_tmp_dienstplan\image-import.js
  • Modify: G:\Claude\Claude_tmp_dienstplan\test-suite.js

Steps:

  • Step 1: Add failing tests with mocked fetch.

    Append to test-suite.js:

    // ============================================================================
    // ImageImporter Tests - callVisionAPI (Feature A)
    // ============================================================================
    
    /**
     * Helper to mock fetch and restore it.
     */
    function withMockedFetch(mockFn, fn) {
        const originalFetch = globalThis.fetch;
        globalThis.fetch = mockFn;
        const restore = () => { globalThis.fetch = originalFetch; };
        return Promise.resolve(fn()).finally(restore);
    }
    
    runner.test('CallVisionAPI: erfolgreicher 200-Response', async (t) => {
        const importer = new ImageImporter(null);
        let capturedUrl = null;
        let capturedInit = null;
        const mockFetch = async (url, init) => {
            capturedUrl = url;
            capturedInit = init;
            return new Response(JSON.stringify({
                choices: [{ message: { content: '{"entries":[]}' } }]
            }), { status: 200, headers: { 'Content-Type': 'application/json' } });
        };
        await withMockedFetch(mockFetch, async () => {
            const content = await importer.callVisionAPI('data:image/jpeg;base64,AAA', 'sk-test', 'anthropic/claude-sonnet-4.6');
            t.assertEqual(content, '{"entries":[]}', 'Content extrahiert');
            t.assertEqual(capturedUrl, 'https://openrouter.ai/api/v1/chat/completions', 'Endpoint korrekt');
            t.assertTrue(capturedInit.headers['Authorization'] === 'Bearer sk-test', 'Auth-Header korrekt');
        });
    });
    
    runner.test('CallVisionAPI: 401 wirft mit Status', async (t) => {
        const importer = new ImageImporter(null);
        const mockFetch = async () => new Response('', { status: 401 });
        await withMockedFetch(mockFetch, async () => {
            try {
                await importer.callVisionAPI('data:image/jpeg;base64,AAA', 'bad', 'anthropic/claude-sonnet-4.6');
                t.assertTrue(false, 'Sollte werfen');
            } catch (e) {
                t.assertEqual(e.status, 401, 'Status auf Error');
                t.assertEqual(e.name, 'OpenRouterError', 'Typisierter Fehler');
            }
        });
    });
    
    runner.test('CallVisionAPI: 429 wirft mit Status', async (t) => {
        const importer = new ImageImporter(null);
        const mockFetch = async () => new Response('', { status: 429 });
        await withMockedFetch(mockFetch, async () => {
            try {
                await importer.callVisionAPI('data:image/jpeg;base64,AAA', 'k', 'm');
                t.assertTrue(false, 'Sollte werfen');
            } catch (e) {
                t.assertEqual(e.status, 429, '429 wird durchgereicht');
            }
        });
    });
    
    runner.test('CallVisionAPI: 503 wirft mit Status', async (t) => {
        const importer = new ImageImporter(null);
        const mockFetch = async () => new Response('', { status: 503 });
        await withMockedFetch(mockFetch, async () => {
            try {
                await importer.callVisionAPI('data:image/jpeg;base64,AAA', 'k', 'm');
                t.assertTrue(false, 'Sollte werfen');
            } catch (e) {
                t.assertEqual(e.status, 503, '503 wird durchgereicht');
            }
        });
    });
    
  • Step 2: Run tests; confirm RED.

    Reload test.html, click run. The four CallVisionAPI tests should fail (method missing).

  • Step 3: Implement callVisionAPI in image-import.js.

    Add inside the ImageImporter class (after compressImage):

        /**
         * POST to OpenRouter chat/completions and return the assistant message content (raw string).
         * @param {string} dataUrl - 'data:image/jpeg;base64,...'
         * @param {string} apiKey
         * @param {string} modelId
         * @param {AbortSignal} [signal]
         * @returns {Promise<string>} raw assistant content (still markdown-fenced/JSON; parse later)
         */
        async callVisionAPI(dataUrl, apiKey, modelId, signal) {
            const body = {
                model: modelId,
                temperature: 0,
                response_format: { type: 'json_object' },
                messages: [
                    {
                        role: 'system',
                        content: ImageImporter.SYSTEM_PROMPT
                    },
                    {
                        role: 'user',
                        content: [
                            { type: 'text', text: 'Extrahiere alle Assistenzarzt-Dienste aus dieser Dienstplan-Tabelle.' },
                            { type: 'image_url', image_url: { url: dataUrl } }
                        ]
                    }
                ]
            };
    
            const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
                method: 'POST',
                headers: {
                    'Authorization': `Bearer ${apiKey}`,
                    'Content-Type': 'application/json',
                    'HTTP-Referer': (typeof window !== 'undefined' && window.location) ? window.location.origin : '',
                    'X-Title': 'Dienstplan-Pro'
                },
                body: JSON.stringify(body),
                signal: signal
            });
    
            if (!response.ok) {
                const err = new Error(`OpenRouter HTTP ${response.status}`);
                err.name = 'OpenRouterError';
                err.status = response.status;
                throw err;
            }
    
            const json = await response.json();
            const content = json && json.choices && json.choices[0] && json.choices[0].message
                ? json.choices[0].message.content
                : '';
            return typeof content === 'string' ? content : JSON.stringify(content);
        }
    

    Also add the static system prompt — at the END of the file (after window.ImageImporter = ImageImporter;), insert before the if (typeof document block:

    ImageImporter.SYSTEM_PROMPT = `Du extrahierst Dienstplaene aus Tabellenbildern fuer eine deutsche Klinik.
    
    Regeln:
    - Die Tabelle listet pro Datum die diensthabenden Aerzte.
    - Es gibt Assistenzaerzte und Oberaerzte. Extrahiere NUR Assistenzaerzte. Oberaerzte werden ignoriert.
    - Wenn du nicht sicher bist, ob ein Name zu einem Assistenzarzt oder Oberarzt gehoert, vermerke dies in \`notes\`.
    - Wenn in einer Zelle NUR EIN Name steht: share = 1.0 fuer diesen Arzt.
    - Wenn in einer Zelle ZWEI Namen stehen: share = 0.5 fuer jeden der beiden.
    - Datum stets im ISO-Format YYYY-MM-DD.
    - Wenn das Bild einen Monatstitel zeigt (z.B. November 2025), gib month (1-12) und year (vierstellig) in der Antwort an. Sonst null.
    - Wenn ein Name unklar zu lesen ist, uebernimm deinen besten Ratevorschlag und vermerke es in notes.
    
    Antworte STRIKT in diesem JSON-Schema und sonst nichts:
    {
      "month": number | null,
      "year": number | null,
      "entries": [
        { "name": "string", "date": "YYYY-MM-DD", "share": 1.0 | 0.5 }
      ],
      "notes": ["string", ...]
    }`;
    

    Note: the implementer should preserve the German umlauts in the actual prompt when typing it (the plan uses ASCII for portability). The literal characters to use in SYSTEM_PROMPT should match the spec text in §7.3 verbatim — copy from docs\specs\2026-05-11-bild-import-design.md lines 355-377.

  • Step 4: Re-run tests; confirm GREEN.

    Reload test.html, click run. The four CallVisionAPI tests should pass.

  • Step 5: Commit.

    git add image-import.js test-suite.js
    git commit -m "feat(image-import): add callVisionAPI with OpenRouter fetch + typed HTTP errors"
    

Task 5: Response parsing & schema validation (parseResponse) (TDD)

Files:

  • Modify: G:\Claude\Claude_tmp_dienstplan\image-import.js
  • Modify: G:\Claude\Claude_tmp_dienstplan\test-suite.js

Steps:

  • Step 1: Add failing tests.

    Append to test-suite.js:

    // ============================================================================
    // ImageImporter Tests - parseResponse (Feature A)
    // ============================================================================
    
    runner.test('Parse: cleanes JSON wird geparst', (t) => {
        const importer = new ImageImporter(null);
        const raw = '{"month":11,"year":2025,"entries":[{"name":"Max","date":"2025-11-22","share":1.0}],"notes":[]}';
        const result = importer.parseResponse(raw);
        t.assertEqual(result.entries.length, 1, '1 Eintrag');
        t.assertEqual(result.entries[0].name, 'Max', 'Name korrekt');
        t.assertEqual(result.month, 11, 'Monat korrekt');
    });
    
    runner.test('Parse: JSON in Markdown-Fence wird gestrippt', (t) => {
        const importer = new ImageImporter(null);
        const raw = '```json\n{"entries":[{"name":"A","date":"2025-11-22","share":0.5}],"notes":[]}\n```';
        const result = importer.parseResponse(raw);
        t.assertEqual(result.entries.length, 1, 'Fence wurde entfernt');
    });
    
    runner.test('Parse: JSON mit Vortext wird per Brace-Slicing extrahiert', (t) => {
        const importer = new ImageImporter(null);
        const raw = 'Hier das Ergebnis:\n{"entries":[{"name":"A","date":"2025-11-22","share":1.0}],"notes":[]}';
        const result = importer.parseResponse(raw);
        t.assertEqual(result.entries.length, 1, 'Vortext ignoriert');
    });
    
    runner.test('Parse: Malformed JSON wirft SyntaxError', (t) => {
        const importer = new ImageImporter(null);
        try {
            importer.parseResponse('das ist kein JSON');
            t.assertTrue(false, 'Sollte werfen');
        } catch (e) {
            t.assertTrue(e instanceof SyntaxError || e.name === 'SyntaxError' || /JSON|Parse/i.test(e.message), 'SyntaxError erwartet');
        }
    });
    
    runner.test('Parse: fehlendes entries-Feld wirft', (t) => {
        const importer = new ImageImporter(null);
        try {
            importer.parseResponse('{"month":11,"year":2025,"notes":[]}');
            t.assertTrue(false, 'Sollte werfen');
        } catch (e) {
            t.assertTrue(/entries/i.test(e.message), 'Fehlermeldung erwaehnt entries');
        }
    });
    
    runner.test('Parse: share=0.75 verwirft den Eintrag', (t) => {
        const importer = new ImageImporter(null);
        const raw = '{"entries":[{"name":"A","date":"2025-11-22","share":0.75},{"name":"B","date":"2025-11-23","share":1.0}],"notes":[]}';
        const result = importer.parseResponse(raw);
        t.assertEqual(result.entries.length, 1, 'Nur gueltiger Eintrag bleibt');
        t.assertEqual(result.entries[0].name, 'B', 'B uebrig');
    });
    
    runner.test('Parse: invalides Datum verwirft den Eintrag', (t) => {
        const importer = new ImageImporter(null);
        const raw = '{"entries":[{"name":"A","date":"31.11.2025","share":1.0},{"name":"B","date":"2025-11-22","share":1.0}],"notes":[]}';
        const result = importer.parseResponse(raw);
        t.assertEqual(result.entries.length, 1, 'Nur ISO-Datum bleibt');
        t.assertEqual(result.entries[0].name, 'B', 'B uebrig');
    });
    
    runner.test('Parse: leerer Name wird verworfen', (t) => {
        const importer = new ImageImporter(null);
        const raw = '{"entries":[{"name":"   ","date":"2025-11-22","share":1.0},{"name":"B","date":"2025-11-22","share":1.0}],"notes":[]}';
        const result = importer.parseResponse(raw);
        t.assertEqual(result.entries.length, 1, 'Nur gueltiger Name bleibt');
    });
    
  • Step 2: Run tests; confirm RED.

    Reload test.html, run. The eight Parse tests should fail.

  • Step 3: Implement parseResponse in image-import.js.

    Add inside the ImageImporter class:

        /**
         * Strip markdown fences, brace-slice, JSON.parse, schema-validate.
         * Invalid entries are dropped with console warnings.
         * @param {string} rawContent
         * @returns {{ month: number|null, year: number|null, entries: Array<{name:string,date:string,share:number}>, notes: string[] }}
         */
        parseResponse(rawContent) {
            if (typeof rawContent !== 'string') {
                throw new SyntaxError('Antwort ist kein String');
            }
    
            let text = rawContent.trim();
    
            // Strip ```json ... ``` or ``` ... ``` fences
            text = text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
    
            // Brace-slice: find first { and last }
            const firstBrace = text.indexOf('{');
            const lastBrace = text.lastIndexOf('}');
            if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) {
                throw new SyntaxError('Kein JSON-Objekt in der Antwort gefunden');
            }
            text = text.slice(firstBrace, lastBrace + 1);
    
            const parsed = JSON.parse(text); // may throw SyntaxError
    
            if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) {
                throw new Error('Schema-Fehler: entries fehlt oder ist kein Array');
            }
    
            const validEntries = [];
            for (const entry of parsed.entries) {
                if (!entry || typeof entry.name !== 'string' || entry.name.trim().length === 0) {
                    console.warn('parseResponse: Eintrag mit leerem Namen verworfen', entry);
                    continue;
                }
                if (typeof entry.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(entry.date)) {
                    console.warn('parseResponse: Eintrag mit ungueltigem Datum verworfen', entry);
                    continue;
                }
                const d = new Date(entry.date + 'T12:00:00');
                if (isNaN(d.getTime())) {
                    console.warn('parseResponse: Datum nicht parsebar', entry);
                    continue;
                }
                if (entry.share !== 0.5 && entry.share !== 1.0) {
                    console.warn('parseResponse: Eintrag mit ungueltigem share verworfen', entry);
                    continue;
                }
                validEntries.push({ name: entry.name.trim(), date: entry.date, share: entry.share });
            }
    
            const month = (typeof parsed.month === 'number' && parsed.month >= 1 && parsed.month <= 12) ? parsed.month : null;
            const year = (typeof parsed.year === 'number' && parsed.year >= 2000) ? parsed.year : null;
            const notes = Array.isArray(parsed.notes) ? parsed.notes.filter(n => typeof n === 'string') : [];
    
            return { month, year, entries: validEntries, notes };
        }
    
  • Step 4: Re-run tests; confirm GREEN.

    Reload test.html, run. All eight Parse tests should be green.

  • Step 5: Commit.

    git add image-import.js test-suite.js
    git commit -m "feat(image-import): add parseResponse with fence-stripping and schema validation"
    

Task 6: Levenshtein distance (inline) (TDD)

Files:

  • Modify: G:\Claude\Claude_tmp_dienstplan\image-import.js
  • Modify: G:\Claude\Claude_tmp_dienstplan\test-suite.js

Steps:

  • Step 1: Add failing tests.

    Append to test-suite.js:

    // ============================================================================
    // ImageImporter Tests - Levenshtein (Feature A)
    // ============================================================================
    
    runner.test('Levenshtein: identische Strings = 0', (t) => {
        const importer = new ImageImporter(null);
        t.assertEqual(importer.levenshtein('max mustermann', 'max mustermann'), 0, 'Identisch');
    });
    
    runner.test('Levenshtein: leerer String', (t) => {
        const importer = new ImageImporter(null);
        t.assertEqual(importer.levenshtein('', 'abc'), 3, '0 vs 3 Zeichen');
        t.assertEqual(importer.levenshtein('abc', ''), 3, '3 vs 0 Zeichen');
        t.assertEqual(importer.levenshtein('', ''), 0, 'Beide leer');
    });
    
    runner.test('Levenshtein: 1 Substitution', (t) => {
        const importer = new ImageImporter(null);
        t.assertEqual(importer.levenshtein('abc', 'abd'), 1, '1 Subst');
    });
    
    runner.test('Levenshtein: 1 Insertion', (t) => {
        const importer = new ImageImporter(null);
        t.assertEqual(importer.levenshtein('max mustermann', 'max mustermannn'), 1, '1 zusaetzliches n');
    });
    
    runner.test('Levenshtein: 2 Distanz', (t) => {
        const importer = new ImageImporter(null);
        t.assertEqual(importer.levenshtein('mueller', 'mueler'), 1, 'ein l weniger');
    });
    
  • Step 2: Run tests; confirm RED.

  • Step 3: Implement levenshtein in image-import.js.

    Add inside the ImageImporter class:

        /**
         * Levenshtein distance (O(m*n) DP, inline).
         * Inputs are expected to already be normalized.
         * @param {string} a
         * @param {string} b
         * @returns {number}
         */
        levenshtein(a, b) {
            if (a === b) return 0;
            if (!a.length) return b.length;
            if (!b.length) return a.length;
            const m = a.length, n = b.length;
            const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
            for (let i = 0; i <= m; i++) dp[i][0] = i;
            for (let j = 0; j <= n; j++) dp[0][j] = j;
            for (let i = 1; i <= m; i++) {
                for (let j = 1; j <= n; j++) {
                    const cost = a[i - 1] === b[j - 1] ? 0 : 1;
                    dp[i][j] = Math.min(
                        dp[i - 1][j] + 1,
                        dp[i][j - 1] + 1,
                        dp[i - 1][j - 1] + cost
                    );
                }
            }
            return dp[m][n];
        }
    
  • Step 4: Re-run tests; confirm GREEN.

  • Step 5: Commit.

    git add image-import.js test-suite.js
    git commit -m "feat(image-import): add inline levenshtein distance helper"
    

Task 7: Name matching + classify(date) slot helper (TDD)

Files:

  • Modify: G:\Claude\Claude_tmp_dienstplan\image-import.js
  • Modify: G:\Claude\Claude_tmp_dienstplan\test-suite.js

Steps:

  • Step 1: Add failing tests for normalizeName, matchNames, classify.

    Append to test-suite.js:

    // ============================================================================
    // ImageImporter Tests - normalizeName, matchNames, classify (Feature A)
    // ============================================================================
    
    runner.test('Match: exakter Match (case + whitespace identisch)', (t) => {
        const importer = new ImageImporter(null);
        const r = importer.matchNames(
            [{ name: 'Max Mustermann', date: '2025-11-22', share: 1.0 }],
            ['Max Mustermann']
        );
        t.assertEqual(r.matched.length, 1, '1 zugeordnet');
        t.assertEqual(r.matched[0].resolvedName, 'Max Mustermann', 'Direkt aufgeloest');
        t.assertEqual(r.unknowns.length, 0, 'Keine Unknowns');
    });
    
    runner.test('Match: normalisierter Match (Whitespace + Case)', (t) => {
        const importer = new ImageImporter(null);
        const r = importer.matchNames(
            [{ name: '  MAX   mustermann  ', date: '2025-11-22', share: 1.0 }],
            ['Max Mustermann']
        );
        t.assertEqual(r.matched.length, 1, '1 zugeordnet');
        t.assertEqual(r.matched[0].resolvedName, 'Max Mustermann', 'Normalisiert aufgeloest');
    });
    
    runner.test('Match: Fuzzy mit Distance 1', (t) => {
        const importer = new ImageImporter(null);
        const r = importer.matchNames(
            [{ name: 'Max Mustermannn', date: '2025-11-22', share: 1.0 }],
            ['Max Mustermann']
        );
        t.assertEqual(r.matched.length, 0, 'Nicht automatisch gematcht');
        t.assertEqual(r.unknowns.length, 1, '1 Unknown');
        t.assertEqual(r.unknowns[0].suggested, 'Max Mustermann', 'Vorschlag = naechster');
        t.assertEqual(r.unknowns[0].candidate, 'Max Mustermannn', 'Original-Kandidat');
    });
    
    runner.test('Match: Distance > 2 ohne Vorschlag', (t) => {
        const importer = new ImageImporter(null);
        const r = importer.matchNames(
            [{ name: 'Egon Olsen', date: '2025-11-22', share: 1.0 }],
            ['Max Mustermann']
        );
        t.assertEqual(r.unknowns.length, 1, '1 Unknown');
        t.assertEqual(r.unknowns[0].suggested, null, 'Kein Vorschlag');
    });
    
    runner.test('Match: leere Employee-Liste alle Unknowns', (t) => {
        const importer = new ImageImporter(null);
        const r = importer.matchNames(
            [{ name: 'Max', date: '2025-11-22', share: 1.0 }],
            []
        );
        t.assertEqual(r.unknowns.length, 1, 'Unknown');
        t.assertEqual(r.unknowns[0].suggested, null, 'Kein Vorschlag moeglich');
    });
    
    runner.test('Match: mehrere Fuzzy-Treffer gleiche Distanz alphabetisch erster', (t) => {
        const importer = new ImageImporter(null);
        const r = importer.matchNames(
            [{ name: 'Anne', date: '2025-11-22', share: 1.0 }],
            ['Anna', 'Anni']
        );
        t.assertEqual(r.unknowns[0].suggested, 'Anna', 'Alphabetisch erster');
    });
    
    runner.test('Classify: Freitag = fr', (t) => {
        const importer = new ImageImporter(null);
        importer.holidayProvider = new HolidayProvider();
        const fri = new Date('2025-11-21T12:00:00');
        t.assertEqual(importer.classify(fri), 'fr', 'Freitag');
    });
    
    runner.test('Classify: Samstag = sa', (t) => {
        const importer = new ImageImporter(null);
        importer.holidayProvider = new HolidayProvider();
        const sat = new Date('2025-11-22T12:00:00');
        t.assertEqual(importer.classify(sat), 'sa', 'Samstag');
    });
    
    runner.test('Classify: Feiertag (Werktag) = so', (t) => {
        const importer = new ImageImporter(null);
        importer.holidayProvider = new HolidayProvider();
        const may1 = new Date('2025-05-01T12:00:00');
        t.assertEqual(importer.classify(may1), 'so', 'Feiertag = so');
    });
    
    runner.test('Classify: Tag vor Feiertag (Werktag) = fr', (t) => {
        const importer = new ImageImporter(null);
        importer.holidayProvider = new HolidayProvider();
        const apr30 = new Date('2025-04-30T12:00:00');
        t.assertEqual(importer.classify(apr30), 'fr', 'Tag vor Feiertag');
    });
    
    runner.test('Classify: Werktag = weekday', (t) => {
        const importer = new ImageImporter(null);
        importer.holidayProvider = new HolidayProvider();
        const mon = new Date('2025-11-24T12:00:00');
        t.assertEqual(importer.classify(mon), 'weekday', 'Werktag');
    });
    
  • Step 2: Run tests; confirm RED.

  • Step 3: Implement normalizeName, matchNames, classify in image-import.js.

    Add inside the ImageImporter class:

        /**
         * Normalize: lowercase, trim, collapse internal whitespace.
         * No umlaut folding (per spec section 10.1).
         * @param {string} name
         * @returns {string}
         */
        normalizeName(name) {
            return String(name).toLowerCase().trim().replace(/\s+/g, ' ');
        }
    
        /**
         * For each extracted entry, try exact-normalized match against existing employees;
         * else compute Levenshtein nearest with distance <= 2.
         * @param {Array<{name:string,date:string,share:number}>} extractedEntries
         * @param {string[]} existingEmployees
         * @returns {{ matched: Array<{entry:object, resolvedName:string}>, unknowns: Array<{candidate:string, suggested:string|null}> }}
         */
        matchNames(extractedEntries, existingEmployees) {
            const normalizedMap = new Map();
            for (const emp of existingEmployees) {
                normalizedMap.set(this.normalizeName(emp), emp);
            }
            const sortedEmployees = [...existingEmployees].sort();
    
            const matched = [];
            const unknownsByCandidate = new Map();
    
            for (const entry of extractedEntries) {
                const normCandidate = this.normalizeName(entry.name);
                if (normalizedMap.has(normCandidate)) {
                    matched.push({ entry, resolvedName: normalizedMap.get(normCandidate) });
                    continue;
                }
    
                let best = null;
                let bestDist = Infinity;
                for (const emp of sortedEmployees) {
                    const d = this.levenshtein(normCandidate, this.normalizeName(emp));
                    if (d < bestDist) {
                        bestDist = d;
                        best = emp;
                    }
                }
                const suggested = (best !== null && bestDist <= 2) ? best : null;
    
                if (!unknownsByCandidate.has(entry.name)) {
                    unknownsByCandidate.set(entry.name, { candidate: entry.name, suggested });
                }
            }
    
            return { matched, unknowns: Array.from(unknownsByCandidate.values()) };
        }
    
        /**
         * Slot classification, duplicated from Feature B per spec section 9.3 (independent feature).
         * @param {Date} date
         * @returns {'fr'|'sa'|'so'|'weekday'}
         */
        classify(date) {
            const wd = date.getDay();
            if (wd === 5) return 'fr';
            if (wd === 6) return 'sa';
            if (wd === 0) return 'so';
            const isFeiertag = this.holidayProvider && this.holidayProvider.isHoliday(date);
            const isTagVorFeiertag = this.holidayProvider && this.holidayProvider.isDayBeforeHoliday(date);
            if (isFeiertag && isTagVorFeiertag) return 'sa';
            if (isTagVorFeiertag) return 'fr';
            if (isFeiertag) return 'so';
            return 'weekday';
        }
    
  • Step 4: Re-run tests; confirm GREEN.

  • Step 5: Commit.

    git add image-import.js test-suite.js
    git commit -m "feat(image-import): add normalizeName, matchNames (exact+fuzzy), classify slot helper"
    

Task 8: API-Key prompt + openImportDialog entry point

Files:

  • Modify: G:\Claude\Claude_tmp_dienstplan\image-import.js
  • Modify: G:\Claude\Claude_tmp_dienstplan\index.html
  • Modify: G:\Claude\Claude_tmp_dienstplan\styles.css
  • Modify: G:\Claude\Claude_tmp_dienstplan\app.js

Steps:

  • Step 1: Add the Bild-Import button + card-header flex wrapper in index.html.

    In G:\Claude\Claude_tmp_dienstplan\index.html, replace the <div id="tab-duties"> opening block:

            <!-- Tab: Dienste eintragen -->
            <div id="tab-duties" class="tab-content active">
                <div class="card">
                    <h2>Dienste eintragen</h2>
    

    with:

            <!-- Tab: Dienste eintragen -->
            <div id="tab-duties" class="tab-content active">
                <div class="card">
                    <div class="card-header">
                        <h2>Dienste eintragen</h2>
                        <button id="open-image-import-btn" class="btn btn-secondary">Bild importieren</button>
                    </div>
    

    (The Camera/Bild emoji in the button label is per spec 5.1. The implementer should include the camera emoji in the actual button text — the plan uses ASCII for safety.)

  • Step 2: Add .card-header flex CSS to styles.css.

    In G:\Claude\Claude_tmp_dienstplan\styles.css, before the /* Form Elements */ section, insert:

    /* Card header (used by Bild-Import button) */
    .card-header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin-bottom: 20px;
        gap: 10px;
        flex-wrap: wrap;
    }
    
    .card-header h2 {
        margin-bottom: 0;
    }
    
  • Step 3: Wire the button in app.js setupEventListeners().

    In G:\Claude\Claude_tmp_dienstplan\app.js, locate the duty management block:

            // Duty management
            document.getElementById('add-duty-btn').addEventListener('click', () => this.addDuty());
            document.getElementById('employee-select-duty').addEventListener('change', () => this.loadDutiesForSelectedEmployee());
            document.getElementById('month-select').addEventListener('change', () => this.loadDutiesForSelectedEmployee());
            document.getElementById('year-select').addEventListener('change', () => this.loadDutiesForSelectedEmployee());
    

    Append immediately after, inside the same setupEventListeners method:

            // Bild-Import (Feature A)
            const imageImportBtn = document.getElementById('open-image-import-btn');
            if (imageImportBtn) {
                imageImportBtn.addEventListener('click', () => {
                    if (window.imageImporter) {
                        window.imageImporter.openImportDialog();
                    } else {
                        this.showToast('Bild-Import nicht verfuegbar.', 'error');
                    }
                });
            }
    
  • Step 4: Implement openImportDialog, close, showModal, showStage in image-import.js.

    Add inside the ImageImporter class:

        /**
         * Entry point. Ensure API key, then open the modal on Stage 1.
         */
        openImportDialog() {
            let key = this.storage.getApiKey();
            if (!key) {
                const promptText =
                    'Fuer die Bilderkennung wird ein OpenRouter-API-Key benoetigt.\n' +
                    'Der Key wird ausschliesslich lokal in Ihrem Browser gespeichert\n' +
                    'und nur an openrouter.ai gesendet.\n\n' +
                    'Key auf https://openrouter.ai/keys anlegen und hier eintragen:';
                const input = window.prompt(promptText, '');
                if (!input || !input.trim()) {
                    if (this.app) this.app.showToast('Kein API-Key gespeichert - Import abgebrochen', 'info');
                    return;
                }
                this.storage.setApiKey(input.trim());
                key = input.trim();
            }
    
            this.session = {
                file: null,
                thumbnailUrl: null,
                dataUrl: null,
                raw: null,
                entries: [],
                unknowns: [],
                resolvedNames: new Map(),
                targetYear: this.app ? this.app.currentYear : new Date().getFullYear(),
                targetMonth: this.app ? this.app.currentMonth : (new Date().getMonth() + 1),
                detectedMonth: null,
                detectedYear: null,
                notes: []
            };
    
            this.wireEventsOnce();
            this.showModal();
            this.showStage(1);
        }
    
        close() {
            if (this.session && this.session.thumbnailUrl) {
                URL.revokeObjectURL(this.session.thumbnailUrl);
            }
            this.session = null;
            if (this.abortController) {
                try { this.abortController.abort(); } catch (e) { /* ignore */ }
                this.abortController = null;
            }
            const modal = document.getElementById('image-import-modal');
            if (modal) modal.hidden = true;
    
            const recognizeBtn = document.getElementById('image-import-recognize-btn');
            if (recognizeBtn) recognizeBtn.disabled = true;
            const thumbWrap = document.getElementById('image-import-thumb-wrap');
            if (thumbWrap) thumbWrap.hidden = true;
            const fileInput = document.getElementById('image-import-file-input');
            if (fileInput) fileInput.value = '';
        }
    
        showModal() {
            const modal = document.getElementById('image-import-modal');
            if (modal) modal.hidden = false;
        }
    
        showStage(stageId) {
            const modal = document.getElementById('image-import-modal');
            if (!modal) return;
            modal.querySelectorAll('.modal-stage').forEach(s => {
                s.hidden = (parseInt(s.dataset.stage, 10) !== stageId);
            });
        }
    
        /**
         * Placeholder until Task 9 implements full wiring. Idempotent.
         */
        wireEventsOnce() {
            if (this._wired) return;
            this._wired = true;
            // Real handlers added in Task 9.
        }
    
  • Step 5: Manual verification — key prompt fires.

    Note: The modal markup is added in Task 9, so this step only verifies the key prompt and that openImportDialog runs without throwing.

    1. Open DevTools Console at http://localhost:8000/.
    2. Run localStorage.removeItem('dienstplan_openrouter_key').
    3. Reload the page.
    4. In the "Dienste eintragen" tab, click the Bild-Import button.
    5. Expect: prompt() dialog appears with the OpenRouter explainer text.
    6. Cancel → toast "Kein API-Key gespeichert - Import abgebrochen" appears.
    7. Click button again → prompt again. Enter sk-dummy-test. Confirm. The modal is not yet visible (Task 9 adds markup), but window.imageImporter.session is populated. Console: window.imageImporter.session.file === null should be true; window.imageImporter.storage.getApiKey() returns 'sk-dummy-test'.
  • Step 6: Commit.

    git add image-import.js index.html styles.css app.js
    git commit -m "feat(image-import): add openImportDialog entry point with API-key prompt and Bild-Import button"
    

Task 9: Modal Stage 1 (Upload) HTML + CSS + wiring

Files:

  • Modify: G:\Claude\Claude_tmp_dienstplan\index.html
  • Modify: G:\Claude\Claude_tmp_dienstplan\styles.css
  • Modify: G:\Claude\Claude_tmp_dienstplan\image-import.js

Steps:

  • Step 1: Add modal skeleton to index.html.

    In G:\Claude\Claude_tmp_dienstplan\index.html, locate:

        <!-- Toast Notification -->
        <div id="toast" class="toast"></div>
    

    Insert immediately BEFORE that line:

        <!-- Bild-Import Modal (Feature A) -->
        <div id="image-import-modal" class="modal" hidden>
            <div class="modal-backdrop"></div>
            <div class="modal-content">
                <button class="modal-close" id="image-import-close-btn" aria-label="Schliessen">&times;</button>
    
                <!-- Stage 1: Upload -->
                <div class="modal-stage" data-stage="1">
                    <h2>Bild importieren - Schritt 1: Bild auswaehlen</h2>
                    <p class="privacy-notice">Das Bild wird zur Erkennung an OpenRouter gesendet.</p>
    
                    <div class="drag-drop-zone" id="image-import-dropzone">
                        <p>Bild hier ablegen oder Datei auswaehlen</p>
                        <input type="file" id="image-import-file-input" accept="image/png,image/jpeg,image/webp" hidden>
                        <button class="btn btn-secondary" id="image-import-pick-btn">Datei auswaehlen</button>
                        <input type="file" id="image-import-camera-input" accept="image/*" capture="environment" hidden>
                        <button class="btn btn-secondary" id="image-import-camera-btn">Mit Kamera aufnehmen</button>
                    </div>
    
                    <div class="thumbnail-preview" id="image-import-thumb-wrap" hidden>
                        <img id="image-import-thumb" alt="Vorschau">
                        <div class="thumbnail-meta">
                            <span id="image-import-thumb-name"></span>
                            <span id="image-import-thumb-size"></span>
                        </div>
                    </div>
    
                    <div class="modal-actions">
                        <button class="btn btn-secondary" id="image-import-cancel-1-btn">Abbrechen</button>
                        <button class="btn btn-primary" id="image-import-recognize-btn" disabled>Erkennen</button>
                    </div>
                </div>
    
                <!-- Stage 2: Processing -->
                <div class="modal-stage" data-stage="2" hidden>
                    <h2>Analysiere Bild...</h2>
                    <div class="spinner"></div>
                    <p class="text-muted">Das kann 5-15 Sekunden dauern.</p>
                    <div class="modal-actions">
                        <button class="btn btn-secondary" id="image-import-cancel-2-btn">Abbrechen</button>
                    </div>
                </div>
    
                <!-- Stage 3: Preview & Confirm -->
                <div class="modal-stage" data-stage="3" hidden>
                    <h2>Vorschau und Bestaetigen</h2>
                    <div id="image-import-notes-box"></div>
                    <div class="unknown-names-box" id="image-import-unknowns-box" hidden>
                        <h3>Unbekannte Namen</h3>
                        <div id="image-import-unknowns-list"></div>
                    </div>
                    <div id="image-import-preview-table"></div>
                    <div class="modal-actions">
                        <button class="btn btn-secondary" id="image-import-cancel-3-btn">Abbrechen</button>
                        <button class="btn btn-primary" id="image-import-confirm-btn">Bestaetigen und Importieren</button>
                    </div>
                </div>
    
                <!-- Stage 4: Done -->
                <div class="modal-stage" data-stage="4" hidden>
                    <h2>Import abgeschlossen</h2>
                    <p id="image-import-done-summary"></p>
                </div>
            </div>
        </div>
    
  • Step 2: Add modal CSS to styles.css.

    Append to G:\Claude\Claude_tmp_dienstplan\styles.css at the very end:

    /* ============================================================
       Bild-Import Modal (Feature A)
       ============================================================ */
    .modal {
        position: fixed;
        inset: 0;
        z-index: 1500;
        display: flex;
        align-items: center;
        justify-content: center;
    }
    
    .modal[hidden] { display: none; }
    
    .modal-backdrop {
        position: absolute;
        inset: 0;
        background: rgba(0, 0, 0, 0.5);
    }
    
    .modal-content {
        position: relative;
        background: white;
        border-radius: 12px;
        box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
        padding: 30px;
        max-width: 800px;
        width: 90%;
        max-height: 90vh;
        overflow-y: auto;
    }
    
    .modal-close {
        position: absolute;
        top: 10px;
        right: 10px;
        width: 32px;
        height: 32px;
        border: none;
        background: transparent;
        font-size: 24px;
        cursor: pointer;
        color: #666;
    }
    
    .modal-stage {
        animation: fadeIn 0.2s ease;
    }
    
    .modal-stage[hidden] { display: none; }
    
    .modal-actions {
        display: flex;
        justify-content: flex-end;
        gap: 10px;
        margin-top: 20px;
        flex-wrap: wrap;
    }
    
    .privacy-notice {
        font-size: 0.875rem;
        color: #6c757d;
        margin-bottom: 15px;
        font-style: italic;
    }
    
    .drag-drop-zone {
        border: 2px dashed #c0c0c8;
        border-radius: 8px;
        padding: 30px;
        text-align: center;
        transition: all 0.2s ease;
        margin-bottom: 15px;
    }
    
    .drag-drop-zone.drag-over {
        border-color: #667eea;
        background: rgba(102, 126, 234, 0.05);
    }
    
    .drag-drop-zone p {
        margin-bottom: 15px;
        color: #555;
    }
    
    .thumbnail-preview {
        display: flex;
        gap: 15px;
        align-items: center;
        padding: 15px;
        background: #f8f9fa;
        border-radius: 6px;
        margin-bottom: 15px;
    }
    
    .thumbnail-preview img {
        max-width: 240px;
        max-height: 240px;
        border-radius: 4px;
        border: 1px solid #e0e0e0;
    }
    
    .thumbnail-meta {
        display: flex;
        flex-direction: column;
        gap: 4px;
        font-size: 0.875rem;
        color: #555;
    }
    
    .spinner {
        width: 48px;
        height: 48px;
        margin: 30px auto;
        border: 4px solid #e0e0e0;
        border-top-color: #667eea;
        border-radius: 50%;
        animation: spin 0.8s linear infinite;
    }
    
    @keyframes spin {
        to { transform: rotate(360deg); }
    }
    
    .unknown-names-box {
        background: #fff3cd;
        border-left: 4px solid #ffc107;
        border-radius: 6px;
        padding: 15px;
        margin-bottom: 20px;
    }
    
    .unknown-name-row {
        display: flex;
        align-items: center;
        gap: 10px;
        padding: 8px 0;
        border-bottom: 1px solid rgba(0, 0, 0, 0.05);
        flex-wrap: wrap;
    }
    
    .unknown-name-row:last-child { border-bottom: none; }
    
    .unknown-name-row .unknown-candidate {
        font-weight: 600;
        flex: 1;
        min-width: 140px;
    }
    
    .unknown-name-row select {
        flex: 2;
        min-width: 180px;
        padding: 6px 10px;
        border: 2px solid #e0e0e0;
        border-radius: 4px;
    }
    
    .unknown-name-row .fuzzy-hint {
        flex-basis: 100%;
        font-size: 0.8rem;
        color: #856404;
        padding-left: 4px;
    }
    
    .preview-employee-group {
        margin-bottom: 20px;
        background: #f8f9fa;
        border-radius: 6px;
        padding: 15px;
    }
    
    .preview-employee-group h3 {
        color: #667eea;
        margin-bottom: 10px;
        font-size: 1.1rem;
    }
    
    .preview-table {
        width: 100%;
        border-collapse: collapse;
    }
    
    .preview-table th,
    .preview-table td {
        padding: 6px 8px;
        border-bottom: 1px solid #e0e0e0;
        text-align: left;
        font-size: 0.9rem;
    }
    
    .preview-table th { background: #ececf3; }
    
    .preview-row.outside-month { background: #fff0f0; }
    
    .preview-row .row-remove-btn {
        background: transparent;
        border: none;
        cursor: pointer;
        font-size: 1rem;
    }
    
    .slot-badge {
        display: inline-block;
        padding: 2px 8px;
        border-radius: 4px;
        font-size: 0.75rem;
        font-weight: 600;
        color: white;
    }
    
    .slot-badge.slot-fr { background: #fd7e14; }
    .slot-badge.slot-sa { background: #dc3545; }
    .slot-badge.slot-so { background: #dc3545; }
    .slot-badge.slot-weekday { background: #6c757d; }
    
    .api-key-status-ok { color: #28a745; font-weight: 500; }
    .api-key-status-none { color: #6c757d; font-style: italic; }
    
  • Step 3: Replace the wireEventsOnce() placeholder and add onFileSelected in image-import.js.

    Replace the placeholder wireEventsOnce method (from Task 8 Step 4) with the full version, and add onFileSelected:

        /**
         * Attach DOM event listeners. Called lazily on first openImportDialog.
         * Safe to call multiple times (idempotent via this._wired flag).
         */
        wireEventsOnce() {
            if (this._wired) return;
            this._wired = true;
    
            const closeBtn = document.getElementById('image-import-close-btn');
            const cancel1 = document.getElementById('image-import-cancel-1-btn');
            const cancel3 = document.getElementById('image-import-cancel-3-btn');
            [closeBtn, cancel1, cancel3].forEach(b => {
                if (b) b.addEventListener('click', () => this.close());
            });
    
            const cancel2 = document.getElementById('image-import-cancel-2-btn');
            if (cancel2) cancel2.addEventListener('click', () => {
                if (this.abortController) {
                    try { this.abortController.abort(); } catch (e) { /* ignore */ }
                }
                this.close();
            });
    
            const fileInput = document.getElementById('image-import-file-input');
            const pickBtn = document.getElementById('image-import-pick-btn');
            if (pickBtn) pickBtn.addEventListener('click', () => fileInput && fileInput.click());
            if (fileInput) fileInput.addEventListener('change', (e) => {
                const f = e.target.files && e.target.files[0];
                if (f) this.onFileSelected(f);
            });
    
            const cameraInput = document.getElementById('image-import-camera-input');
            const cameraBtn = document.getElementById('image-import-camera-btn');
            if (cameraBtn) cameraBtn.addEventListener('click', () => cameraInput && cameraInput.click());
            if (cameraInput) cameraInput.addEventListener('change', (e) => {
                const f = e.target.files && e.target.files[0];
                if (f) this.onFileSelected(f);
            });
    
            const dz = document.getElementById('image-import-dropzone');
            if (dz) {
                dz.addEventListener('dragover', (e) => { e.preventDefault(); dz.classList.add('drag-over'); });
                dz.addEventListener('dragleave', () => dz.classList.remove('drag-over'));
                dz.addEventListener('drop', (e) => {
                    e.preventDefault();
                    dz.classList.remove('drag-over');
                    const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
                    if (f) this.onFileSelected(f);
                });
            }
    
            const recognizeBtn = document.getElementById('image-import-recognize-btn');
            if (recognizeBtn) recognizeBtn.addEventListener('click', () => this.runRecognition());
    
            const confirmBtn = document.getElementById('image-import-confirm-btn');
            if (confirmBtn) confirmBtn.addEventListener('click', () => this.commitImport());
        }
    
        /**
         * Validate file (type + size), set into session, render thumbnail, enable Erkennen.
         * @param {File} file
         */
        onFileSelected(file) {
            if (!file.type || !file.type.startsWith('image/')) {
                if (this.app) this.app.showToast('Nur Bildformate werden unterstuetzt', 'error');
                return;
            }
            const MAX = 20 * 1024 * 1024;
            if (file.size > MAX) {
                if (this.app) this.app.showToast('Bild zu gross (max. 20 MB)', 'error');
                return;
            }
    
            if (this.session.thumbnailUrl) URL.revokeObjectURL(this.session.thumbnailUrl);
            this.session.file = file;
            this.session.thumbnailUrl = URL.createObjectURL(file);
    
            const wrap = document.getElementById('image-import-thumb-wrap');
            const img = document.getElementById('image-import-thumb');
            const nameEl = document.getElementById('image-import-thumb-name');
            const sizeEl = document.getElementById('image-import-thumb-size');
            if (img) img.src = this.session.thumbnailUrl;
            if (nameEl) nameEl.textContent = file.name;
            if (sizeEl) sizeEl.textContent = `${Math.round(file.size / 1024)} KB`;
            if (wrap) wrap.hidden = false;
    
            const recognizeBtn = document.getElementById('image-import-recognize-btn');
            if (recognizeBtn) recognizeBtn.disabled = false;
        }
    
  • Step 4: Manual verification — Stage 1 works end-to-end (without recognition yet).

    1. Open http://localhost:8000/. Reload. Click Bild-Import button. Modal opens on Stage 1.
    2. Click "Datei auswaehlen", choose any PNG/JPEG (e.g. a screenshot). Thumbnail appears with filename + KB. "Erkennen" button becomes enabled.
    3. Try selecting a .txt file → toast "Nur Bildformate werden unterstuetzt".
    4. Drag any image file onto the drop zone → same effect as picking via dialog.
    5. Click "Abbrechen" → modal closes; window.imageImporter.session === null.
    6. Click the close (x) button → modal closes.
    7. Re-open modal → no stale thumbnail (Stage 1 is fresh because session is rebuilt and Erkennen disabled).
  • Step 5: Commit.

    git add index.html styles.css image-import.js
    git commit -m "feat(image-import): add modal markup, CSS, and Stage 1 file/drop/camera wiring"
    

Task 10: Modal Stage 2 (Processing) — wire runRecognition

Files:

  • Modify: G:\Claude\Claude_tmp_dienstplan\image-import.js

Steps:

  • Step 1: Implement runRecognition and handleRecognitionError in image-import.js.

    Add inside the ImageImporter class:

        /**
         * Stage 1 to Stage 2 to (Stage 3 | back-to-1 on error).
         * Compress, call API, parse, dedupe, match names, then showStage(3).
         */
        async runRecognition() {
            if (!this.session || !this.session.file) {
                if (this.app) this.app.showToast('Kein Bild ausgewaehlt', 'error');
                return;
            }
    
            this.showStage(2);
            this.abortController = new AbortController();
    
            try {
                const compressed = await this.compressImage(this.session.file);
                this.session.dataUrl = compressed.dataUrl;
    
                const apiKey = this.storage.getApiKey();
                const modelId = this.storage.getApiModel();
                const rawContent = await this.callVisionAPI(
                    compressed.dataUrl,
                    apiKey,
                    modelId,
                    this.abortController.signal
                );
    
                const parsed = this.parseResponse(rawContent);
                if (parsed.entries.length === 0) {
                    if (this.app) this.app.showToast('Keine Dienste erkannt', 'info');
                    this.showStage(1);
                    return;
                }
    
                // Dedupe (name, date) — keep higher share on conflict
                const dedup = new Map();
                const dupeNotes = [];
                for (const e of parsed.entries) {
                    const key = `${e.name}|${e.date}`;
                    const prev = dedup.get(key);
                    if (prev) {
                        if (e.share > prev.share) {
                            dedup.set(key, e);
                            dupeNotes.push(`Doppelter Eintrag fuer ${e.name} am ${e.date} - hoeherer Anteil verwendet`);
                        }
                    } else {
                        dedup.set(key, e);
                    }
                }
                const dedupedEntries = Array.from(dedup.values());
    
                const employees = this.storage.getEmployees();
                const matchResult = this.matchNames(dedupedEntries, employees);
    
                this.session.detectedMonth = parsed.month;
                this.session.detectedYear = parsed.year;
                this.session.notes = [...(parsed.notes || []), ...dupeNotes];
                this.session.entries = dedupedEntries.map(e => ({
                    name: e.name,
                    date: new Date(e.date + 'T12:00:00'),
                    dateStr: e.date,
                    share: e.share
                }));
                this.session.unknowns = matchResult.unknowns.map(u => ({
                    candidate: u.candidate,
                    suggested: u.suggested,
                    choice: u.suggested ? `assign:${u.suggested}` : 'new'
                }));
                this.session.resolvedNames = new Map();
                matchResult.matched.forEach(m => {
                    this.session.resolvedNames.set(m.entry.name, m.resolvedName);
                });
    
                this.renderPreview();
                this.showStage(3);
            } catch (err) {
                this.handleRecognitionError(err);
            } finally {
                this.abortController = null;
            }
        }
    
        /**
         * Toast appropriate message and return to Stage 1 (or close on AbortError).
         * @param {Error} err
         */
        handleRecognitionError(err) {
            if (err && err.name === 'AbortError') {
                this.close();
                return;
            }
            let msg;
            if (err && err.name === 'OpenRouterError') {
                switch (err.status) {
                    case 401: msg = 'API-Key ungueltig'; break;
                    case 402:
                    case 429: msg = 'Limit erreicht oder Guthaben aufgebraucht'; break;
                    default:
                        if (err.status >= 500) msg = `Server-Fehler, spaeter nochmal (HTTP ${err.status})`;
                        else msg = `Anfrage abgelehnt (HTTP ${err.status})`;
                }
            } else if (err instanceof TypeError) {
                msg = 'Keine Verbindung zu OpenRouter - Internet pruefen';
            } else if (err instanceof SyntaxError || (err && /JSON|Schema|entries/i.test(err.message))) {
                msg = 'Erkennung fehlgeschlagen - anderes Modell probieren oder Bild pruefen';
            } else {
                msg = 'Erkennung fehlgeschlagen';
            }
            if (this.app) this.app.showToast(msg, 'error');
            this.showStage(1);
        }
    
  • Step 2: Manual verification — Stage 2 transition with monkey-patched API.

    1. Open http://localhost:8000/. In DevTools Console, run:
      window.imageImporter.callVisionAPI = async () => {
          await new Promise(r => setTimeout(r, 2000));
          return JSON.stringify({
              month: 11, year: 2025,
              entries: [
                  { name: 'Max Mustermann', date: '2025-11-22', share: 1.0 },
                  { name: 'Anna Schmidt', date: '2025-11-23', share: 0.5 }
              ],
              notes: []
          });
      };
      
    2. Click Bild-Import, pick any image, click "Erkennen".
    3. Stage 2 shows for ~2 s with spinner.
    4. The Console should log no errors. (Stage 3 rendering happens in next task; for now expect a no-op when renderPreview is called — that's the Task 11 boundary. Optionally guard with if (typeof this.renderPreview === 'function').)

    To confirm Stage 2 cancel: replace setTimeout 2000 with 30000; click "Abbrechen" during processing → modal closes silently, no error toast.

  • Step 3: Commit.

    git add image-import.js
    git commit -m "feat(image-import): add runRecognition pipeline (compress, call, parse, dedupe, match) + Stage 2 cancel"
    

Task 11: Modal Stage 3 (Preview & Confirm) — renderPreview

Files:

  • Modify: G:\Claude\Claude_tmp_dienstplan\image-import.js

Steps:

  • Step 1: Implement renderPreview, groupEntriesByResolvedEmployee, onUnknownChoiceChange, onRemoveEntry.

    All DOM construction uses document.createElement + textContent (XSS-safe) — no innerHTML with user data.

    Add inside the ImageImporter class:

        /**
         * Render Stage 3 from this.session. Idempotent (clears and rebuilds DOM).
         * Uses createElement + textContent only — never innerHTML with user data.
         */
        renderPreview() {
            const monthNames = ['Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni',
                                'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
            const weekdayFmt = new Intl.DateTimeFormat('de-DE', { weekday: 'long' });
    
            // ---- Notes box ----
            const notesBox = document.getElementById('image-import-notes-box');
            if (notesBox) {
                while (notesBox.firstChild) notesBox.removeChild(notesBox.firstChild);
                const detected = (this.session.detectedMonth && this.session.detectedYear)
                    ? `${monthNames[this.session.detectedMonth - 1]} ${this.session.detectedYear}`
                    : null;
                const target = `${monthNames[this.session.targetMonth - 1]} ${this.session.targetYear}`;
                if (detected && (this.session.detectedMonth !== this.session.targetMonth
                                 || this.session.detectedYear !== this.session.targetYear)) {
                    const p = document.createElement('p');
                    p.className = 'text-warning';
                    p.textContent = `Erkannter Monat: ${detected}, aktuell ausgewaehlt: ${target}. Import laeuft auf den ausgewaehlten Monat.`;
                    notesBox.appendChild(p);
                }
                for (const note of this.session.notes) {
                    const p = document.createElement('p');
                    p.className = 'text-muted';
                    p.textContent = note;
                    notesBox.appendChild(p);
                }
            }
    
            // ---- Unknown names box ----
            const unknownsBox = document.getElementById('image-import-unknowns-box');
            const unknownsList = document.getElementById('image-import-unknowns-list');
            if (unknownsBox && unknownsList) {
                while (unknownsList.firstChild) unknownsList.removeChild(unknownsList.firstChild);
                if (this.session.unknowns.length === 0) {
                    unknownsBox.hidden = true;
                } else {
                    unknownsBox.hidden = false;
                    const employees = [...this.storage.getEmployees()].sort();
                    for (const unk of this.session.unknowns) {
                        const row = document.createElement('div');
                        row.className = 'unknown-name-row';
    
                        const nameSpan = document.createElement('span');
                        nameSpan.className = 'unknown-candidate';
                        nameSpan.textContent = unk.candidate;
                        row.appendChild(nameSpan);
    
                        const select = document.createElement('select');
                        const optNew = document.createElement('option');
                        optNew.value = 'new';
                        optNew.textContent = 'Neuer Mitarbeiter anlegen';
                        select.appendChild(optNew);
                        for (const emp of employees) {
                            const o = document.createElement('option');
                            o.value = `assign:${emp}`;
                            o.textContent = `Zuordnen zu ${emp}`;
                            select.appendChild(o);
                        }
                        const optIgnore = document.createElement('option');
                        optIgnore.value = 'ignore';
                        optIgnore.textContent = 'Ignorieren';
                        select.appendChild(optIgnore);
    
                        select.value = unk.choice;
                        select.addEventListener('change', (e) => {
                            this.onUnknownChoiceChange(unk.candidate, e.target.value);
                        });
                        row.appendChild(select);
    
                        if (unk.suggested) {
                            const hint = document.createElement('div');
                            hint.className = 'fuzzy-hint';
                            hint.textContent = `moeglicher Match: ${unk.suggested}`;
                            row.appendChild(hint);
                        }
                        unknownsList.appendChild(row);
                    }
                }
            }
    
            // ---- Preview table grouped by resolved employee ----
            const tableHost = document.getElementById('image-import-preview-table');
            if (tableHost) {
                while (tableHost.firstChild) tableHost.removeChild(tableHost.firstChild);
                const grouped = this.groupEntriesByResolvedEmployee();
    
                for (const [employeeName, rows] of grouped.entries()) {
                    if (employeeName === null) continue;
                    const group = document.createElement('div');
                    group.className = 'preview-employee-group';
                    const h3 = document.createElement('h3');
                    h3.textContent = employeeName;
                    group.appendChild(h3);
    
                    const table = document.createElement('table');
                    table.className = 'preview-table';
                    const thead = document.createElement('thead');
                    const headRow = document.createElement('tr');
                    for (const headText of ['Datum', 'Wochentag', 'Slot', 'Anteil', 'Aktion']) {
                        const th = document.createElement('th');
                        th.textContent = headText;
                        headRow.appendChild(th);
                    }
                    thead.appendChild(headRow);
                    table.appendChild(thead);
    
                    const tbody = document.createElement('tbody');
                    for (const r of rows) {
                        const tr = document.createElement('tr');
                        tr.className = 'preview-row';
                        const m = r.entry.date.getMonth() + 1;
                        const y = r.entry.date.getFullYear();
                        const outside = (m !== this.session.targetMonth || y !== this.session.targetYear);
                        if (outside) tr.classList.add('outside-month');
    
                        const tdDate = document.createElement('td');
                        tdDate.textContent = r.entry.dateStr + (outside ? ' (ausserhalb Monat)' : '');
                        tr.appendChild(tdDate);
    
                        const tdWeekday = document.createElement('td');
                        tdWeekday.textContent = weekdayFmt.format(r.entry.date);
                        tr.appendChild(tdWeekday);
    
                        const tdSlot = document.createElement('td');
                        const slot = this.classify(r.entry.date);
                        const slotBadge = document.createElement('span');
                        slotBadge.className = `slot-badge slot-${slot}`;
                        slotBadge.textContent = slot;
                        tdSlot.appendChild(slotBadge);
                        tr.appendChild(tdSlot);
    
                        const tdShare = document.createElement('td');
                        tdShare.textContent = r.entry.share.toFixed(1);
                        tr.appendChild(tdShare);
    
                        const tdAction = document.createElement('td');
                        const removeBtn = document.createElement('button');
                        removeBtn.className = 'row-remove-btn';
                        removeBtn.title = 'Entfernen';
                        removeBtn.textContent = 'Entfernen';
                        removeBtn.addEventListener('click', () => this.onRemoveEntry(r.index));
                        tdAction.appendChild(removeBtn);
                        tr.appendChild(tdAction);
    
                        tbody.appendChild(tr);
                    }
                    table.appendChild(tbody);
                    group.appendChild(table);
                    tableHost.appendChild(group);
                }
            }
        }
    
        /**
         * Build Map<resolvedEmployeeName|null, [{ entry, index }]> based on session.entries
         * and unknowns choices.
         */
        groupEntriesByResolvedEmployee() {
            const choiceByCandidate = new Map();
            for (const u of this.session.unknowns) {
                choiceByCandidate.set(u.candidate, u.choice);
            }
    
            const grouped = new Map();
            for (let i = 0; i < this.session.entries.length; i++) {
                const e = this.session.entries[i];
                let resolved;
                if (this.session.resolvedNames.has(e.name)) {
                    resolved = this.session.resolvedNames.get(e.name);
                } else {
                    const choice = choiceByCandidate.get(e.name) || 'new';
                    if (choice === 'ignore') resolved = null;
                    else if (choice === 'new') resolved = e.name;
                    else if (choice.startsWith('assign:')) resolved = choice.slice('assign:'.length);
                    else resolved = e.name;
                }
                if (!grouped.has(resolved)) grouped.set(resolved, []);
                grouped.get(resolved).push({ entry: e, index: i });
            }
            return grouped;
        }
    
        onUnknownChoiceChange(candidate, choice) {
            const unk = this.session.unknowns.find(u => u.candidate === candidate);
            if (unk) unk.choice = choice;
            this.renderPreview();
        }
    
        onRemoveEntry(index) {
            this.session.entries.splice(index, 1);
            this.renderPreview();
        }
    
  • Step 2: Manual verification — Stage 3 renders.

    1. Open http://localhost:8000/. Reload. In Console:
      app.storage.clearAll();
      app.storage.addEmployee('Max Mustermann');
      app.storage.addEmployee('Anna Schmidt');
      app.loadEmployeeSelects();
      
      window.imageImporter.callVisionAPI = async () => JSON.stringify({
          month: 11, year: 2025,
          entries: [
              { name: 'Max Mustermann', date: '2025-11-22', share: 1.0 },
              { name: 'Max Mustermannn', date: '2025-11-23', share: 0.5 },
              { name: 'Egon Olsen', date: '2025-11-28', share: 1.0 }
          ],
          notes: ['Testlauf']
      });
      
    2. Click Bild-Import, pick any image, click "Erkennen".
    3. Stage 3 appears with:
      • Notes box: "Testlauf".
      • Unknowns box visible with two rows: Max Mustermannn (default Zuordnen zu Max Mustermann, hint moeglicher Match: Max Mustermann) and Egon Olsen (default Neuer Mitarbeiter anlegen).
      • Preview table grouped: one group for Max Mustermann containing rows 2025-11-22 (Samstag, slot=sa), 2025-11-23 (Sonntag, slot=so).
      • One group for Egon Olsen containing 2025-11-28 (Freitag, slot=fr).
    4. Change the Egon Olsen dropdown to Zuordnen zu Anna Schmidt → table regroups (Egon's row moves under Anna Schmidt).
    5. Click Entfernen on one row → that row disappears, table re-renders.
    6. Click Abbrechen → modal closes.
  • Step 3: Commit.

    git add image-import.js
    git commit -m "feat(image-import): add renderPreview + Stage 3 grouping, unknowns, slot badges, remove-row"
    

Task 12: Commit-to-storage logic (resolveImports + commitImport) (TDD where pure)

Files:

  • Modify: G:\Claude\Claude_tmp_dienstplan\image-import.js
  • Modify: G:\Claude\Claude_tmp_dienstplan\test-suite.js

Steps:

  • Step 1: Add failing pure-function tests for resolveImports.

    Append to test-suite.js:

    // ============================================================================
    // ImageImporter Tests - resolveImports (pure) (Feature A)
    // ============================================================================
    
    runner.test('Resolve: gemischte unknowns (new + assign + ignore)', (t) => {
        const importer = new ImageImporter(null);
        const session = {
            entries: [
                { name: 'Max Mustermann', date: new Date('2025-11-22T12:00:00'), dateStr: '2025-11-22', share: 1.0 },
                { name: 'Max Mustermannn', date: new Date('2025-11-23T12:00:00'), dateStr: '2025-11-23', share: 0.5 },
                { name: 'Egon Olsen', date: new Date('2025-11-28T12:00:00'), dateStr: '2025-11-28', share: 1.0 },
                { name: 'Hugo Ignored', date: new Date('2025-11-29T12:00:00'), dateStr: '2025-11-29', share: 1.0 }
            ],
            unknowns: [
                { candidate: 'Max Mustermannn', suggested: 'Max Mustermann', choice: 'assign:Max Mustermann' },
                { candidate: 'Egon Olsen', suggested: null, choice: 'new' },
                { candidate: 'Hugo Ignored', suggested: null, choice: 'ignore' }
            ],
            resolvedNames: new Map([['Max Mustermann', 'Max Mustermann']]),
            targetYear: 2025,
            targetMonth: 11
        };
        const plan = importer.resolveImports(session);
        t.assertEqual(plan.newEmployees.length, 1, '1 neuer MA');
        t.assertEqual(plan.newEmployees[0], 'Egon Olsen', 'Egon ist neu');
        t.assertEqual(plan.commits.length, 3, '3 Commits (Hugo ignoriert)');
        t.assertEqual(plan.skippedOutsideMonth, 0, 'Keine ausserhalb Monat');
    
        const max22 = plan.commits.find(c => c.employeeName === 'Max Mustermann' && c.dateStr === '2025-11-22');
        t.assertTrue(!!max22, 'Max am 22.11 vorhanden');
        t.assertEqual(max22.share, 1.0, 'Share 1.0');
    
        const maxFromFuzzy = plan.commits.find(c => c.employeeName === 'Max Mustermann' && c.dateStr === '2025-11-23');
        t.assertTrue(!!maxFromFuzzy, 'Fuzzy-Match wurde aufgeloest');
        t.assertEqual(maxFromFuzzy.share, 0.5, 'Share 0.5');
    
        const egon = plan.commits.find(c => c.employeeName === 'Egon Olsen');
        t.assertTrue(!!egon, 'Egon committed');
    });
    
    runner.test('Resolve: ausserhalb Monat wird uebersprungen', (t) => {
        const importer = new ImageImporter(null);
        const session = {
            entries: [
                { name: 'A', date: new Date('2025-11-22T12:00:00'), dateStr: '2025-11-22', share: 1.0 },
                { name: 'A', date: new Date('2025-12-01T12:00:00'), dateStr: '2025-12-01', share: 1.0 }
            ],
            unknowns: [{ candidate: 'A', suggested: null, choice: 'new' }],
            resolvedNames: new Map(),
            targetYear: 2025,
            targetMonth: 11
        };
        const plan = importer.resolveImports(session);
        t.assertEqual(plan.commits.length, 1, 'Nur November-Eintrag bleibt');
        t.assertEqual(plan.skippedOutsideMonth, 1, '1 uebersprungen');
    });
    
  • Step 2: Run tests; confirm RED.

  • Step 3: Implement resolveImports + commitImport in image-import.js.

    Add inside the ImageImporter class:

        /**
         * Pure: turn session state into a commit plan.
         * @param {object} session
         * @returns {{ newEmployees: string[], commits: Array<{employeeName:string,year:number,month:number,date:Date,dateStr:string,share:number}>, skippedOutsideMonth: number }}
         */
        resolveImports(session) {
            const choiceByCandidate = new Map();
            for (const u of session.unknowns) {
                choiceByCandidate.set(u.candidate, u.choice);
            }
    
            const newEmployees = new Set();
            const commits = [];
            let skippedOutsideMonth = 0;
    
            for (const e of session.entries) {
                let resolved;
                if (session.resolvedNames.has(e.name)) {
                    resolved = session.resolvedNames.get(e.name);
                } else {
                    const choice = choiceByCandidate.get(e.name) || 'new';
                    if (choice === 'ignore') continue;
                    if (choice === 'new') {
                        resolved = e.name;
                        newEmployees.add(e.name);
                    } else if (choice.startsWith('assign:')) {
                        resolved = choice.slice('assign:'.length);
                    } else {
                        resolved = e.name;
                    }
                }
    
                const y = e.date.getFullYear();
                const m = e.date.getMonth() + 1;
                if (y !== session.targetYear || m !== session.targetMonth) {
                    skippedOutsideMonth++;
                    continue;
                }
    
                commits.push({
                    employeeName: resolved,
                    year: session.targetYear,
                    month: session.targetMonth,
                    date: e.date,
                    dateStr: e.dateStr,
                    share: e.share
                });
            }
    
            return { newEmployees: Array.from(newEmployees), commits, skippedOutsideMonth };
        }
    
        /**
         * Stage 3 to Stage 4. Resolve plan, persist via DataStorage, refresh UI.
         */
        async commitImport() {
            if (!this.session) return;
            const plan = this.resolveImports(this.session);
    
            for (const name of plan.newEmployees) {
                this.storage.addEmployee(name);
            }
    
            let okCount = 0;
            let errCount = 0;
            const affectedEmployees = new Set();
            for (const c of plan.commits) {
                try {
                    this.storage.addDuty(c.employeeName, c.year, c.month, c.date, c.share);
                    affectedEmployees.add(c.employeeName);
                    okCount++;
                } catch (e) {
                    console.error('commitImport: addDuty failed', e);
                    errCount++;
                    break; // per spec 13.4
                }
            }
    
            if (this.app) {
                if (plan.newEmployees.length > 0) {
                    this.app.loadEmployeeSelects();
                    this.app.loadEmployeeList();
                }
                this.app.loadDutiesForSelectedEmployee();
    
                if (errCount > 0) {
                    this.app.showToast(`Speicherfehler - Import unvollstaendig (${okCount} von ${plan.commits.length} erfolgreich)`, 'error');
                } else {
                    const msg = `${okCount} Dienste fuer ${affectedEmployees.size} Mitarbeiter importiert`;
                    this.app.showToast(msg, 'success');
                    if (plan.skippedOutsideMonth > 0) {
                        setTimeout(() => {
                            this.app.showToast(`${plan.skippedOutsideMonth} Eintraege ausserhalb des gewaehlten Monats uebersprungen`, 'info');
                        }, 1600);
                    }
                }
            }
    
            const doneSummary = document.getElementById('image-import-done-summary');
            if (doneSummary) {
                doneSummary.textContent = `${okCount} Dienste fuer ${affectedEmployees.size} Mitarbeiter importiert.`;
            }
            this.showStage(4);
            setTimeout(() => this.close(), 1500);
        }
    
  • Step 4: Re-run tests; confirm GREEN.

    Reload test.html, run. Both Resolve: tests should be green.

  • Step 5: Manual verification — full commit path.

    1. Open http://localhost:8000/, reload. In Console: same mock as Task 11 (two employees + monkey-patched callVisionAPI).
    2. Open Bild-Import, pick image, Erkennen. In Stage 3, click "Bestaetigen und Importieren".
    3. Modal switches to Stage 4 for ~1.5 s, then closes. Toast: "X Dienste fuer Y Mitarbeiter importiert".
    4. In Console verify: app.storage.getEmployees() includes Egon Olsen (because default for unknown was new). app.storage.getDutiesForMonth('Max Mustermann', 2025, 11) returns 2 duties.
    5. Switch to "Mitarbeiter verwalten" tab → Egon Olsen visible.
  • Step 6: Commit.

    git add image-import.js test-suite.js
    git commit -m "feat(image-import): add resolveImports (pure) and commitImport persisting duties via DataStorage"
    

Task 13: Settings-tab section "Bild-Import (KI)"

Files:

  • Modify: G:\Claude\Claude_tmp_dienstplan\index.html
  • Modify: G:\Claude\Claude_tmp_dienstplan\app.js

Steps:

  • Step 1: Add the settings section to index.html.

    In G:\Claude\Claude_tmp_dienstplan\index.html, locate the start of the "Alle Daten löschen" section:

                    <div class="settings-section">
                        <h3>Alle Daten löschen</h3>
                        <p class="text-warning">Achtung: Diese Aktion kann nicht rückgängig gemacht werden!</p>
                        <button id="clear-all-btn" class="btn btn-danger">Alle Daten löschen</button>
                    </div>
    

    Insert immediately BEFORE that block:

                    <div class="settings-section">
                        <h3>Bild-Import (KI)</h3>
                        <p id="api-key-status" class="api-key-status-none">Kein Key hinterlegt</p>
                        <button id="set-api-key-btn" class="btn btn-secondary">Key aendern</button>
                        <button id="clear-api-key-btn" class="btn btn-danger">Key loeschen</button>
    
                        <div class="form-group" style="margin-top: 12px;">
                            <label for="api-model-select">Modell:</label>
                            <select id="api-model-select">
                                <option value="anthropic/claude-sonnet-4.6">Claude Sonnet 4.6</option>
                                <option value="google/gemini-2.5-pro">Gemini 2.5 Pro</option>
                                <option value="openai/gpt-4.1">GPT-4.1</option>
                            </select>
                        </div>
    
                        <p class="text-muted" style="margin-top: 10px;">
                            Hinweis: Der API-Key wird ausschliesslich lokal in Ihrem Browser gespeichert
                            und nur an OpenRouter (openrouter.ai) gesendet.
                        </p>
                    </div>
    
  • Step 2: Wire settings handlers in app.js.

    In G:\Claude\Claude_tmp_dienstplan\app.js, locate the end of setupEventListeners():

            document.getElementById('clear-all-btn').addEventListener('click', () => this.clearAllData());
        }
    

    Replace with:

            document.getElementById('clear-all-btn').addEventListener('click', () => this.clearAllData());
    
            // Bild-Import (KI) settings
            const setKeyBtn = document.getElementById('set-api-key-btn');
            if (setKeyBtn) setKeyBtn.addEventListener('click', () => this.setApiKeyFromPrompt());
            const clearKeyBtn = document.getElementById('clear-api-key-btn');
            if (clearKeyBtn) clearKeyBtn.addEventListener('click', () => this.clearApiKey());
            const modelSelect = document.getElementById('api-model-select');
            if (modelSelect) {
                modelSelect.value = this.storage.getApiModel();
                modelSelect.addEventListener('change', () => {
                    this.storage.setApiModel(modelSelect.value);
                    this.showToast(`Modell geaendert: ${modelSelect.options[modelSelect.selectedIndex].text}`, 'success');
                });
            }
            this.refreshApiKeyStatus();
        }
    

    Add the three helper methods immediately above showToast(...):

        /**
         * Update the API-key status line in Settings.
         */
        refreshApiKeyStatus() {
            const el = document.getElementById('api-key-status');
            if (!el) return;
            if (this.storage.getApiKey()) {
                el.textContent = 'API-Key gespeichert';
                el.className = 'api-key-status-ok';
            } else {
                el.textContent = 'Kein Key hinterlegt';
                el.className = 'api-key-status-none';
            }
        }
    
        setApiKeyFromPrompt() {
            const input = window.prompt('OpenRouter API-Key eingeben:', '');
            if (input === null) return;
            const trimmed = input.trim();
            if (!trimmed) return;
            this.storage.setApiKey(trimmed);
            this.refreshApiKeyStatus();
            this.showToast('API-Key gespeichert.', 'success');
        }
    
        clearApiKey() {
            if (!window.confirm('API-Key wirklich loeschen?')) return;
            this.storage.clearApiKey();
            this.refreshApiKeyStatus();
            this.showToast('API-Key geloescht.', 'info');
        }
    

    Also call refreshApiKeyStatus() when switching into the settings tab. In switchTab(tabName), replace:

            // Refresh data when switching to certain tabs
            if (tabName === 'employees') {
                this.loadEmployeeList();
            } else if (tabName === 'duties') {
                this.loadDutiesForSelectedEmployee();
            }
        }
    

    with:

            // Refresh data when switching to certain tabs
            if (tabName === 'employees') {
                this.loadEmployeeList();
            } else if (tabName === 'duties') {
                this.loadDutiesForSelectedEmployee();
            } else if (tabName === 'settings') {
                this.refreshApiKeyStatus();
            }
        }
    
  • Step 3: Manual verification — Settings section works.

    1. Open http://localhost:8000/. In Console: localStorage.removeItem('dienstplan_openrouter_key'). Reload.
    2. Click "Einstellungen" tab. Status line reads "Kein Key hinterlegt" (grey/italic).
    3. Click "Key aendern" → prompt. Type sk-or-xyz → submit. Toast "API-Key gespeichert.". Status line flips to "API-Key gespeichert" (green).
    4. Change model to "Gemini 2.5 Pro" → toast confirms. In Console: app.storage.getApiModel() === 'google/gemini-2.5-pro'.
    5. Reload page → model dropdown still shows "Gemini 2.5 Pro" (persisted).
    6. Click "Key loeschen" → confirm prompt. After OK: toast "API-Key geloescht.", status returns to "Kein Key hinterlegt".
  • Step 4: Commit.

    git add index.html app.js
    git commit -m "feat(settings): add Bild-Import (KI) section with key management and model picker"
    

Task 14: Error-handling polish (consolidate + verify via tests)

Files:

  • Modify: G:\Claude\Claude_tmp_dienstplan\test-suite.js

Steps:

  • Step 1: Lock error strings via dedicated unit tests on handleRecognitionError.

    All HTTP-error toasts and the parse-error toast were implemented in Task 10's handleRecognitionError. This task adds dedicated assertions to pin the strings.

    Append to test-suite.js:

    // ============================================================================
    // ImageImporter Tests - Error toasts (Feature A)
    // ============================================================================
    
    runner.test('ImageImporter Error: 401 = "API-Key ungueltig"', (t) => {
        let capturedMsg = null;
        let capturedType = null;
        const fakeApp = {
            showToast: (m, type) => { capturedMsg = m; capturedType = type; },
            currentYear: 2025, currentMonth: 11,
            storage: { getEmployees: () => [] },
            holidayProvider: new HolidayProvider()
        };
        const importer = new ImageImporter(fakeApp);
        const err = Object.assign(new Error('x'), { name: 'OpenRouterError', status: 401 });
        importer.showStage = () => {};
        importer.handleRecognitionError(err);
        t.assertEqual(capturedMsg, 'API-Key ungueltig', 'Exakte Meldung');
        t.assertEqual(capturedType, 'error', 'Typ error');
    });
    
    runner.test('ImageImporter Error: 402 = "Limit erreicht oder Guthaben aufgebraucht"', (t) => {
        let capturedMsg = null;
        const fakeApp = { showToast: (m) => { capturedMsg = m; }, holidayProvider: new HolidayProvider() };
        const importer = new ImageImporter(fakeApp);
        importer.showStage = () => {};
        importer.handleRecognitionError(Object.assign(new Error('x'), { name: 'OpenRouterError', status: 402 }));
        t.assertEqual(capturedMsg, 'Limit erreicht oder Guthaben aufgebraucht', 'Exakte Meldung');
    });
    
    runner.test('ImageImporter Error: 429 = "Limit erreicht oder Guthaben aufgebraucht"', (t) => {
        let capturedMsg = null;
        const fakeApp = { showToast: (m) => { capturedMsg = m; }, holidayProvider: new HolidayProvider() };
        const importer = new ImageImporter(fakeApp);
        importer.showStage = () => {};
        importer.handleRecognitionError(Object.assign(new Error('x'), { name: 'OpenRouterError', status: 429 }));
        t.assertEqual(capturedMsg, 'Limit erreicht oder Guthaben aufgebraucht', 'Exakte Meldung');
    });
    
    runner.test('ImageImporter Error: 503 = Server-Fehler', (t) => {
        let capturedMsg = null;
        const fakeApp = { showToast: (m) => { capturedMsg = m; }, holidayProvider: new HolidayProvider() };
        const importer = new ImageImporter(fakeApp);
        importer.showStage = () => {};
        importer.handleRecognitionError(Object.assign(new Error('x'), { name: 'OpenRouterError', status: 503 }));
        t.assertTrue(capturedMsg.includes('Server-Fehler'), 'Enthaelt Server-Fehler');
        t.assertTrue(capturedMsg.includes('503'), 'Enthaelt Status');
    });
    
    runner.test('ImageImporter Error: TypeError (Offline) = "Keine Verbindung"', (t) => {
        let capturedMsg = null;
        const fakeApp = { showToast: (m) => { capturedMsg = m; }, holidayProvider: new HolidayProvider() };
        const importer = new ImageImporter(fakeApp);
        importer.showStage = () => {};
        importer.handleRecognitionError(new TypeError('Failed to fetch'));
        t.assertTrue(capturedMsg.includes('Keine Verbindung'), 'Offline-Meldung');
    });
    
    runner.test('ImageImporter Error: SyntaxError = "Erkennung fehlgeschlagen"', (t) => {
        let capturedMsg = null;
        const fakeApp = { showToast: (m) => { capturedMsg = m; }, holidayProvider: new HolidayProvider() };
        const importer = new ImageImporter(fakeApp);
        importer.showStage = () => {};
        importer.handleRecognitionError(new SyntaxError('Unexpected token'));
        t.assertTrue(capturedMsg.includes('Erkennung fehlgeschlagen'), 'Parse-Fehlermeldung');
    });
    
  • Step 2: Run tests; expect GREEN.

    Reload test.html, click run. Six new "ImageImporter Error" tests should pass on first execution (the error-handling code already exists from Task 10).

  • Step 3: Commit.

    git add test-suite.js
    git commit -m "test(image-import): pin error-toast strings for HTTP/parse/network errors"
    

Task 15: Privacy notice in Stage 1 — verify wording

Files: (no code changes — the notice was added in Task 9)

Steps:

  • Step 1: Manual verification — privacy notice is visible.

    1. Open http://localhost:8000/. Reload. Click Bild-Import button.
    2. In Stage 1, immediately under the heading, the text reads exactly: Das Bild wird zur Erkennung an OpenRouter gesendet.
    3. The text is grey, italic, font-size ~0.875rem (per .privacy-notice CSS in Task 9).
    4. If the wording differs, edit index.html and the .privacy-notice element. Otherwise no change needed.
  • Step 2: No commit required if no change. If wording was wrong and needed an edit:

    git add index.html
    git commit -m "fix(image-import): correct privacy-notice wording in Stage 1"
    

Task 16: PWA cache version bump

Files:

  • Modify: G:\Claude\Claude_tmp_dienstplan\sw.js

Steps:

  • Step 1: Bump cache name and add image-import.js.

    In G:\Claude\Claude_tmp_dienstplan\sw.js, replace the entire file content:

    const CACHE_NAME = 'dienstplan-pro-v1';
    const ASSETS = [
      './',
      './index.html',
      './styles.css',
      './app.js',
      './calculator.js',
      './holidays.js',
      './storage.js'
    ];
    
    self.addEventListener('install', (e) => {
      e.waitUntil(
        caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS))
      );
    });
    
    self.addEventListener('fetch', (e) => {
      e.respondWith(
        caches.match(e.request).then((response) => response || fetch(e.request))
      );
    });
    

    with:

    const CACHE_NAME = 'dienstplan-pro-v3';
    const ASSETS = [
      './',
      './index.html',
      './styles.css',
      './app.js',
      './calculator.js',
      './holidays.js',
      './storage.js',
      './image-import.js'
    ];
    
    self.addEventListener('install', (e) => {
      e.waitUntil(
        caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS))
      );
    });
    
    self.addEventListener('activate', (e) => {
      e.waitUntil(
        caches.keys().then((keys) =>
          Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
        )
      );
    });
    
    self.addEventListener('fetch', (e) => {
      e.respondWith(
        caches.match(e.request).then((response) => response || fetch(e.request))
      );
    });
    

    (The activate listener is added so old caches are evicted on version bump — without it, dienstplan-pro-v1 would linger in the user's browser and the user could still serve stale assets.)

  • Step 2: Manual verification — old cache evicted.

    1. Open http://localhost:8000/. In DevTools → Application → Service Workers → confirm a registered worker.
    2. Application → Cache Storage. Confirm only dienstplan-pro-v3 exists (old dienstplan-pro-v1 should be gone after the new worker activates).
    3. If both exist, click "Update on reload" + hard reload (Ctrl+Shift+R).
    4. Open Network panel → reload → confirm image-import.js is served (status 200 from SW after first load, from cache on subsequent loads).
  • Step 3: Commit.

    git add sw.js
    git commit -m "chore(pwa): bump cache to v3, precache image-import.js, evict old caches on activate"
    

Task 17: Final manual smoke test (real OpenRouter key, real image)

Files: none

REQUIRES USER INPUT AT EXECUTION TIME:

  • A valid OpenRouter API key (sk-or-v1-...).
  • A real sample image: photo or screenshot of an Assistenzarzt-Dienstplan table for a known month (e.g. November 2025), with at least 3 employees and a mix of weekend / weekday duties.

Steps:

  • Step 1: Clean slate.

    1. Open http://localhost:8000/. DevTools Console:
      localStorage.clear();
      location.reload();
      
    2. After reload, add 2-3 employees in "Mitarbeiter verwalten" matching the names you expect in the image (deliberately misspell one to test fuzzy matching, e.g. add Mueller even though the image says Müller).
    3. In "Dienste eintragen", select the month/year matching the image.
  • Step 2: Trigger import.

    1. Click Bild-Import button. Paste your real OpenRouter API key into the prompt.
    2. Modal opens on Stage 1. Pick the sample image. Thumbnail appears. Click "Erkennen".
    3. Stage 2 spinner. Wait 5-15 s.
  • Step 3: Verify Stage 3 contents.

    • Expected employees from the image are grouped correctly under their resolved names.
    • The misspelled name appears in the unknowns box with "moeglicher Match: " hint.
    • Slot badges show correct colors: fr=orange, sa/so=red, weekday=grey.
    • Notes (if any) appear above.
    • Rows with dates outside the selected month are highlighted in pink with (ausserhalb Monat).
  • Step 4: Confirm and verify persistence.

    1. Click "Bestaetigen und Importieren". Toast confirms count.
    2. Modal closes after Stage 4.
    3. The duty list under each affected employee reflects the new entries.
    4. Switch to "Berechnung" tab → click "Berechnung durchfuehren" → numbers reflect the imported duties.
  • Step 5: Error path probes.

    1. Settings → "Key loeschen". Trigger import → expect API-key prompt.
    2. Type a clearly invalid key (e.g. sk-or-broken). Pick image, Erkennen → expect Toast "API-Key ungueltig", modal returns to Stage 1.
  • Step 6: No commit (this is a verification task).


Self-review pass

Spec coverage matrix

Spec section Implemented in task(s)
1 Ziel / Problemstellung Whole plan
2 Out of Scope N/A (boundaries respected by design)
3 User Flow (4 Stages) 8 (entry), 9 (Stage 1), 10 (Stage 2), 11 (Stage 3), 12 (Stage 4 + commit)
3.1 Stage 1 — Upload, validation, privacy 9, 15
3.2 Stage 2 — Processing, Cancel 10
3.3 Stage 3 — Block A / B / C 11
3.4 Stage 4 — Done + auto-close 12
4 Architecture & file layout 1 (skeleton + load order)
4.3 Public API of ImageImporter 1, 3-7, 9-12
5.1 Button in Duties tab 8
5.2 Modal skeleton 9
5.3 Settings section "Bild-Import (KI)" 13
6.1 API-Key first-use prompt 8
6.2 Subsequent usage (no re-prompt) 8
6.3 Key only in Authorization header 4
7 OpenRouter request shape, system prompt 4
7.4 AbortController for Cancel 10
8.1 Parsing (fence strip, brace slice) 5
8.2 Schema validation (per-field rules) 5
8.3 Month/Year consistency note 11 (rendered in renderPreview notes box)
8.4 Dedup of (name, date) 10
9 Slot classification (classify) 7
9.3 Independent duplication of classify 7
9.4 Slot badges in preview 9 (CSS), 11 (rendering)
10 Name matching (normalize / fuzzy <= 2) 7
10.3 Levenshtein inline 6
11.1 Replace semantics (kept) 12 (uses addDuty as-is)
11.2 Iteration order: addEmployee then addDuty 12
11.3 Target month = tab month 12 (skippedOutsideMonth)
12 Image preprocessing 3
13 Error handling (HTTP, network, parse, storage) 10 (handleRecognitionError), 12 (storage), 14 (tests)
13.5 Empty entries handling 10
14.1 New localStorage keys 2
14.1 exportData/clearAll not extended 2 (tests assert this)
14.2 In-memory session shape 8 (init), 10 (populate)
15.1 API Key persistence tests 2
15.2 Preprocessing tests 3
15.3 Response Parsing tests 5
15.4 Name Matching tests 7
15.5 Conflict Handling (replace via addDuty) 12 (existing storage test in repo + manual smoke)
15.6 Edge cases (month mismatch, empty, dup) 10, 11, 12
15.7 Storage round-trip (no key/model in export) 2
16 Future work Out of scope
17 Open questions Resolved or deferred

Placeholder scan

Searched the plan for TBD, TODO, implement appropriate, similar to above, etc. — none present.

Type / name consistency

  • compressImage(file) returns { blob, dataUrl, width, height } (Task 3); consumed in runRecognition (Task 10) via .dataUrl. OK.
  • callVisionAPI(dataUrl, apiKey, modelId, signal) returns a string (Task 4); consumed in runRecognition (Task 10). OK.
  • parseResponse(rawContent) returns { month, year, entries, notes } (Task 5); consumed in runRecognition (Task 10). OK.
  • levenshtein(a, b) (Task 6) used by matchNames (Task 7). OK.
  • matchNames(extractedEntries, existingEmployees) returns { matched, unknowns } (Task 7); consumed in runRecognition (Task 10). OK.
  • classify(date) (Task 7) used by renderPreview (Task 11). OK.
  • resolveImports(session) returns { newEmployees, commits, skippedOutsideMonth } (Task 12); consumed by commitImport (Task 12). OK.
  • DataStorage.addDuty(employeeName, year, month, date, share) — verified against storage.js line 209. Used in commitImport (Task 12) as addDuty(c.employeeName, c.year, c.month, c.date, c.share). OK.
  • DataStorage.addEmployee(name) — verified against storage.js line 55. Used in commitImport (Task 12). OK.
  • app.showToast(message, type), app.loadDutiesForSelectedEmployee(), app.loadEmployeeSelects(), app.loadEmployeeList(), app.currentMonth, app.currentYear — all verified against app.js. OK.
  • HolidayProvider.isHoliday(date) and isDayBeforeHoliday(date) — used in classify (Task 7); existing methods (referenced in test-suite.js HolidayProvider tests). OK.
  • DOM IDs introduced and consistently referenced across HTML + JS edits: open-image-import-btn (Task 8), image-import-modal, image-import-close-btn, image-import-dropzone, image-import-file-input, image-import-pick-btn, image-import-camera-input, image-import-camera-btn, image-import-thumb-wrap, image-import-thumb, image-import-thumb-name, image-import-thumb-size, image-import-cancel-1-btn, image-import-cancel-2-btn, image-import-cancel-3-btn, image-import-recognize-btn, image-import-confirm-btn, image-import-notes-box, image-import-unknowns-box, image-import-unknowns-list, image-import-preview-table, image-import-done-summary (all added in Task 9); set-api-key-btn, clear-api-key-btn, api-key-status, api-model-select (Task 13). All IDs referenced in JS exist in the HTML edits of the same plan.