From 1b515c7d5409f0d098fa16d2dcb3afcc9ccace4d Mon Sep 17 00:00:00 2001 From: Kenearos Date: Tue, 12 May 2026 00:10:21 +0200 Subject: [PATCH] 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) --- docs/plans/2026-05-11-bild-import-plan.md | 2874 +++++++++++++++++ docs/plans/2026-05-11-bonus-varianten-plan.md | 2599 +++++++++++++++ docs/specs/2026-05-11-bild-import-design.md | 819 +++++ .../2026-05-11-bonus-varianten-design.md | 617 ++++ 4 files changed, 6909 insertions(+) create mode 100644 docs/plans/2026-05-11-bild-import-plan.md create mode 100644 docs/plans/2026-05-11-bonus-varianten-plan.md create mode 100644 docs/specs/2026-05-11-bild-import-design.md create mode 100644 docs/specs/2026-05-11-bonus-varianten-design.md diff --git a/docs/plans/2026-05-11-bild-import-plan.md b/docs/plans/2026-05-11-bild-import-plan.md new file mode 100644 index 0000000..8f93e8b --- /dev/null +++ b/docs/plans/2026-05-11-bild-import-plan.md @@ -0,0 +1,2874 @@ +# 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 ` + + ``` + + (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: + + ```css + /* 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: + + ```javascript + // 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: + + ```javascript + // 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: + + ```javascript + /** + * 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: + + ```html + +
+ ``` + + Insert immediately BEFORE that line: + + ```html + + + ``` + +- [ ] **Step 2: Add modal CSS to `styles.css`.** + + Append to `G:\Claude\Claude_tmp_dienstplan\styles.css` at the very end: + + ```css + /* ============================================================ + 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`: + + ```javascript + /** + * 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: + + ```javascript + /** + * 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: + ```javascript + 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: + + ```javascript + /** + * 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 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: + ```javascript + 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`: + + ```javascript + // ============================================================================ + // 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: + + ```javascript + /** + * 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: + + ```html +
+

Alle Daten löschen

+

Achtung: Diese Aktion kann nicht rückgängig gemacht werden!

+ +
+ ``` + + Insert immediately BEFORE that block: + + ```html +
+

Bild-Import (KI)

+

Kein Key hinterlegt

+ + + +
+ + +
+ +

+ Hinweis: Der API-Key wird ausschliesslich lokal in Ihrem Browser gespeichert + und nur an OpenRouter (openrouter.ai) gesendet. +

+
+ ``` + +- [ ] **Step 2: Wire settings handlers in `app.js`.** + + In `G:\Claude\Claude_tmp_dienstplan\app.js`, locate the end of `setupEventListeners()`: + + ```javascript + document.getElementById('clear-all-btn').addEventListener('click', () => this.clearAllData()); + } + ``` + + Replace with: + + ```javascript + 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(...)`: + + ```javascript + /** + * 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: + + ```javascript + // Refresh data when switching to certain tabs + if (tabName === 'employees') { + this.loadEmployeeList(); + } else if (tabName === 'duties') { + this.loadDutiesForSelectedEmployee(); + } + } + ``` + + with: + + ```javascript + // 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`: + + ```javascript + // ============================================================================ + // 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: + + ```javascript + 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: + + ```javascript + 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: + ```javascript + 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. diff --git a/docs/plans/2026-05-11-bonus-varianten-plan.md b/docs/plans/2026-05-11-bonus-varianten-plan.md new file mode 100644 index 0000000..b6efef2 --- /dev/null +++ b/docs/plans/2026-05-11-bonus-varianten-plan.md @@ -0,0 +1,2599 @@ +# Feature B: Bonus-Varianten + Urlaubsmodus + Date-Stepper 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:** Replace the current single-rule bonus calculation with three configurable variants (V1, V2, V3 loose), auto-selecting the highest-bonus variant. Add a per-employee-per-month vacation toggle that halves variant thresholds. Add a small date-stepper UX to the duty entry tab. + +**Architecture:** Strategy-pattern split: `variants.js` holds three pure variant functions and the day classification helper; `calculator.js` becomes the public API that runs all variants and picks the winner. `storage.js` grows two methods for the new `dienstplan_vacation` localStorage key. UI changes in `app.js` add a vacation checkbox per employee in the calculation tab and a collapsible variant breakdown in the result card. + +**Tech Stack:** Vanilla ES6+ classes (no build step), browser localStorage, the existing in-repo test runner (`test-suite.js`). + +--- + +## How to run tests + +This project has no Node test runner. Tests live in `test-suite.js` and run in the browser via `test.html`. + +1. Start a local server in the project root: `python -m http.server 8000` (or `npx http-server -p 8000`). +2. Open `http://localhost:8000/test.html`. +3. Click the **"Alle Tests ausfuehren"** button. +4. The summary at the top shows total / passed / failed; failed tests are listed below with the assertion message printed in red. + +**TDD verification convention used below:** +- "Red": after pasting in a new `runner.test(...)`, reload `test.html` and click run. The new test must appear under "Fehlgeschlagen" with the expected error message. +- "Green": after the implementation step, reload `test.html` and click run. The test must move to "Bestanden". + +The runner is global (`const runner = new TestRunner();`) and assertions are: `t.assertEqual(actual, expected, message)`, `t.assertAlmostEqual(actual, expected, tolerance, message)`, `t.assertTrue(value, message)`, `t.assertFalse(value, message)`. + +**Date gotcha (per `CLAUDE.md`):** always construct date literals as `new Date('YYYY-MM-DDT12:00:00')` to avoid timezone drift to the previous day. + +--- + +## File Structure + +| Path | Status | Responsibility after this PR | +|---|---|---| +| `G:\Claude\Claude_tmp_dienstplan\variants.js` | **NEW** | Pure functions: `classify(date, holidayProvider)`, `classifyDuties(duties, holidayProvider)`, `variant1/2/3(classified, isVacation)`. Exposed on `window`. | +| `G:\Claude\Claude_tmp_dienstplan\calculator.js` | MODIFIED | `BonusCalculator` becomes a thin orchestrator: calls `classifyDuties`, runs all three variants, picks the winner. New result shape `{ winner, allResults, totalBonus, classified, isVacation, dutyDetails }`. `calculateAllEmployees(employeeDuties, vacationMap)` gains a vacation map parameter. Old fields (`qualifyingDaysFriday`, `thresholdReached`, `bonusNormalDays`, `bonusQualifyingDays`, `qualifyingDaysDeducted`, `normalDaysPaid`, `qualifyingDaysPaid`, `qualifyingDays`) are removed. | +| `G:\Claude\Claude_tmp_dienstplan\storage.js` | MODIFIED | Adds `STORAGE_KEY_VACATION = 'dienstplan_vacation'` plus `getVacationMode(name, yearMonth)` / `setVacationMode(name, yearMonth, value)`. `exportData()` includes the vacation map. `importData()` accepts the optional `vacation` field. `clearAll()` removes the new key. | +| `G:\Claude\Claude_tmp_dienstplan\index.html` | MODIFIED | New `` into `index.html` between `holidays.js` and `calculator.js`.** + + Edit `G:\Claude\Claude_tmp_dienstplan\index.html`. Find: + + ```html + + + + + ``` + + Replace with: + + ```html + + + + + + ``` + +- [ ] **Step 3: Insert `` into `test.html` between `holidays.js` and `calculator.js`.** + + Edit `G:\Claude\Claude_tmp_dienstplan\test.html`. Find: + + ```html + + + + + ``` + + Replace with: + + ```html + + + + + + ``` + +- [ ] **Step 4: Manual verification - page still loads.** + + Start the local server and open `http://localhost:8000/index.html`. The app must render without a JS console error. Open `http://localhost:8000/test.html`, click "Alle Tests ausfuehren" - all existing tests still pass (the new `variants.js` is loaded but unused, so behavior is unchanged). + +- [ ] **Step 5: Commit.** + + ```bash + git add variants.js index.html test.html + git commit -m "feat: add variants.js skeleton and wire into index.html + test.html" + ``` + +--- + +### Task 2: Implement `classify(date, holidayProvider)` - red phase + +**Files:** +- Test: `G:\Claude\Claude_tmp_dienstplan\test-suite.js` (append to end, before `runAllTests`) + +- [ ] **Step 1: Add the 7 spec example tests for `classify`.** + + Append the following block to `test-suite.js` immediately before the `// Display Functions` divider (around line 476): + + ```javascript + // ============================================================================ + // Variants - classify() + // ============================================================================ + + runner.test('classify: Karfreitag 2025 (Fr-Feiertag) -> fr', (t) => { + const hp = new HolidayProvider(); + const date = new Date('2025-04-18T12:00:00'); + t.assertEqual(classify(date, hp), 'fr', 'Karfreitag (Fr) muss fr sein'); + }); + + runner.test('classify: Ostermontag 2025 (Mo-Feiertag) -> so', (t) => { + const hp = new HolidayProvider(); + const date = new Date('2025-04-21T12:00:00'); + t.assertEqual(classify(date, hp), 'so', 'Ostermontag (Mo-Feiertag) muss so sein'); + }); + + runner.test('classify: Christi Himmelfahrt 2025 (Do-Feiertag) -> so', (t) => { + const hp = new HolidayProvider(); + const date = new Date('2025-05-29T12:00:00'); + t.assertEqual(classify(date, hp), 'so', 'Do-Feiertag ohne Fr-Feiertag muss so sein'); + }); + + runner.test('classify: Mi vor Christi Himmelfahrt 2025 -> fr', (t) => { + const hp = new HolidayProvider(); + const date = new Date('2025-05-28T12:00:00'); + t.assertEqual(classify(date, hp), 'fr', 'Tag vor Mo-Do-Feiertag muss fr sein'); + }); + + runner.test('classify: Tag der Deutschen Einheit 2025 (Fr-Feiertag) -> fr', (t) => { + const hp = new HolidayProvider(); + const date = new Date('2025-10-03T12:00:00'); + t.assertEqual(classify(date, hp), 'fr', 'Fr-Feiertag muss fr sein'); + }); + + runner.test('classify: Sandwich Do+Fr Feiertag -> Do=sa, Fr=fr', (t) => { + // Use a fake HolidayProvider that flags Do AND Fr as Feiertag. + const fakeHp = { + isHoliday(date) { + const day = date.getDay(); + return day === 4 || day === 5; // Thu or Fri + }, + isDayBeforeHoliday(date) { + const next = new Date(date); + next.setDate(next.getDate() + 1); + return this.isHoliday(next); + } + }; + const thursday = new Date('2025-11-20T12:00:00'); // Donnerstag + const friday = new Date('2025-11-21T12:00:00'); // Freitag + t.assertEqual(classify(thursday, fakeHp), 'sa', 'Do Feiertag + Tag vor Fr Feiertag -> sa (Sandwich)'); + t.assertEqual(classify(friday, fakeHp), 'fr', 'Fr Feiertag bleibt fr (Wochentag gewinnt)'); + }); + + runner.test('classify: Sandwich Mo+Di Feiertag -> Mo=sa, Di=so', (t) => { + const fakeHp = { + isHoliday(date) { + const day = date.getDay(); + return day === 1 || day === 2; // Mon or Tue + }, + isDayBeforeHoliday(date) { + const next = new Date(date); + next.setDate(next.getDate() + 1); + return this.isHoliday(next); + } + }; + const monday = new Date('2025-11-24T12:00:00'); // Montag + const tuesday = new Date('2025-11-25T12:00:00'); // Dienstag + t.assertEqual(classify(monday, fakeHp), 'sa', 'Mo Feiertag + Tag vor Di Feiertag -> sa'); + t.assertEqual(classify(tuesday, fakeHp), 'so', 'Di Feiertag (kein Sandwich, kein Tag-vor) -> so'); + }); + ``` + +- [ ] **Step 2: Red verification.** + + Reload `test.html`, click "Alle Tests ausfuehren". All 7 new `classify:` tests must appear under "Fehlgeschlagen" with the message `classify: not implemented`. + +- [ ] **Step 3: Commit.** + + ```bash + git add test-suite.js + git commit -m "test: add classify() spec example tests (red)" + ``` + +--- + +### Task 3: Implement `classify(date, holidayProvider)` - green phase + +**Files:** +- Modify: `G:\Claude\Claude_tmp_dienstplan\variants.js` (replace `classify` stub) + +- [ ] **Step 1: Replace the `classify` stub with the full implementation.** + + Edit `variants.js`. Replace the entire `function classify(date, holidayProvider) { ... }` block with: + + ```javascript + function classify(date, holidayProvider) { + const wd = date.getDay(); // 0=So, 1=Mo, ..., 5=Fr, 6=Sa + + // Real Fr/Sa/So always win + if (wd === 5) return 'fr'; + if (wd === 6) return 'sa'; + if (wd === 0) return 'so'; + + // Mo-Do (wd 1..4) + const isFeiertag = holidayProvider.isHoliday(date); + const isTagVorFeiertag = holidayProvider.isDayBeforeHoliday(date); + + if (isFeiertag && isTagVorFeiertag) return 'sa'; // Sandwich-Tag + if (isTagVorFeiertag) return 'fr'; // Tag vor Mo-Do-Feiertag + if (isFeiertag) return 'so'; // Feiertag Mo-Do + return 'weekday'; + } + ``` + +- [ ] **Step 2: Green verification.** + + Reload `test.html`, click "Alle Tests ausfuehren". All 7 new `classify:` tests must pass. No previously-passing test may regress. + +- [ ] **Step 3: Commit.** + + ```bash + git add variants.js + git commit -m "feat: implement classify(date, holidayProvider) day-slot mapping" + ``` + +--- + +### Task 4: Implement `classifyDuties(duties, holidayProvider)` - red + green + +**Files:** +- Modify: `G:\Claude\Claude_tmp_dienstplan\test-suite.js` (append tests) +- Modify: `G:\Claude\Claude_tmp_dienstplan\variants.js` (replace stub) + +- [ ] **Step 1: Append `classifyDuties` tests to `test-suite.js`.** + + Append immediately after the `classify:` tests added in Task 2: + + ```javascript + runner.test('classifyDuties: leeres Array -> alle Slots 0', (t) => { + const hp = new HolidayProvider(); + const result = classifyDuties([], hp); + t.assertEqual(result.fr, 0, 'fr=0'); + t.assertEqual(result.sa, 0, 'sa=0'); + t.assertEqual(result.so, 0, 'so=0'); + t.assertEqual(result.weekday, 0, 'weekday=0'); + }); + + runner.test('classifyDuties: halbe Schicht auf Freitag zaehlt 0.5', (t) => { + const hp = new HolidayProvider(); + const duties = [ + { date: new Date('2025-11-21T12:00:00'), share: 0.5 } // Fr + ]; + const result = classifyDuties(duties, hp); + t.assertAlmostEqual(result.fr, 0.5, 0.0001, 'fr=0.5'); + t.assertEqual(result.sa, 0, 'sa=0'); + t.assertEqual(result.so, 0, 'so=0'); + t.assertEqual(result.weekday, 0, 'weekday=0'); + }); + + runner.test('classifyDuties: mehrere Dienste pro Slot summieren', (t) => { + const hp = new HolidayProvider(); + const duties = [ + { date: new Date('2025-11-21T12:00:00'), share: 1.0 }, // Fr + { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, // Sa + { date: new Date('2025-11-23T12:00:00'), share: 0.5 }, // So + { date: new Date('2025-11-24T12:00:00'), share: 1.0 }, // Mo (weekday) + { date: new Date('2025-11-25T12:00:00'), share: 0.5 } // Di (weekday) + ]; + const result = classifyDuties(duties, hp); + t.assertAlmostEqual(result.fr, 1.0, 0.0001, 'fr=1.0'); + t.assertAlmostEqual(result.sa, 1.0, 0.0001, 'sa=1.0'); + t.assertAlmostEqual(result.so, 0.5, 0.0001, 'so=0.5'); + t.assertAlmostEqual(result.weekday, 1.5, 0.0001, 'weekday=1.5'); + }); + + runner.test('classifyDuties: Tag vor Feiertag (Mi vor Christi Himmelfahrt) zaehlt in fr', (t) => { + const hp = new HolidayProvider(); + const duties = [ + { date: new Date('2025-05-28T12:00:00'), share: 1.0 } // Mi vor Christi Himmelfahrt + ]; + const result = classifyDuties(duties, hp); + t.assertAlmostEqual(result.fr, 1.0, 0.0001, 'Mi-vor-Do-Feiertag -> fr'); + t.assertEqual(result.weekday, 0, 'weekday=0'); + }); + ``` + +- [ ] **Step 2: Red verification.** + + Reload `test.html`. The 4 new `classifyDuties:` tests must fail with `classifyDuties: not implemented`. + +- [ ] **Step 3: Replace the `classifyDuties` stub in `variants.js`.** + + ```javascript + function classifyDuties(duties, holidayProvider) { + const result = { fr: 0, sa: 0, so: 0, weekday: 0 }; + if (!Array.isArray(duties)) return result; + for (const duty of duties) { + const slot = classify(duty.date, holidayProvider); + result[slot] += duty.share; + } + return result; + } + ``` + +- [ ] **Step 4: Green verification.** + + Reload `test.html`. All `classifyDuties:` tests pass. + +- [ ] **Step 5: Commit.** + + ```bash + git add variants.js test-suite.js + git commit -m "feat: implement classifyDuties() aggregation by slot" + ``` + +--- + +### Task 5: Implement `variant3(classified, isVacation)` - red + green + +This is V3 loose (the existing logic in `BonusCalculator`). Implementing this first lets us validate that historical inputs still produce the same numbers. + +**Files:** +- Modify: `G:\Claude\Claude_tmp_dienstplan\test-suite.js` (append) +- Modify: `G:\Claude\Claude_tmp_dienstplan\variants.js` (replace stub) + +- [ ] **Step 1: Append `variant3` tests.** + + Append to `test-suite.js`: + + ```javascript + // ============================================================================ + // Variants - variant3 (loose: 2 qualifying days, pool fr+sa+so) + // ============================================================================ + + runner.test('variant3: unter Schwelle (1 sa) -> not eligible, bonus 0', (t) => { + const classified = { fr: 0, sa: 1, so: 0, weekday: 4 }; + const r = variant3(classified, false); + t.assertFalse(r.eligible, 'eligible=false'); + t.assertEqual(r.bonus, 0, 'bonus=0'); + t.assertEqual(r.variantId, 3, 'variantId=3'); + }); + + runner.test('variant3: 2x sa -> eligible, beide abgezogen, bonus 0', (t) => { + const classified = { fr: 0, sa: 2, so: 0, weekday: 0 }; + const r = variant3(classified, false); + t.assertTrue(r.eligible, 'eligible=true'); + t.assertEqual(r.deduction.sa, 2, 'sa-deduction=2'); + t.assertEqual(r.paidShares.sa, 0, 'sa-paid=0'); + t.assertEqual(r.bonus, 0, 'bonus=0'); + }); + + runner.test('variant3: Friday priority fr->so->sa', (t) => { + // fr=2, sa=1, so=1, weekday=0 -> 2 von fr abgezogen, sa+so voll bezahlt + const classified = { fr: 2, sa: 1, so: 1, weekday: 0 }; + const r = variant3(classified, false); + t.assertTrue(r.eligible, 'eligible=true'); + t.assertEqual(r.deduction.fr, 2, 'fr-deduction=2'); + t.assertEqual(r.deduction.so, 0, 'so-deduction=0'); + t.assertEqual(r.deduction.sa, 0, 'sa-deduction=0'); + t.assertEqual(r.paidShares.fr, 0, 'fr-paid=0'); + t.assertEqual(r.paidShares.so, 1, 'so-paid=1'); + t.assertEqual(r.paidShares.sa, 1, 'sa-paid=1'); + t.assertEqual(r.bonus, 2 * 450, 'bonus = 2 * 450 = 900'); + }); + + runner.test('variant3: fr=1, sa=1, so=0 -> fr+sa abgezogen', (t) => { + const classified = { fr: 1, sa: 1, so: 0, weekday: 0 }; + const r = variant3(classified, false); + t.assertEqual(r.deduction.fr, 1, 'fr=1'); + t.assertEqual(r.deduction.so, 0, 'so=0'); + t.assertEqual(r.deduction.sa, 1, 'sa=1'); + t.assertEqual(r.bonus, 0, 'bonus=0'); + }); + + runner.test('variant3: weekday wird voll bezahlt, nicht abgezogen', (t) => { + const classified = { fr: 1, sa: 1, so: 0, weekday: 3 }; + const r = variant3(classified, false); + t.assertEqual(r.paidShares.weekday, 3, 'weekday-paid=3'); + t.assertEqual(r.deduction.weekday, 0, 'weekday-deduction=0'); + t.assertEqual(r.bonus, 3 * 250, 'bonus = 3 * 250 = 750'); + }); + + runner.test('variant3: Urlaubsmodus halbiert Schwelle auf 1', (t) => { + const classified = { fr: 0, sa: 0.5, so: 0.5, weekday: 0 }; + const r = variant3(classified, true); + t.assertTrue(r.eligible, 'eligible=true (Schwelle 1)'); + // Abzug 1 aus Pool, fr-Prio -> so zuerst (fr=0), dann sa + t.assertEqual(r.deduction.fr, 0, 'fr=0'); + t.assertEqual(r.deduction.so, 0.5, 'so=0.5'); + t.assertEqual(r.deduction.sa, 0.5, 'sa=0.5'); + t.assertEqual(r.bonus, 0, 'bonus=0'); + }); + + runner.test('variant3: Urlaubsmodus, halbe sa und 1 fr -> fr-Prio frisst 1', (t) => { + const classified = { fr: 1, sa: 0.5, so: 0, weekday: 0 }; + const r = variant3(classified, true); + t.assertTrue(r.eligible, 'eligible=true'); + t.assertEqual(r.deduction.fr, 1, 'fr=1'); + t.assertEqual(r.deduction.sa, 0, 'sa unangetastet'); + t.assertEqual(r.paidShares.sa, 0.5, 'sa-paid=0.5'); + t.assertEqual(r.bonus, 0.5 * 450, 'bonus = 0.5 * 450 = 225'); + }); + + runner.test('variant3: threshold-Shape ist {pool: 2} normal, {pool: 1} im Urlaub', (t) => { + const r1 = variant3({ fr: 0, sa: 2, so: 0, weekday: 0 }, false); + const r2 = variant3({ fr: 0, sa: 1, so: 0, weekday: 0 }, true); + t.assertEqual(r1.threshold.pool, 2, 'normal pool=2'); + t.assertEqual(r2.threshold.pool, 1, 'vacation pool=1'); + }); + ``` + +- [ ] **Step 2: Red verification.** + + Reload `test.html`. All new `variant3:` tests fail with `variant3: not implemented`. + +- [ ] **Step 3: Implement `variant3` in `variants.js`.** + + Replace the `variant3` stub with: + + ```javascript + function variant3(classified, isVacation) { + const RATE_NORMAL = 250; + const RATE_WEEKEND = 450; + const poolThreshold = isVacation ? 1 : 2; + const totalDeduction = isVacation ? 1 : 2; + + const pool = classified.fr + classified.sa + classified.so; + const eligible = pool >= poolThreshold - 1e-9; + + if (!eligible) { + return { + variantId: 3, + eligible: false, + threshold: { pool: poolThreshold }, + deduction: { fr: 0, sa: 0, so: 0, weekday: 0 }, + paidShares: { fr: 0, sa: 0, so: 0, weekday: 0 }, + bonus: 0, + isWinner: false + }; + } + + // Friday priority: fr -> so -> sa + let remaining = totalDeduction; + const deduction = { fr: 0, sa: 0, so: 0, weekday: 0 }; + for (const slot of ['fr', 'so', 'sa']) { + const take = Math.min(remaining, classified[slot]); + deduction[slot] = take; + remaining -= take; + if (remaining <= 1e-9) break; + } + + const paidShares = { + fr: Math.max(0, classified.fr - deduction.fr), + sa: Math.max(0, classified.sa - deduction.sa), + so: Math.max(0, classified.so - deduction.so), + weekday: classified.weekday // weekday never deducted in V3 + }; + + const bonus = (paidShares.fr + paidShares.sa + paidShares.so) * RATE_WEEKEND + + paidShares.weekday * RATE_NORMAL; + + return { + variantId: 3, + eligible: true, + threshold: { pool: poolThreshold }, + deduction, + paidShares, + bonus, + isWinner: false + }; + } + ``` + +- [ ] **Step 4: Green verification.** + + Reload `test.html`. All `variant3:` tests pass. + +- [ ] **Step 5: Commit.** + + ```bash + git add variants.js test-suite.js + git commit -m "feat: implement variant3 (loose, pool fr+sa+so, fr-priority)" + ``` + +--- + +### Task 6: Implement `variant1(classified, isVacation)` - red + green + +**Files:** +- Modify: `G:\Claude\Claude_tmp_dienstplan\test-suite.js` (append) +- Modify: `G:\Claude\Claude_tmp_dienstplan\variants.js` (replace stub) + +- [ ] **Step 1: Append `variant1` tests.** + + Append: + + ```javascript + // ============================================================================ + // Variants - variant1 (1 fr+so + 3 weekday) + // ============================================================================ + + runner.test('variant1: Schwelle nicht erreicht (fr+so=0)', (t) => { + const r = variant1({ fr: 0, sa: 5, so: 0, weekday: 3 }, false); + t.assertFalse(r.eligible, 'eligible=false'); + t.assertEqual(r.bonus, 0, 'bonus=0'); + }); + + runner.test('variant1: Schwelle nicht erreicht (weekday<3)', (t) => { + const r = variant1({ fr: 1, sa: 5, so: 0, weekday: 2 }, false); + t.assertFalse(r.eligible, 'eligible=false'); + t.assertEqual(r.bonus, 0, 'bonus=0'); + }); + + runner.test('variant1: Spec-Beispiel fr=2,sa=1,so=0,weekday=4 -> 1150', (t) => { + const r = variant1({ fr: 2, sa: 1, so: 0, weekday: 4 }, false); + t.assertTrue(r.eligible, 'eligible=true'); + t.assertEqual(r.deduction.fr, 1, 'fr-deduction=1 (Fr-Prio)'); + t.assertEqual(r.deduction.so, 0, 'so-deduction=0'); + t.assertEqual(r.deduction.sa, 0, 'sa nicht abgezogen'); + t.assertEqual(r.deduction.weekday, 3, 'weekday-deduction=3'); + t.assertEqual(r.paidShares.fr, 1, 'fr-paid=1'); + t.assertEqual(r.paidShares.sa, 1, 'sa-paid=1'); + t.assertEqual(r.paidShares.so, 0, 'so-paid=0'); + t.assertEqual(r.paidShares.weekday, 1, 'weekday-paid=1'); + t.assertEqual(r.bonus, 1150, 'bonus = (1+1+0)*450 + 1*250 = 1150'); + }); + + runner.test('variant1: nur so vorhanden -> 1 von so abgezogen', (t) => { + const r = variant1({ fr: 0, sa: 0, so: 1, weekday: 3 }, false); + t.assertTrue(r.eligible, 'eligible=true'); + t.assertEqual(r.deduction.fr, 0, 'fr-deduction=0'); + t.assertEqual(r.deduction.so, 1, 'so-deduction=1'); + t.assertEqual(r.deduction.weekday, 3, 'weekday-deduction=3'); + t.assertEqual(r.bonus, 0, 'bonus=0'); + }); + + runner.test('variant1: sa wird voll bezahlt, nicht abgezogen', (t) => { + const r = variant1({ fr: 1, sa: 2, so: 0, weekday: 3 }, false); + t.assertEqual(r.deduction.sa, 0, 'sa-deduction=0'); + t.assertEqual(r.paidShares.sa, 2, 'sa-paid=2'); + // bonus = (0+2+0)*450 + 0*250 = 900 + t.assertEqual(r.bonus, 900, 'bonus=900'); + }); + + runner.test('variant1: Urlaubsmodus halbiert Schwellen (0.5 + 1.5)', (t) => { + const r = variant1({ fr: 0.5, sa: 0, so: 0, weekday: 1.5 }, true); + t.assertTrue(r.eligible, 'eligible=true im Urlaub'); + t.assertEqual(r.threshold.frSo, 0.5, 'threshold.frSo=0.5'); + t.assertEqual(r.threshold.weekday, 1.5, 'threshold.weekday=1.5'); + t.assertEqual(r.deduction.fr, 0.5, 'fr-deduction=0.5'); + t.assertEqual(r.deduction.weekday, 1.5, 'weekday-deduction=1.5'); + t.assertEqual(r.bonus, 0, 'bonus=0'); + }); + + runner.test('variant1: threshold-Shape normal {frSo:1, weekday:3}', (t) => { + const r = variant1({ fr: 1, sa: 0, so: 0, weekday: 3 }, false); + t.assertEqual(r.threshold.frSo, 1, 'threshold.frSo=1'); + t.assertEqual(r.threshold.weekday, 3, 'threshold.weekday=3'); + }); + ``` + +- [ ] **Step 2: Red verification.** All new `variant1:` tests fail with `variant1: not implemented`. + +- [ ] **Step 3: Implement `variant1`.** + + Replace the `variant1` stub in `variants.js`: + + ```javascript + function variant1(classified, isVacation) { + const RATE_NORMAL = 250; + const RATE_WEEKEND = 450; + const frSoThreshold = isVacation ? 0.5 : 1; + const weekdayThreshold = isVacation ? 1.5 : 3; + const frSoDeduction = isVacation ? 0.5 : 1; + const weekdayDeduction = isVacation ? 1.5 : 3; + + const frSoPool = classified.fr + classified.so; + const eligible = (frSoPool >= frSoThreshold - 1e-9) + && (classified.weekday >= weekdayThreshold - 1e-9); + + if (!eligible) { + return { + variantId: 1, + eligible: false, + threshold: { frSo: frSoThreshold, weekday: weekdayThreshold }, + deduction: { fr: 0, sa: 0, so: 0, weekday: 0 }, + paidShares: { fr: 0, sa: 0, so: 0, weekday: 0 }, + bonus: 0, + isWinner: false + }; + } + + // Friday priority within fr+so pool: fr first, then so + let remaining = frSoDeduction; + const deduction = { fr: 0, sa: 0, so: 0, weekday: weekdayDeduction }; + for (const slot of ['fr', 'so']) { + const take = Math.min(remaining, classified[slot]); + deduction[slot] = take; + remaining -= take; + if (remaining <= 1e-9) break; + } + + const paidShares = { + fr: Math.max(0, classified.fr - deduction.fr), + sa: classified.sa, // sa never deducted in V1 + so: Math.max(0, classified.so - deduction.so), + weekday: Math.max(0, classified.weekday - deduction.weekday) + }; + + const bonus = (paidShares.fr + paidShares.sa + paidShares.so) * RATE_WEEKEND + + paidShares.weekday * RATE_NORMAL; + + return { + variantId: 1, + eligible: true, + threshold: { frSo: frSoThreshold, weekday: weekdayThreshold }, + deduction, + paidShares, + bonus, + isWinner: false + }; + } + ``` + +- [ ] **Step 4: Green verification.** All `variant1:` tests pass. + +- [ ] **Step 5: Commit.** + + ```bash + git add variants.js test-suite.js + git commit -m "feat: implement variant1 (1 fr+so + 3 weekday, fr-priority)" + ``` + +--- + +### Task 7: Implement `variant2(classified, isVacation)` - red + green + +**Files:** +- Modify: `G:\Claude\Claude_tmp_dienstplan\test-suite.js` (append) +- Modify: `G:\Claude\Claude_tmp_dienstplan\variants.js` (replace stub) + +- [ ] **Step 1: Append `variant2` tests.** + + ```javascript + // ============================================================================ + // Variants - variant2 (1 sa + 2 weekday) + // ============================================================================ + + runner.test('variant2: Schwelle nicht erreicht (sa=0)', (t) => { + const r = variant2({ fr: 5, sa: 0, so: 5, weekday: 3 }, false); + t.assertFalse(r.eligible, 'eligible=false'); + t.assertEqual(r.bonus, 0, 'bonus=0'); + }); + + runner.test('variant2: Schwelle nicht erreicht (weekday<2)', (t) => { + const r = variant2({ fr: 0, sa: 2, so: 0, weekday: 1 }, false); + t.assertFalse(r.eligible, 'eligible=false'); + }); + + runner.test('variant2: Spec-Beispiel fr=1,sa=2,so=0,weekday=3 -> 1150', (t) => { + const r = variant2({ fr: 1, sa: 2, so: 0, weekday: 3 }, false); + t.assertTrue(r.eligible, 'eligible=true'); + t.assertEqual(r.deduction.sa, 1, 'sa-deduction=1'); + t.assertEqual(r.deduction.weekday, 2, 'weekday-deduction=2'); + t.assertEqual(r.deduction.fr, 0, 'fr nicht abgezogen'); + t.assertEqual(r.deduction.so, 0, 'so nicht abgezogen'); + t.assertEqual(r.paidShares.fr, 1, 'fr-paid=1'); + t.assertEqual(r.paidShares.sa, 1, 'sa-paid=1'); + t.assertEqual(r.paidShares.weekday, 1, 'weekday-paid=1'); + t.assertEqual(r.bonus, 1150, 'bonus = (1+1+0)*450 + 1*250 = 1150'); + }); + + runner.test('variant2: sa=1,weekday=2 -> alles weg, bonus 0', (t) => { + const r = variant2({ fr: 0, sa: 1, so: 0, weekday: 2 }, false); + t.assertTrue(r.eligible, 'eligible=true'); + t.assertEqual(r.bonus, 0, 'bonus=0'); + }); + + runner.test('variant2: sa=2,weekday=2,fr=1,so=1 -> fr/so voll bezahlt', (t) => { + const r = variant2({ fr: 1, sa: 2, so: 1, weekday: 2 }, false); + t.assertEqual(r.paidShares.fr, 1, 'fr-paid=1'); + t.assertEqual(r.paidShares.sa, 1, 'sa-paid=1'); + t.assertEqual(r.paidShares.so, 1, 'so-paid=1'); + t.assertEqual(r.paidShares.weekday, 0, 'weekday-paid=0'); + t.assertEqual(r.bonus, 3 * 450, 'bonus = 3*450 = 1350'); + }); + + runner.test('variant2: Urlaubsmodus halbiert (0.5 sa + 1 weekday)', (t) => { + const r = variant2({ fr: 0, sa: 0.5, so: 0, weekday: 1 }, true); + t.assertTrue(r.eligible, 'eligible=true im Urlaub'); + t.assertEqual(r.threshold.sa, 0.5, 'threshold.sa=0.5'); + t.assertEqual(r.threshold.weekday, 1, 'threshold.weekday=1'); + t.assertEqual(r.deduction.sa, 0.5, 'sa-deduction=0.5'); + t.assertEqual(r.deduction.weekday, 1, 'weekday-deduction=1'); + t.assertEqual(r.bonus, 0, 'bonus=0'); + }); + + runner.test('variant2: threshold-Shape normal {sa:1, weekday:2}', (t) => { + const r = variant2({ fr: 0, sa: 1, so: 0, weekday: 2 }, false); + t.assertEqual(r.threshold.sa, 1, 'threshold.sa=1'); + t.assertEqual(r.threshold.weekday, 2, 'threshold.weekday=2'); + }); + ``` + +- [ ] **Step 2: Red verification.** All new `variant2:` tests fail with `variant2: not implemented`. + +- [ ] **Step 3: Implement `variant2`.** + + Replace the `variant2` stub in `variants.js`: + + ```javascript + function variant2(classified, isVacation) { + const RATE_NORMAL = 250; + const RATE_WEEKEND = 450; + const saThreshold = isVacation ? 0.5 : 1; + const weekdayThreshold = isVacation ? 1 : 2; + const saDeduction = isVacation ? 0.5 : 1; + const weekdayDeduction = isVacation ? 1 : 2; + + const eligible = (classified.sa >= saThreshold - 1e-9) + && (classified.weekday >= weekdayThreshold - 1e-9); + + if (!eligible) { + return { + variantId: 2, + eligible: false, + threshold: { sa: saThreshold, weekday: weekdayThreshold }, + deduction: { fr: 0, sa: 0, so: 0, weekday: 0 }, + paidShares: { fr: 0, sa: 0, so: 0, weekday: 0 }, + bonus: 0, + isWinner: false + }; + } + + const deduction = { fr: 0, sa: saDeduction, so: 0, weekday: weekdayDeduction }; + + const paidShares = { + fr: classified.fr, // fr never deducted in V2 + sa: Math.max(0, classified.sa - deduction.sa), + so: classified.so, // so never deducted in V2 + weekday: Math.max(0, classified.weekday - deduction.weekday) + }; + + const bonus = (paidShares.fr + paidShares.sa + paidShares.so) * RATE_WEEKEND + + paidShares.weekday * RATE_NORMAL; + + return { + variantId: 2, + eligible: true, + threshold: { sa: saThreshold, weekday: weekdayThreshold }, + deduction, + paidShares, + bonus, + isWinner: false + }; + } + ``` + +- [ ] **Step 4: Green verification.** All `variant2:` tests pass. + +- [ ] **Step 5: Commit.** + + ```bash + git add variants.js test-suite.js + git commit -m "feat: implement variant2 (1 sa + 2 weekday)" + ``` + +--- + +### Task 8: Refactor `BonusCalculator` to use variants and pick the winner - red + green + +**Files:** +- Modify: `G:\Claude\Claude_tmp_dienstplan\test-suite.js` (append winner-selection tests) +- Modify: `G:\Claude\Claude_tmp_dienstplan\calculator.js` (full rewrite of business logic) + +- [ ] **Step 1: Append winner-selection tests.** + + ```javascript + // ============================================================================ + // BonusCalculator - Winner Selection (new shape) + // ============================================================================ + + runner.test('Winner: klarer Sieger mit weekdays + 1 Fr', (t) => { + const hp = new HolidayProvider(); + const calc = new BonusCalculator(hp); + const duties = [ + { date: new Date('2025-11-21T12:00:00'), share: 1.0 }, // Fr + { date: new Date('2025-11-24T12:00:00'), share: 1.0 }, // Mo + { date: new Date('2025-11-25T12:00:00'), share: 1.0 }, // Di + { date: new Date('2025-11-26T12:00:00'), share: 1.0 }, // Mi + { date: new Date('2025-11-27T12:00:00'), share: 1.0 }, // Do + { date: new Date('2025-11-04T12:00:00'), share: 1.0 } // Di + ]; + const result = calc.calculateMonthlyBonus(duties, false); + t.assertTrue(result.winner.isWinner, 'winner.isWinner=true'); + t.assertEqual(result.allResults.length, 3, '3 Varianten im allResults'); + t.assertTrue(result.totalBonus > 0, 'Bonus > 0'); + }); + + runner.test('Winner: klarer V3-Sieger (nur WE-Dienste)', (t) => { + const hp = new HolidayProvider(); + const calc = new BonusCalculator(hp); + const duties = [ + { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, // Sa + { date: new Date('2025-11-23T12:00:00'), share: 1.0 }, // So + { date: new Date('2025-11-29T12:00:00'), share: 1.0 } // Sa + ]; + const result = calc.calculateMonthlyBonus(duties, false); + // V1: fr+so=1, weekday=0 -> not eligible + // V2: sa=2, weekday=0 -> not eligible + // V3: pool=3 -> eligible, deduction 2 (fr=0,so=1 abgezogen, sa=1 abgezogen) -> 1 sa paid -> 450 + t.assertEqual(result.winner.variantId, 3, 'V3 muss Sieger sein'); + t.assertEqual(result.totalBonus, 450, 'bonus=450'); + }); + + runner.test('Winner: Tie-Breaker - alle three not eligible -> V1 nominal winner, totalBonus 0', (t) => { + const hp = new HolidayProvider(); + const calc = new BonusCalculator(hp); + // fr=1, sa=0, so=0, weekday=3: + // V1: fr+so=1 ok, weekday=3 ok -> eligible. Abzug fr=1, weekday=3 -> alles weg, bonus 0. + // V2: sa=0 -> not eligible (0). + // V3: pool=1 < 2 -> not eligible (0). + // -> tie at 0; V1 has eligible=true so its result is still 0. Strict > keeps v1 as winner. + const duties = [ + { date: new Date('2025-11-21T12:00:00'), share: 1.0 }, // Fr + { date: new Date('2025-11-24T12:00:00'), share: 1.0 }, // Mo + { date: new Date('2025-11-25T12:00:00'), share: 1.0 }, // Di + { date: new Date('2025-11-26T12:00:00'), share: 1.0 } // Mi + ]; + const result = calc.calculateMonthlyBonus(duties, false); + t.assertEqual(result.winner.variantId, 1, 'V1 wins tie (lowest variantId)'); + t.assertEqual(result.totalBonus, 0, 'totalBonus=0 (all-zero tie)'); + }); + + runner.test('Winner: nur V3 eligible -> V3 gewinnt egal wie klein der Bonus', (t) => { + const hp = new HolidayProvider(); + const calc = new BonusCalculator(hp); + // fr=0, sa=2, so=0, weekday=0 -> V1 no (fr+so=0), V2 no (weekday=0), V3 yes (bonus=0) + const duties = [ + { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, // Sa + { date: new Date('2025-11-29T12:00:00'), share: 1.0 } // Sa + ]; + const result = calc.calculateMonthlyBonus(duties, false); + t.assertEqual(result.winner.variantId, 3, 'V3 gewinnt (einzig eligible mit positive logic, V1/V2 also bonus 0)'); + // Tie-breaker note: V1 bonus=0, V2 bonus=0, V3 bonus=0 -> strict > keeps V1 as winner. + // The assertion above will FAIL for this scenario - V1 wins the tie. Adjust below. + // (See implementation: strict > means V1 wins all-zero ties. Test must reflect that.) + }); + + runner.test('Winner: result-Shape enthaelt classified, isVacation, dutyDetails', (t) => { + const hp = new HolidayProvider(); + const calc = new BonusCalculator(hp); + const duties = [ + { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, + { date: new Date('2025-11-23T12:00:00'), share: 1.0 } + ]; + const result = calc.calculateMonthlyBonus(duties, false); + t.assertTrue('classified' in result, 'classified field exists'); + t.assertTrue('isVacation' in result, 'isVacation field exists'); + t.assertTrue('dutyDetails' in result, 'dutyDetails field exists'); + t.assertEqual(result.dutyDetails.length, 2, 'dutyDetails has 2 entries'); + t.assertEqual(result.isVacation, false, 'isVacation=false'); + }); + + runner.test('Winner: Urlaubsmodus halbiert alle Schwellen', (t) => { + const hp = new HolidayProvider(); + const calc = new BonusCalculator(hp); + // fr=0.5, weekday=1.5 -> V1 eligible im Urlaub (0.5 >= 0.5, 1.5 >= 1.5) + const duties = [ + { date: new Date('2025-11-21T12:00:00'), share: 0.5 }, // Fr + { date: new Date('2025-11-24T12:00:00'), share: 1.0 }, // Mo + { date: new Date('2025-11-25T12:00:00'), share: 0.5 } // Di + ]; + const result = calc.calculateMonthlyBonus(duties, true); + t.assertEqual(result.isVacation, true, 'isVacation propagated'); + t.assertEqual(result.winner.variantId, 1, 'V1 wins under vacation'); + }); + ``` + + **Important correction:** The test `'Winner: nur V3 eligible -> V3 gewinnt egal wie klein der Bonus'` as written above is **wrong** — with strict `>` for winner selection and all-zero ties going to V1, V3's `eligible=true` does not change the bonus value, so V1 wins the tie. Either replace the assertion or remove the test. The clean replacement to use instead: + + ```javascript + runner.test('Winner: nur V3 produziert positive bonus -> V3 winner', (t) => { + const hp = new HolidayProvider(); + const calc = new BonusCalculator(hp); + // Three Saturdays: V1 not eligible, V2 not eligible (weekday=0), V3 eligible with positive bonus. + const duties = [ + { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, + { date: new Date('2025-11-29T12:00:00'), share: 1.0 }, + { date: new Date('2025-11-15T12:00:00'), share: 1.0 } + ]; + const result = calc.calculateMonthlyBonus(duties, false); + // V3: pool=3, abzug 2 (so=0, fr=0, sa=2 abgezogen) -> 1 sa paid -> 450 + t.assertEqual(result.winner.variantId, 3, 'V3 winner'); + t.assertEqual(result.totalBonus, 450, 'bonus=450'); + }); + ``` + + Use this corrected version; delete the prior broken one. + +- [ ] **Step 2: Red verification.** + + Reload `test.html`. The new winner tests fail because `BonusCalculator` still uses the old shape - specifically the assertions `result.winner.variantId` and `result.allResults.length` throw "Erwartet: ... / Erhalten: undefined". Also, **all existing** `Berechnung:` tests will still pass at this stage (we have not changed `calculator.js` yet). + +- [ ] **Step 3: Rewrite `calculator.js` to use variants.** + + Replace the **entire contents** of `G:\Claude\Claude_tmp_dienstplan\calculator.js` with: + + ```javascript + /** + * Bonus Calculator (NRW Psychiatrie 2011) + * Orchestrator: classifies duties, runs all three variants (V1/V2/V3), picks the winner. + * Pure variant logic lives in variants.js. + */ + class BonusCalculator { + constructor(holidayProvider) { + this.holidayProvider = holidayProvider; + this.RATE_NORMAL = 250; + this.RATE_WEEKEND = 450; + } + + /** + * Whether the given date is a "qualifying" day (used by UI for badge coloring). + * Mirrors the old isQualifyingDay so app.js does not break. + */ + isQualifyingDay(date) { + const slot = classify(date, this.holidayProvider); + return slot !== 'weekday'; + } + + /** + * Human-readable label for the date's day type (used by UI). + */ + getDayTypeLabel(date) { + const dayOfWeek = date.getDay(); + const isHoliday = this.holidayProvider.isHoliday(date); + const holidayName = this.holidayProvider.getHolidayName(date); + const isDayBefore = this.holidayProvider.isDayBeforeHoliday(date); + + if (isHoliday) return `Feiertag (${holidayName})`; + if (isDayBefore) return 'Tag vor Feiertag'; + if (dayOfWeek === 5) return 'Freitag'; + if (dayOfWeek === 6) return 'Samstag'; + if (dayOfWeek === 0) return 'Sonntag'; + + const days = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; + return days[dayOfWeek]; + } + + /** + * Build the dutyDetails array (date, share, isQualifying, dayType) for the UI. + */ + buildDutyDetails(duties) { + return duties.map(duty => ({ + date: duty.date, + share: duty.share, + isQualifying: this.isQualifyingDay(duty.date), + dayType: this.getDayTypeLabel(duty.date) + })); + } + + /** + * Calculate the bonus for a single employee for a given month. + * @param {Array} duties - Array of { date: Date, share: number } + * @param {boolean} isVacation - Vacation toggle (halves thresholds + deductions) + * @returns {Object} new-shape result (winner, allResults, totalBonus, classified, isVacation, dutyDetails) + */ + calculateMonthlyBonus(duties, isVacation = false) { + if (!duties || duties.length === 0) { + return this.getEmptyResult(isVacation); + } + + const classified = classifyDuties(duties, this.holidayProvider); + const v1 = variant1(classified, isVacation); + const v2 = variant2(classified, isVacation); + const v3 = variant3(classified, isVacation); + const results = [v1, v2, v3]; + + // Pick winner: highest bonus, tie-breaker = lowest variantId (strict >) + let winner = results[0]; + for (let i = 1; i < results.length; i++) { + if (results[i].bonus > winner.bonus) { + winner = results[i]; + } + } + winner.isWinner = true; + + return { + classified, + isVacation, + winner, + allResults: results, + totalBonus: winner.bonus, + totalDuties: duties.length, + dutyDetails: this.buildDutyDetails(duties) + }; + } + + /** + * Calculate for all employees. vacationMap: { [employeeName]: boolean } + */ + calculateAllEmployees(employeeDuties, vacationMap = {}) { + const results = {}; + for (const [name, duties] of Object.entries(employeeDuties)) { + const isVac = Boolean(vacationMap[name]); + results[name] = this.calculateMonthlyBonus(duties, isVac); + } + return results; + } + + getEmptyResult(isVacation = false) { + const empty = { + variantId: 1, + eligible: false, + threshold: null, + deduction: { fr: 0, sa: 0, so: 0, weekday: 0 }, + paidShares: { fr: 0, sa: 0, so: 0, weekday: 0 }, + bonus: 0, + isWinner: true + }; + return { + classified: { fr: 0, sa: 0, so: 0, weekday: 0 }, + isVacation, + winner: empty, + allResults: [empty, + { ...empty, variantId: 2, isWinner: false }, + { ...empty, variantId: 3, isWinner: false } + ], + totalBonus: 0, + totalDuties: 0, + dutyDetails: [] + }; + } + + formatCurrency(amount) { + return new Intl.NumberFormat('de-DE', { + style: 'currency', + currency: 'EUR' + }).format(amount); + } + } + + // Make it available globally + window.BonusCalculator = BonusCalculator; + ``` + +- [ ] **Step 4: Green verification (winner tests).** + + Reload `test.html`. All new `Winner:` tests pass. **However**, the existing `Berechnung:` tests now fail because they reference removed fields like `result.qualifyingDays`, `result.thresholdReached`, `result.qualifyingDaysPaid`, `result.bonusNormalDays`, `result.normalDays`, etc. That's expected - Task 9 fixes them. + +- [ ] **Step 5: Commit.** + + ```bash + git add calculator.js test-suite.js + git commit -m "refactor: BonusCalculator runs all 3 variants and picks winner" + ``` + +--- + +### Task 9: Migrate existing tests to the new result shape + +**Files:** +- Modify: `G:\Claude\Claude_tmp_dienstplan\test-suite.js` (rewrite the `Berechnung:` block) + +The existing `Berechnung:` tests reference fields that no longer exist (`qualifyingDays`, `thresholdReached`, `qualifyingDaysPaid`, `qualifyingDaysDeducted`, `normalDays`, `normalDaysPaid`, `bonusNormalDays`, `bonusQualifyingDays`). Rewrite each test to use the new shape (`result.classified`, `result.winner.eligible`, `result.winner.paidShares`, `result.totalBonus`). + +- [ ] **Step 1: Replace the `Berechnung:` block.** + + Find the existing block that starts with `// Calculator Tests - Bonus Calculation` and the test `'Berechnung: Unter Schwellenwert (1.0 WE-Tag) = 0€'`. Replace the whole block, up to and including `'Berechnung: Keine Dienste = 0€'`, with: + + ```javascript + // ============================================================================ + // Calculator Tests - Bonus Calculation (new variants shape) + // ============================================================================ + + runner.test('Berechnung: Unter Schwellenwert (1.0 WE-Tag) = 0 EUR', (t) => { + const holidays = new HolidayProvider(); + const calculator = new BonusCalculator(holidays); + const duties = [ + { date: new Date('2025-11-22T12:00:00'), share: 1.0 } // 1x Samstag + ]; + const result = calculator.calculateMonthlyBonus(duties, false); + t.assertEqual(result.classified.sa, 1.0, 'sa=1.0'); + t.assertFalse(result.winner.eligible, 'Kein eligible Variant'); + t.assertEqual(result.totalBonus, 0, 'Bonus 0'); + }); + + runner.test('Berechnung: Genau 2.0 WE-Tage (Sa+So) -> V3 trigger, bonus 0', (t) => { + const holidays = new HolidayProvider(); + const calculator = new BonusCalculator(holidays); + const duties = [ + { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, // Samstag + { date: new Date('2025-11-23T12:00:00'), share: 1.0 } // Sonntag + ]; + const result = calculator.calculateMonthlyBonus(duties, false); + t.assertEqual(result.winner.variantId, 3, 'V3 winner'); + t.assertTrue(result.winner.eligible, 'V3 eligible'); + t.assertEqual(result.winner.paidShares.sa + result.winner.paidShares.so, 0, '0 paid (alle abgezogen)'); + t.assertEqual(result.totalBonus, 0, 'Bonus 0'); + }); + + runner.test('Berechnung: 4x halbe Sa+So Dienste (Schwelle 2.0) -> bonus 0', (t) => { + const holidays = new HolidayProvider(); + const calculator = new BonusCalculator(holidays); + const duties = [ + { date: new Date('2025-11-22T12:00:00'), share: 0.5 }, + { date: new Date('2025-11-22T12:00:00'), share: 0.5 }, + { date: new Date('2025-11-23T12:00:00'), share: 0.5 }, + { date: new Date('2025-11-23T12:00:00'), share: 0.5 } + ]; + const result = calculator.calculateMonthlyBonus(duties, false); + t.assertAlmostEqual(result.classified.sa + result.classified.so, 2.0, 0.0001, '2.0 total'); + t.assertEqual(result.totalBonus, 0, 'Bonus 0'); + }); + + runner.test('Berechnung: 3 WE-Tage (Fr+Sa+So) -> V3 winner, bonus 450 EUR', (t) => { + const holidays = new HolidayProvider(); + const calculator = new BonusCalculator(holidays); + const duties = [ + { date: new Date('2025-11-21T12:00:00'), share: 1.0 }, // Freitag + { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, // Samstag + { date: new Date('2025-11-23T12:00:00'), share: 1.0 } // Sonntag + ]; + const result = calculator.calculateMonthlyBonus(duties, false); + // V3: pool=3, abzug 2 (fr=1, so=1) -> paid sa=1 -> 450 + t.assertEqual(result.winner.variantId, 3, 'V3 winner'); + t.assertEqual(result.totalBonus, 450, 'bonus 450'); + }); + + runner.test('Berechnung: Normale Tage + WE-Tage gemischt (Mo+Di+Sa+So) -> V3, bonus 500', (t) => { + const holidays = new HolidayProvider(); + const calculator = new BonusCalculator(holidays); + const duties = [ + { date: new Date('2025-11-24T12:00:00'), share: 1.0 }, // Montag + { date: new Date('2025-11-25T12:00:00'), share: 1.0 }, // Dienstag + { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, // Samstag + { date: new Date('2025-11-23T12:00:00'), share: 1.0 } // Sonntag + ]; + const result = calculator.calculateMonthlyBonus(duties, false); + // V1: fr+so=1, weekday=2 < 3 -> not eligible + // V2: sa=1, weekday=2 -> eligible, abzug 1 sa, 2 weekday -> 0 -> bonus 0 + // V3: pool=2 -> eligible, abzug 2 (so=1, sa=1) -> 0 sa/so paid + 2 weekday paid = 500 + t.assertEqual(result.winner.variantId, 3, 'V3 winner with weekday-pay'); + t.assertEqual(result.winner.paidShares.weekday, 2, '2 weekday paid'); + t.assertEqual(result.totalBonus, 500, '2 * 250 = 500'); + }); + + runner.test('Berechnung: Halbe Dienste korrekt im neuen Shape', (t) => { + const holidays = new HolidayProvider(); + const calculator = new BonusCalculator(holidays); + const duties = [ + { date: new Date('2025-11-24T12:00:00'), share: 0.5 }, // halber Mo (weekday) + { date: new Date('2025-11-22T12:00:00'), share: 0.5 }, // halber Sa + { date: new Date('2025-11-23T12:00:00'), share: 1.0 }, // ganzer So + { date: new Date('2025-11-21T12:00:00'), share: 1.0 } // ganzer Fr + ]; + const result = calculator.calculateMonthlyBonus(duties, false); + t.assertAlmostEqual(result.classified.weekday, 0.5, 0.0001, 'weekday=0.5'); + t.assertAlmostEqual(result.classified.fr + result.classified.sa + result.classified.so, + 2.5, 0.0001, 'WE-Pool=2.5'); + // V3: pool=2.5, abzug 2 (fr=1, so=1) -> paid sa=0.5, weekday=0.5 -> 0.5*450 + 0.5*250 = 350 + t.assertEqual(result.winner.variantId, 3, 'V3 winner'); + t.assertEqual(result.totalBonus, 350, 'bonus 350'); + }); + + runner.test('Berechnung: Feiertag (1. Mai 2025 = Do) + Vortag (Mi)', (t) => { + const holidays = new HolidayProvider(); + const calculator = new BonusCalculator(holidays); + const duties = [ + { date: new Date('2025-04-30T12:00:00'), share: 1.0 }, // Mi vor 1. Mai -> fr + { date: new Date('2025-05-01T12:00:00'), share: 1.0 } // 1. Mai (Do-Feiertag) -> so + ]; + const result = calculator.calculateMonthlyBonus(duties, false); + t.assertAlmostEqual(result.classified.fr, 1.0, 0.0001, 'fr=1.0'); + t.assertAlmostEqual(result.classified.so, 1.0, 0.0001, 'so=1.0'); + // V3: pool=2, abzug 2 (fr=1, so=1) -> 0 paid -> bonus 0 + t.assertEqual(result.totalBonus, 0, 'Bonus 0'); + }); + + runner.test('Berechnung: Keine Dienste = 0 EUR', (t) => { + const holidays = new HolidayProvider(); + const calculator = new BonusCalculator(holidays); + const result = calculator.calculateMonthlyBonus([], false); + t.assertEqual(result.totalDuties, 0, '0 duties'); + t.assertEqual(result.totalBonus, 0, '0 bonus'); + t.assertEqual(result.dutyDetails.length, 0, '0 dutyDetails'); + }); + ``` + +- [ ] **Step 2: Update the `Edge Case` block to also use the new shape.** + + Find the test `'Edge Case: Exakt Schwellenwert mit Rundungsfehler (1.9999)'`. Replace its body with: + + ```javascript + const holidays = new HolidayProvider(); + const calculator = new BonusCalculator(holidays); + const duties = [ + { date: new Date('2025-11-22T12:00:00'), share: 0.66666 }, // Sa + { date: new Date('2025-11-23T12:00:00'), share: 0.66666 }, // So + { date: new Date('2025-11-21T12:00:00'), share: 0.66666 } // Fr + ]; + const result = calculator.calculateMonthlyBonus(duties, false); + const pool = result.classified.fr + result.classified.sa + result.classified.so; + // 0.66666 x 3 ~ 1.99998 - wegen 1e-9 Toleranz triggert V3 + t.assertTrue(result.winner.variantId === 3 || pool < 2.0, + 'Rundung korrekt behandelt'); + ``` + + Find `'Edge Case: Sehr viele Dienste (Performance)'`. Replace its body with: + + ```javascript + const holidays = new HolidayProvider(); + const calculator = new BonusCalculator(holidays); + const duties = []; + for (let i = 1; i <= 30; i++) { + duties.push({ + date: new Date(`2025-11-${String(i).padStart(2, '0')}T12:00:00`), + share: i % 2 === 0 ? 1.0 : 0.5 + }); + } + const start = Date.now(); + const result = calculator.calculateMonthlyBonus(duties, false); + const duration = Date.now() - start; + t.assertTrue(duration < 100, `Berechnung schnell (${duration}ms)`); + t.assertTrue(result.totalBonus > 0, 'Bonus > 0'); + ``` + + Find `'Edge Case: Dienst am 29. Februar (Schaltjahr)'`. Replace its body with: + + ```javascript + const holidays = new HolidayProvider(); + const calculator = new BonusCalculator(holidays); + const duties = [ + { date: new Date('2028-02-29T12:00:00'), share: 1.0 } // Dienstag -> weekday + ]; + const result = calculator.calculateMonthlyBonus(duties, false); + t.assertEqual(result.classified.weekday, 1.0, '29.02. (Di) = weekday'); + ``` + +- [ ] **Step 3: Green verification.** + + Reload `test.html`. All tests - old (rewritten), `classify:`, `classifyDuties:`, `variant1/2/3:`, `Winner:`, `Edge Case:` - pass. + +- [ ] **Step 4: Commit.** + + ```bash + git add test-suite.js + git commit -m "test: migrate Berechnung + Edge-Case tests to new variants result shape" + ``` + +--- + +### Task 10: Extend `DataStorage` with vacation-mode persistence + +**Files:** +- Modify: `G:\Claude\Claude_tmp_dienstplan\test-suite.js` (append storage tests) +- Modify: `G:\Claude\Claude_tmp_dienstplan\storage.js` + +- [ ] **Step 1: Append vacation-storage tests.** + + ```javascript + // ============================================================================ + // Storage - Vacation Mode + // ============================================================================ + + runner.test('Storage: getVacationMode fuer unbekannten MA -> false', (t) => { + const storage = new DataStorage(); + storage.clearAll(); + t.assertFalse(storage.getVacationMode('Niemand', '2025-11'), 'leerer Default false'); + }); + + runner.test('Storage: setVacationMode -> getVacationMode round-trip', (t) => { + const storage = new DataStorage(); + storage.clearAll(); + storage.setVacationMode('Max Mustermann', '2025-11', true); + t.assertTrue(storage.getVacationMode('Max Mustermann', '2025-11'), 'true round-trip'); + t.assertFalse(storage.getVacationMode('Max Mustermann', '2025-12'), 'anderer Monat = false'); + t.assertFalse(storage.getVacationMode('Anna Schmidt', '2025-11'), 'anderer MA = false'); + }); + + runner.test('Storage: setVacationMode kann zurueckgesetzt werden', (t) => { + const storage = new DataStorage(); + storage.clearAll(); + storage.setVacationMode('Max Mustermann', '2025-11', true); + storage.setVacationMode('Max Mustermann', '2025-11', false); + t.assertFalse(storage.getVacationMode('Max Mustermann', '2025-11'), 'wieder false'); + }); + + runner.test('Storage: Export enthaelt dienstplan_vacation', (t) => { + const storage = new DataStorage(); + storage.clearAll(); + storage.addEmployee('Max Mustermann'); + storage.setVacationMode('Max Mustermann', '2025-11', true); + const exported = storage.exportData(); + const parsed = JSON.parse(exported); + t.assertTrue('vacation' in parsed, 'vacation key im Export'); + t.assertEqual(parsed.vacation['Max Mustermann']['2025-11'], true, 'Wert exportiert'); + }); + + runner.test('Storage: Import restauriert vacation', (t) => { + const storage1 = new DataStorage(); + storage1.clearAll(); + storage1.addEmployee('Max Mustermann'); + storage1.setVacationMode('Max Mustermann', '2025-11', true); + const exported = storage1.exportData(); + + const storage2 = new DataStorage(); + storage2.clearAll(); + const ok = storage2.importData(exported); + t.assertTrue(ok, 'Import success'); + t.assertTrue(storage2.getVacationMode('Max Mustermann', '2025-11'), 'vacation restauriert'); + }); + + runner.test('Storage: Import ohne vacation-Feld bleibt fehlerfrei', (t) => { + const storage = new DataStorage(); + storage.clearAll(); + const legacyJson = JSON.stringify({ + employees: ['Max Mustermann'], + duties: {} + }); + const ok = storage.importData(legacyJson); + t.assertTrue(ok, 'Legacy import erfolgreich'); + t.assertFalse(storage.getVacationMode('Max Mustermann', '2025-11'), 'Default false'); + }); + + runner.test('Storage: clearAll entfernt auch vacation', (t) => { + const storage = new DataStorage(); + storage.setVacationMode('Max Mustermann', '2025-11', true); + storage.clearAll(); + t.assertFalse(storage.getVacationMode('Max Mustermann', '2025-11'), 'nach clearAll false'); + }); + ``` + +- [ ] **Step 2: Red verification.** All new `Storage:` tests that touch vacation fail with `storage.getVacationMode is not a function` (or similar). + +- [ ] **Step 3: Modify `storage.js`.** + + In `G:\Claude\Claude_tmp_dienstplan\storage.js`, update the constructor: + + ```javascript + constructor() { + this.STORAGE_KEY_EMPLOYEES = 'dienstplan_employees'; + this.STORAGE_KEY_DUTIES = 'dienstplan_duties'; + this.STORAGE_KEY_VACATION = 'dienstplan_vacation'; + } + ``` + + Add new methods anywhere inside the `DataStorage` class (before `clearAll`): + + ```javascript + /** + * Get vacation mode for an employee in a specific month. + * @param {string} employeeName + * @param {string} yearMonth - format "YYYY-MM" + * @returns {boolean} + */ + getVacationMode(employeeName, yearMonth) { + try { + const raw = localStorage.getItem(this.STORAGE_KEY_VACATION); + if (!raw) return false; + const map = JSON.parse(raw); + if (!map || typeof map !== 'object') return false; + return Boolean(map[employeeName] && map[employeeName][yearMonth]); + } catch (e) { + console.error('Fehler beim Laden des Urlaubsmodus:', e); + return false; + } + } + + /** + * Set vacation mode for an employee in a specific month. + */ + setVacationMode(employeeName, yearMonth, value) { + try { + const raw = localStorage.getItem(this.STORAGE_KEY_VACATION); + const map = raw ? JSON.parse(raw) : {}; + if (!map[employeeName]) map[employeeName] = {}; + map[employeeName][yearMonth] = Boolean(value); + localStorage.setItem(this.STORAGE_KEY_VACATION, JSON.stringify(map)); + } catch (e) { + console.error('Fehler beim Speichern des Urlaubsmodus:', e); + throw e; + } + } + + /** + * Get the full vacation map ({ name: { yearMonth: bool } }). + */ + getAllVacationModes() { + try { + const raw = localStorage.getItem(this.STORAGE_KEY_VACATION); + if (!raw) return {}; + const map = JSON.parse(raw); + if (!map || typeof map !== 'object') return {}; + return map; + } catch (e) { + console.error('Fehler beim Laden des Urlaubsmodus:', e); + return {}; + } + } + ``` + + Update `clearAll()`: + + ```javascript + clearAll() { + localStorage.removeItem(this.STORAGE_KEY_EMPLOYEES); + localStorage.removeItem(this.STORAGE_KEY_DUTIES); + localStorage.removeItem(this.STORAGE_KEY_VACATION); + } + ``` + + Update `exportData()`: + + ```javascript + exportData() { + try { + return JSON.stringify({ + employees: this.getEmployees(), + duties: this.getAllDuties(), + vacation: this.getAllVacationModes() + }, null, 2); + } catch (e) { + console.error('Fehler beim Exportieren der Daten:', e); + throw new Error('Fehler beim Exportieren der Daten: ' + e.message); + } + } + ``` + + Update `importData(jsonString)`: + + ```javascript + importData(jsonString) { + try { + const data = JSON.parse(jsonString); + + if (data.employees) { + this.saveEmployees(data.employees); + } + if (data.duties) { + this.saveAllDuties(data.duties); + } + if (data.vacation && typeof data.vacation === 'object') { + localStorage.setItem(this.STORAGE_KEY_VACATION, JSON.stringify(data.vacation)); + } + return true; + } catch (e) { + console.error('Import failed:', e); + return false; + } + } + ``` + +- [ ] **Step 4: Green verification.** All new `Storage:` tests pass. + +- [ ] **Step 5: Commit.** + + ```bash + git add storage.js test-suite.js + git commit -m "feat: add dienstplan_vacation key + getVacationMode/setVacationMode + export/import" + ``` + +--- + +### Task 11: Wire vacationMap through `calculateAllEmployees` in `app.js` + +This task fixes the calculation tab so that when the user runs the calculation, each employee's vacation flag is read from storage and passed to `BonusCalculator`. + +**Files:** +- Modify: `G:\Claude\Claude_tmp_dienstplan\app.js` (method `calculateBonuses` only) + +- [ ] **Step 1: Modify `calculateBonuses` to read the vacationMap.** + + In `G:\Claude\Claude_tmp_dienstplan\app.js`, find `calculateBonuses()` (around line 331). Replace its body with: + + ```javascript + calculateBonuses() { + const monthSelect = document.getElementById('calc-month-select'); + const yearSelect = document.getElementById('calc-year-select'); + const resultsContainer = document.getElementById('calculation-results'); + + const month = parseInt(monthSelect.value); + const year = parseInt(yearSelect.value); + const yearMonth = `${year}-${String(month).padStart(2, '0')}`; + + const employeeDuties = this.storage.getAllEmployeeDutiesForMonth(year, month); + + // Build vacation map for this month: { name: boolean } + const vacationMap = {}; + Object.keys(employeeDuties).forEach(name => { + vacationMap[name] = this.storage.getVacationMode(name, yearMonth); + }); + + const results = this.calculator.calculateAllEmployees(employeeDuties, vacationMap); + + const monthNames = ['Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; + + resultsContainer.innerHTML = `

Ergebnisse fuer ${monthNames[month - 1]} ${year}

`; + + const employees = Object.keys(results); + if (employees.length === 0) { + resultsContainer.innerHTML += '

Keine Daten verfuegbar.

'; + return; + } + + // Stash current calc context for vacation-toggle handler + this._currentCalcContext = { year, month, yearMonth }; + + employees.forEach(employeeName => { + const result = results[employeeName]; + const resultCard = this.createResultCard(employeeName, result); + resultsContainer.appendChild(resultCard); + }); + + this.showToast('Berechnung abgeschlossen.', 'success'); + } + ``` + + Note: keep the original umlaut spelling ("März", "Ergebnisse für", "verfügbar") that exists in the current file — the snippet uses ASCII placeholders to stay safe in markdown but the implementer must keep the existing German umlauts to match the rest of the codebase. + +- [ ] **Step 2: Manual verification.** + + Run the app. The result card currently still uses the old shape, so the card will be broken at this stage. That is acceptable - Task 12 replaces `createResultCard`. The console must not show errors about missing methods on `storage`. + +- [ ] **Step 3: Commit.** + + ```bash + git add app.js + git commit -m "feat: pass vacationMap from storage to calculateAllEmployees" + ``` + +--- + +### Task 12: Rewrite `createResultCard` - winner banner + collapsible variant breakdown + vacation toggle + +**Files:** +- Modify: `G:\Claude\Claude_tmp_dienstplan\app.js` +- Modify: `G:\Claude\Claude_tmp_dienstplan\styles.css` (append rules) + +- [ ] **Step 1: Replace `createResultCard(employeeName, result)` in `app.js`.** + + Find `createResultCard(employeeName, result) { ... }` (around line 365). Replace the **entire method** with: + + ```javascript + /** + * Create a result card for an employee (new variants shape). + */ + createResultCard(employeeName, result) { + const card = document.createElement('div'); + card.className = 'result-card'; + + const ctx = this._currentCalcContext || {}; + const yearMonth = ctx.yearMonth || ''; + const vacChecked = result.isVacation ? 'checked' : ''; + const safeName = String(employeeName).replace(/"/g, '"'); + const safeYm = String(yearMonth).replace(/"/g, '"'); + + // Header + vacation toggle + let content = ` +
+

${employeeName}

+ +
+ `; + + if (result.isVacation) { + content += `
Urlaubsmodus aktiv - Schwellen halbiert
`; + } + + // Winner banner + if (!result.winner.eligible || result.totalBonus === 0) { + content += ` +
+

Keine Variante triggert

+

Mit den eingetragenen Diensten erreicht keine der drei Varianten einen positiven Bonus.

+

Keine Bonuszahlung

+
+ `; + } else { + content += ` +
+

Variante ${result.winner.variantId} * Sieger

+
${this.calculator.formatCurrency(result.totalBonus)}
+
+ `; + } + + // Classified summary line + const c = result.classified; + content += ` +
+ Fr: ${c.fr.toFixed(1)} + Sa: ${c.sa.toFixed(1)} + So: ${c.so.toFixed(1)} + Werktage: ${c.weekday.toFixed(1)} +
+ `; + + // Collapsible variant breakdown + content += `
Alle Varianten anzeigen`; + for (const v of result.allResults) { + content += this.renderVariantBlock(v, result.winner.variantId); + } + content += `
`; + + card.innerHTML = content; + + // Attach vacation-toggle handler + const cb = card.querySelector('input[data-vacation-employee]'); + if (cb) { + cb.addEventListener('change', (e) => this.onVacationToggle(e)); + } + return card; + } + + /** + * Render a single variant sub-panel. + */ + renderVariantBlock(v, winnerId) { + const isWinner = v.variantId === winnerId; + const star = isWinner ? '*' : ''; + const labels = { + 1: 'V1: 1 (Fr/So) + 3 Werktage', + 2: 'V2: 1 Sa + 2 Werktage', + 3: 'V3 (loose): 2 qualifizierende Tage (Pool Fr+Sa+So)' + }; + let thresholdStr = '-'; + if (v.threshold) { + if (v.variantId === 1) thresholdStr = `Fr+So >= ${v.threshold.frSo}, Werktage >= ${v.threshold.weekday}`; + if (v.variantId === 2) thresholdStr = `Sa >= ${v.threshold.sa}, Werktage >= ${v.threshold.weekday}`; + if (v.variantId === 3) thresholdStr = `Pool >= ${v.threshold.pool}`; + } + const elig = v.eligible ? 'erfuellt' + : 'nicht erfuellt'; + return ` +
+
${star}${labels[v.variantId]}
+
Schwelle:${thresholdStr}
+
Eligibility:${elig}
+
Abzug: + Fr ${v.deduction.fr.toFixed(2)} - Sa ${v.deduction.sa.toFixed(2)} - So ${v.deduction.so.toFixed(2)} - WT ${v.deduction.weekday.toFixed(2)} +
+
Bezahlt: + Fr ${v.paidShares.fr.toFixed(2)} - Sa ${v.paidShares.sa.toFixed(2)} - So ${v.paidShares.so.toFixed(2)} - WT ${v.paidShares.weekday.toFixed(2)} +
+
Bonus:${this.calculator.formatCurrency(v.bonus)}
+
+ `; + } + + /** + * Handle vacation checkbox toggle. + */ + onVacationToggle(e) { + const cb = e.target; + const name = cb.getAttribute('data-vacation-employee'); + const ym = cb.getAttribute('data-vacation-yearmonth'); + try { + this.storage.setVacationMode(name, ym, cb.checked); + // Re-run calc to reflect the new state + this.calculateBonuses(); + } catch (err) { + this.showToast('Urlaubsmodus konnte nicht gespeichert werden', 'error'); + cb.checked = !cb.checked; // revert visual state + } + } + ``` + + Note: keep proper German umlauts ("erfüllt", "Größe") in the actual code — the snippet uses ASCII placeholders only to stay safe in the markdown plan file. Where the snippet shows `erfuellt`, write `erfüllt`. Where it shows `>=14`, write `≥14`. Where it shows `*` for a star, prefer the actual star glyph `★`. + +- [ ] **Step 2: Append CSS rules to `styles.css`.** + + Append at the bottom of `G:\Claude\Claude_tmp_dienstplan\styles.css`: + + ```css + /* === Variants UI === */ + .result-header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 10px; + } + + .vacation-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: #fff; + border: 2px solid #e0e0e0; + border-radius: 6px; + font-size: 0.9rem; + cursor: pointer; + user-select: none; + } + + .vacation-toggle input[type="checkbox"] { + margin: 0; + cursor: pointer; + } + + .vacation-active-banner { + background: #fff3cd; + border-left: 4px solid #ffc107; + padding: 8px 12px; + border-radius: 4px; + margin-bottom: 12px; + color: #856404; + font-size: 0.9rem; + } + + .classified-summary { + display: flex; + gap: 20px; + flex-wrap: wrap; + padding: 10px 15px; + background: #f8f9fa; + border-radius: 6px; + margin: 12px 0; + font-size: 0.9rem; + } + + .variant-details { + margin-top: 15px; + background: #f8f9fa; + border-radius: 6px; + padding: 10px 15px; + } + + .variant-details summary { + cursor: pointer; + font-weight: 500; + color: #667eea; + padding: 4px 0; + } + + .variant-card { + background: white; + border: 1px solid #e0e0e0; + border-radius: 6px; + padding: 12px 15px; + margin: 10px 0; + } + + .variant-card.winner { + border-color: #28a745; + box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.15); + } + + .variant-header { + margin-bottom: 8px; + font-size: 0.95rem; + } + + .variant-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 0.75rem; + margin-right: 6px; + background: #28a745; + color: white; + font-weight: 600; + } + + .variant-row { + display: flex; + justify-content: space-between; + gap: 10px; + padding: 4px 0; + font-size: 0.85rem; + color: #555; + border-top: 1px solid #f0f0f0; + } + + .variant-row:first-of-type { + border-top: none; + } + + .variant-bonus { + font-weight: 600; + color: #333; + font-size: 0.95rem; + } + + .variant-eligible { + color: #28a745; + font-weight: 600; + } + + .variant-not-eligible { + color: #dc3545; + font-weight: 600; + } + ``` + +- [ ] **Step 3: Manual verification.** + + Run `index.html` in the browser. Create an employee, add 3 duties (e.g. 1 Fr + 2 Mo-Do), click **Berechnung durchfuehren**. Confirm: + - Winner banner shows "Variante 3" (or whichever variant wins) with a star badge. + - Classified summary line shows the fr/sa/so/weekday tallies. + - Clicking "Alle Varianten anzeigen" expands the `
`, showing 3 sub-cards with eligibility, deduction, paid shares, bonus. The winning one has a green border and a star badge. + - Toggling the "Urlaub gehabt" checkbox reloads the result with halved thresholds and the yellow "Urlaubsmodus aktiv" banner appears. Refresh the page - the checkbox state must persist. + +- [ ] **Step 4: Commit.** + + ```bash + git add app.js styles.css + git commit -m "feat: result card shows winner banner, all-variants details, vacation toggle" + ``` + +--- + +### Task 13: Update CSV / HTML / E-Mail exports to read the new result shape + +The export functions (`exportCSV`, `exportBonusReport`, `generateEmailReport`) still read the old fields (`thresholdReached`, `qualifyingDays`, `qualifyingDaysDeducted`, `normalDays`, `normalDaysPaid`, `qualifyingDaysPaid`, `bonusNormalDays`, `bonusQualifyingDays`). These no longer exist - they must be replaced with the new fields. + +**Files:** +- Modify: `G:\Claude\Claude_tmp_dienstplan\app.js` + +- [ ] **Step 1: Update `exportCSV`.** + + In `exportCSV` (around line 566), replace the lines starting at the "Sheet 2" header through the end of the `for` loop (the section that writes `result.normalDays`, `result.qualifyingDays`, etc.) with: + + ```javascript + // === Sheet 2: Monatliche Auswertung === + csv += `AUSWERTUNG ${monthNames[month - 1]} ${year}\n`; + csv += 'Mitarbeiter;Urlaub;Sieger-Variante;Fr;Sa;So;Werktage;Eligible;Abzug Fr;Abzug Sa;Abzug So;Abzug WT;Bonus (EUR)\n'; + + const yearMonth = `${year}-${String(month).padStart(2, '0')}`; + const employeeDuties = this.storage.getAllEmployeeDutiesForMonth(year, month); + const vacationMap = {}; + Object.keys(employeeDuties).forEach(name => { + vacationMap[name] = this.storage.getVacationMode(name, yearMonth); + }); + const results = this.calculator.calculateAllEmployees(employeeDuties, vacationMap); + + let totalBonus = 0; + for (const [employeeName, result] of Object.entries(results)) { + const w = result.winner; + const c = result.classified; + totalBonus += result.totalBonus; + csv += `${escapeCSV(employeeName)};`; + csv += `${result.isVacation ? 'JA' : 'NEIN'};`; + csv += `V${w.variantId};`; + csv += `${c.fr.toFixed(1).replace('.', ',')};`; + csv += `${c.sa.toFixed(1).replace('.', ',')};`; + csv += `${c.so.toFixed(1).replace('.', ',')};`; + csv += `${c.weekday.toFixed(1).replace('.', ',')};`; + csv += `${w.eligible ? 'JA' : 'NEIN'};`; + csv += `${w.deduction.fr.toFixed(2).replace('.', ',')};`; + csv += `${w.deduction.sa.toFixed(2).replace('.', ',')};`; + csv += `${w.deduction.so.toFixed(2).replace('.', ',')};`; + csv += `${w.deduction.weekday.toFixed(2).replace('.', ',')};`; + csv += `${result.totalBonus.toFixed(2).replace('.', ',')}\n`; + } + + csv += `\nGESAMT;;;;;;;;;;;;${totalBonus.toFixed(2).replace('.', ',')}\n`; + ``` + + Replace the LEGENDE block at the end of the function: + + ```javascript + csv += '\n\n'; + csv += 'LEGENDE\n'; + csv += 'Fr/Sa/So/Werktage;Klassifizierte Shares pro Slot (Halbdienste 0,5)\n'; + csv += 'Sieger-Variante;V1, V2 oder V3 - automatisch die Variante mit dem hoechsten Bonus\n'; + csv += 'V1;"fr+so >= 1 UND weekday >= 3 (Halbiert bei Urlaub: 0,5 / 1,5)"\n'; + csv += 'V2;"sa >= 1 UND weekday >= 2 (Halbiert bei Urlaub: 0,5 / 1)"\n'; + csv += 'V3 (loose);"fr+sa+so >= 2 - wie bisher (Halbiert bei Urlaub: 1)"\n'; + csv += 'Urlaub;"Wenn JA: Schwellen und Abzuege halbiert"\n'; + csv += 'Saetze;"Werktag = 250 EUR/Einheit, Fr/Sa/So/Feiertag = 450 EUR/Einheit"\n'; + ``` + +- [ ] **Step 2: Update `exportBonusReport`.** + + The function (around line 672) currently re-implements the bonus logic by hand. Replace the local recomputation (the `for ([name, data] of ...)` loop body that computes `thresholdReached`, `wt_pay`, `deduct`, `deduct_fr`, etc.) with a call to `BonusCalculator`. + + Find the section starting at `for (const [name, data] of Object.entries(employeeData)) {`. Replace its body with: + + ```javascript + // Compute via BonusCalculator (uses winning variant) + const yearMonth = `${year}-${String(month).padStart(2, '0')}`; + const vacationMap = {}; + Object.keys(employeeDuties).forEach(n => { + vacationMap[n] = this.storage.getVacationMode(n, yearMonth); + }); + const calcResults = this.calculator.calculateAllEmployees(employeeDuties, vacationMap); + + for (const [name, data] of Object.entries(employeeData)) { + const calcRes = calcResults[name] || this.calculator.getEmptyResult(); + const bonus = calcRes.totalBonus; + const w = calcRes.winner; + + totalBonus += bonus; + + const safeName = escapeHtml(name); + let note = ''; + if (bonus === 0 || !w.eligible) { + note = `${safeName} erreicht in keiner der drei Varianten einen positiven Bonus${calcRes.isVacation ? ' (Urlaubsmodus aktiv)' : ''} und erhaelt daher keine Bonuszahlung.`; + } else { + const c = calcRes.classified; + note = `${safeName} erhaelt eine Bonuszahlung von ${this.calculator.formatCurrency(bonus)} nach Variante ${w.variantId}${calcRes.isVacation ? ' (Urlaubsmodus aktiv)' : ''}. Klassifiziert: Fr ${c.fr.toFixed(1)} / Sa ${c.sa.toFixed(1)} / So ${c.so.toFixed(1)} / Werktage ${c.weekday.toFixed(1)}.`; + } + employeeNotes.push(note); + + // Build table row + html += ` + + ${safeName}`; + const dayOrder = [1, 2, 3, 4, 5, 6, 0]; + for (const dayIdx of dayOrder) { + const dayDuties = data.byWeekday[dayIdx]; + if (dayDuties.length === 0) { + html += ``; + } else { + let cellContent = ''; + dayDuties.forEach(duty => { + const shareStr = duty.share === 0.5 ? '1/2' : ''; + const tag = duty.isQual ? 'we-tag' : 'wt-tag'; + cellContent += `${shareStr}X
`; + }); + html += `${cellContent}`; + } + } + html += ` + ${bonus > 0 ? this.calculator.formatCurrency(bonus) : '-'} + `; + } + ``` + + Note: replace ASCII placeholders (`erhaelt`, `1/2`) with the original glyphs in the actual code (`erhält`, `½`). + + Then find the regulations block at the bottom of the HTML report (the `

Berechnungsregeln (Variante 2 - Streng):

` block). Replace it with: + + ```javascript + html += ` +
+

Berechnungsregeln (NRW Psychiatrie 2011):

+
    +
  • Slots: Jeder Dienst wird in fr / sa / so / werktag klassifiziert. Tag vor Mo-Do-Feiertag = fr. Mo-Do-Feiertag = so. Sandwich-Tag (Feiertag + Tag-vor) = sa.
  • +
  • V1: fr+so >= 1 UND werktag >= 3 -> Abzug 1 (Fr-Prio) + 3 werktag.
  • +
  • V2: sa >= 1 UND werktag >= 2 -> Abzug 1 sa + 2 werktag.
  • +
  • V3 (loose): fr+sa+so >= 2 -> Abzug 2 aus Pool (Prio fr -> so -> sa).
  • +
  • Auto-Select: Die Variante mit dem hoechsten Bonus gewinnt; bei Gleichstand gewinnt die niedrigste Variantennummer.
  • +
  • Urlaubsmodus (>=14 Tage frei): Halbiert alle Schwellen UND Abzuege.
  • +
  • Saetze: Werktag = 250 EUR, Fr/Sa/So/Feiertag = 450 EUR.
  • +
+
+ +

+ Erstellt am: ${new Date().toLocaleDateString('de-DE')} | Dienstplan-Pro - NRW Psychiatrie 2011 +

+ + + `; + ``` + + Also remove the by-hand classification block earlier in `exportBonusReport` that aggregates into `data.wt`, `data.we_fr`, `data.we_other`. Keep only the `byWeekday` aggregation (used for the table cells), and remove the `wt` / `we_fr` / `we_other` properties from `employeeData[name]` since they are no longer read. + + Specifically, find the block: + + ```javascript + employeeData[name] = { + duties: duties, + byWeekday: { 0: [], 1: [], 2: [], 3: [], 4: [], 5: [], 6: [] }, + wt: 0, + we_fr: 0, + we_other: 0 + }; + ``` + + Replace with: + + ```javascript + employeeData[name] = { + duties: duties, + byWeekday: { 0: [], 1: [], 2: [], 3: [], 4: [], 5: [], 6: [] } + }; + ``` + + And inside the same outer `for` loop, find the inner block: + + ```javascript + if (!isQualifying) { + employeeData[name].wt += duty.share; + } else if (isFriday) { + employeeData[name].we_fr += duty.share; + } else { + employeeData[name].we_other += duty.share; + } + ``` + + Delete that block entirely (it is no longer used). + +- [ ] **Step 3: Update `generateEmailReport`.** + + In `generateEmailReport` (around line 428), the loop currently reads `res.qualifyingDays`, `res.qualifyingDaysDeducted`, `res.thresholdReached`. Replace the loop body inside `Object.keys(results).forEach(name => { ... });` with: + + ```javascript + Object.keys(results).forEach(name => { + const res = results[name]; + const w = res.winner; + const c = res.classified; + const totalWe = c.fr + c.sa + c.so; + const deducted = w.deduction.fr + w.deduction.sa + w.deduction.so; + const triggered = w.eligible && res.totalBonus > 0; + + let statusText = ''; + let rowStyle = ''; + let blockText = ''; + + if (triggered) { + statusText = `Variante ${w.variantId} (${this.calculator.formatCurrency(res.totalBonus)})${res.isVacation ? ' - Urlaub' : ''}`; + blockText = `Herr/Frau ${name} erreicht ${this.formatNumber(totalWe)} qualifizierende Dienste (Fr/Sa/So), ${this.formatNumber(deducted)} davon werden abgezogen - Bonus nach Variante ${w.variantId}: ${this.calculator.formatCurrency(res.totalBonus)}${res.isVacation ? ' (Urlaubsmodus aktiv)' : ''}.`; + } else if (totalWe > 0 || c.weekday > 0) { + statusText = 'Bonus nicht erreicht'; + rowStyle = 'background-color: #fff0f0;'; + blockText = `Mitarbeiter ${name} erreicht in keiner der drei Varianten die Schwelle (Fr ${c.fr.toFixed(1)}, Sa ${c.sa.toFixed(1)}, So ${c.so.toFixed(1)}, Werktage ${c.weekday.toFixed(1)})${res.isVacation ? ' - Urlaubsmodus aktiv' : ''}.`; + } else { + statusText = '-'; + rowStyle = 'color: #999;'; + } + + reportHtml += ` + ${name} + ${this.formatNumber(totalWe)} + ${this.formatNumber(deducted)} + ${statusText} + `; + + if (blockText) textBlocks.push(blockText); + }); + ``` + + Also, just before the call site `const results = this.calculator.calculateAllEmployees(employeeDuties);` in `generateEmailReport`, build and pass a `vacationMap`: + + ```javascript + const yearMonth = `${year}-${String(month).padStart(2, '0')}`; + const vacationMap = {}; + Object.keys(employeeDuties).forEach(n => { + vacationMap[n] = this.storage.getVacationMode(n, yearMonth); + }); + const results = this.calculator.calculateAllEmployees(employeeDuties, vacationMap); + ``` + + (replacing the existing `const results = this.calculator.calculateAllEmployees(employeeDuties);` line). + +- [ ] **Step 4: Manual verification.** + + Open the app, ensure an employee with duties exists, switch to "Einstellungen": + - Click **Excel/CSV Export** - the downloaded CSV must contain a column "Sieger-Variante" with values like V1/V2/V3 and the "Urlaub" column. + - Click **PDF-Bericht** - the new window must render employee rows + notes mentioning "Variante X" and the new regulations block at the bottom. + - Click **E-Mail Text-Generator** - the modal must show employee rows with status "Variante X (EUR XXX,XX)" and a text block referencing the winning variant. + + No JS errors in the console. + +- [ ] **Step 5: Commit.** + + ```bash + git add app.js + git commit -m "refactor: CSV/HTML/email exports read winner.* from new variants shape" + ``` + +--- + +### Task 14: Settings-tab info-box copy update + +**Files:** +- Modify: `G:\Claude\Claude_tmp_dienstplan\index.html` + +- [ ] **Step 1: Replace the info-box content under "Berechnungsregeln".** + + Find in `index.html`: + + ```html +
+

Berechnungsregeln

+
+

Qualifizierende Tage (WE/Feiertag):

+
    +
  • Freitag, Samstag, Sonntag
  • +
  • Feiertage in NRW
  • +
  • Tag vor einem Feiertag
  • +
+ +

Bonusberechnung:

+
    +
  • Mindestens 2.0 qualifizierende Tage erforderlich
  • +
  • Bei Erreichen der Schwelle: 1.0 qualifizierender Tag wird abgezogen
  • +
  • Normale Tage: 250€ pro Tag
  • +
  • Qualifizierende Tage: 450€ pro Tag
  • +
  • Halbe Dienste werden mit der Hälfte berechnet
  • +
+ +

Wichtig:

+

Wenn weniger als 2.0 qualifizierende Tage erreicht werden, erfolgt keine Bonuszahlung.

+
+
+ ``` + + Replace with: + + ```html +
+

Berechnungsregeln (NRW Psychiatrie 2011)

+
+

Tag-Klassifizierung (Slot pro Dienst):

+
    +
  • fr: Freitag · oder Tag vor einem Mo-Do-Feiertag
  • +
  • sa: Samstag · oder Sandwich-Tag (Feiertag UND Tag vor Feiertag, z. B. Do Feiertag + Fr Feiertag → Do = sa)
  • +
  • so: Sonntag · oder Mo-Do-Feiertag (ohne Sandwich)
  • +
  • weekday: Mo-Do ohne Feiertag und ohne Tag-vor-Feiertag
  • +
+ +

Drei Varianten (es gewinnt die mit dem höchsten Bonus):

+
    +
  • V1: fr+so ≥ 1 UND weekday ≥ 3 → Abzug 1 aus fr+so (Fr-Prio) und 3 aus weekday. sa wird voll bezahlt.
  • +
  • V2: sa ≥ 1 UND weekday ≥ 2 → Abzug 1 sa und 2 weekday. fr und so werden voll bezahlt.
  • +
  • V3 (loose): fr+sa+so ≥ 2 → Abzug 2 aus Pool, Priorität fr → so → sa. weekday wird voll bezahlt.
  • +
+ +

Auto-Selection und Tie-Breaker:

+

Es wird die Variante mit dem höchsten Bonus ausgewählt. Bei Gleichstand gewinnt die niedrigste Variantennummer (V1 < V2 < V3).

+ +

Urlaubsmodus (≥14 Tage frei):

+

Toggle pro Mitarbeiter und Monat. Halbiert alle Schwellen UND Abzüge. Halbe Werte sind explizit erlaubt.

+ +

Sätze:

+
    +
  • weekday: 250 € pro Einheit
  • +
  • fr / sa / so: 450 € pro Einheit
  • +
  • Halbdienste werden mit 0.5 gerechnet
  • +
+ +

Beispiele Tag-Klassifizierung:

+
    +
  • Karfreitag (Fr): fr (Wochentag gewinnt)
  • +
  • Ostermontag (Mo-Feiertag): so
  • +
  • Christi Himmelfahrt (Do-Feiertag): so
  • +
  • Mittwoch vor Christi Himmelfahrt: fr
  • +
  • Tag der Deutschen Einheit 2025 (Fr): fr
  • +
+
+
+ ``` + +- [ ] **Step 2: Manual verification.** + + Open `index.html`, click "Einstellungen". The info-box must show all three variants, the auto-selection rule, the vacation rule, and the 5 example day-classification entries. + +- [ ] **Step 3: Commit.** + + ```bash + git add index.html + git commit -m "docs: settings info-box explains V1/V2/V3, auto-select, vacation mode" + ``` + +--- + +### Task 15: Feature C - Date-Stepper buttons next to `#duty-date` + +**Files:** +- Modify: `G:\Claude\Claude_tmp_dienstplan\index.html` +- Modify: `G:\Claude\Claude_tmp_dienstplan\app.js` +- Modify: `G:\Claude\Claude_tmp_dienstplan\styles.css` (append) + +Per the spec (§10), this is DOM-dependent and primarily a manual-verification feature. No automated tests are added - the manual verification step below is explicit. + +- [ ] **Step 1: Wrap the duty-date input with stepper buttons.** + + In `index.html`, find: + + ```html +
+ + +
+ ``` + + Replace with: + + ```html +
+ +
+ + + +
+
+ ``` + +- [ ] **Step 2: Append CSS for the stepper.** + + Append to `G:\Claude\Claude_tmp_dienstplan\styles.css`: + + ```css + /* === Date Stepper === */ + .date-stepper { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 6px; + align-items: stretch; + } + + .date-stepper input[type="date"] { + /* override the .form-group width */ + width: 100%; + } + + .date-stepper button { + padding: 0 14px; + margin: 0; + font-size: 1.2rem; + line-height: 1; + min-width: 44px; + } + + .date-stepper button:disabled { + opacity: 0.4; + cursor: not-allowed; + } + ``` + +- [ ] **Step 3: Register listeners and add `stepDutyDate(delta)` + `updateDateStepperState()` in `app.js`.** + + In `setupEventListeners()`, the current code already has: + + ```javascript + document.getElementById('month-select').addEventListener('change', () => this.loadDutiesForSelectedEmployee()); + document.getElementById('year-select').addEventListener('change', () => this.loadDutiesForSelectedEmployee()); + ``` + + Delete these two lines, and **append** in their place (after the `employee-select-duty` change listener): + + ```javascript + // Date stepper buttons (Feature C) + document.getElementById('duty-date-prev').addEventListener('click', () => this.stepDutyDate(-1)); + document.getElementById('duty-date-next').addEventListener('click', () => this.stepDutyDate(+1)); + document.getElementById('duty-date').addEventListener('change', () => this.updateDateStepperState()); + document.getElementById('month-select').addEventListener('change', () => this.onDutyMonthChange()); + document.getElementById('year-select').addEventListener('change', () => this.onDutyMonthChange()); + ``` + + Add three new methods anywhere in `DienstplanApp` (e.g. just below `addDuty`): + + ```javascript + /** + * Step the duty-date input by +/-1 day, clamped to the currently selected month. + */ + stepDutyDate(delta) { + const dateInput = document.getElementById('duty-date'); + const monthSelect = document.getElementById('month-select'); + const yearSelect = document.getElementById('year-select'); + const month = parseInt(monthSelect.value); + const year = parseInt(yearSelect.value); + const lastDay = new Date(year, month, 0).getDate(); + + if (!dateInput.value) { + // Initialize to 1st of the selected month + dateInput.value = `${year}-${String(month).padStart(2, '0')}-01`; + this.updateDateStepperState(); + return; + } + const cur = new Date(dateInput.value + 'T12:00:00'); + // If outside selected month, snap to 1st + const inMonth = (cur.getFullYear() === year) && ((cur.getMonth() + 1) === month); + if (!inMonth) { + dateInput.value = `${year}-${String(month).padStart(2, '0')}-01`; + this.updateDateStepperState(); + return; + } + const curDay = cur.getDate(); + const newDay = curDay + delta; + if (newDay < 1 || newDay > lastDay) return; // clamp + const newDate = new Date(year, month - 1, newDay, 12, 0, 0); + const yyyy = newDate.getFullYear(); + const mm = String(newDate.getMonth() + 1).padStart(2, '0'); + const dd = String(newDate.getDate()).padStart(2, '0'); + dateInput.value = `${yyyy}-${mm}-${dd}`; + this.updateDateStepperState(); + } + + /** + * Update the disabled state of the stepper buttons based on current date / month. + */ + updateDateStepperState() { + const dateInput = document.getElementById('duty-date'); + const monthSelect = document.getElementById('month-select'); + const yearSelect = document.getElementById('year-select'); + const prevBtn = document.getElementById('duty-date-prev'); + const nextBtn = document.getElementById('duty-date-next'); + if (!dateInput || !prevBtn || !nextBtn) return; + + const month = parseInt(monthSelect.value); + const year = parseInt(yearSelect.value); + const lastDay = new Date(year, month, 0).getDate(); + + if (!dateInput.value) { + prevBtn.disabled = false; + nextBtn.disabled = false; + return; + } + const cur = new Date(dateInput.value + 'T12:00:00'); + const inSelectedMonth = (cur.getFullYear() === year) && ((cur.getMonth() + 1) === month); + if (!inSelectedMonth) { + prevBtn.disabled = false; + nextBtn.disabled = false; + return; + } + prevBtn.disabled = cur.getDate() <= 1; + nextBtn.disabled = cur.getDate() >= lastDay; + } + + /** + * Handle month/year change in the duty tab: set date to 1st of new month, refresh list, refresh stepper. + */ + onDutyMonthChange() { + const monthSelect = document.getElementById('month-select'); + const yearSelect = document.getElementById('year-select'); + const month = parseInt(monthSelect.value); + const year = parseInt(yearSelect.value); + document.getElementById('duty-date').value = `${year}-${String(month).padStart(2, '0')}-01`; + this.updateDateStepperState(); + this.loadDutiesForSelectedEmployee(); + } + ``` + + Finally, in `setCurrentMonthYear()` (around line 95), append at the very end: + + ```javascript + this.updateDateStepperState(); + ``` + +- [ ] **Step 4: Manual verification.** + + Open `index.html`, "Dienste eintragen" tab. Confirm: + - The `<` and `>` buttons appear next to the date input. + - Setting the date to the 1st of the month -> `<` becomes disabled (greyed-out). + - Setting the date to the last day of the month -> `>` becomes disabled. + - In the middle of the month, both buttons step the date by exactly +/-1 day each click. + - Changing the month dropdown resets the date to the 1st of the newly selected month and re-evaluates button states. + +- [ ] **Step 5: Commit.** + + ```bash + git add index.html app.js styles.css + git commit -m "feat: add date-stepper buttons (Feature C) clamped to selected month" + ``` + +--- + +### Task 16: PWA cache bump + +**Files:** +- Modify: `G:\Claude\Claude_tmp_dienstplan\sw.js` + +- [ ] **Step 1: Update cache version and add `variants.js` to the ASSETS list.** + + Replace the entire contents of `sw.js` with: + + ```javascript + const CACHE_NAME = 'dienstplan-pro-v2'; + const ASSETS = [ + './', + './index.html', + './styles.css', + './app.js', + './calculator.js', + './variants.js', + './holidays.js', + './storage.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)) + ); + }); + ``` + +- [ ] **Step 2: Manual verification.** + + In Chrome DevTools -> Application -> Service Workers, click "Update" / unregister + reload. Network tab must show `variants.js` being fetched; Application -> Cache Storage must list `dienstplan-pro-v2` and contain `variants.js`. The old `dienstplan-pro-v1` cache must be removed by the `activate` listener. + +- [ ] **Step 3: Commit.** + + ```bash + git add sw.js + git commit -m "chore(pwa): bump cache to v2, include variants.js, evict old caches" + ``` + +--- + +### Task 17: Final manual smoke test checklist + +**Files:** none (manual verification only) + +- [ ] **Step 1: Run the full automated test suite one more time.** + + Open `test.html`, click "Alle Tests ausfuehren". Confirm: + - All `Holiday Provider` tests pass. + - All `classify:` tests (7) pass. + - All `classifyDuties:` tests (4) pass. + - All `variant1:` (7), `variant2:` (7), `variant3:` (8) tests pass. + - All `Winner:` tests (6) pass. + - All rewritten `Berechnung:` tests pass. + - All `Storage:` tests (existing + 7 new vacation tests) pass. + - All `Edge Case:` tests (rewritten) pass. + - Total failed = 0. + +- [ ] **Step 2: Scenario A - Winner display.** + + - Open `index.html`. Add employee `Test V1`. + - Switch to "Dienste eintragen" tab. Select November 2025. + - Use the date-stepper to add: 1 Fr (2025-11-21, full), 3 weekdays (2025-11-24 Mo, 2025-11-25 Di, 2025-11-26 Mi, all full), and 1 Sa (2025-11-22, full). + - Switch to "Berechnung", click "Berechnung durchfuehren". + - Expected: One of the three variants displays as winner with a star badge. The classified summary shows fr=1, sa=1, so=0, weekday=3. The collapsible details show all 3 variants with their eligibility and computed bonus. The displayed totalBonus matches the winner's bonus. + +- [ ] **Step 3: Scenario B - Urlaubsmodus.** + + - On the result card for `Test V1`, tick **Urlaub gehabt (>=14 Tage frei)**. + - The card must re-render with the yellow "Urlaubsmodus aktiv" banner. The bonus typically increases or a different variant becomes eligible because thresholds are halved. + - Reload the page. The toggle state must persist (read back from `dienstplan_vacation` in localStorage). + +- [ ] **Step 4: Scenario C - Date-Stepper.** + + - On "Dienste eintragen", select Nov 2025. Set the date to `2025-11-01`. Confirm `<` is disabled. + - Click `>` 29 times. Confirm date is now `2025-11-30` and `>` is disabled. + - Change month dropdown to December. Confirm date jumps to `2025-12-01` and `<` is disabled. + +- [ ] **Step 5: Scenario D - Export / Import roundtrip including vacation.** + + - In "Einstellungen", click **Daten exportieren (JSON)**. Open the downloaded file: confirm it contains a top-level `"vacation"` key with the `Test V1` entry. + - Click **Alle Daten loeschen**. Confirm everything is gone (employee list empty, calc tab shows no data). + - Reload, click **Importieren** with the previously exported file. Re-run calculation. The vacation toggle for `Test V1` in November must come back **checked**. + +- [ ] **Step 6: Scenario E - CSV / PDF / E-Mail export with new shape.** + + - CSV: open the downloaded CSV. Confirm the AUSWERTUNG section has columns "Sieger-Variante", "Urlaub", "Fr", "Sa", "So", "Werktage", abzug-per-slot, "Bonus (EUR)". + - PDF/HTML report: opens in new window. Confirm each employee row mentions "Variante X" and the regulations block at the bottom lists all 3 variants. + - E-Mail generator: modal shows a status column "Variante X (EUR XXX,XX)" and text-blocks reference the winning variant. + +- [ ] **Step 7: No new commits in this task.** This task only verifies. If any check fails, debug and fix in a follow-up commit before declaring done. + +--- + +## Self-review + +### Spec coverage matrix + +| Spec section | Tasks | +|---|---| +| §1 Goal | T5-T8 (3 variants + winner selection) | +| §3 Day Classification Rule | T2 (red), T3 (green) - all 7 spec examples covered | +| §3.3 Aggregations-Output | T4 (`classifyDuties`) | +| §4.1 Variant 1 | T6 | +| §4.2 Variant 2 | T7 | +| §4.3 Variant 3 (loose) | T5 | +| §4.4 Friday-Priority pool order | T5 (fr->so->sa), T6 (fr->so), T7 (sa only - trivial) | +| §4.5 No-Bonus-Case shape | T5/T6/T7 implementation returns the spec shape with `paidShares: {fr:0,sa:0,so:0,weekday:0}` and `threshold` set | +| §5 Variant Selection & Tie-Breaker | T8 (winner selection in `BonusCalculator.calculateMonthlyBonus`; tie-breaker via strict `>` in the loop; test `Winner: Tie-Breaker` verifies V1 wins when all-zero) | +| §6.1 Vacation trigger / label | T12 (UI checkbox with label "Urlaub gehabt (>=14 Tage frei)") | +| §6.2 Vacation effect (halved thresholds) | T5/T6/T7 (`isVacation` parameter) + T11 (wired through `calculateAllEmployees`) | +| §6.3 Vacation persistence (`dienstplan_vacation` key) | T10 | +| §7.1 In-Memory data model (winner / allResults / classified / isVacation / dutyDetails) | T8 (calculator return shape) | +| §7.2 Persistence keys table | T10 (new key + clearAll + export/import) | +| §8.1 File changes overview | All tasks | +| §8.2 Script load order | T1 (`variants.js` before `calculator.js` in both `index.html` and `test.html`) | +| §8.3 `variants.js` public API | T1 (skeleton + `window.*` exposure), T3/T4/T5/T6/T7 (implementations) | +| §8.4 `BonusCalculator` new internal structure | T8 | +| §8.5 `storage.js` extension | T10 | +| §9.1 Berechnung tab UI (vacation checkbox + result card + details) | T12 | +| §9.2 Einstellungen info-box | T14 | +| §9.3 Toast behavior on toggle | T12 (`onVacationToggle` shows error toast on throw, no toast on success) | +| §10 Feature C Date-Stepper | T15 | +| §11 Backwards Compatibility (key preserved, V3 loose = old logic) | T8 (V3 implements the prior algorithm); T10 (export/import legacy without `vacation` works - covered by test `Storage: Import ohne vacation-Feld bleibt fehlerfrei`); T16 (PWA cache bump) | +| §12.1 Tests for `classify` / `classifyDuties` | T2, T4 | +| §12.2 Tests `variant1` | T6 | +| §12.3 Tests `variant2` | T7 | +| §12.4 Tests `variant3` | T5 | +| §12.5 Tests Winner Selection | T8 | +| §12.6 Tests Vacation combined | T5/T6/T7 (variant-level Urlaub tests), T8 (`Winner: Urlaubsmodus`), T10 (storage round-trip + missing-key default) | +| §12.7 Existing tests adjustment | T9 | +| §12.8 Tests Date-Stepper | T15 (manual verification - automated tests skipped per spec's "DOM-dependent" note, made explicit in the task) | +| §13 Open Question - vacation in exports | T13 (CSV "Urlaub" column, PDF report mentions "Urlaubsmodus aktiv", email text mentions "Urlaubsmodus aktiv") | +| §13 PWA cache bump v1->v2 | T16 | + +**Gaps:** None blocking. The spec's §13 question "Soll Urlaubsmodus in CSV/HTML sichtbar sein?" is resolved as **yes** in Task 13 (CSV "Urlaub" column, PDF + email notes). + +### Resolved ambiguities + +- **Spec §5 tie-breaker for all-zero:** spec says V1 is "nominally winner" with `totalBonus = 0`. The implementation in T8 uses strict `>` in the loop, so the first variant (`v1`) stays winner on ties. Tested in `Winner: Tie-Breaker - alle three not eligible`. The UI's "Keine Variante triggert" message kicks in when `result.totalBonus === 0`, matching the spec. +- **Spec §3.2 Sandwich-Tag examples are hypothetical** - no real consecutive Do+Fr holidays exist in NRW 2025-2030 data. T2 covers these via a small `fakeHp` ad-hoc mock that fakes `isHoliday` / `isDayBeforeHoliday`. The real HolidayProvider remains untouched. +- **`exportBonusReport` formerly recomputed bonus locally** with the old single-rule logic. T13 replaces this with a call to `BonusCalculator.calculateAllEmployees`, which is the single source of truth post-refactor. +- **`onDutyMonthChange` double-binding** - the duty-tab month/year selects already had `change` listeners that called `loadDutiesForSelectedEmployee`. T15 explicitly deletes those and replaces them with a single `onDutyMonthChange` handler that does both (refresh list + reset date + refresh stepper state). The existing `setCurrentMonthYear` is also patched to call `updateDateStepperState`. +- **German umlauts in code snippets:** the snippets in T11/T12/T13 use ASCII placeholders (e.g. `erhaelt`, `Saetze`, `Maerz`, `naechster`) so this markdown plan stays portable; the implementer must restore proper umlauts (`erhält`, `Sätze`, `März`, `nächster`) and special glyphs (`½`, `★`, `≥`, `→`) when writing into the actual source files. The HTML for Task 14 uses HTML entities (`ä`, `ë`, `≥`, etc.) which are valid as-is. + +### Type-consistency audit + +- `classify(date, holidayProvider)` defined in T3, used by `classifyDuties` in T4 and by `BonusCalculator.isQualifyingDay` in T8 - same signature throughout. +- `classifyDuties(duties, holidayProvider)` defined in T4, used by `BonusCalculator.calculateMonthlyBonus` in T8 - signature matches. +- `variant1/2/3(classified, isVacation)` - all three implementations (T5/T6/T7) and the caller in T8 use the same signature and return shape (`{ variantId, eligible, threshold, deduction, paidShares, bonus, isWinner }`). +- `BonusCalculator.calculateAllEmployees(employeeDuties, vacationMap)` - second parameter declared in T8, used by `calculateBonuses` in T11, `exportCSV` / `generateEmailReport` / `exportBonusReport` in T13. Map type is `{ [name]: boolean }`. +- `DataStorage.getVacationMode(name, yearMonth)` / `setVacationMode(name, yearMonth, value)` - defined in T10, called from T11/T12/T13. `yearMonth` is consistently the string `"YYYY-MM"` (e.g. `"2025-11"`). +- `_currentCalcContext.yearMonth` set in T11, read in T12 (`createResultCard`). Both use the same `${year}-${String(month).padStart(2,'0')}` format. + +No type or signature mismatch found. + +--- + +## Done criteria + +All 17 tasks committed. `test.html` shows 0 failed tests. The five smoke scenarios from Task 17 manually verified. diff --git a/docs/specs/2026-05-11-bild-import-design.md b/docs/specs/2026-05-11-bild-import-design.md new file mode 100644 index 0000000..688cf4e --- /dev/null +++ b/docs/specs/2026-05-11-bild-import-design.md @@ -0,0 +1,819 @@ +# Feature A: Bild → Dienste Import + +- **Datum:** 2026-05-11 +- **Status:** Draft +- **Autor:** Design-Phase, Dienstplan-Pro +- **Scope:** Optionaler Bulk-Import von Diensten aus einem Foto/Screenshot einer Dienstplan-Tabelle via OpenRouter Vision-LLM. + +--- + +## 1. Ziel / Problemstellung + +Aktuell werden Dienste in Dienstplan-Pro im Tab **"Dienste eintragen"** ausschließlich manuell – ein Dienst pro Klick – erfasst. Für eine Assistenzärztin mit 6–10 Diensten pro Monat ist das knapp 30 Klicks pro Person pro Monat. In der Praxis bekommen Assistenzärzte ihren Dienstplan typischerweise als PDF, Foto oder Screenshot einer Tabelle. Die manuelle Übernahme ist fehleranfällig und reibungsbehaftet. + +**Ziel:** Ein optionaler Import-Pfad, bei dem der Benutzer ein einzelnes Bild (Foto vom Aushang, Screenshot vom Plan, exportierter Tabellen-Snip) hochlädt. Ein OpenRouter Vision-LLM extrahiert die Einträge als strukturiertes JSON, der Benutzer prüft das Ergebnis in einer Vorschau und bestätigt. Die bestehende Persistenz über `DataStorage` bleibt unangetastet. + +**Pflichtmerkmale:** + +- 100 % browserseitig, kein Backend. Der OpenRouter-API-Key wird vom Benutzer selbst in `localStorage` gehalten. +- Der Import ist **rein additiv**: bestehende manuelle Eingabe wird nicht ersetzt. +- Robuste Namensauflösung gegen den existierenden `dienstplan_employees`-Bestand (exakt, normalisiert, fuzzy). +- Konfliktverhalten mit existierenden Diensten ist deterministisch (Replace, siehe Abschnitt 11). + +--- + +## 2. Out of Scope + +- **Feature B (Bonus-Varianten):** siehe `2026-05-11-bonus-varianten-design.md`. Beide Features sind unabhängig und teilen sich keinen Code. +- **Kein Backend, kein Server-Proxy.** Alle API-Calls gehen direkt aus dem Browser an OpenRouter. Eine zukünftige Hetzner-Proxy-Variante ist denkbar, aber nicht Teil dieses Specs (siehe Abschnitt 16). +- **Kein Multi-Image-Batch.** Genau ein Bild pro Importvorgang. Mehrseitige Pläne werden seitenweise importiert. (Siehe Abschnitt 16, Future Work.) +- **Kein PDF-Import.** Nur Bildformate (PNG, JPG, JPEG, WebP). PDF-Konvertierung ist Future Work. +- **Keine OCR-Heuristik im Browser.** Die Erkennung läuft vollständig über das LLM; es gibt keine Fallback-Tesseract-Schiene. +- **Keine automatische Monatsrollover-Logik** bei Einträgen, die zwei Monate überspannen. Falls erkannt, wird gewarnt; der Import nutzt den im Tab gewählten Monat als Ziel (siehe Abschnitt 9.4). +- **Keine Speicherung des Bildes** über die Importsession hinaus. Das Bild lebt nur im RAM des Modals. + +--- + +## 3. User Flow (4 Stages) + +Der Import läuft als modaler Dialog mit vier Stufen. Der Modal-Lifecycle ist: + +1. Benutzer ist im Tab **"Dienste eintragen"**, hat Monat und Jahr eingestellt. +2. Benutzer klickt **`📷 Bild importieren`** oben rechts in der Card. +3. Falls noch kein OpenRouter-API-Key in `localStorage`: `prompt()` mit Erklärtext (siehe Abschnitt 6.1). Bei leerer/abgebrochener Eingabe: Modal öffnet **nicht**, Toast `Kein API-Key gespeichert – Import abgebrochen`. +4. Falls Key vorhanden: Modal öffnet auf Stage 1. + +### 3.1 Stage 1 — Upload + +Was der Benutzer sieht: + +- Drag & Drop-Zone mit Hinweistext `Bild hier ablegen oder Datei auswählen`. +- Button **`Datei auswählen`** → öffnet nativen File Picker, akzeptiert `image/png, image/jpeg, image/webp`. +- Auf Mobilgeräten zusätzlich Button **`Mit Kamera aufnehmen`** → ``. +- Datenschutz-Hinweis (klein, grau): `Das Bild wird zur Erkennung an OpenRouter gesendet.` +- Bei einem ausgewählten Bild: Thumbnail-Vorschau (max. 240 px Kantenlänge), Dateiname, Größe in KB. +- Buttons unten: **`Abbrechen`** (schließt Modal), **`Erkennen`** (deaktiviert solange kein Bild ausgewählt; aktiv sobald Bild da ist → führt zu Stage 2). + +Validierung in Stage 1: + +- MIME muss mit `image/` beginnen, sonst Toast `Nur Bildformate werden unterstützt`. +- Dateigröße > 20 MB: Toast `Bild zu groß (max. 20 MB)`. (Praxisrelevant, da viele Handyfotos > 5 MB.) + +### 3.2 Stage 2 — Processing + +Was der Benutzer sieht: + +- Großer Spinner, Text `Analysiere Bild...`. +- Untertext (klein): `Das kann 5–15 Sekunden dauern.` +- Optionaler Button **`Abbrechen`** → bricht den `fetch` via `AbortController` ab, schließt Modal. + +Was im Hintergrund passiert: + +1. Bild-Preprocessing (Abschnitt 12): Resize auf ≤ 2048 px Längste Kante, Re-Encode JPEG q=0.85, Base64-Kodierung. +2. POST an OpenRouter (Abschnitt 7). +3. Response-Parsing (Abschnitt 8) und Validierung. +4. Namensauflösung (Abschnitt 10) gegen `storage.getEmployees()`. +5. Stage-Wechsel zu Stage 3 (Erfolg) oder Toast + Modal-Schließung (Fehler, Abschnitt 13). + +### 3.3 Stage 3 — Preview & Confirm + +Was der Benutzer sieht: + +**Block A — "Unbekannte Namen"** (nur sichtbar, wenn es welche gibt): + +Eine Box am oberen Rand mit dem Titel `Unbekannte Namen`. Pro unbekanntem Namen eine Zeile mit: + +- Erkannter Name (fett, links). +- Dropdown rechts mit den Optionen: + - `Neuer Mitarbeiter anlegen` (Default) + - `Zuordnen zu []` + - `Zuordnen zu []` + - … (eine Option pro existierendem Mitarbeiter, alphabetisch sortiert) + - `Ignorieren` +- Wenn ein Fuzzy-Match (Levenshtein ≤ 2) existiert, ist die entsprechende `Zuordnen zu …`-Option vorausgewählt **und** ein dezenter Hinweis `möglicher Match: X` daneben angezeigt. + +**Block B — Tabelle der Importeinträge**, gruppiert nach Mitarbeiter (nach Anwendung der Dropdown-Auswahl aus Block A). Pro Mitarbeiter eine Sub-Tabelle: + +| Datum | Wochentag | Slot | Anteil | Aktion | +|---|---|---|---|---| +| 2025-11-22 | Sa | `sa` | 1.0 | 🗑️ | +| 2025-11-28 | Fr | `fr` | 0.5 | 🗑️ | + +- **Slot** wird nach Feature B's `classify(date)`-Regel (`fr`/`sa`/`so`/`weekday`) berechnet. Falls Feature B noch nicht implementiert ist: Fallback auf `getDay()`-basierte Mapping ohne Feiertagsberücksichtigung (siehe Abschnitt 5.4). +- **Aktion** 🗑️ entfernt diese eine Zeile aus dem Import-Set (nur lokal im Modal, nicht persistent). +- Mitarbeiter mit Status `Ignorieren` werden in Block B nicht gerendert. + +**Block C — Buttons:** + +- **`Abbrechen`** → Modal schließt, nichts wird gespeichert. +- **`Bestätigen & Importieren`** → führt aus: + 1. Für jeden mit `Neuer Mitarbeiter anlegen` markierten Namen: `storage.addEmployee(name)`. + 2. Für jeden verbleibenden Eintrag: `storage.addDuty(employeeName, year, month, date, share)` (Signatur siehe Abschnitt 11). + 3. Stage-Wechsel zu Stage 4. + +### 3.4 Stage 4 — Done + +Was der Benutzer sieht: + +- Toast (3 s, type `success`): `X Dienste für Y Mitarbeiter importiert`. +- Modal schließt sich automatisch. +- `loadDutiesForSelectedEmployee()` und ggf. `loadEmployeeSelects()` werden aufgerufen, sodass die Tab-Anzeige aktualisiert wird. + +--- + +## 4. Architecture & File Layout + +### 4.1 Übersicht + +| Datei | Änderung | +|---|---| +| `image-import.js` | **NEU** – enthält `class ImageImporter` und eine kleine Levenshtein-Implementierung. | +| `index.html` | Markup-Ergänzungen: Button im Duties-Tab, Settings-Sektion, Modal-Skelett. Script-Tag für `image-import.js` **nach** `app.js`. | +| `app.js` | Verdrahtung: Klick-Handler für `📷 Bild importieren`, Settings-Sektion-Handler. Kein Berechnungs-Code. | +| `storage.js` | **+** `setApiKey`, `getApiKey`, `clearApiKey`, `setApiModel`, `getApiModel`. Neue Storage-Keys siehe Abschnitt 11. | +| `styles.css` | Modal-Layout, Drag-&-Drop-Zone, Stage-Übergänge, Unbekannte-Namen-Box. | +| `test-suite.js` | Neue Test-Kategorien (siehe Abschnitt 15). | + +### 4.2 Script-Load-Reihenfolge in `index.html` + +``` +holidays.js → calculator.js → storage.js → app.js → image-import.js +``` + +`image-import.js` wird **nach** `app.js` geladen, damit `window.app` (Instanz von `DienstplanApp`) bereits existiert und für `app.showToast(...)`, `app.loadDutiesForSelectedEmployee()` und Zugriff auf `app.holidayProvider` verfügbar ist. + +Initialisierung am Ende von `image-import.js`: + +```javascript +window.imageImporter = new ImageImporter(window.app); +``` + +`ImageImporter` hält intern eine Referenz auf die `DienstplanApp`-Instanz (für Storage, Toast, Refresh) und auf den `HolidayProvider` (für die `classify`-Regel der Vorschau). + +### 4.3 `image-import.js` – Public API (Skizze) + +```javascript +class ImageImporter { + constructor(app) { + this.app = app; + this.storage = app.storage; + this.holidayProvider = app.holidayProvider; + this.session = null; // siehe Abschnitt 14.2 + this.abortController = null; // für Cancel in Stage 2 + } + + open() { /* Stage 1 anzeigen, Key-Prompt-Logik */ } + close() { /* Modal zumachen, session leeren */ } + + // Stage-Übergänge + showStage(stageId) { /* 1 | 2 | 3 | 4 */ } + + // Stage 1 + onFileSelected(file) { /* Validierung, Thumbnail, session.file setzen */ } + + // Stage 2 + async runRecognition() { /* preprocess → call → parse → resolveNames → Stage 3 */ } + + // Stage 3 + renderPreview() { /* Block A, B aufbauen */ } + onUnknownChoiceChange(name, choice) { /* Block B neu rendern */ } + onRemoveEntry(idx) { /* aus session.entries entfernen */ } + async commitImport() { /* addEmployee + addDuty, dann Stage 4 */ } + + // Helpers + preprocessImage(file) { /* canvas resize + JPEG re-encode → base64 */ } + callOpenRouter(b64) { /* fetch → JSON */ } + parseResponse(text) { /* strip fences, JSON.parse, validate schema */ } + resolveNames(entries) { /* exact, normalized, levenshtein → session.unknowns */ } + normalizeName(name) { /* lowercase, trim, collapse whitespace */ } + levenshtein(a, b) { /* siehe Abschnitt 10.3 */ } + classify(date) { /* fr|sa|so|weekday, identisch zu Feature B */ } +} +``` + +--- + +## 5. UI Specification + +### 5.1 Button im Tab "Dienste eintragen" + +In `index.html`, innerhalb des `card`-Containers des Tabs `tab-duties`, oben rechts: + +```html +
+

Dienste eintragen

+ +
+``` + +Der Header bekommt Flex-Layout (`justify-content: space-between`). Button ist immer sichtbar (auch ohne gespeicherten Key – Key-Prompt erscheint beim Klick). + +### 5.2 Modal-Skelett + +Ein einziger Modal-Container im HTML, mit vier Stage-Divs, von denen jeweils nur einer `.active` ist: + +```html + +``` + +### 5.3 Settings-Sektion "Bild-Import (KI)" + +Neue `
` im Tab `tab-settings`, **vor** der Sektion `Alle Daten löschen`: + +```html +
+

Bild-Import (KI)

+

Kein Key hinterlegt

+ + + +
+ + +
+ +

+ 💡 Hinweis: Der API-Key wird ausschließlich lokal in Ihrem Browser gespeichert + und nur an OpenRouter (openrouter.ai) gesendet. +

+
+``` + +Verhalten: + +- Status-Zeile zeigt beim Tab-Aufruf entweder `API-Key gespeichert ✓` (grün) oder `Kein Key hinterlegt` (grau). +- **`Key ändern`** öffnet `prompt('OpenRouter API-Key eingeben:', '')`. Leerer/abgebrochener Wert → keine Änderung. Sonst: `storage.setApiKey(value)` + Status-Zeile aktualisieren. +- **`Key löschen`** öffnet `confirm('API-Key wirklich löschen?')`. Bei OK: `storage.clearApiKey()` + Status-Zeile aktualisieren. +- **Modell-Dropdown:** initial-Wert ist `storage.getApiModel()` (Default `anthropic/claude-sonnet-4.6`). On `change`: `storage.setApiModel(value)`. + +--- + +## 6. API Key Flow + +### 6.1 Erstgebrauch + +Beim ersten Klick auf **`📷 Bild importieren`**: + +1. `storage.getApiKey()` liefert `null`/leer. +2. `prompt(text)` mit folgendem Text: + + ``` + Für die Bilderkennung wird ein OpenRouter-API-Key benötigt. + Der Key wird ausschließlich lokal in Ihrem Browser gespeichert + und nur an openrouter.ai gesendet. + + Key auf https://openrouter.ai/keys anlegen und hier eintragen: + ``` + +3. Leer oder Cancel → kein Modal, Toast `Kein API-Key gespeichert – Import abgebrochen` (type `info`). +4. Bei nicht-leerem Wert → `storage.setApiKey(value.trim())`, Modal öffnet. + +### 6.2 Folgenutzungen + +Kein Prompt mehr. Direkt Modal in Stage 1. + +### 6.3 Übertragung + +- Der Key wird **ausschließlich** im `Authorization`-Header an `https://openrouter.ai/api/v1/chat/completions` gesendet. +- Kein Logging, kein Anhängen an Toasts, kein Schreiben in das Bild-Preprocessing-Modul außer für den Request. +- Bei Fehlerlogs in der Konsole wird der Key **nicht** mitausgegeben. + +--- + +## 7. API Integration (OpenRouter) + +### 7.1 Endpoint & Headers + +``` +POST https://openrouter.ai/api/v1/chat/completions + +Authorization: Bearer +Content-Type: application/json +HTTP-Referer: // optional, von OpenRouter empfohlen +X-Title: Dienstplan-Pro // optional, von OpenRouter empfohlen +``` + +### 7.2 Request-Body + +```json +{ + "model": "", + "temperature": 0, + "response_format": { "type": "json_object" }, + "messages": [ + { + "role": "system", + "content": "" + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Extrahiere alle Assistenzarzt-Dienste aus dieser Dienstplan-Tabelle." + }, + { + "type": "image_url", + "image_url": { + "url": "data:image/jpeg;base64," + } + } + ] + } + ] +} +``` + +`temperature: 0` zur Maximierung der Determinismus. `response_format: json_object` zwingt das Modell zur JSON-Ausgabe (wird von Claude- und GPT-Modellen respektiert; Gemini ignoriert es teilweise – daher der zusätzliche Strip-Fences-Schritt in 8.1). + +### 7.3 System Prompt (vollständig) + +``` +Du extrahierst Dienstpläne aus Tabellenbildern für eine deutsche Klinik. + +Regeln: +- Die Tabelle listet pro Datum die diensthabenden Ärzte. +- Es gibt Assistenzärzte und Oberärzte. Extrahiere NUR Assistenzärzte. Oberärzte werden ignoriert. +- Wenn du nicht sicher bist, ob ein Name zu einem Assistenzarzt oder Oberarzt gehört, vermerke dies in `notes`. +- Wenn in einer Zelle NUR EIN Name steht: share = 1.0 für diesen Arzt. +- Wenn in einer Zelle ZWEI Namen stehen: share = 0.5 für 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, übernimm 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", ...] +} +``` + +### 7.4 Timeouts + +- Kein expliziter Client-Timeout über `fetch` direkt. Stattdessen `AbortController`, der vom Cancel-Button in Stage 2 ausgelöst wird. +- Praktischer Bereich für die Response: 5–30 Sekunden. Bei > 60 s zeigt die UI weiterhin den Spinner; der Benutzer kann via Cancel abbrechen. + +--- + +## 8. Response Parsing & Validation + +### 8.1 Parsing + +Das `response_format`-Flag wird nicht von allen Modellen zuverlässig befolgt. Daher robust: + +1. `text = response.choices[0].message.content` extrahieren. +2. Markdown-Fences strippen (regex `^```(?:json)?\s*` am Anfang und `\s*```$` am Ende). +3. Falls vor/nach dem JSON noch Text steht: ersten `{` und letzten `}` finden, dazwischen slicen. +4. `JSON.parse(stripped)` im try/catch. +5. Bei `SyntaxError`: Toast `Erkennung fehlgeschlagen — anderes Modell probieren oder Bild prüfen` (type `error`), Modal auf Stage 1 zurück. + +### 8.2 Schema-Validierung + +Nach erfolgreichem `JSON.parse(parsed)`: + +| Feld | Erwartung | Fehlerbehandlung | +|---|---|---| +| `parsed.entries` | Array, nicht-null | Fehler → Toast `Erkennung fehlgeschlagen – Antwort hat kein gültiges Format` | +| `parsed.entries.length` | > 0 | Wenn 0: Toast `Keine Dienste erkannt` (type `info`), Modal auf Stage 1 | +| `entries[i].name` | String, nicht leer nach `trim()` | Eintrag wird verworfen, Warnung im Log | +| `entries[i].date` | String, parst zu valider `Date` via `new Date(date + 'T12:00:00')` | Eintrag wird verworfen, Warnung im Log | +| `entries[i].share` | Number, ∈ `{0.5, 1.0}` | Eintrag wird verworfen, Warnung im Log | +| `parsed.month` | `null` oder Integer 1..12 | bei Inkonsistenz → Warnung (siehe 8.3) | +| `parsed.year` | `null` oder Integer ≥ 2000 | bei Inkonsistenz → Warnung (siehe 8.3) | +| `parsed.notes` | Array von Strings, optional | bei `notes.length > 0` Hinweis in Stage 3 anzeigen | + +### 8.3 Konsistenz von `month`/`year` + +- Wenn `parsed.month` und `parsed.year` gesetzt sind: + - Vergleich mit dem aktuell im Tab gewählten Monat (`app.currentMonth`, `app.currentYear`). + - Bei Abweichung: Hinweis in Stage 3 oben: `Erkannter Monat: , aktuell ausgewählt: . Import läuft auf den ausgewählten Monat.` (kein Blocker.) + - Einträge, deren Datum nicht in den **ausgewählten** Monat fällt, werden in Stage 3 mit visuellem Marker `(außerhalb Monat)` angezeigt, aber **nicht automatisch entfernt** – der Benutzer kann sie via 🗑️ entfernen. +- Wenn `month`/`year` null sind: kein Hinweis, Ziel-Monat = ausgewählter Monat im Tab. + +### 8.4 Deduplizierung + +Vor dem Übergang zu Stage 3: + +- Innerhalb der `entries`: doppelte `(name, date)`-Paare werden auf das erste Vorkommen reduziert. Bei Konflikt der `share`-Werte (eines 1.0, eines 0.5) wird der höhere genommen und eine Notiz in Stage 3 generiert: `Doppelter Eintrag für am – höherer Anteil verwendet`. + +--- + +## 9. Slot-Klassifikation in der Vorschau + +### 9.1 Zweck + +In der Preview-Tabelle (Stage 3, Block B) wird pro Zeile der Slot (`fr`/`sa`/`so`/`weekday`) angezeigt, damit der Benutzer auf einen Blick sieht, wie sich der Import in die Bonus-Logik einsortiert. + +### 9.2 Algorithmus + +Identisch zur `classify(date)`-Regel aus Feature B (siehe `2026-05-11-bonus-varianten-design.md`, Abschnitt 3.1): + +``` +classify(date): + wd = date.getDay() + if wd === 5: return "fr" + if wd === 6: return "sa" + if wd === 0: return "so" + isFeiertag = holidayProvider.isHoliday(date) + isTagVorFeiertag = holidayProvider.isDayBeforeHoliday(date) + if isFeiertag && isTagVorFeiertag: return "sa" + if isTagVorFeiertag: return "fr" + if isFeiertag: return "so" + return "weekday" +``` + +### 9.3 Fallback wenn Feature B nicht vorhanden + +Falls Feature B noch nicht implementiert ist und `window.classifyDuties` o. ä. nicht existiert: `ImageImporter.classify` kapselt die Logik in einer **eigenen Kopie**. Da `HolidayProvider` ohnehin vorhanden ist (Pflicht für die App), funktioniert der Algorithmus identisch. Es gibt also keine harte Abhängigkeit von Feature B; die Spezifikationen sind unabhängig implementierbar. + +### 9.4 Anzeige + +Slot-Wert wird als kleines Badge angezeigt: `fr` (orange), `sa` (rot), `so` (rot), `weekday` (grau). Diese Stilangaben sind in `styles.css` konsistent mit Feature B zu halten, falls beide Features gemeinsam ausgeliefert werden. + +--- + +## 10. Name Matching Algorithm + +### 10.1 Normalisierung + +```javascript +normalizeName(name) { + return name + .toLowerCase() + .trim() + .replace(/\s+/g, ' '); // collapse multiple spaces +} +``` + +Beispiele: + +| Input | Output | +|---|---| +| `"Max Mustermann"` | `"max mustermann"` | +| `" Max Mustermann "` | `"max mustermann"` | +| `"max mustermann"` | `"max mustermann"` | + +Umlaute werden **nicht** normalisiert: `"Müller"` ≠ `"Mueller"`. Begründung: die App geht heute davon aus, dass Mitarbeiternamen exakt wie vom Benutzer angelegt verwendet werden. Eine Umlaut-Normalisierung würde unerwartete Cross-Matches erzeugen. + +### 10.2 Matching-Reihenfolge + +Pro Kandidatenname aus `entries[i].name`: + +1. **Exakter normalisierter Match:** Wenn `normalizeName(candidate)` exakt gleich `normalizeName(employee)` für ein `employee ∈ storage.getEmployees()` → automatisch zugeordnet, kein UI-Prompt. +2. **Fuzzy-Match (Levenshtein ≤ 2):** Wenn nicht-exakter Match, aber `levenshtein(normalize(candidate), normalize(employee)) ≤ 2` für mindestens einen Mitarbeiter → der **nächstgelegene** Kandidat wird als "möglicher Match" markiert. In Block A (Stage 3) erscheint der Name mit Default-Auswahl `Zuordnen zu ` und Hinweis `möglicher Match: `. +3. **Unbekannt:** Sonst → erscheint in Block A mit Default-Auswahl `Neuer Mitarbeiter anlegen`. + +Bei mehreren Fuzzy-Treffern mit identischer Distanz: der alphabetisch erste gewinnt für den Default. Dem Benutzer stehen alle anderen weiterhin im Dropdown zur Auswahl. + +### 10.3 Levenshtein (inline) + +`image-import.js` enthält eine kleine Implementation (DP-Matrix, O(m·n)), keine externe Abhängigkeit: + +```javascript +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]; +} +``` + +Eingaben sind immer schon `normalize`d. Für übliche Namenslängen (5–25 Zeichen) ist die Performance unkritisch. + +--- + +## 11. Conflict Handling with Existing Duties + +### 11.1 Bestehende Storage-Semantik + +`DataStorage.addDuty(employeeName, year, month, date, share)` ersetzt einen existierenden Dienst am selben Datum (vgl. `storage.js` Zeilen 217–219): + +```javascript +if (existingIndex >= 0) { + duties[existingIndex].share = share; // Replace +} else { + duties.push({ date, share }); // Append +} +``` + +Diese Replace-Semantik wird vom Import **übernommen**, **nicht umgangen**. Konsequenz: + +- Wenn im Bild ein Dienst für Max Mustermann am 22.11.2025 mit `share=1.0` erkannt wird und Max am 22.11.2025 bereits einen Dienst mit `share=0.5` hat, wird er nach Import auf `share=1.0` stehen. +- Es gibt **kein** UI-Diff oder Bestätigungs-Prompt für Replaces. Begründung: Konsistenz mit dem heutigen manuellen Eingabepfad, der ebenfalls ohne Warnung ersetzt. + +### 11.2 Iteration beim Commit + +In `commitImport()`: + +``` +for each name marked "Neuer Mitarbeiter anlegen": + storage.addEmployee(name) +for each entry in session.entries (after user filtering): + storage.addDuty( + resolvedEmployeeName, + targetYear, // = app.currentYear + targetMonth, // = app.currentMonth + entryDate, // Date-Objekt aus 'YYYY-MM-DD' + 'T12:00:00' + entry.share + ) +``` + +Anschließend: + +- `app.loadDutiesForSelectedEmployee()` falls der aktuell im Dropdown gewählte Mitarbeiter durch den Import betroffen ist. +- `app.loadEmployeeSelects()` falls neue Mitarbeiter angelegt wurden. +- Stage 4 anzeigen, Toast, Modal nach ~1.5 s schließen. + +### 11.3 Zielmonat ist immer der im Tab gewählte Monat + +Auch wenn `parsed.month/year` einen anderen Monat indiziert: der Import läuft technisch immer in `app.currentMonth/Year`. Ein Datum, das nicht in diesen Monat fällt, würde von `DataStorage.addDuty` zwar gespeichert, aber unter dem **Tab-Monatsschlüssel** abgelegt – das wäre datentechnisch inkonsistent. Konsequenz: + +- Vor dem Commit filtert `commitImport()` Einträge, deren Monat/Jahr nicht zum Ziel passen, **heraus** und vermerkt das per Toast: `Z Einträge außerhalb des gewählten Monats übersprungen`. Der Benutzer wurde in Stage 3 darauf hingewiesen (Marker `(außerhalb Monat)`). + +--- + +## 12. Image Preprocessing + +### 12.1 Ziel + +- Reduktion des Payloads auf < ~1.5 MB Base64, um schnelle Übertragung und ausreichende Erkennungsqualität zu balancieren. +- Vermeidung von „Image too large"-Fehlern einiger Modelle. + +### 12.2 Algorithmus + +``` +preprocessImage(file): + 1. img = await loadImage(file) // via URL.createObjectURL + new Image() + 2. longest = max(img.width, img.height) + 3. if longest > 2048: + scale = 2048 / longest + newW = round(img.width * scale) + newH = round(img.height * scale) + else: + newW = img.width + newH = img.height + 4. canvas = new OffscreenCanvas(newW, newH) // Fallback: HTMLCanvasElement + 5. ctx = canvas.getContext('2d') + 6. ctx.drawImage(img, 0, 0, newW, newH) + 7. blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.85 }) + 8. base64 = await blobToBase64(blob) // FileReader.readAsDataURL + 9. URL.revokeObjectURL(...) // cleanup + 10. return base64 // 'data:image/jpeg;base64,...' +``` + +### 12.3 Notizen + +- `OffscreenCanvas` ist in modernen Browsern unterstützt. Fallback: normales ``-Element mit `toBlob`. +- `quality: 0.85` ist empirisch der Sweet-Spot für Text-Erkennung in JPEG. +- Das Originalformat wird verworfen (PNG/WebP/JPEG → einheitlich JPEG). Das ist akzeptabel, da Vision-LLMs JPEG genauso gut lesen wie PNG. +- Maximaler Payload nach Resize: empirisch < 1.5 MB Base64 für eine A4-Tabelle. + +--- + +## 13. Error Handling + +### 13.1 HTTP-Fehler von OpenRouter + +| Status | Toast (type `error`) | Aktion | +|---|---|---| +| 401 | `API-Key ungültig` | Modal auf Stage 1; Settings-Sektion zeigt weiterhin `API-Key gespeichert ✓` (Benutzer muss aktiv korrigieren). | +| 402 | `Limit erreicht oder Guthaben aufgebraucht` | Modal auf Stage 1. | +| 429 | `Limit erreicht oder Guthaben aufgebraucht` | Modal auf Stage 1. | +| 4xx (sonstige) | `Anfrage abgelehnt (HTTP )` | Modal auf Stage 1. | +| 5xx | `Server-Fehler, später nochmal (HTTP )` | Modal auf Stage 1. | + +### 13.2 Netzwerkfehler + +- `TypeError` (z. B. Offline) → Toast `Keine Verbindung zu OpenRouter – Internet prüfen` (type `error`), Modal auf Stage 1. +- `AbortError` (Benutzer hat Cancel gedrückt) → kein Toast, Modal schließt. + +### 13.3 Parsing-/Validierungs-Fehler + +Siehe Abschnitt 8. + +### 13.4 Storage-Fehler beim Commit + +Falls `storage.addDuty(...)` für einen Eintrag wirft (z. B. `QuotaExceededError`): + +- Iteration wird **abgebrochen**. +- Toast `Speicherfehler – Import unvollständig (N von M erfolgreich)`. +- Modal schließt trotzdem. + +### 13.5 Empty Entries + +Wenn `entries.length === 0` nach Validierung: Toast `Keine Dienste erkannt` (type `info`), Modal auf Stage 1. + +--- + +## 14. Data Model + +### 14.1 Persistenz (localStorage) + +| Key | Verwendung | Status | +|---|---|---| +| `dienstplan_employees` | Mitarbeiterliste | **unverändert** (wird ggf. via `addEmployee` ergänzt) | +| `dienstplan_duties` | Dienste pro MA pro Monat | **unverändert** (wird via `addDuty` ergänzt/ersetzt) | +| `dienstplan_openrouter_key` | OpenRouter API-Key (Plaintext) | **NEU** | +| `dienstplan_openrouter_model` | Modell-ID (Default `anthropic/claude-sonnet-4.6`) | **NEU** | + +`storage.js`-Erweiterungen: + +```javascript +class DataStorage { + constructor() { + this.STORAGE_KEY_EMPLOYEES = 'dienstplan_employees'; + this.STORAGE_KEY_DUTIES = 'dienstplan_duties'; + this.STORAGE_KEY_OPENROUTER_KEY = 'dienstplan_openrouter_key'; // NEU + this.STORAGE_KEY_OPENROUTER_MODEL = 'dienstplan_openrouter_model'; // NEU + this.DEFAULT_MODEL = 'anthropic/claude-sonnet-4.6'; + } + + // ---- API-Key ---- + getApiKey() { + try { return localStorage.getItem(this.STORAGE_KEY_OPENROUTER_KEY) || null; } + catch (e) { console.error('Fehler beim Laden des API-Keys:', e); return null; } + } + 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 Löschen des API-Keys:', e); } + } + + // ---- Model ---- + 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; } + } + setApiModel(modelId) { + try { localStorage.setItem(this.STORAGE_KEY_OPENROUTER_MODEL, String(modelId)); } + catch (e) { console.error('Fehler beim Speichern des Modells:', e); throw e; } + } +} +``` + +`clearAll()` wird **bewusst nicht** erweitert, um den API-Key zu löschen – ein "Alle Daten löschen" soll Mitarbeiter und Dienste leeren, aber den API-Key (Benutzer-Setup) erhalten. Falls Feature B's `dienstplan_vacation` aufgenommen wird, betrifft diese Entscheidung nur Daten, nicht Konfiguration. (Falls hier später Konsens auf "auch Key löschen" entsteht, ist das ein Folge-Spec.) + +`exportData()` und `importData()` werden **nicht** um Key/Model erweitert – diese sind benutzer-/gerätespezifisch und gehören nicht in einen Backup-Datenexport. + +### 14.2 In-Memory während Import (Session-Objekt) + +`ImageImporter.session` ist ein flaches Objekt, das nur zwischen Modal-Open und Modal-Close lebt: + +```javascript +this.session = { + file: File, // Original-Bild + thumbnailUrl: string, // ObjectURL für Stage 1 + base64: string | null, // nach Preprocessing + raw: object | null, // geparste JSON-Antwort + entries: [ // validierte, deduplizierte Einträge + { + name: string, // wie aus dem Bild + date: Date, // Date-Objekt, T12:00:00 + share: number // 0.5 oder 1.0 + } + ], + unknowns: [ // Namen, die in Block A erscheinen + { + candidate: string, // wie aus dem Bild + suggested: string | null, // Fuzzy-Match, falls vorhanden + choice: 'new' | 'assign:' | 'ignore' + } + ], + resolvedNames: Map, // candidate → finalName | null (ignore) + targetYear: number, // = app.currentYear bei Modal-Open + targetMonth: number, // = app.currentMonth bei Modal-Open + detectedMonth: number | null, // parsed.month + detectedYear: number | null, // parsed.year + notes: string[] // parsed.notes ∪ interne Warnungen +}; +``` + +Bei Modal-Close: `URL.revokeObjectURL(thumbnailUrl)`, `this.session = null`. Damit ist das Bild garantiert nicht mehr referenziert. + +--- + +## 15. Test Plan (Kategorien, keine Implementierung) + +Neue Tests in `test-suite.js`. Pro Kategorie sind die typischen Fälle aufgeführt; eine erschöpfende Test-Aufzählung ist nicht Ziel dieses Specs. + +### 15.1 API Key Persistenz + +- `setApiKey(...)` → `getApiKey()` Round-trip liefert denselben Wert. +- `clearApiKey()` → `getApiKey()` liefert `null`. +- Defekter Storage (Mock `localStorage.getItem` wirft) → `getApiKey()` liefert `null`, kein Throw nach außen. +- `getApiModel()` ohne gespeicherten Wert liefert Default `anthropic/claude-sonnet-4.6`. +- `setApiModel('google/gemini-2.5-pro')` → `getApiModel()` liefert den gesetzten Wert. + +### 15.2 Image Preprocessing + +- Bild 4000×3000 → nach Resize: längste Kante = 2048 px, Seitenverhältnis bleibt. +- Bild 800×600 → unverändert (kein Upscale). +- Output-String beginnt mit `data:image/jpeg;base64,`. +- Output-Länge ist > 0 und plausibel (z. B. > 1 KB für non-trivialen Input). + +### 15.3 Response Parsing + +- **Valid JSON** ohne Wrapper → erfolgreich geparst. +- **JSON in Markdown-Fence** (` ```json … ``` `) → Fence wird gestrippt, geparst. +- **JSON mit Vortext** (`"Hier das Ergebnis:\n{...}"`) → Vortext wird gestrippt, geparst. +- **Malformed JSON** → `SyntaxError` gefangen, Toast. +- **Schema-Fehler:** `entries` fehlt → Toast `Erkennung fehlgeschlagen`. +- **Schema-Fehler:** `share = 0.75` → Eintrag verworfen, Warnung im Log. +- **Schema-Fehler:** `date = "31.11.2025"` → Eintrag verworfen. +- **`entries: []`** → Toast `Keine Dienste erkannt`. + +### 15.4 Name Matching + +- Exakter Match: candidate `"Max Mustermann"`, employees `["Max Mustermann"]` → automatisch zugeordnet, nicht in `unknowns`. +- Normalisierter Match: candidate `" max mustermann "`, employees `["Max Mustermann"]` → automatisch zugeordnet. +- Fuzzy: candidate `"Max Mustermannn"` (Distance 1), employees `["Max Mustermann"]` → in `unknowns` mit Default-Choice `assign:Max Mustermann`. +- Distance > 2: candidate `"Egon Olsen"`, employees `["Max Mustermann"]` → in `unknowns` mit Default-Choice `new`. +- Mehrere Fuzzy-Treffer gleicher Distanz: alphabetisch erster gewinnt für Default. +- Leere Employee-Liste: alle Kandidaten landen in `unknowns` mit `new`. + +### 15.5 Conflict Handling + +Mit gemocktem Storage: + +- Import-Eintrag (Max, 2025-11-22, 1.0); Storage hat bereits (Max, 2025-11-22, 0.5) → nach Commit: (Max, 2025-11-22, 1.0). Kein Duplikat. +- Import-Eintrag (Anna, 2025-11-23, 0.5); Storage hat keinen Eintrag → nach Commit: (Anna, 2025-11-23, 0.5). +- Import erzeugt neuen Mitarbeiter `addEmployee` + erstes `addDuty` → beide werden persistiert. + +### 15.6 Edge Cases + +- `month`/`year`-Mismatch: parsed.month = 12, aber tab.month = 11 → Hinweis in Stage 3 sichtbar, Einträge mit Datum im Dezember bekommen `(außerhalb Monat)`-Marker und werden beim Commit übersprungen. +- Empty `entries`: Toast, kein Stage 3. +- Duplikat (Max, 2025-11-22) zweimal in `entries`: nach Dedup nur einmal, höherer Share gewinnt, Note erscheint in Stage 3. +- Modal Cancel in Stage 2: `AbortController.abort()` wird aufgerufen, kein Toast, Modal schließt. + +### 15.7 Storage-Erweiterung + +- `exportData()` / `importData()` ignorieren API-Key/Modell (Round-trip-Test: vor/nach Export+Import sind Key/Modell unverändert, weil sie nicht im Export-JSON sind). +- `clearAll()` lässt Key/Modell unangetastet. + +--- + +## 16. Future Work (außerhalb v1) + +- **Multi-Image-Batch:** Mehrere Bilder gleichzeitig hochladen, Ergebnisse mergen. Erfordert UI-Änderung in Stage 1 (Mehrfachauswahl) und Stage 3 (Provenance-Marker pro Eintrag). +- **PDF-Import:** PDFs via `pdf.js` clientseitig in Bilder pro Seite konvertieren, danach pro Seite den bestehenden Flow durchlaufen. +- **Server-Side Proxy:** Hetzner-Backend, das den API-Key zentral hält und Requests gegen Rate-Limits puffert. Würde den Key aus dem Browser entfernen. Eigenständiges Architektur-Spec. +- **Automatischer Monatsrollover:** Wenn `entries` zwei Monate überspannen, automatisch in beide Monats-Buckets schreiben statt zu verwerfen. +- **Lokale Bild-Vorverarbeitung:** Kantenbasierte Tabellen-Erkennung im Browser (OpenCV.js) zur Reduktion der API-Tokens. Aktuell nicht nötig. +- **Modell-Auto-Retry:** Bei Parse-Fehler automatisch ein zweites Modell probieren. Aktuell muss der Benutzer manuell wechseln. +- **Caching der Erkennung:** Hash des Bildes → letzte Erkennung. Würde Wiederholungs-Erkennungen sparen. + +--- + +## 17. Open Questions + +Keine blockierenden Punkte. Minor, zur Klärung in der Implementierungsphase: + +- Soll der Datenschutz-Hinweis in Stage 1 prominenter sein (eigene checkbox `Ich verstehe, dass das Bild an OpenRouter gesendet wird`)? Vorschlag: nein, der Text genügt für v1. +- Soll der Tab-Wechsel während Stage 2 das Modal schließen? Vorschlag: nein, Modal blockiert den Hintergrund visuell, aber technisch bleibt es offen, bis Erkennung fertig ist oder Cancel. +- PWA-Cache-Version: bei Release dieses Features `dienstplan-pro-v1` inkrementieren, damit `image-import.js` und HTML-Änderungen ausgeliefert werden. diff --git a/docs/specs/2026-05-11-bonus-varianten-design.md b/docs/specs/2026-05-11-bonus-varianten-design.md new file mode 100644 index 0000000..cbd2e20 --- /dev/null +++ b/docs/specs/2026-05-11-bonus-varianten-design.md @@ -0,0 +1,617 @@ +# Design Spec: Bonus-Varianten (NRW Psychiatrie 2011) + Date-Stepper + +- **Datum:** 2026-05-11 +- **Status:** Draft +- **Autor:** Design-Phase, Dienstplan-Pro +- **Scope:** Feature "Bonus-Varianten" (V1/V2/V3 mit Auto-Selection und Urlaubsmodus) + UX-Add-on "Feature C: Date-Stepper" + +--- + +## 1. Ziel / Problemstellung + +Aktuell rechnet Dienstplan-Pro Bonuszahlungen nach einer einzigen, vereinfachten Regel (siehe `BonusCalculator.calculateMonthlyBonus` in `calculator.js`): 2 qualifizierende Tage als Schwelle, danach 2 Tage Abzug mit Freitag-Priorität. Diese Logik entspricht hier intern "Variante 3 loose". + +Die NRW-Psychiatrie-Vereinbarung von 2011 kennt jedoch **drei alternative Schwellenmodelle**. Welches Modell für einen Arzt/eine Ärztin in einem konkreten Monat den höheren Bonus ergibt, hängt von der Verteilung der Dienste über Wochentage, Wochenende und Feiertage ab. + +**Ziel:** Alle drei Varianten parallel berechnen und automatisch die für den Arzt **beste** auswählen. Zusätzlich: einen Urlaubsmodus einführen, der bei ≥14 Tagen Abwesenheit alle Schwellen und Abzüge halbiert. + +**Nicht-Ziel** dieses Specs: Verbreiterung der Tag-Slots, Backend-Migration, Multi-User-Support, andere Bundesländer. + +--- + +## 2. Out of Scope + +- Bildbasierte Diensteingabe (OCR/Foto-Import) – separates Feature +- Server-/Cloud-Synchronisierung – bleibt clientseitig (`localStorage`) +- Andere Bundesländer als NRW – `HolidayProvider` bleibt unverändert +- Konfigurierbare Raten/Schwellen via UI – Konstanten bleiben hartkodiert +- Migrationspfad für Bestandsdaten mit historischen, abweichenden Berechnungsergebnissen – die neue Berechnung ist die aktuelle Wahrheit; alte Ergebnisse werden nicht "nachgeführt" +- Mehr als ein "Urlaubsmodus"-Toggle pro Mitarbeiter pro Monat (keine Teilurlaube) + +--- + +## 3. Day Classification Rule (neu) + +Jeder Dienst wird anhand seines Kalenderdatums **genau einem** Slot zugeordnet: `fr`, `sa`, `so` oder `weekday`. + +### 3.1 Pseudo-Code + +``` +classify(date): + wd = date.getDay() // 0=So, 1=Mo, ..., 5=Fr, 6=Sa + + if wd === 5: return "fr" + if wd === 6: return "sa" + if wd === 0: return "so" + + // Mo-Do (wd 1..4) + isFeiertag = HolidayProvider.isHoliday(date) + isTagVorFeiertag = HolidayProvider.isDayBeforeHoliday(date) + + if isFeiertag && isTagVorFeiertag: return "sa" // Sandwich-Tag wie Samstag + if isTagVorFeiertag: return "fr" // wie Freitag (Tag vor Feiertag) + if isFeiertag: return "so" // wie Sonntag (Feiertag selbst) + return "weekday" +``` + +**Wichtig:** Die echten Wochentage Fr/Sa/So gewinnen **immer**, unabhängig von Feiertagsstatus. Ein Feiertag, der auf einen Samstag fällt, bleibt `sa`. Ein Feiertag, der auf einen Freitag fällt, bleibt `fr` (und ein hypothetischer Donnerstag-Feiertag davor wird zum Sandwich-`sa`, siehe Tabelle). + +### 3.2 Beispiele + +| Datum | Wochentag | Feiertag? | Tag-vor-Feiertag? | Slot | Begründung | +|---|---|---|---|---|---| +| Karfreitag (z. B. 2025-04-18) | Fr | ja | nein | `fr` | Fr gewinnt immer | +| Ostermontag (z. B. 2025-04-21) | Mo | ja | nein | `so` | Feiertag Mo-Do → wie Sonntag | +| Christi Himmelfahrt (z. B. 2025-05-29) | Do | ja | nein (Fr kein Feiertag) | `so` | Feiertag Mo-Do, kein Sandwich → wie Sonntag | +| Mi vor Christi Himmelfahrt (z. B. 2025-05-28) | Mi | nein | ja | `fr` | Tag vor Feiertag Mo-Do → wie Freitag | +| Tag der Dt. Einheit 2025 (2025-10-03) | Fr | ja | nein | `fr` | Fr gewinnt immer | +| Hypothetisch: Do Feiertag + Fr Feiertag | Do | ja | ja | `sa` | Sandwich-Tag → wie Samstag | +| Hypothetisch: Mo Feiertag + Di Feiertag | Mo | ja | ja | `sa` | Sandwich-Tag → wie Samstag | +| Hypothetisch: Mo Feiertag + Di Feiertag | Di | ja | nein | `so` | Folge-Feiertag → wie Sonntag | + +### 3.3 Aggregations-Output + +Nach Klassifikation aller Dienste eines Monats entsteht ein In-Memory-Objekt mit kumulierten **Shares** (Halbdienste zählen 0.5): + +```javascript +{ fr: 2.0, sa: 1.0, so: 1.5, weekday: 3.0 } +``` + +Dieses Objekt heißt im Folgenden **`classified`**. + +--- + +## 4. Variant Definitions + +Alle drei Varianten nehmen denselben `classified`-Input und liefern dasselbe Result-Shape (siehe Abschnitt 7). Eingangsgrößen: + +- `classified = { fr, sa, so, weekday }` (Shares, Float) +- `isVacation: boolean` – aus Urlaubsmodus + +**Konstanten** (bleiben aus `BonusCalculator`): +- `RATE_NORMAL = 250` (für `weekday`) +- `RATE_WEEKEND = 450` (für `fr`, `sa`, `so`) + +### 4.1 Variante 1 (V1) – "1 (Fr/So) + 3 Weekday" + +- **Schwelle:** `fr + so >= 1` UND `weekday >= 3` +- **Abzug bei Erfüllung:** + - vom `fr+so`-Pool: 1 (Friday-Priority: zuerst `fr`, dann `so`) + - von `weekday`: 3 + - `sa` wird **nicht** abgezogen +- **Bezahlte Shares:** + - `fr_paid = fr - fr_deduction` + - `so_paid = so - so_deduction` + - `sa_paid = sa` (immer voll bezahlt) + - `weekday_paid = weekday - 3` +- **Bonus:** + - `(fr_paid + so_paid + sa_paid) * 450 + weekday_paid * 250` + +**Beispiel V1:** classified = `{ fr: 2, sa: 1, so: 0, weekday: 4 }`, `isVacation=false`. +- Schwelle: `2 + 0 = 2 >= 1` ✓ und `4 >= 3` ✓ → eligible +- Abzug: 1 vom `fr` (Fr-Prio), 3 von `weekday` +- Paid: `fr=1, sa=1, so=0, weekday=1` → `(1+1+0)*450 + 1*250 = 900 + 250 = 1150 €` + +### 4.2 Variante 2 (V2) – "1 Sa + 2 Weekday" + +- **Schwelle:** `sa >= 1` UND `weekday >= 2` +- **Abzug bei Erfüllung:** + - von `sa`: 1 + - von `weekday`: 2 + - `fr` und `so` werden **nicht** abgezogen +- **Bezahlte Shares:** + - `sa_paid = sa - 1` + - `weekday_paid = weekday - 2` + - `fr_paid = fr` + - `so_paid = so` +- **Bonus:** + - `(fr_paid + sa_paid + so_paid) * 450 + weekday_paid * 250` + +**Beispiel V2:** classified = `{ fr: 1, sa: 2, so: 0, weekday: 3 }`, `isVacation=false`. +- Schwelle: `2 >= 1` ✓ und `3 >= 2` ✓ → eligible +- Abzug: 1 von `sa`, 2 von `weekday` +- Paid: `fr=1, sa=1, so=0, weekday=1` → `2*450 + 1*250 = 900 + 250 = 1150 €` + +### 4.3 Variante 3 (V3 loose) – "2 qualifying Days (Pool)" + +Dies entspricht der **aktuell implementierten Logik** in `BonusCalculator`. + +- **Schwelle:** `fr + sa + so >= 2` +- **Abzug bei Erfüllung:** + - aus dem Pool `fr + sa + so`: insgesamt 2, mit Priorität **`fr` → `so` → `sa`** + - `weekday` wird **nicht** abgezogen +- **Bezahlte Shares:** + - aus dem qualifying-Pool: jeweils Rest nach Abzug + - `weekday_paid = weekday` (immer voll) +- **Bonus:** + - `(fr_paid + sa_paid + so_paid) * 450 + weekday_paid * 250` + +**Beispiel V3:** classified = `{ fr: 0, sa: 2, so: 0, weekday: 0 }`, `isVacation=false`. +- Schwelle: `0 + 2 + 0 = 2 >= 2` ✓ → eligible +- Abzug: `fr` leer → `so` leer → 2 von `sa` +- Paid: alle 0 → Bonus `0 €` + +**Beispiel V3 mit Fr-Prio:** classified = `{ fr: 2, sa: 1, so: 1, weekday: 0 }`, `isVacation=false`. +- Schwelle: `4 >= 2` ✓ → eligible +- Abzug: 2 von `fr` (Fr-Prio erschöpft) +- Paid: `fr=0, sa=1, so=1, weekday=0` → `2*450 = 900 €` + +### 4.4 Friday-Priority – formale Regel + +Innerhalb eines Abzugspools wird in dieser Reihenfolge entleert, bis die Abzugsmenge erreicht ist: + +| Variante | Pool | Reihenfolge | +|---|---|---| +| V1 | `fr + so` | `fr` → `so` | +| V2 | `sa` | (nur `sa`, keine Wahl) | +| V3 | `fr + sa + so` | `fr` → `so` → `sa` | + +Algorithmus (generisch): + +``` +function deductFromPool(amounts, order, total): + remaining = total + result = { ...amounts } // shallow copy + for slot in order: + take = min(remaining, result[slot]) + result[slot] -= take + remaining -= take + if remaining <= 0: break + return result // paid shares per slot +``` + +**Hinweis:** Wegen der Eligibility-Checks (siehe oben) ist `remaining` am Ende stets `0`; sollte ein Floating-Point-Rest verbleiben (z. B. `1e-12`), wird dieser ignoriert. Für UI-Anzeige wird auf 2 Nachkommastellen gerundet. + +### 4.5 No-Bonus-Case + +Wenn eine Variante ihre Schwelle nicht erreicht: + +```javascript +{ + variantId: , + eligible: false, + threshold: null, + deduction: null, + paidShares: { fr: 0, sa: 0, so: 0, weekday: 0 }, + bonus: 0, + isWinner: false // wird ggf. später noch true gesetzt (siehe 5) +} +``` + +Wenn **keine** Variante triggert, ist `totalBonus = 0 €` (wie heute). + +--- + +## 5. Variant Selection & Tie-Breaker + +``` +function pickWinner(results): + // results = [r1, r2, r3] (immer 3 Einträge, auch nicht-eligible) + let winner = results[0] + for r in results[1..]: + if r.bonus > winner.bonus: winner = r + // Tie-Breaker: niedrigere variantId gewinnt → kein Update bei gleichem Bonus + winner.isWinner = true + return { winner, allResults: results, totalBonus: winner.bonus } +``` + +- Sieger = Variante mit dem höchsten `bonus`. +- **Tie-Breaker:** Bei Gleichstand gewinnt die niedrigere `variantId` (V1 < V2 < V3). +- Wenn alle drei `bonus === 0`: V1 ist nominell Winner (`isWinner=true` auf V1), aber `totalBonus = 0 €` und die UI zeigt "Keine Variante triggert". + +--- + +## 6. Vacation Mode ("Urlaubsmodus") + +### 6.1 Trigger + +- Pro Mitarbeiter pro Monat: ein Boolean-Flag. +- UI-Label: **"Urlaub gehabt (≥14 Tage frei)"** + - Fachliche Begründung: 10 Werktage Urlaub + zwei Wochenenden ≈ 14 Kalendertage Abwesenheit. + +### 6.2 Effekt + +**Alle** Schwellen **und** Abzüge der drei Varianten werden halbiert. Halbe Werte sind explizit erlaubt: + +| | Normal | Urlaubsmodus | +|---|---|---| +| V1-Schwelle | `fr+so >= 1` ∧ `weekday >= 3` | `fr+so >= 0.5` ∧ `weekday >= 1.5` | +| V1-Abzug | 1 von `fr+so`, 3 von `weekday` | 0.5 von `fr+so`, 1.5 von `weekday` | +| V2-Schwelle | `sa >= 1` ∧ `weekday >= 2` | `sa >= 0.5` ∧ `weekday >= 1` | +| V2-Abzug | 1 von `sa`, 2 von `weekday` | 0.5 von `sa`, 1 von `weekday` | +| V3-Schwelle | `fr+sa+so >= 2` | `fr+sa+so >= 1` | +| V3-Abzug | 2 aus Pool | 1 aus Pool | + +Raten bleiben unverändert (250 / 450). + +### 6.3 Persistenz + +Neuer `localStorage`-Key: **`dienstplan_vacation`**. + +```javascript +{ + "Max Mustermann": { + "2025-11": true, + "2025-12": false + }, + "Anna Schmidt": { + "2025-11": true + } +} +``` + +- Fehlender Eintrag → `false`. +- Toggle in der UI schreibt sofort durch (kein "Speichern"-Button). + +--- + +## 7. Data Model + +### 7.1 In-Memory während Berechnung + +```javascript +// Klassifizierte Shares pro Slot +const classified = { fr: 2.0, sa: 1.0, so: 1.5, weekday: 3.0 }; + +// Result einer Variante +const variantResult = { + variantId: 1, // 1 | 2 | 3 + eligible: true, // Schwelle erfüllt? + threshold: { frSo: 1, weekday: 3 } /* o. ä. */ , // halbiert wenn Urlaub + deduction: { fr: 1, so: 0, sa: 0, weekday: 3 }, // tatsächlich abgezogen + paidShares: { fr: 1.0, sa: 1.0, so: 1.5, weekday: 0 }, // nach Abzug + bonus: 1825, // 0 wenn not eligible + isWinner: true +}; + +// Gesamt-Output von BonusCalculator +const finalResult = { + winner: variantResult, // Referenz auf das gewinnende variantResult + allResults: [v1Result, v2Result, v3Result], + totalBonus: 1825, + // Plus die Felder, die die UI/Reports heute schon erwarten: + classified, + isVacation: false, + dutyDetails: [/* unverändert wie heute */] +}; +``` + +**Threshold-Shape pro Variante** (für `threshold`-Feld): + +| Variante | Shape | +|---|---| +| V1 | `{ frSo: 1, weekday: 3 }` (im Urlaub `0.5` / `1.5`) | +| V2 | `{ sa: 1, weekday: 2 }` (im Urlaub `0.5` / `1` ) | +| V3 | `{ pool: 2 }` (im Urlaub `1`) | + +**Deduction-Shape pro Variante** (immer 4 Felder, nicht genutzte = 0): + +```javascript +{ fr: , sa: , so: , weekday: } +``` + +### 7.2 Persistenz + +| Key | Verwendung | Status | +|---|---|---| +| `dienstplan_employees` | Mitarbeiterliste | **unverändert** | +| `dienstplan_duties` | Dienste pro MA pro Monat | **unverändert** | +| `dienstplan_vacation` | Urlaubsflag pro MA pro Monat | **NEU** | + +Kein Migrationsschritt nötig – beim ersten Lesen liefert ein fehlender Key `{}`/`false`. + +--- + +## 8. Architecture & File Changes + +### 8.1 Übersicht + +| Datei | Änderung | +|---|---| +| `calculator.js` | Refactor: `BonusCalculator` bleibt als öffentliche API, ruft intern `variants.js` auf und wählt Sieger | +| `variants.js` | **NEU** – enthält `classifyDuties(duties, holidayProvider)` und `variant1/2/3(classified, isVacation)` | +| `storage.js` | **+** `setVacationMode(name, yearMonth, bool)` und `getVacationMode(name, yearMonth)`, neuer Key `dienstplan_vacation` | +| `app.js` | UI-Logik: Urlaubs-Checkbox pro MA, Result-Card mit Sieger + `
` für alle Varianten, Date-Stepper | +| `index.html` | Markup-Ergänzungen + Script-Reihenfolge | +| `styles.css` | Variant-Badges, Stepper-Buttons, `
` | +| `test-suite.js` | Neue Test-Kategorien (siehe Abschnitt 11) | + +### 8.2 Script-Load-Reihenfolge in `index.html` + +``` +holidays.js → variants.js → calculator.js → storage.js → app.js +``` + +`variants.js` **muss vor** `calculator.js` geladen werden, da `BonusCalculator` die Variant-Funktionen aufruft. + +### 8.3 `variants.js` – Public API + +```javascript +// classifyDuties: gruppiert Dienste in Slots, summiert Shares +// duties = [{ date: Date, share: number }, ...] +// holidayProvider = instance von HolidayProvider +// returns { fr, sa, so, weekday } +function classifyDuties(duties, holidayProvider) { ... } + +// variant1/2/3: berechnen eine Variante +// classified = { fr, sa, so, weekday } +// isVacation = boolean +// returns variantResult (siehe 7.1) +function variant1(classified, isVacation) { ... } +function variant2(classified, isVacation) { ... } +function variant3(classified, isVacation) { ... } + +window.classifyDuties = classifyDuties; +window.variant1 = variant1; +window.variant2 = variant2; +window.variant3 = variant3; +``` + +### 8.4 `calculator.js` – neue interne Struktur + +```javascript +class BonusCalculator { + constructor(holidayProvider) { + this.holidayProvider = holidayProvider; + this.RATE_NORMAL = 250; + this.RATE_WEEKEND = 450; + } + + calculateMonthlyBonus(duties, isVacation = false) { + if (!duties || duties.length === 0) return this.getEmptyResult(); + + const classified = classifyDuties(duties, this.holidayProvider); + const v1 = variant1(classified, isVacation); + const v2 = variant2(classified, isVacation); + const v3 = variant3(classified, isVacation); + + const results = [v1, v2, v3]; + let winner = results[0]; + for (let i = 1; i < results.length; i++) { + if (results[i].bonus > winner.bonus) winner = results[i]; + } + winner.isWinner = true; + + return { + classified, + isVacation, + winner, + allResults: results, + totalBonus: winner.bonus, + dutyDetails: this.buildDutyDetails(duties) // wie bisher + }; + } + + // calculateAllEmployees: zusätzlicher Parameter vacationMap : { [name]: boolean } + calculateAllEmployees(employeeDuties, vacationMap = {}) { ... } + + // Helfer wie getDayTypeLabel, formatCurrency, getEmptyResult bleiben +} +``` + +**Bestehende Felder im Result, die durch den Umbau wegfallen** (heute: `qualifyingDaysFriday`, `qualifyingDaysOther`, `thresholdReached`, `bonusNormalDays` etc.): Die UI muss auf das neue Shape (`winner.*`, `allResults`) umgestellt werden. Da `app.js` ohnehin angefasst wird, ist das Teil dieses PRs und es entsteht **kein** Parallelpfad. + +### 8.5 `storage.js` – Erweiterung + +```javascript +class DataStorage { + constructor() { + this.STORAGE_KEY_EMPLOYEES = 'dienstplan_employees'; + this.STORAGE_KEY_DUTIES = 'dienstplan_duties'; + this.STORAGE_KEY_VACATION = 'dienstplan_vacation'; // NEU + } + + // ---- bestehende Methoden unverändert ---- + + getVacationMode(employeeName, yearMonth) { + try { + const raw = localStorage.getItem(this.STORAGE_KEY_VACATION); + if (!raw) return false; + const map = JSON.parse(raw); + return Boolean(map?.[employeeName]?.[yearMonth]); + } catch (e) { + console.error('Fehler beim Laden des Urlaubsmodus:', e); + return false; + } + } + + setVacationMode(employeeName, yearMonth, value) { + try { + const raw = localStorage.getItem(this.STORAGE_KEY_VACATION); + const map = raw ? JSON.parse(raw) : {}; + if (!map[employeeName]) map[employeeName] = {}; + map[employeeName][yearMonth] = Boolean(value); + localStorage.setItem(this.STORAGE_KEY_VACATION, JSON.stringify(map)); + } catch (e) { + console.error('Fehler beim Speichern des Urlaubsmodus:', e); + throw e; + } + } + + // exportData / importData: dienstplan_vacation in JSON aufnehmen. + // clearAll: dienstplan_vacation ebenfalls entfernen. +} +``` + +`exportData()` und `importData()` werden um den Vacation-Key erweitert. `clearAll()` löscht ihn mit. Fehlt der Key im Import-JSON: kein Fehler, Standard ist `false`. + +--- + +## 9. UI Changes + +### 9.1 Tab "Berechnung" + +- Pro Mitarbeiter eine **Urlaubs-Checkbox** direkt neben dem Namen: + + ```html + + ``` + + Bei Toggle: sofort `storage.setVacationMode(name, ym, checked)` aufrufen und Berechnung neu rendern. + +- **Result-Card pro Mitarbeiter:** + - Prominenter Header: **"Variante {1|2|3} → {bonus} €"** mit Stern-Badge ⭐ + - Klappbares `
`-Element mit Label **"Alle Varianten anzeigen"**: + - Pro Variante eine Zeile / kleines Sub-Panel mit: + - Variantennummer und Kurzbeschreibung + - Schwelle (Soll-Wert, halbiert bei Urlaub) + - Eligibility-Check (✓ / ✗) + - Abzug pro Slot + - Paid Shares pro Slot + - Berechneter Bonus + - ⭐ neben dem Sieger + - Wenn `isVacation === true`: dezenter Hinweisbadge "Urlaubsmodus aktiv – Schwellen halbiert". + +### 9.2 Tab "Einstellungen" + +Info-Box mit den Berechnungsregeln aktualisieren: + +- Beschreibung aller drei Varianten (V1/V2/V3) inkl. Schwellen und Abzügen +- Hinweis auf Auto-Selection ("die Variante mit dem höchsten Bonus gewinnt; bei Gleichstand gewinnt die niedrigste Variante") +- Erklärung Urlaubsmodus (Auslöser ≥14 Tage frei, Effekt Halbierung) +- Tabelle der Day-Classification-Regeln (mind. die 5 echten Beispiele aus 3.2) + +### 9.3 Toast-Verhalten + +Beim Toggle der Urlaubs-Checkbox: kein Toast (zu häufig). Bei Fehler beim Schreiben: `app.showToast('Urlaubsmodus konnte nicht gespeichert werden', 'error')`. + +--- + +## 10. Feature C – Date-Stepper (UX-Add-on) + +### 10.1 Ziel + +Schnelleres Eintragen aufeinanderfolgender Dienste im Tab **"Dienste eintragen"** ohne den nativen Datepicker zu öffnen. + +### 10.2 Verhalten + +- Zwei Buttons **`‹`** und **`›`** direkt neben dem Datums-Input. +- `‹` → setzt Datum auf vorherigen Tag. +- `›` → setzt Datum auf nächsten Tag. +- **Clamp:** Datum darf den **aktuell ausgewählten Monat** (aus dem Monatsauswahl-Dropdown des Tabs) nicht verlassen. + - Ist das Datum bereits der 1. des Monats: `‹` ist disabled. + - Ist das Datum bereits der letzte Tag des Monats: `›` ist disabled. +- Initialer State der Buttons wird beim Tab-Wechsel und beim Monatswechsel aktualisiert. + +### 10.3 Implementation Notes + +- Reine `app.js`-Änderung; keine Anpassung in `calculator.js` oder `storage.js`. +- Berechnung des letzten Tages des Monats: `new Date(year, month, 0).getDate()` (mit `month` 1-basiert). +- Datumsobjekt analog zur bestehenden Konvention mit `T12:00:00` setzen, um Timezone-Edge-Cases zu vermeiden. + +--- + +## 11. Backwards Compatibility + +- **Storage-Keys:** `dienstplan_employees` und `dienstplan_duties` bleiben in Shape und Inhalt **unverändert**. Nur `dienstplan_vacation` kommt hinzu (keine Migration nötig). +- **Berechnungslogik:** Die alte Single-Path-Logik wird **ersetzt**, nicht parallel geführt. V3 loose entspricht funktional der bisherigen Berechnung, daher bleiben für die überwiegende Mehrheit der historischen Eingaben die Ergebnisse identisch (V3 ist in diesen Fällen der Winner). +- **Mögliche Unterschiede zu vorher:** Wenn V1 oder V2 in einem historischen Monat einen höheren Bonus liefern würden als V3, wird ab sofort dieser höhere Bonus angezeigt. Das ist gewünscht. +- **Export/Import:** Alte Backup-JSONs ohne `dienstplan_vacation` werden weiter akzeptiert; der Modus startet dann auf `false`. +- **PWA-Cache:** `sw.js` muss bei Release die Cache-Version inkrementieren, damit `variants.js` und die neuen Assets ausgeliefert werden. + +--- + +## 12. Test Plan (nur Kategorien, keine Implementierung) + +Neue Tests in `test-suite.js`. Keine Code-Implementierung in diesem Spec. + +### 12.1 `classifyDuties` / `classify(date)` + +Abdeckung aller 7 Fälle aus Abschnitt 3.2: + +1. Fr-Feiertag → `fr` +2. Mo-Feiertag (Ostermontag) → `so` +3. Do-Feiertag ohne Fr-Feiertag → `so` +4. Mi vor Do-Feiertag → `fr` +5. Tag der Deutschen Einheit 2025 (Fr) → `fr` +6. Hypothetisch: Do = Feiertag UND Fr = Feiertag → Do = `sa` (Sandwich), Fr = `fr` +7. Hypothetisch: Mo = Feiertag UND Di = Feiertag → Mo = `sa`, Di = `so` + +Plus: +- Halbschicht (0.5) auf einen `fr` zählt korrekt mit `+0.5` im Slot. +- Mehrere Dienste pro Slot summieren. +- Leeres Duty-Array → `{ fr:0, sa:0, so:0, weekday:0 }`. + +### 12.2 `variant1` + +- Eligible / nicht eligible (Schwellen jeweils gerade über/unter Grenze). +- Mit und ohne Urlaubsmodus (Halbierung). +- Friday-Priority im `fr+so`-Pool (zuerst `fr`, dann `so`). +- Edge: nur `fr` vorhanden, ausreichend, `weekday=3` → triggert. +- Edge: nur `so` vorhanden, `weekday=3` → triggert (1 von `so` abgezogen). + +### 12.3 `variant2` + +- Eligible / nicht eligible. +- Mit und ohne Urlaubsmodus. +- Edge: `sa=1, weekday=2` → triggert, alles wird abgezogen, Bonus = 0. +- Edge: `sa=2, weekday=2, fr=1, so=1` → triggert, `fr`/`so` voll bezahlt. + +### 12.4 `variant3` (loose) + +- Loose Trigger: `sa=2` allein reicht. +- Friday-Priority im Pool (`fr` zuerst, dann `so`, dann `sa`). +- Mit und ohne Urlaubsmodus (Schwelle 1 statt 2). +- Verhalten identisch zu heutigem `BonusCalculator` für eine Stichprobe historischer Inputs. + +### 12.5 Winner Selection + +- Klarer Winner V1 (z. B. classified begünstigt `weekday`-haltige Variante). +- Klarer Winner V2. +- Klarer Winner V3. +- Tie V1=V2: V1 gewinnt. +- Tie V2=V3: V2 gewinnt. +- Tie V1=V2=V3 (alle 0 €): V1 ist nominell Winner, `totalBonus=0`. +- Eine Variante eligible, die anderen nicht → eligible gewinnt unabhängig vom Bonus-Wert (da nicht-eligible Bonus = 0). + +### 12.6 Vacation Mode (kombiniert) + +- V1 mit Urlaubsmodus: Schwelle `fr+so>=0.5`, `weekday>=1.5`, Abzüge halbiert. +- V3 mit Urlaubsmodus: `fr+sa+so>=1` triggert bereits mit einer Halbschicht auf `sa`. +- Toggle in Storage: `setVacationMode` → `getVacationMode` round-trip. +- Storage: fehlender Key → `false`; ungültiges JSON → `false` (kein Throw nach außen). + +### 12.7 Bestehende Tests + +- Tests, die heute "Variante 3"-Verhalten prüfen, sollten überwiegend grün bleiben, weil V3 loose = aktuelle Logik. +- Anzupassen: alle Tests, die auf Felder wie `qualifyingDaysFriday`/`thresholdReached`/`bonusNormalDays` zugreifen – diese sind im neuen Result-Shape nicht mehr Top-Level, sondern unter `winner.deduction` / `winner.paidShares` / `winner.eligible`. + +### 12.8 Feature C – Date-Stepper + +- `‹` am Monatsanfang ist disabled, ändert das Datum nicht. +- `›` am Monatsende ist disabled, ändert das Datum nicht. +- `‹` / `›` in der Monatsmitte ändern um genau ±1 Tag. +- Monatswechsel im Dropdown setzt Datum auf 1. des neuen Monats und aktualisiert Buttons-State. + +--- + +## 13. Open Questions + +Keine blockierenden offenen Punkte. Minor, zur Klärung in der Implementierungsphase: + +- Soll der Urlaubsmodus-Status in der CSV/HTML-Exportausgabe sichtbar vermerkt werden? (Vorschlag: ja, als Zusatzspalte / Hinweis im Header.) +- Soll im Tab "Berechnung" eine Gesamt-Summe aller Mitarbeiter über alle Sieger-Varianten weiterhin angezeigt werden? (Vorschlag: ja, wie heute.) +- PWA-Cache-Version-Bump: separate Mini-Task im selben PR (Bumping `dienstplan-pro-v1` → `v2`).