From 262be954bbd3b0786ea3f5e097442322c2acf408 Mon Sep 17 00:00:00 2001 From: Kenearos Date: Tue, 12 May 2026 19:36:22 +0200 Subject: [PATCH] chore: remove implementation plans for shipped features The bild-import and bonus-varianten plans were step-by-step build guides consumed during implementation. Both features are merged and live. Design specs in docs/specs/ stay for reference. --- docs/plans/2026-05-11-bild-import-plan.md | 2874 ----------------- docs/plans/2026-05-11-bonus-varianten-plan.md | 2599 --------------- 2 files changed, 5473 deletions(-) delete mode 100644 docs/plans/2026-05-11-bild-import-plan.md delete mode 100644 docs/plans/2026-05-11-bonus-varianten-plan.md diff --git a/docs/plans/2026-05-11-bild-import-plan.md b/docs/plans/2026-05-11-bild-import-plan.md deleted file mode 100644 index 8f93e8b..0000000 --- a/docs/plans/2026-05-11-bild-import-plan.md +++ /dev/null @@ -1,2874 +0,0 @@ -# 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 deleted file mode 100644 index b6efef2..0000000 --- a/docs/plans/2026-05-11-bonus-varianten-plan.md +++ /dev/null @@ -1,2599 +0,0 @@ -# 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.