# Feature A: Bild → Dienste Import
- **Datum:** 2026-05-11
- **Status:** Draft
- **Autor:** Design-Phase, Dienstplan-Pro
- **Scope:** Optionaler Bulk-Import von Diensten aus einem Foto/Screenshot einer Dienstplan-Tabelle via OpenRouter Vision-LLM.
---
## 1. Ziel / Problemstellung
Aktuell werden Dienste in Dienstplan-Pro im Tab **"Dienste eintragen"** ausschließlich manuell – ein Dienst pro Klick – erfasst. Für eine Assistenzärztin mit 6–10 Diensten pro Monat ist das knapp 30 Klicks pro Person pro Monat. In der Praxis bekommen Assistenzärzte ihren Dienstplan typischerweise als PDF, Foto oder Screenshot einer Tabelle. Die manuelle Übernahme ist fehleranfällig und reibungsbehaftet.
**Ziel:** Ein optionaler Import-Pfad, bei dem der Benutzer ein einzelnes Bild (Foto vom Aushang, Screenshot vom Plan, exportierter Tabellen-Snip) hochlädt. Ein OpenRouter Vision-LLM extrahiert die Einträge als strukturiertes JSON, der Benutzer prüft das Ergebnis in einer Vorschau und bestätigt. Die bestehende Persistenz über `DataStorage` bleibt unangetastet.
**Pflichtmerkmale:**
- 100 % browserseitig, kein Backend. Der OpenRouter-API-Key wird vom Benutzer selbst in `localStorage` gehalten.
- Der Import ist **rein additiv**: bestehende manuelle Eingabe wird nicht ersetzt.
- Robuste Namensauflösung gegen den existierenden `dienstplan_employees`-Bestand (exakt, normalisiert, fuzzy).
- Konfliktverhalten mit existierenden Diensten ist deterministisch (Replace, siehe Abschnitt 11).
---
## 2. Out of Scope
- **Feature B (Bonus-Varianten):** siehe `2026-05-11-bonus-varianten-design.md`. Beide Features sind unabhängig und teilen sich keinen Code.
- **Kein Backend, kein Server-Proxy.** Alle API-Calls gehen direkt aus dem Browser an OpenRouter. Eine zukünftige Hetzner-Proxy-Variante ist denkbar, aber nicht Teil dieses Specs (siehe Abschnitt 16).
- **Kein Multi-Image-Batch.** Genau ein Bild pro Importvorgang. Mehrseitige Pläne werden seitenweise importiert. (Siehe Abschnitt 16, Future Work.)
- **Kein PDF-Import.** Nur Bildformate (PNG, JPG, JPEG, WebP). PDF-Konvertierung ist Future Work.
- **Keine OCR-Heuristik im Browser.** Die Erkennung läuft vollständig über das LLM; es gibt keine Fallback-Tesseract-Schiene.
- **Keine automatische Monatsrollover-Logik** bei Einträgen, die zwei Monate überspannen. Falls erkannt, wird gewarnt; der Import nutzt den im Tab gewählten Monat als Ziel (siehe Abschnitt 9.4).
- **Keine Speicherung des Bildes** über die Importsession hinaus. Das Bild lebt nur im RAM des Modals.
---
## 3. User Flow (4 Stages)
Der Import läuft als modaler Dialog mit vier Stufen. Der Modal-Lifecycle ist:
1. Benutzer ist im Tab **"Dienste eintragen"**, hat Monat und Jahr eingestellt.
2. Benutzer klickt **`📷 Bild importieren`** oben rechts in der Card.
3. Falls noch kein OpenRouter-API-Key in `localStorage`: `prompt()` mit Erklärtext (siehe Abschnitt 6.1). Bei leerer/abgebrochener Eingabe: Modal öffnet **nicht**, Toast `Kein API-Key gespeichert – Import abgebrochen`.
4. Falls Key vorhanden: Modal öffnet auf Stage 1.
### 3.1 Stage 1 — Upload
Was der Benutzer sieht:
- Drag & Drop-Zone mit Hinweistext `Bild hier ablegen oder Datei auswählen`.
- Button **`Datei auswählen`** → öffnet nativen File Picker, akzeptiert `image/png, image/jpeg, image/webp`.
- Auf Mobilgeräten zusätzlich Button **`Mit Kamera aufnehmen`** → ``.
- Datenschutz-Hinweis (klein, grau): `Das Bild wird zur Erkennung an OpenRouter gesendet.`
- Bei einem ausgewählten Bild: Thumbnail-Vorschau (max. 240 px Kantenlänge), Dateiname, Größe in KB.
- Buttons unten: **`Abbrechen`** (schließt Modal), **`Erkennen`** (deaktiviert solange kein Bild ausgewählt; aktiv sobald Bild da ist → führt zu Stage 2).
Validierung in Stage 1:
- MIME muss mit `image/` beginnen, sonst Toast `Nur Bildformate werden unterstützt`.
- Dateigröße > 20 MB: Toast `Bild zu groß (max. 20 MB)`. (Praxisrelevant, da viele Handyfotos > 5 MB.)
### 3.2 Stage 2 — Processing
Was der Benutzer sieht:
- Großer Spinner, Text `Analysiere Bild...`.
- Untertext (klein): `Das kann 5–15 Sekunden dauern.`
- Optionaler Button **`Abbrechen`** → bricht den `fetch` via `AbortController` ab, schließt Modal.
Was im Hintergrund passiert:
1. Bild-Preprocessing (Abschnitt 12): Resize auf ≤ 2048 px Längste Kante, Re-Encode JPEG q=0.85, Base64-Kodierung.
2. POST an OpenRouter (Abschnitt 7).
3. Response-Parsing (Abschnitt 8) und Validierung.
4. Namensauflösung (Abschnitt 10) gegen `storage.getEmployees()`.
5. Stage-Wechsel zu Stage 3 (Erfolg) oder Toast + Modal-Schließung (Fehler, Abschnitt 13).
### 3.3 Stage 3 — Preview & Confirm
Was der Benutzer sieht:
**Block A — "Unbekannte Namen"** (nur sichtbar, wenn es welche gibt):
Eine Box am oberen Rand mit dem Titel `Unbekannte Namen`. Pro unbekanntem Namen eine Zeile mit:
- Erkannter Name (fett, links).
- Dropdown rechts mit den Optionen:
- `Neuer Mitarbeiter anlegen` (Default)
- `Zuordnen zu []`
- `Zuordnen zu []`
- … (eine Option pro existierendem Mitarbeiter, alphabetisch sortiert)
- `Ignorieren`
- Wenn ein Fuzzy-Match (Levenshtein ≤ 2) existiert, ist die entsprechende `Zuordnen zu …`-Option vorausgewählt **und** ein dezenter Hinweis `möglicher Match: X` daneben angezeigt.
**Block B — Tabelle der Importeinträge**, gruppiert nach Mitarbeiter (nach Anwendung der Dropdown-Auswahl aus Block A). Pro Mitarbeiter eine Sub-Tabelle:
| Datum | Wochentag | Slot | Anteil | Aktion |
|---|---|---|---|---|
| 2025-11-22 | Sa | `sa` | 1.0 | 🗑️ |
| 2025-11-28 | Fr | `fr` | 0.5 | 🗑️ |
- **Slot** wird nach Feature B's `classify(date)`-Regel (`fr`/`sa`/`so`/`weekday`) berechnet. Falls Feature B noch nicht implementiert ist: Fallback auf `getDay()`-basierte Mapping ohne Feiertagsberücksichtigung (siehe Abschnitt 5.4).
- **Aktion** 🗑️ entfernt diese eine Zeile aus dem Import-Set (nur lokal im Modal, nicht persistent).
- Mitarbeiter mit Status `Ignorieren` werden in Block B nicht gerendert.
**Block C — Buttons:**
- **`Abbrechen`** → Modal schließt, nichts wird gespeichert.
- **`Bestätigen & Importieren`** → führt aus:
1. Für jeden mit `Neuer Mitarbeiter anlegen` markierten Namen: `storage.addEmployee(name)`.
2. Für jeden verbleibenden Eintrag: `storage.addDuty(employeeName, year, month, date, share)` (Signatur siehe Abschnitt 11).
3. Stage-Wechsel zu Stage 4.
### 3.4 Stage 4 — Done
Was der Benutzer sieht:
- Toast (3 s, type `success`): `X Dienste für Y Mitarbeiter importiert`.
- Modal schließt sich automatisch.
- `loadDutiesForSelectedEmployee()` und ggf. `loadEmployeeSelects()` werden aufgerufen, sodass die Tab-Anzeige aktualisiert wird.
---
## 4. Architecture & File Layout
### 4.1 Übersicht
| Datei | Änderung |
|---|---|
| `image-import.js` | **NEU** – enthält `class ImageImporter` und eine kleine Levenshtein-Implementierung. |
| `index.html` | Markup-Ergänzungen: Button im Duties-Tab, Settings-Sektion, Modal-Skelett. Script-Tag für `image-import.js` **nach** `app.js`. |
| `app.js` | Verdrahtung: Klick-Handler für `📷 Bild importieren`, Settings-Sektion-Handler. Kein Berechnungs-Code. |
| `storage.js` | **+** `setApiKey`, `getApiKey`, `clearApiKey`, `setApiModel`, `getApiModel`. Neue Storage-Keys siehe Abschnitt 11. |
| `styles.css` | Modal-Layout, Drag-&-Drop-Zone, Stage-Übergänge, Unbekannte-Namen-Box. |
| `test-suite.js` | Neue Test-Kategorien (siehe Abschnitt 15). |
### 4.2 Script-Load-Reihenfolge in `index.html`
```
holidays.js → calculator.js → storage.js → app.js → image-import.js
```
`image-import.js` wird **nach** `app.js` geladen, damit `window.app` (Instanz von `DienstplanApp`) bereits existiert und für `app.showToast(...)`, `app.loadDutiesForSelectedEmployee()` und Zugriff auf `app.holidayProvider` verfügbar ist.
Initialisierung am Ende von `image-import.js`:
```javascript
window.imageImporter = new ImageImporter(window.app);
```
`ImageImporter` hält intern eine Referenz auf die `DienstplanApp`-Instanz (für Storage, Toast, Refresh) und auf den `HolidayProvider` (für die `classify`-Regel der Vorschau).
### 4.3 `image-import.js` – Public API (Skizze)
```javascript
class ImageImporter {
constructor(app) {
this.app = app;
this.storage = app.storage;
this.holidayProvider = app.holidayProvider;
this.session = null; // siehe Abschnitt 14.2
this.abortController = null; // für Cancel in Stage 2
}
open() { /* Stage 1 anzeigen, Key-Prompt-Logik */ }
close() { /* Modal zumachen, session leeren */ }
// Stage-Übergänge
showStage(stageId) { /* 1 | 2 | 3 | 4 */ }
// Stage 1
onFileSelected(file) { /* Validierung, Thumbnail, session.file setzen */ }
// Stage 2
async runRecognition() { /* preprocess → call → parse → resolveNames → Stage 3 */ }
// Stage 3
renderPreview() { /* Block A, B aufbauen */ }
onUnknownChoiceChange(name, choice) { /* Block B neu rendern */ }
onRemoveEntry(idx) { /* aus session.entries entfernen */ }
async commitImport() { /* addEmployee + addDuty, dann Stage 4 */ }
// Helpers
preprocessImage(file) { /* canvas resize + JPEG re-encode → base64 */ }
callOpenRouter(b64) { /* fetch → JSON */ }
parseResponse(text) { /* strip fences, JSON.parse, validate schema */ }
resolveNames(entries) { /* exact, normalized, levenshtein → session.unknowns */ }
normalizeName(name) { /* lowercase, trim, collapse whitespace */ }
levenshtein(a, b) { /* siehe Abschnitt 10.3 */ }
classify(date) { /* fr|sa|so|weekday, identisch zu Feature B */ }
}
```
---
## 5. UI Specification
### 5.1 Button im Tab "Dienste eintragen"
In `index.html`, innerhalb des `card`-Containers des Tabs `tab-duties`, oben rechts:
```html
Dienste eintragen
```
Der Header bekommt Flex-Layout (`justify-content: space-between`). Button ist immer sichtbar (auch ohne gespeicherten Key – Key-Prompt erscheint beim Klick).
### 5.2 Modal-Skelett
Ein einziger Modal-Container im HTML, mit vier Stage-Divs, von denen jeweils nur einer `.active` ist:
```html
```
### 5.3 Settings-Sektion "Bild-Import (KI)"
Neue `
` im Tab `tab-settings`, **vor** der Sektion `Alle Daten löschen`:
```html
Bild-Import (KI)
Kein Key hinterlegt
💡 Hinweis: Der API-Key wird ausschließlich lokal in Ihrem Browser gespeichert
und nur an OpenRouter (openrouter.ai) gesendet.
```
Verhalten:
- Status-Zeile zeigt beim Tab-Aufruf entweder `API-Key gespeichert ✓` (grün) oder `Kein Key hinterlegt` (grau).
- **`Key ändern`** öffnet `prompt('OpenRouter API-Key eingeben:', '')`. Leerer/abgebrochener Wert → keine Änderung. Sonst: `storage.setApiKey(value)` + Status-Zeile aktualisieren.
- **`Key löschen`** öffnet `confirm('API-Key wirklich löschen?')`. Bei OK: `storage.clearApiKey()` + Status-Zeile aktualisieren.
- **Modell-Dropdown:** initial-Wert ist `storage.getApiModel()` (Default `anthropic/claude-sonnet-4.6`). On `change`: `storage.setApiModel(value)`.
---
## 6. API Key Flow
### 6.1 Erstgebrauch
Beim ersten Klick auf **`📷 Bild importieren`**:
1. `storage.getApiKey()` liefert `null`/leer.
2. `prompt(text)` mit folgendem Text:
```
Für die Bilderkennung wird ein OpenRouter-API-Key benötigt.
Der Key wird ausschließlich lokal in Ihrem Browser gespeichert
und nur an openrouter.ai gesendet.
Key auf https://openrouter.ai/keys anlegen und hier eintragen:
```
3. Leer oder Cancel → kein Modal, Toast `Kein API-Key gespeichert – Import abgebrochen` (type `info`).
4. Bei nicht-leerem Wert → `storage.setApiKey(value.trim())`, Modal öffnet.
### 6.2 Folgenutzungen
Kein Prompt mehr. Direkt Modal in Stage 1.
### 6.3 Übertragung
- Der Key wird **ausschließlich** im `Authorization`-Header an `https://openrouter.ai/api/v1/chat/completions` gesendet.
- Kein Logging, kein Anhängen an Toasts, kein Schreiben in das Bild-Preprocessing-Modul außer für den Request.
- Bei Fehlerlogs in der Konsole wird der Key **nicht** mitausgegeben.
---
## 7. API Integration (OpenRouter)
### 7.1 Endpoint & Headers
```
POST https://openrouter.ai/api/v1/chat/completions
Authorization: Bearer
Content-Type: application/json
HTTP-Referer: // optional, von OpenRouter empfohlen
X-Title: Dienstplan-Pro // optional, von OpenRouter empfohlen
```
### 7.2 Request-Body
```json
{
"model": "",
"temperature": 0,
"response_format": { "type": "json_object" },
"messages": [
{
"role": "system",
"content": ""
},
{
"role": "user",
"content": [
{
"type": "text",
"text": "Extrahiere alle Assistenzarzt-Dienste aus dieser Dienstplan-Tabelle."
},
{
"type": "image_url",
"image_url": {
"url": "data:image/jpeg;base64,"
}
}
]
}
]
}
```
`temperature: 0` zur Maximierung der Determinismus. `response_format: json_object` zwingt das Modell zur JSON-Ausgabe (wird von Claude- und GPT-Modellen respektiert; Gemini ignoriert es teilweise – daher der zusätzliche Strip-Fences-Schritt in 8.1).
### 7.3 System Prompt (vollständig)
```
Du extrahierst Dienstpläne aus Tabellenbildern für eine deutsche Klinik.
Regeln:
- Die Tabelle listet pro Datum die diensthabenden Ärzte.
- Es gibt Assistenzärzte und Oberärzte. Extrahiere NUR Assistenzärzte. Oberärzte werden ignoriert.
- Wenn du nicht sicher bist, ob ein Name zu einem Assistenzarzt oder Oberarzt gehört, vermerke dies in `notes`.
- Wenn in einer Zelle NUR EIN Name steht: share = 1.0 für diesen Arzt.
- Wenn in einer Zelle ZWEI Namen stehen: share = 0.5 für jeden der beiden.
- Datum stets im ISO-Format YYYY-MM-DD.
- Wenn das Bild einen Monatstitel zeigt (z.B. „November 2025"), gib `month` (1–12) und `year` (vierstellig) in der Antwort an. Sonst null.
- Wenn ein Name unklar zu lesen ist, übernimm deinen besten Ratevorschlag und vermerke es in `notes`.
Antworte STRIKT in diesem JSON-Schema und sonst nichts:
{
"month": number | null,
"year": number | null,
"entries": [
{ "name": "string", "date": "YYYY-MM-DD", "share": 1.0 | 0.5 }
],
"notes": ["string", ...]
}
```
### 7.4 Timeouts
- Kein expliziter Client-Timeout über `fetch` direkt. Stattdessen `AbortController`, der vom Cancel-Button in Stage 2 ausgelöst wird.
- Praktischer Bereich für die Response: 5–30 Sekunden. Bei > 60 s zeigt die UI weiterhin den Spinner; der Benutzer kann via Cancel abbrechen.
---
## 8. Response Parsing & Validation
### 8.1 Parsing
Das `response_format`-Flag wird nicht von allen Modellen zuverlässig befolgt. Daher robust:
1. `text = response.choices[0].message.content` extrahieren.
2. Markdown-Fences strippen (regex `^```(?:json)?\s*` am Anfang und `\s*```$` am Ende).
3. Falls vor/nach dem JSON noch Text steht: ersten `{` und letzten `}` finden, dazwischen slicen.
4. `JSON.parse(stripped)` im try/catch.
5. Bei `SyntaxError`: Toast `Erkennung fehlgeschlagen — anderes Modell probieren oder Bild prüfen` (type `error`), Modal auf Stage 1 zurück.
### 8.2 Schema-Validierung
Nach erfolgreichem `JSON.parse(parsed)`:
| Feld | Erwartung | Fehlerbehandlung |
|---|---|---|
| `parsed.entries` | Array, nicht-null | Fehler → Toast `Erkennung fehlgeschlagen – Antwort hat kein gültiges Format` |
| `parsed.entries.length` | > 0 | Wenn 0: Toast `Keine Dienste erkannt` (type `info`), Modal auf Stage 1 |
| `entries[i].name` | String, nicht leer nach `trim()` | Eintrag wird verworfen, Warnung im Log |
| `entries[i].date` | String, parst zu valider `Date` via `new Date(date + 'T12:00:00')` | Eintrag wird verworfen, Warnung im Log |
| `entries[i].share` | Number, ∈ `{0.5, 1.0}` | Eintrag wird verworfen, Warnung im Log |
| `parsed.month` | `null` oder Integer 1..12 | bei Inkonsistenz → Warnung (siehe 8.3) |
| `parsed.year` | `null` oder Integer ≥ 2000 | bei Inkonsistenz → Warnung (siehe 8.3) |
| `parsed.notes` | Array von Strings, optional | bei `notes.length > 0` Hinweis in Stage 3 anzeigen |
### 8.3 Konsistenz von `month`/`year`
- Wenn `parsed.month` und `parsed.year` gesetzt sind:
- Vergleich mit dem aktuell im Tab gewählten Monat (`app.currentMonth`, `app.currentYear`).
- Bei Abweichung: Hinweis in Stage 3 oben: `Erkannter Monat: , aktuell ausgewählt: . Import läuft auf den ausgewählten Monat.` (kein Blocker.)
- Einträge, deren Datum nicht in den **ausgewählten** Monat fällt, werden in Stage 3 mit visuellem Marker `(außerhalb Monat)` angezeigt, aber **nicht automatisch entfernt** – der Benutzer kann sie via 🗑️ entfernen.
- Wenn `month`/`year` null sind: kein Hinweis, Ziel-Monat = ausgewählter Monat im Tab.
### 8.4 Deduplizierung
Vor dem Übergang zu Stage 3:
- Innerhalb der `entries`: doppelte `(name, date)`-Paare werden auf das erste Vorkommen reduziert. Bei Konflikt der `share`-Werte (eines 1.0, eines 0.5) wird der höhere genommen und eine Notiz in Stage 3 generiert: `Doppelter Eintrag für am – höherer Anteil verwendet`.
---
## 9. Slot-Klassifikation in der Vorschau
### 9.1 Zweck
In der Preview-Tabelle (Stage 3, Block B) wird pro Zeile der Slot (`fr`/`sa`/`so`/`weekday`) angezeigt, damit der Benutzer auf einen Blick sieht, wie sich der Import in die Bonus-Logik einsortiert.
### 9.2 Algorithmus
Identisch zur `classify(date)`-Regel aus Feature B (siehe `2026-05-11-bonus-varianten-design.md`, Abschnitt 3.1):
```
classify(date):
wd = date.getDay()
if wd === 5: return "fr"
if wd === 6: return "sa"
if wd === 0: return "so"
isFeiertag = holidayProvider.isHoliday(date)
isTagVorFeiertag = holidayProvider.isDayBeforeHoliday(date)
if isFeiertag && isTagVorFeiertag: return "sa"
if isTagVorFeiertag: return "fr"
if isFeiertag: return "so"
return "weekday"
```
### 9.3 Fallback wenn Feature B nicht vorhanden
Falls Feature B noch nicht implementiert ist und `window.classifyDuties` o. ä. nicht existiert: `ImageImporter.classify` kapselt die Logik in einer **eigenen Kopie**. Da `HolidayProvider` ohnehin vorhanden ist (Pflicht für die App), funktioniert der Algorithmus identisch. Es gibt also keine harte Abhängigkeit von Feature B; die Spezifikationen sind unabhängig implementierbar.
### 9.4 Anzeige
Slot-Wert wird als kleines Badge angezeigt: `fr` (orange), `sa` (rot), `so` (rot), `weekday` (grau). Diese Stilangaben sind in `styles.css` konsistent mit Feature B zu halten, falls beide Features gemeinsam ausgeliefert werden.
---
## 10. Name Matching Algorithm
### 10.1 Normalisierung
```javascript
normalizeName(name) {
return name
.toLowerCase()
.trim()
.replace(/\s+/g, ' '); // collapse multiple spaces
}
```
Beispiele:
| Input | Output |
|---|---|
| `"Max Mustermann"` | `"max mustermann"` |
| `" Max Mustermann "` | `"max mustermann"` |
| `"max mustermann"` | `"max mustermann"` |
Umlaute werden **nicht** normalisiert: `"Müller"` ≠ `"Mueller"`. Begründung: die App geht heute davon aus, dass Mitarbeiternamen exakt wie vom Benutzer angelegt verwendet werden. Eine Umlaut-Normalisierung würde unerwartete Cross-Matches erzeugen.
### 10.2 Matching-Reihenfolge
Pro Kandidatenname aus `entries[i].name`:
1. **Exakter normalisierter Match:** Wenn `normalizeName(candidate)` exakt gleich `normalizeName(employee)` für ein `employee ∈ storage.getEmployees()` → automatisch zugeordnet, kein UI-Prompt.
2. **Fuzzy-Match (Levenshtein ≤ 2):** Wenn nicht-exakter Match, aber `levenshtein(normalize(candidate), normalize(employee)) ≤ 2` für mindestens einen Mitarbeiter → der **nächstgelegene** Kandidat wird als "möglicher Match" markiert. In Block A (Stage 3) erscheint der Name mit Default-Auswahl `Zuordnen zu ` und Hinweis `möglicher Match: `.
3. **Unbekannt:** Sonst → erscheint in Block A mit Default-Auswahl `Neuer Mitarbeiter anlegen`.
Bei mehreren Fuzzy-Treffern mit identischer Distanz: der alphabetisch erste gewinnt für den Default. Dem Benutzer stehen alle anderen weiterhin im Dropdown zur Auswahl.
### 10.3 Levenshtein (inline)
`image-import.js` enthält eine kleine Implementation (DP-Matrix, O(m·n)), keine externe Abhängigkeit:
```javascript
levenshtein(a, b) {
if (a === b) return 0;
if (!a.length) return b.length;
if (!b.length) return a.length;
const m = a.length, n = b.length;
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + cost
);
}
}
return dp[m][n];
}
```
Eingaben sind immer schon `normalize`d. Für übliche Namenslängen (5–25 Zeichen) ist die Performance unkritisch.
---
## 11. Conflict Handling with Existing Duties
### 11.1 Bestehende Storage-Semantik
`DataStorage.addDuty(employeeName, year, month, date, share)` ersetzt einen existierenden Dienst am selben Datum (vgl. `storage.js` Zeilen 217–219):
```javascript
if (existingIndex >= 0) {
duties[existingIndex].share = share; // Replace
} else {
duties.push({ date, share }); // Append
}
```
Diese Replace-Semantik wird vom Import **übernommen**, **nicht umgangen**. Konsequenz:
- Wenn im Bild ein Dienst für Max Mustermann am 22.11.2025 mit `share=1.0` erkannt wird und Max am 22.11.2025 bereits einen Dienst mit `share=0.5` hat, wird er nach Import auf `share=1.0` stehen.
- Es gibt **kein** UI-Diff oder Bestätigungs-Prompt für Replaces. Begründung: Konsistenz mit dem heutigen manuellen Eingabepfad, der ebenfalls ohne Warnung ersetzt.
### 11.2 Iteration beim Commit
In `commitImport()`:
```
for each name marked "Neuer Mitarbeiter anlegen":
storage.addEmployee(name)
for each entry in session.entries (after user filtering):
storage.addDuty(
resolvedEmployeeName,
targetYear, // = app.currentYear
targetMonth, // = app.currentMonth
entryDate, // Date-Objekt aus 'YYYY-MM-DD' + 'T12:00:00'
entry.share
)
```
Anschließend:
- `app.loadDutiesForSelectedEmployee()` falls der aktuell im Dropdown gewählte Mitarbeiter durch den Import betroffen ist.
- `app.loadEmployeeSelects()` falls neue Mitarbeiter angelegt wurden.
- Stage 4 anzeigen, Toast, Modal nach ~1.5 s schließen.
### 11.3 Zielmonat ist immer der im Tab gewählte Monat
Auch wenn `parsed.month/year` einen anderen Monat indiziert: der Import läuft technisch immer in `app.currentMonth/Year`. Ein Datum, das nicht in diesen Monat fällt, würde von `DataStorage.addDuty` zwar gespeichert, aber unter dem **Tab-Monatsschlüssel** abgelegt – das wäre datentechnisch inkonsistent. Konsequenz:
- Vor dem Commit filtert `commitImport()` Einträge, deren Monat/Jahr nicht zum Ziel passen, **heraus** und vermerkt das per Toast: `Z Einträge außerhalb des gewählten Monats übersprungen`. Der Benutzer wurde in Stage 3 darauf hingewiesen (Marker `(außerhalb Monat)`).
---
## 12. Image Preprocessing
### 12.1 Ziel
- Reduktion des Payloads auf < ~1.5 MB Base64, um schnelle Übertragung und ausreichende Erkennungsqualität zu balancieren.
- Vermeidung von „Image too large"-Fehlern einiger Modelle.
### 12.2 Algorithmus
```
preprocessImage(file):
1. img = await loadImage(file) // via URL.createObjectURL + new Image()
2. longest = max(img.width, img.height)
3. if longest > 2048:
scale = 2048 / longest
newW = round(img.width * scale)
newH = round(img.height * scale)
else:
newW = img.width
newH = img.height
4. canvas = new OffscreenCanvas(newW, newH) // Fallback: HTMLCanvasElement
5. ctx = canvas.getContext('2d')
6. ctx.drawImage(img, 0, 0, newW, newH)
7. blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.85 })
8. base64 = await blobToBase64(blob) // FileReader.readAsDataURL
9. URL.revokeObjectURL(...) // cleanup
10. return base64 // 'data:image/jpeg;base64,...'
```
### 12.3 Notizen
- `OffscreenCanvas` ist in modernen Browsern unterstützt. Fallback: normales `