diff --git a/scripts/health/assets/health-assets/dashboard-v5-record.js b/scripts/health/assets/health-assets/dashboard-v5-record.js index b33263e..04ee31a 100644 --- a/scripts/health/assets/health-assets/dashboard-v5-record.js +++ b/scripts/health/assets/health-assets/dashboard-v5-record.js @@ -1,140 +1,142 @@ (() => { 'use strict'; const shell = document.querySelector('.record-view'); if (!shell) return; const tabs = [...shell.querySelectorAll('[data-record-tab]')]; const content = shell.querySelector('[data-record-content]'); const status = shell.querySelector('[data-record-status]'); const uploadDialog = shell.querySelector('[data-document-upload-dialog]'); const uploadForm = shell.querySelector('[data-document-upload-form]'); const uploadStatus = shell.querySelector('[data-document-upload-status]'); const allowedTabs = new Set(['overview', 'labs', 'medications', 'appointments', 'documents', 'report']); const allowedParams = new Set(['view', 'tab', 'document', 'preview', 'queued']); const session = window.healthApiSessionReady || Promise.reject(new Error('session_owner_missing')); let version = 0; let searchState = { documents: '', document: '', matches: [] }; let lastDocumentTrigger = ''; let documentListScroll = 0; const FILTER_KEYS = { overview: [], labs: ['q', 'from', 'to', 'status', 'source'], medications: ['from', 'to', 'medication', 'status', 'source'], appointments: ['from', 'to', 'order', 'institution'], documents: ['q', 'from', 'to', 'category', 'institution', 'type', 'review_status', 'original_status', 'extraction_status', 'search_status', 'queue', 'sort', 'cursor', 'limit'], report: ['from', 'to', 'sections', 'charts'], }; Object.values(FILTER_KEYS).flat().forEach(key => allowedParams.add(key)); let filtersByTab = Object.fromEntries([...allowedTabs].map(tab => [tab, {}])); function sanitizeFilters(tab, raw = {}) { const allowed = new Set(FILTER_KEYS[tab] || []); const clean = {}; if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return clean; Object.entries(raw).forEach(([key, value]) => { if (!allowed.has(key)) return; if (key === 'sections' || key === 'charts') { if (Array.isArray(value)) clean[key] = value.filter(item => typeof item === 'string').slice(0, key === 'charts' ? 4 : 20); return; } if (typeof value === 'string' || typeof value === 'number') clean[key] = String(value); }); return clean; } function snapshotFilters() { return Object.fromEntries([...allowedTabs].map(tab => { const clean = sanitizeFilters(tab, filtersByTab[tab]); if (tab === 'medications') delete clean.medication; return [tab, clean]; })); } function restoreFilters(state) { const source = state?.record?.filtersByTab; filtersByTab = Object.fromEntries([...allowedTabs].map(tab => [tab, sanitizeFilters(tab, source?.[tab])])); } const text = value => String(value ?? '—'); const sourceLabel = value => ({document:'Dokument',doctor_report:'Arztbericht',manual:'Manuell dokumentiert',capture:'Mobile Erfassung',imported:'Importierte Dokumentation',medication_plan:'Medikationsplan'})[String(value||'')] || 'Dokumentierte Quelle'; + const medicationRouteLabel = value => ({unknown:'Unbekannt',oral:'Oral',subcutaneous:'Subkutan',intravenous:'Intravenös',intramuscular:'Intramuskulär',topical:'Äusserlich',inhaled:'Inhalativ',other:'Andere dokumentierte Route'})[String(value||'')] || 'Unbekannt'; + const medicationSideLabel = value => ({left:'Links',right:'Rechts',unspecified:'Seite nicht angegeben'})[String(value||'')] || ''; const element = (tag, value, className) => { const node = document.createElement(tag); if (className) node.className = className; if (value !== undefined) node.textContent = value; return node; }; const button = (label, listener) => { const node = element('button', label); node.type = 'button'; node.addEventListener('click', listener); return node; }; const STALE_REQUEST = Symbol('stale-request'); const request = async (path, expectedVersion) => { await session; if (expectedVersion !== undefined && expectedVersion !== version) throw STALE_REQUEST; const controller = new AbortController(); const timeout = window.setTimeout(() => controller.abort(), 10000); let response; try { response = await fetch(path, { credentials: 'same-origin', signal: controller.signal }); } catch (error) { const failure = new Error(error?.name === 'AbortError' ? 'timeout' : 'network_error'); failure.code = failure.message; throw failure; } finally { window.clearTimeout(timeout); } if (expectedVersion !== undefined && expectedVersion !== version) throw STALE_REQUEST; let data = null; try { data = await response.json(); } catch (_error) { data = null; } if (!response.ok) { const code = data?.error?.code || (typeof data?.error === 'string' ? data.error : `api_${response.status}`); const failure = new Error(code); failure.code = code; throw failure; } if (expectedVersion !== undefined && expectedVersion !== version) throw STALE_REQUEST; return data; }; const query = params => { const result = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value !== '' && value !== undefined && value !== null) result.set(key, value); }); return result.toString(); }; async function fileBase64(file) { return await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(new Error('file_read_failed')); reader.onload = () => resolve(String(reader.result || '').split(',', 2)[1] || ''); reader.readAsDataURL(file); }); } function setupDocumentUpload() { if (!uploadDialog || !uploadForm) return; const uploadFiles = [...uploadForm.querySelectorAll('[data-document-upload-file]')]; let selectedFileInput = uploadFiles[0] || null; uploadFiles.forEach(input => input.addEventListener('change', () => { selectedFileInput = input; uploadFiles.filter(other => other !== input).forEach(other => { other.value = ''; }); })); shell.querySelector('[data-document-upload-close]')?.addEventListener('click', () => uploadDialog.close()); uploadForm.addEventListener('submit', async event => { event.preventDefault(); const file = selectedFileInput?.files?.[0]; if (!file) return; uploadStatus.textContent = 'Datei wird lokal geprüft und sicher vorgemerkt …'; try { await session; const csrfResponse = await fetch('/api/v1/capture/csrf', { credentials: 'same-origin' }); const csrf = await csrfResponse.json(); if (!csrfResponse.ok || !csrf.csrf_token) throw new Error('csrf_unavailable'); const metadata = Object.fromEntries(['document_date','document_type','institution','personal_title','investigation_day','note'].map(name => [name, String(uploadForm.elements[name]?.value || '')])); const response = await fetch('/api/v1/documents/upload', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-Health-Capture-CSRF': csrf.csrf_token }, @@ -739,303 +741,306 @@ const medicationDose = item => { const actualStructured = [item.actual_quantity_value,item.actual_dosage_form].filter(Boolean).join(' ')+(item.actual_strength?` · ${item.actual_strength}`:''); const plannedStructured = [item.planned_quantity_value,item.planned_dosage_form].filter(Boolean).join(' ')+(item.planned_strength?` · ${item.planned_strength}`:''); const actual = actualStructured || [item.actual_dose_value, item.actual_dose_unit].filter(Boolean).join(' '); const planned = plannedStructured || [item.planned_dose_value, item.planned_dose_unit].filter(Boolean).join(' '); if(item.status==='administered') return actual || (item.legacy_dose ? `Legacy-Freitext (nicht als tatsächliche Dosis strukturiert): ${item.legacy_dose}` : 'Tatsächliche Dosis nicht dokumentiert'); if(item.status==='planned') return planned || (item.legacy_dose ? `Legacy-Freitext (nicht strukturiert): ${item.legacy_dose}` : 'Geplante Dosis nicht dokumentiert'); if(item.status==='corrected') { if(item.effective_status==='administered') return actual || 'Korrigierte tatsächliche Dosis nicht dokumentiert'; if(item.effective_status==='planned') return planned || 'Korrigierte geplante Dosis nicht dokumentiert'; return 'Korrigierter Zielstatus: ausgelassen'; } return item.legacy_dose ? `Legacy-Freitext (nicht strukturiert): ${item.legacy_dose}` : 'Dosis nicht dokumentiert'; }; function medicationActionDialog(prescription, eventItem = null, mode = 'administered') { const dialog = document.createElement('dialog'); dialog.className = 'medication-action-dialog'; const form = document.createElement('form'); form.method = 'dialog'; const heading = ({administered:'Einnahme oder Verabreichung dokumentieren',missed:'Auslassung dokumentieren',corrected:'Eintrag korrigieren'})[mode]; form.append(element('h2', heading), element('p', 'Dokumentation ohne medizinische Bewertung oder Änderung der Verordnung.', 'v5-meta')); const when = document.createElement('input'); when.type='datetime-local'; when.required=true; when.value=document.querySelector("#medication-event-dialog input[name='occurred_at']")?.value || ''; const plannedValue=document.createElement('input'); plannedValue.maxLength=40; plannedValue.value=eventItem?.planned_dose_value||''; const plannedUnit=document.createElement('input'); plannedUnit.maxLength=30; plannedUnit.value=eventItem?.planned_dose_unit||''; const actualValue=document.createElement('input'); actualValue.maxLength=40; actualValue.value=eventItem?.actual_dose_value||''; actualValue.required=mode==='administered'; const actualUnit=document.createElement('input'); actualUnit.maxLength=30; actualUnit.value=eventItem?.actual_dose_unit||''; actualUnit.required=mode==='administered'; const routeOriginal=document.createElement('input'); routeOriginal.maxLength=60; routeOriginal.value=eventItem?.route_original||''; const route=document.createElement('select'); [['unknown','Nicht angegeben'],['oral','Oral'],['subcutaneous','Subkutan'],['intravenous','Intravenös'],['intramuscular','Intramuskulär'],['topical','Äußerlich'],['inhaled','Inhalativ'],['other','Andere dokumentierte Route']].forEach(([value,label])=>{const option=element('option',label);option.value=value;option.selected=(eventItem?.route_normalized||'unknown')===value;route.append(option);}); const region=document.createElement('input');region.maxLength=80;const side=document.createElement('select');[['','Nicht angegeben'],['left','Links'],['right','Rechts'],['unspecified','Seite nicht angegeben']].forEach(([value,label])=>{const option=element('option',label);option.value=value;side.append(option);});const injectionDetail=document.createElement('input');injectionDetail.maxLength=120; const lot=document.createElement('input');lot.maxLength=80;const note=document.createElement('textarea');note.maxLength=300; const correctionStatus=document.createElement('select');[['planned','Geplant'],['administered','Verabreicht / eingenommen'],['missed','Ausgelassen'],['unknown','Unbekannt']].forEach(([value,label])=>{const option=element('option',label);option.value=value;correctionStatus.append(option);});if(mode==='corrected'&&['planned','administered','missed','unknown'].includes(eventItem?.effective_status))correctionStatus.value=eventItem.effective_status;const correctionReason=document.createElement('textarea');correctionReason.maxLength=300;correctionReason.required=mode==='corrected'; const planConfirmed=document.createElement('input');planConfirmed.type='checkbox';const deviationConfirmed=document.createElement('input');deviationConfirmed.type='checkbox';const duplicateConfirmed=document.createElement('input');duplicateConfirmed.type='checkbox'; form.append(formRow('Medikament', element('strong', prescription.name)), formRow('Zeitpunkt', when)); if(mode==='corrected') form.append(formRow('Korrigierte geplante Dosis',plannedValue),formRow('Korrigierte geplante Einheit',plannedUnit)); if(mode!=='missed') form.append(formRow('Tatsächlich dokumentierte Dosis', actualValue),formRow('Einheit',actualUnit),formRow('Originalbezeichnung Applikationsweg',routeOriginal),formRow('Normalisierte Anzeige',route)); if(mode==='corrected') form.append(formRow('Korrigierter Zielstatus',correctionStatus),formRow('Korrekturbegründung',correctionReason)); if(mode==='administered') form.append(formRow('Geplante Angabe bewusst bestätigt',planConfirmed),formRow('Abweichende tatsächliche Angabe bewusst bestätigt',deviationConfirmed)); const extra=document.createElement('details');extra.append(element('summary','Weitere Angaben'));const extraBody=element('div',undefined,'medication-extra-fields');extraBody.append(formRow('Injektionsregion',region),formRow('Seite',side),formRow('Ergänzung zur Injektionsstelle',injectionDetail),formRow('Charge',lot),formRow('Notiz',note),formRow('Möglichen identischen Doppeleintrag nach Prüfung zulassen',duplicateConfirmed));extra.append(extraBody);form.append(extra); const preview=element('section',undefined,'medication-action-preview');preview.hidden=true;preview.setAttribute('aria-live','polite');form.append(preview); const controls=element('div',undefined,'record-actions');const cancel=button('Abbrechen',()=>dialog.close());const submit=button('Vorschau prüfen',()=>form.requestSubmit());controls.append(cancel,submit);form.append(controls);dialog.append(form);document.body.append(dialog); const injectionRoutes=new Set(['subcutaneous','intravenous','intramuscular','other']);const syncInjection=()=>{const enabled=injectionRoutes.has(route.value);[region,side,injectionDetail].forEach(input=>{input.disabled=!enabled;if(!enabled)input.value='';});};route.addEventListener('change',syncInjection);syncInjection(); const syncCorrectionDose=()=>{if(mode!=='corrected')return;const target=correctionStatus.value;const planned=target==='planned';const administered=target==='administered';plannedValue.disabled=!planned;plannedUnit.disabled=!planned;actualValue.disabled=!administered;actualUnit.disabled=!administered;plannedValue.required=planned;plannedUnit.required=planned;actualValue.required=administered;actualUnit.required=administered;if(!planned){plannedValue.value='';plannedUnit.value='';}if(!administered){actualValue.value='';actualUnit.value='';}};correctionStatus.addEventListener('change',syncCorrectionDose);syncCorrectionDose(); let frozen=null;let frozenCsrf='';form.addEventListener('input',()=>{if(frozen){frozen=null;frozenCsrf='';preview.hidden=true;submit.textContent='Vorschau prüfen';}}); form.addEventListener('submit',async event=>{event.preventDefault();if(!frozen){ if(mode==='administered'&&!planConfirmed.checked&&!deviationConfirmed.checked){status.textContent='Bitte geplante oder abweichende tatsächliche Angabe bewusst bestätigen.';return;} const revision=mode==='corrected'?eventItem?.correction_preview_revision:(eventItem?.administration_preview_revision||prescription.preview_revision); if(!revision){status.textContent='Die unveränderliche Vorschau konnte nicht sicher gebunden werden.';return;} const data={contract:'health.medication_action.v1',status:mode,medication_ref:prescription.id,planned_event_ref:mode!=='corrected'&&eventItem?.status==='planned'?eventItem.id:'',name:prescription.name,planned_dose_value:mode==='corrected'?plannedValue.value.trim():(mode==='missed'?'':(eventItem?.planned_dose_value||'')),planned_dose_unit:mode==='corrected'?plannedUnit.value.trim():(mode==='missed'?'':(eventItem?.planned_dose_unit||'')),actual_dose_value:mode==='missed'?'':actualValue.value.trim(),actual_dose_unit:mode==='missed'?'':actualUnit.value.trim(),route_original:mode==='missed'?'':routeOriginal.value.trim(),route_normalized:mode==='missed'?'unknown':route.value,injection_region:region.value.trim(),injection_side:side.value,injection_detail:injectionDetail.value.trim(),lot_number:lot.value.trim(),correction_target_ref:mode==='corrected'?eventItem.id:'',corrected_target_status:mode==='corrected'?correctionStatus.value:'',correction_reason:mode==='corrected'?correctionReason.value.trim():'',note:note.value.trim(),preview_revision:revision,plan_value_confirmed:planConfirmed.checked,deviation_confirmed:deviationConfirmed.checked,duplicate_confirmed:duplicateConfirmed.checked}; const draft={version:1,action:'capture_entry',capture_type:'medication',request_version:1,idempotency_key:[...crypto.getRandomValues(new Uint8Array(16))].map(value=>value.toString(16).padStart(2,'0')).join(''),occurred_at:when.value,ended_at:null,data,attachments:[],corrects_entry_id:null,withdraws_entry_id:null}; submit.disabled=true; try { const csrfResponse=await fetch('/api/v1/capture/csrf',{credentials:'same-origin',headers:{'Accept':'application/json'}}); if(!csrfResponse.ok) throw new Error(`capture_csrf_${csrfResponse.status}`); const csrfResult=await csrfResponse.json(); const captureCsrf=String(csrfResult.csrf_token||''); if(!/^[A-Za-z0-9_-]{24,128}$/.test(captureCsrf)) throw new Error('capture_csrf_invalid'); const body=new URLSearchParams({csrf_token:captureCsrf,return_to:'v5',payload:JSON.stringify(draft)}); const response=await fetch('/health-actions/medication-preview',{method:'POST',credentials:'same-origin',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded;charset=UTF-8'},body:body.toString()}); if(!response.ok) throw new Error(`preview_${response.status}`); const result=await response.json(); if(!/^[a-f0-9]{64}$/.test(String(result.preview_revision||''))) throw new Error('preview_invalid'); draft.data.preview_revision=result.preview_revision; frozen=draft; frozenCsrf=captureCsrf; } catch(_error) { status.textContent='Die Vorschau konnte nicht sicher serverseitig gebunden werden. Es wurde nichts vorgemerkt.'; return; } finally { submit.disabled=false; } preview.replaceChildren(element('h3','Unveränderliche Vorschau'),element('p',`${when.value} · ${prescription.name} · ${mode==='administered'?'Verabreicht / eingenommen':mode==='missed'?'Ausgelassen':`Korrigiert zu ${correctionStatus.options[correctionStatus.selectedIndex].text}`}`),element('p',`Geplant: ${eventItem?medicationDose(eventItem):(prescription.documented_dose||'nicht strukturiert dokumentiert')} · Tatsächlich: ${actualValue.value||'nicht angegeben'} ${actualUnit.value}`,'v5-meta'),element('p',mode==='corrected'?`Begründung: ${correctionReason.value}`:'Keine Änderung der Verordnung.','v5-meta'));preview.hidden=false;submit.textContent='Verbindlich vormerken';return; } const post=document.createElement('form');post.method='post';post.action='/health-actions/capture';post.hidden=true;const fields={csrf_token:frozenCsrf,return_to:'v5',payload:JSON.stringify(frozen)};Object.entries(fields).forEach(([name,value])=>{const input=document.createElement('input');input.type='hidden';input.name=name;input.value=value;post.append(input);});document.body.append(post);post.submit(); }); dialog.addEventListener('close',()=>dialog.remove(),{once:true});dialog.showModal();when.focus(); } function administrationCaptureDialog(prescription, eventItem = null, captureMode = 'historical') { const trigger=document.activeElement; const plannedMode=captureMode==='planned'; + const correctionMode=captureMode==='correction'; if(plannedMode&&!eventItem)return; - const baseline=plannedMode?eventItem?.administration_preset:prescription.administration_preset; + if(correctionMode&&(!eventItem?.capture_entry_ref||!eventItem?.correction_administration_preset))return; + const baseline=correctionMode?eventItem.correction_administration_preset:(plannedMode?eventItem?.administration_preset:prescription.administration_preset); const dialog=document.createElement('dialog');dialog.className='medication-action-dialog';dialog.setAttribute('aria-labelledby','medication-administration-title'); const form=document.createElement('form');form.method='dialog'; - const title=element('h2',plannedMode?'Geplanten Termin dokumentieren':'Historische Einnahme/Gabe erfassen');title.id='medication-administration-title'; - form.append(title,element('p','Manuelle Dokumentation ohne Änderung des Verordnungsstatus.','v5-meta')); + const title=element('h2',correctionMode?'Medikationseintrag korrigieren':(plannedMode?'Geplanten Termin dokumentieren':'Historische Einnahme/Gabe erfassen'));title.id='medication-administration-title'; + form.append(title,element('p',correctionMode?'Append-only Korrektur ohne Änderung der Verordnung.':'Manuelle Dokumentation ohne Änderung des Verordnungsstatus.','v5-meta')); if(prescription.status!=='active')form.append(element('p',`Verordnungsstatus bleibt: ${prescription.status==='unknown'?'unbekannt':prescription.status}.`,'v5-meta')); - const when=document.createElement('input');when.type='datetime-local';when.required=true; - const quantity=document.createElement('input');quantity.maxLength=40;quantity.required=true;quantity.inputMode='decimal';quantity.value=baseline?.quantity_value||''; - const dosageForm=document.createElement('input');dosageForm.maxLength=40;dosageForm.required=true;dosageForm.value=baseline?.dosage_form||''; + const when=document.createElement('input');when.type='datetime-local';when.required=true;when.value=correctionMode?(eventItem?.occurred_at||''):'';when.readOnly=correctionMode; + const quantity=document.createElement('input');quantity.type='number';quantity.min='0.0001';quantity.max='100000';quantity.step='any';quantity.required=true;quantity.inputMode='decimal';quantity.value=baseline?.quantity_value||''; + const dosageForm=document.createElement('select');[['','Bitte wählen'],['Spritze','Spritze'],['Tablette','Tablette']].forEach(([value,label])=>{const option=element('option',label);option.value=value;dosageForm.append(option);});dosageForm.required=true;dosageForm.value=baseline?.dosage_form||''; const strength=document.createElement('input');strength.maxLength=80;strength.required=true;strength.value=baseline?.strength||''; const route=document.createElement('select');[['unknown','Nicht belegt'],['oral','Oral'],['subcutaneous','Subkutan'],['intravenous','Intravenös'],['intramuscular','Intramuskulär'],['topical','Äußerlich'],['inhaled','Inhalativ'],['other','Andere dokumentierte Route']].forEach(([value,label])=>{const option=element('option',label);option.value=value;route.append(option);});route.value=baseline?.route_normalized||'unknown'; const routeOriginal=document.createElement('input');routeOriginal.maxLength=60;routeOriginal.value=baseline?.route_original||''; - const region=document.createElement('input');region.maxLength=80;const side=document.createElement('select');[['','Nicht angegeben'],['left','Links'],['right','Rechts'],['unspecified','Seite nicht angegeben']].forEach(([value,label])=>{const option=element('option',label);option.value=value;side.append(option);});const injectionDetail=document.createElement('input');injectionDetail.maxLength=120; - const note=document.createElement('textarea');note.maxLength=300;const deviation=document.createElement('input');deviation.type='checkbox';const duplicate=document.createElement('input');duplicate.type='checkbox'; + const region=document.createElement('input');region.maxLength=80;region.value=correctionMode?(eventItem?.injection_region||''):'';const side=document.createElement('select');[['','Nicht angegeben'],['left','Links'],['right','Rechts'],['unspecified','Seite nicht angegeben']].forEach(([value,label])=>{const option=element('option',label);option.value=value;side.append(option);});side.value=correctionMode?(eventItem?.injection_side||''):'';const injectionDetail=document.createElement('input');injectionDetail.maxLength=120;injectionDetail.value=correctionMode?(eventItem?.injection_detail||''):''; + const note=document.createElement('textarea');note.maxLength=300;note.value=correctionMode?(eventItem?.note||''):'';const correctionReason=document.createElement('textarea');correctionReason.maxLength=300;correctionReason.required=correctionMode;const deviation=document.createElement('input');deviation.type='checkbox';const duplicate=document.createElement('input');duplicate.type='checkbox'; form.append(formRow('Medikament',element('strong',prescription.name)),formRow('Zeitpunkt (Europe/Zurich)',when),formRow('Menge',quantity),formRow('Darreichungsform',dosageForm),formRow('Wirkstoffstärke',strength),formRow('Applikationsweg',route),formRow('Applikationsweg – dokumentierte Originalangabe',routeOriginal),formRow('Injektionsregion (optional)',region),formRow('Seite (optional)',side),formRow('Injektionsstelle (optional)',injectionDetail),formRow('Notiz (optional)',note)); + if(correctionMode)form.append(formRow('Korrekturbegründung',correctionReason)); const deviationRow=formRow('Abweichung von der verifizierten Vorauswahl bewusst bestätigen',deviation);deviationRow.hidden=true;form.append(deviationRow,formRow('Möglichen identischen Doppeleintrag nach Prüfung zulassen',duplicate)); const fieldError=element('p','', 'capture-inline-status');fieldError.setAttribute('role','alert');form.append(fieldError); const preview=element('section',undefined,'medication-action-preview');preview.hidden=true;preview.setAttribute('aria-live','polite');form.append(preview); const controls=element('div',undefined,'record-actions');const cancel=button('Abbrechen',()=>dialog.close());const submit=button('Vorschau prüfen',()=>form.requestSubmit());controls.append(cancel,submit);form.append(controls);dialog.append(form);document.body.append(dialog); const injectionRoutes=new Set(['subcutaneous','intravenous','intramuscular','other']); const syncInjection=()=>{const enabled=injectionRoutes.has(route.value);[region,side,injectionDetail].forEach(input=>{input.disabled=!enabled;if(!enabled)input.value='';});}; const differs=()=>Boolean(baseline)&&[quantity.value.trim()!==baseline.quantity_value,dosageForm.value.trim()!==baseline.dosage_form,strength.value.trim()!==baseline.strength,routeOriginal.value.trim()!==(baseline.route_original||''),route.value!==(baseline.route_normalized||'unknown')].some(Boolean); const syncDeviation=()=>{deviationRow.hidden=!differs();if(deviationRow.hidden)deviation.checked=false;}; [quantity,dosageForm,strength,routeOriginal,route].forEach(input=>input.addEventListener('input',syncDeviation));route.addEventListener('change',()=>{routeOriginal.value=baseline&&route.value===baseline.route_normalized?(baseline.route_original||''):'';syncInjection();syncDeviation();});syncInjection();syncDeviation(); let frozen=null;let frozenCsrf=''; form.addEventListener('input',()=>{fieldError.textContent='';if(frozen){frozen=null;frozenCsrf='';preview.hidden=true;submit.textContent='Vorschau prüfen';}}); form.addEventListener('submit',async event=>{event.preventDefault();fieldError.textContent='';if(!frozen){ if(differs()&&!deviation.checked){fieldError.textContent='Die Angaben weichen von der verifizierten Vorauswahl ab. Bitte Abweichung bestätigen.';deviation.focus();return;} - const data={contract:'health.medication_action.v2',mode:captureMode,status:'administered',medication_ref:prescription.id,planned_event_ref:plannedMode?eventItem.id:'',name:prescription.name,quantity_value:quantity.value.trim(),dosage_form:dosageForm.value.trim(),strength:strength.value.trim(),route_original:routeOriginal.value.trim(),route_normalized:route.value,injection_region:region.value.trim(),injection_side:side.value,injection_detail:injectionDetail.value.trim(),note:note.value.trim(),preset_revision:baseline?.preset_revision||'',preview_revision:'',deviation_confirmed:deviation.checked,duplicate_confirmed:duplicate.checked}; - const draft={version:1,action:'capture_entry',capture_type:'medication',request_version:1,idempotency_key:[...crypto.getRandomValues(new Uint8Array(16))].map(value=>value.toString(16).padStart(2,'0')).join(''),occurred_at:when.value,ended_at:null,data,attachments:[],corrects_entry_id:null,withdraws_entry_id:null}; + const data={contract:'health.medication_action.v2',mode:captureMode,status:correctionMode?'corrected':'administered',medication_ref:prescription.id,planned_event_ref:plannedMode?eventItem.id:'',name:prescription.name,quantity_value:quantity.value.trim(),dosage_form:dosageForm.value.trim(),strength:strength.value.trim(),route_original:routeOriginal.value.trim(),route_normalized:route.value,injection_region:region.value.trim(),injection_side:side.value,injection_detail:injectionDetail.value.trim(),note:note.value.trim(),preset_revision:baseline?.preset_revision||'',preview_revision:'',deviation_confirmed:deviation.checked,duplicate_confirmed:duplicate.checked,...(correctionMode?{correction_target_ref:eventItem.id,correction_reason:correctionReason.value.trim(),bind_verified_preset:false}:{})}; + const draft={version:1,action:'capture_entry',capture_type:'medication',request_version:1,idempotency_key:[...crypto.getRandomValues(new Uint8Array(16))].map(value=>value.toString(16).padStart(2,'0')).join(''),occurred_at:when.value,ended_at:null,data,attachments:[],corrects_entry_id:correctionMode?eventItem.capture_entry_ref:null,withdraws_entry_id:null}; submit.disabled=true; try{ const csrfResponse=await fetch('/api/v1/capture/csrf',{credentials:'same-origin',headers:{'Accept':'application/json'}});if(!csrfResponse.ok)throw new Error('csrf');const csrfResult=await csrfResponse.json();const captureCsrf=String(csrfResult.csrf_token||'');if(!/^[A-Za-z0-9_-]{24,128}$/.test(captureCsrf))throw new Error('csrf'); - const body=new URLSearchParams({csrf_token:captureCsrf,return_to:'v5',payload:JSON.stringify(draft)});const response=await fetch('/health-actions/medication-preview',{method:'POST',credentials:'same-origin',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded;charset=UTF-8'},body:body.toString()});const result=await response.json().catch(()=>({}));if(!response.ok){fieldError.textContent=result?.error?.message||'Die Angaben sind ungültig. Bitte markierte Felder prüfen.';const field=result?.error?.field;const fieldInputs={medication:quantity,quantity_value:quantity,dosage_form:dosageForm,strength,route_original:routeOriginal,route_normalized:route,injection_region:region,injection_side:side,injection_detail:injectionDetail,note,capture_mode:when,deviation_confirmed:deviation,duplicate_confirmed:duplicate,preset:quantity,preview:submit};fieldInputs[field]?.focus();return;}if(!/^[a-f0-9]{64}$/.test(String(result.preview_revision||'')))throw new Error('preview');draft.data.preview_revision=result.preview_revision;frozen=draft;frozenCsrf=captureCsrf; + const body=new URLSearchParams({csrf_token:captureCsrf,return_to:'v5',payload:JSON.stringify(draft)});const response=await fetch('/health-actions/medication-preview',{method:'POST',credentials:'same-origin',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded;charset=UTF-8'},body:body.toString()});const result=await response.json().catch(()=>({}));if(!response.ok){fieldError.textContent=result?.error?.message||'Die Angaben sind ungültig. Bitte markierte Felder prüfen.';const field=result?.error?.field;const fieldInputs={medication:quantity,quantity_value:quantity,dosage_form:dosageForm,strength,route_original:routeOriginal,route_normalized:route,injection_region:region,injection_side:side,injection_detail:injectionDetail,note,capture_mode:when,correction_target:submit,correction_reason:correctionReason,deviation_confirmed:deviation,duplicate_confirmed:duplicate,preset:quantity,preview:submit};fieldInputs[field]?.focus();return;}if(!/^[a-f0-9]{64}$/.test(String(result.preview_revision||'')))throw new Error('preview');draft.data.preview_revision=result.preview_revision;frozen=draft;frozenCsrf=captureCsrf; }catch(_error){fieldError.textContent='Die Erfassung ist technisch nicht verfügbar. Es wurde nichts vorgemerkt.';return;}finally{submit.disabled=false;} - preview.replaceChildren(element('h3','Vollständige Vorschau'),element('p',`${when.value} · ${prescription.name}`),element('p',`${quantity.value} ${dosageForm.value} · ${strength.value}`),element('p',`Applikationsweg: ${route.options[route.selectedIndex].text}${region.value||side.value||injectionDetail.value?` · ${[region.value,side.options[side.selectedIndex]?.text,injectionDetail.value].filter(Boolean).join(' · ')}`:''}`),element('p',note.value?`Notiz: ${note.value}`:'Keine Notiz.','v5-meta'),element('p',plannedMode?'Mit vorhandenem Plantermin verknüpft.':'Historische manuelle Gabe ohne Plantermin.','v5-meta'));preview.hidden=false;submit.textContent='Verbindlich vormerken';return; + preview.replaceChildren(element('h3','Vollständige Vorschau'),element('p',`${when.value} · ${prescription.name}`),element('p',`${quantity.value} ${dosageForm.value} · ${strength.value}`),element('p',`Applikationsweg: ${route.options[route.selectedIndex].text}${region.value||side.value||injectionDetail.value?` · ${[region.value,side.options[side.selectedIndex]?.text,injectionDetail.value].filter(Boolean).join(' · ')}`:''}`),element('p',correctionMode?`Korrekturbegründung: ${correctionReason.value}`:(note.value?`Notiz: ${note.value}`:'Keine Notiz.'),'v5-meta'),element('p',correctionMode?'Append-only Korrektur des gewählten Ereignisses.':(plannedMode?'Mit vorhandenem Plantermin verknüpft.':'Historische manuelle Gabe ohne Plantermin.'),'v5-meta'));preview.hidden=false;submit.textContent='Verbindlich vormerken';return; } submit.disabled=true; try{ if(!frozenCsrf){const csrfResponse=await fetch('/api/v1/capture/csrf',{credentials:'same-origin',headers:{'Accept':'application/json'}});if(!csrfResponse.ok)throw new Error('csrf');const csrfResult=await csrfResponse.json();frozenCsrf=String(csrfResult.csrf_token||'');if(!/^[A-Za-z0-9_-]{24,128}$/.test(frozenCsrf))throw new Error('csrf');} const body=new URLSearchParams({csrf_token:frozenCsrf,return_to:'v5',payload:JSON.stringify(frozen)}); const response=await fetch('/health-actions/capture',{method:'POST',credentials:'same-origin',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded;charset=UTF-8'},body:body.toString()}); const result=await response.json().catch(()=>({})); if(!response.ok||result.status!=='queued')throw new Error('capture_unavailable'); fieldError.textContent='Vorgemerkt. Die lokale Verarbeitung steht noch aus.';submit.textContent='Vorgemerkt'; const statusKey=String(result.status_key||'');const statusToken=String(result.status_token||''); if(!/^[a-f0-9]{32}$/.test(statusKey)||!/^[a-f0-9]{64}$/.test(statusToken))throw new Error('status_binding'); let attempts=0;const poll=async()=>{attempts+=1;try{const check=await fetch(`/api/v1/capture/status/${statusKey}/${statusToken}`,{credentials:'same-origin',headers:{'Accept':'application/json'}});const state=await check.json();if(state.status==='processed'){fieldError.textContent='Gespeichert.';setTimeout(()=>{dialog.close();render('medications',{push:false});},300);return;}if(state.status==='rejected'){fieldError.textContent='Die Verarbeitung wurde wegen eines geänderten Datenstands oder eines möglichen Duplikats abgelehnt. Eingaben bleiben erhalten; bitte Vorschau erneut prüfen.';frozen=null;frozenCsrf='';preview.hidden=true;submit.disabled=false;submit.textContent='Vorschau erneut prüfen';return;}}catch(_error){}if(attempts<8)setTimeout(poll,750);};setTimeout(poll,750); }catch(_error){fieldError.textContent='Die Erfassung ist technisch nicht verfügbar. Eingaben und identische Aktion bleiben erhalten; bitte erneut senden.';frozenCsrf='';submit.disabled=false;submit.textContent='Erneut senden';} }); dialog.addEventListener('close',()=>{dialog.remove();if(trigger instanceof HTMLElement)trigger.focus();},{once:true});dialog.showModal();when.focus(); } async function renderMedications(filters = {}, current) { const node = card('Medikamente'); const safeFilters=sanitizeFilters('medications',filters); const data = await request(`/api/v1/medications?${query(safeFilters)}`, current); const form=element('form',undefined,'record-filter-form');const from=document.createElement('input');from.type='date';from.value=safeFilters.from||'';const to=document.createElement('input');to.type='date';to.value=safeFilters.to||'';const medication=document.createElement('select');[['','Alle Medikamente'],...(data.prescriptions||[]).map(item=>[item.id,item.name])].forEach(([value,label])=>{const option=element('option',label);option.value=value;option.selected=safeFilters.medication===value;medication.append(option);});const eventStatus=document.createElement('select');[['','Alle Status'],['planned','Geplant'],['administered','Verabreicht / eingenommen'],['missed','Ausgelassen'],['corrected','Korrigiert'],['unknown','Unbekannt']].forEach(([value,label])=>{const option=element('option',label);option.value=value;option.selected=safeFilters.status===value;eventStatus.append(option);});const source=document.createElement('select');[['','Alle Quellen'],...(data.sources||[]).map(value=>[value,sourceLabel(value)])].forEach(([value,label])=>{const option=element('option',label);option.value=value;option.selected=safeFilters.source===value;source.append(option);});form.append(formRow('Von',from),formRow('Bis',to),formRow('Medikament',medication),formRow('Status',eventStatus),formRow('Quelle',source),button('Filter anwenden',()=>form.requestSubmit()));form.addEventListener('submit',event=>{event.preventDefault();render('medications',{push:true,filters:{from:from.value,to:to.value,medication:medication.value,status:eventStatus.value,source:source.value}});});node.append(form); const currentSection=element('section',undefined,'medication-current');currentSection.append(element('h4','Aktive Verordnungen'));(data.current_prescriptions||[]).forEach(item=>{const article=element('article',undefined,'medication-prescription-row');article.append(element('strong',item.name),element('span','Aktiv','status-chip'),element('p',item.documented_dose||'Dosis nicht strukturiert dokumentiert','v5-meta'));article.append(button('Historische Einnahme/Gabe erfassen',()=>administrationCaptureDialog(item,null,'historical')));currentSection.append(article);});if(!(data.current_prescriptions||[]).length)appendEmpty(currentSection);node.append(currentSection); const otherSection=element('section',undefined,'medication-other-prescriptions');otherSection.append(element('h4','Pausierte, beendete oder unklare Verordnungen'));(data.other_prescriptions||[]).forEach(item=>{const article=element('article',undefined,'medication-prescription-row');const label=item.status==='paused'?'Pausiert':item.status==='ended'?'Beendet':'Status unbekannt';article.append(element('strong',item.name),element('span',label,'status-chip'),element('p','Keine Darstellung als aktuell aktive Verordnung. Historische Dokumentation ändert diesen Status nicht.','v5-meta'),button('Historische Einnahme/Gabe erfassen',()=>administrationCaptureDialog(item,null,'historical')));otherSection.append(article);});if(!(data.other_prescriptions||[]).length)appendEmpty(otherSection);node.append(otherSection,element('h4','Verlauf')); const prescriptionsById=new Map((data.prescriptions||[]).map(item=>[item.id,item])); ['planned', 'administered', 'missed', 'corrected', 'unknown'].forEach(kind => { const section = element('section'); section.append(element('h5', ({ planned: 'Geplant', administered: 'Tatsächlich verabreicht / eingenommen', missed: 'Ausgelassen', corrected: 'Korrigiert',unknown:'Status unbekannt' })[kind])); const rows = data[kind] || []; if(!rows.length){section.append(element('p','Keine dokumentierten Angaben.'));node.append(section);return;} - const list=element('div',undefined,'medication-history-list');rows.forEach(item=>{const article=element('article',undefined,'medication-history-row');article.append(element('div',`${item.date} · ${item.name} · ${medicationDose(item)} · ${sourceLabel(item.source)}`));if(item.superseded_by_correction)article.append(element('span','Durch Korrektur ersetzt','status-chip'));const details=document.createElement('details');details.append(element('summary','Details'));const facts=element('dl');[['Status',item.status],['Dokumentierter Folgetermin',item.scheduled_next_date||'Nicht angegeben'],['Geplante Menge',item.planned_quantity_value||'Nicht strukturiert dokumentiert'],['Geplante Darreichungsform',item.planned_dosage_form||'Nicht strukturiert dokumentiert'],['Geplante Wirkstoffstärke',item.planned_strength||'Nicht strukturiert dokumentiert'],['Tatsächliche Menge',item.actual_quantity_value||'Nicht strukturiert dokumentiert'],['Tatsächliche Darreichungsform',item.actual_dosage_form||'Nicht strukturiert dokumentiert'],['Tatsächliche Wirkstoffstärke',item.actual_strength||'Nicht strukturiert dokumentiert'],['Legacy-Dosis Wert/Einheit (uninterpretiert)',[item.actual_dose_value,item.actual_dose_unit].filter(Boolean).join(' ')||[item.planned_dose_value,item.planned_dose_unit].filter(Boolean).join(' ')||'Nicht vorhanden'],['Legacy-Dosisfreitext (uninterpretiert)',item.legacy_dose||'Nicht vorhanden'],['Applikationsweg (Original)',item.route_original||'Nicht angegeben'],['Applikationsweg (Anzeige)',item.route_normalized||'Unbekannt'],['Injektionsstelle',[item.injection_region,item.injection_side,item.injection_detail].filter(Boolean).join(' · ')||'Nicht angegeben'],['Charge',item.lot_number||'Nicht angegeben'],['Notiz',item.note||'Nicht angegeben'],['Korrekturbegründung',item.correction_reason||'Nicht angegeben'],['Quelle',sourceLabel(item.source)]].forEach(([label,value])=>facts.append(element('dt',label),element('dd',value)));details.append(facts);article.append(details);const prescription=prescriptionsById.get(item.prescription_id);if(prescription&&kind==='planned'&&!item.plan_consumed){if(item.administration_preset)article.append(button('Geplanten Termin dokumentieren',()=>administrationCaptureDialog(prescription,item,'planned')));article.append(button('Auslassung dokumentieren',()=>medicationActionDialog(prescription,item,'missed')));}if(prescription&&!item.superseded_by_correction&&item.correction_preview_revision)article.append(button('Eintrag korrigieren',()=>medicationActionDialog(prescription,item,'corrected')));list.append(article);});section.append(list);node.append(section); + const list=element('div',undefined,'medication-history-list');rows.forEach(item=>{const article=element('article',undefined,'medication-history-row');article.append(element('div',`${item.date} · ${item.name} · ${medicationDose(item)} · ${sourceLabel(item.source)}`));if(item.superseded_by_correction)article.append(element('span','Durch Korrektur ersetzt','status-chip'));const details=document.createElement('details');details.append(element('summary','Details'));const facts=element('dl');[['Status',item.status],['Dokumentierter Folgetermin',item.scheduled_next_date||'Nicht angegeben'],['Geplante Menge',item.planned_quantity_value||'Nicht strukturiert dokumentiert'],['Geplante Darreichungsform',item.planned_dosage_form||'Nicht strukturiert dokumentiert'],['Geplante Wirkstoffstärke',item.planned_strength||'Nicht strukturiert dokumentiert'],['Tatsächliche Menge',item.actual_quantity_value||'Nicht strukturiert dokumentiert'],['Tatsächliche Darreichungsform',item.actual_dosage_form||'Nicht strukturiert dokumentiert'],['Tatsächliche Wirkstoffstärke',item.actual_strength||'Nicht strukturiert dokumentiert'],['Legacy-Dosis Wert/Einheit (uninterpretiert)',[item.actual_dose_value,item.actual_dose_unit].filter(Boolean).join(' ')||[item.planned_dose_value,item.planned_dose_unit].filter(Boolean).join(' ')||'Nicht vorhanden'],['Legacy-Dosisfreitext (uninterpretiert)',item.legacy_dose||'Nicht vorhanden'],['Applikationsweg (Original)',item.route_original||'Nicht angegeben'],['Applikationsweg (Anzeige)',medicationRouteLabel(item.route_normalized)],['Injektionsstelle',[item.injection_region,medicationSideLabel(item.injection_side),item.injection_detail].filter(Boolean).join(' · ')||'Nicht angegeben'],['Charge',item.lot_number||'Nicht angegeben'],['Notiz',item.note||'Nicht angegeben'],['Korrekturbegründung',item.correction_reason||'Nicht angegeben'],['Quelle',sourceLabel(item.source)]].forEach(([label,value])=>facts.append(element('dt',label),element('dd',value)));details.append(facts);article.append(details);const prescription=prescriptionsById.get(item.prescription_id);if(prescription&&kind==='planned'&&!item.plan_consumed){if(item.administration_preset)article.append(button('Geplanten Termin dokumentieren',()=>administrationCaptureDialog(prescription,item,'planned')));article.append(button('Auslassung dokumentieren',()=>medicationActionDialog(prescription,item,'missed')));}if(prescription&&!item.superseded_by_correction&&item.correction_preview_revision){if(item.correction_administration_preset&&item.capture_entry_ref)article.append(button('Eintrag korrigieren',()=>administrationCaptureDialog(prescription,item,'correction')));else article.append(button('Legacy-Eintrag korrigieren',()=>medicationActionDialog(prescription,item,'corrected')));}list.append(article);});section.append(list);node.append(section); }); node.append(element('p', `Datenvollständigkeit: ${data.truncated ? `gekürzt (${data.truncated_sections.join(', ')})` : 'vollständige Seite'} · Fehlende Legacy-Felder bleiben unbekannt.`, 'v5-meta')); return node; } async function renderAppointments(filters = {}, current) { const node = card('Termine'); const safeFilters = sanitizeFilters('appointments', filters); const form = element('form', undefined, 'record-filter-form'); const from = document.createElement('input'); from.type = 'date'; from.value = safeFilters.from || ''; const to = document.createElement('input'); to.type = 'date'; to.value = safeFilters.to || ''; const institution = document.createElement('input'); institution.value = safeFilters.institution || ''; institution.maxLength = 120; const order = document.createElement('select'); [['desc', 'Neueste zuerst'], ['asc', 'Älteste zuerst']].forEach(([value, label]) => { const option = element('option', label); option.value = value; option.selected = (safeFilters.order || 'desc') === value; order.append(option); }); form.append(formRow('Von', from), formRow('Bis', to), formRow('Institution', institution), formRow('Sortierung', order), button('Filter anwenden', () => form.requestSubmit())); form.addEventListener('submit', event => { event.preventDefault(); render('appointments', { push: true, filters: { from: from.value, to: to.value, institution: institution.value.trim(), order: order.value } }); }); node.append(form); const data = await request(`/api/v1/appointments?${query({ order: safeFilters.order || 'desc', ...safeFilters })}`, current); node.append(data.items.length ? table(['Tag', 'Institution', 'Anlass', 'Status'], data.items.map(item => [item.date, item.institution, item.reason, item.status === 'documented_visit' ? 'Dokumentierter Termin' : 'Status nicht dokumentiert'])) : element('p', 'Keine dokumentierten Termine.')); return node; } async function renderReport(filters = {}, current) { const node = card('Arzt-Zusammenfassung'); const bundle = JSON.parse(document.getElementById('health-dashboard-bundle')?.textContent || '{}'); const shift = (day, delta) => { const value=new Date(`${day}T12:00:00Z`); value.setUTCDate(value.getUTCDate()+delta); return value.toISOString().slice(0,10); }; const form = element('form', undefined, 'record-filter-form doctor-summary-config'); const from = document.createElement('input'); from.type = 'date'; const to = document.createElement('input'); to.type = 'date'; from.value = filters.from || shift(bundle.today, -29); to.value = filters.to || bundle.today || ''; const presets=element('div',undefined,'doctor-summary-presets'); [[7,'Letzte 7 Tage'],[30,'Letzte 30 Tage'],[90,'Letzte 90 Tage']].forEach(([days,label])=>{const preset=button(label,()=>{from.value=shift(bundle.today,-(days-1));to.value=bundle.today;});preset.type='button';presets.append(preset);}); const sectionNames = { overview: 'Datenabdeckung', symptoms: 'Beschwerden', medications: 'Medikamentenverlauf', supplements: 'Supplemente', labs: 'Laborverläufe', observations: 'Körperwerte', personal_observations: 'Persönliche Fragestellungen', nutrition: 'Ernährung', appointments: 'Termine und Untersuchungen', documents: 'Relevante Dokumente', timeline: 'Chronologische Ereignisse' }; const selected = new Set(filters.sections || Object.keys(sectionNames)); const choices = element('fieldset', undefined, 'record-report-sections'); choices.append(element('legend', 'Abschnitte')); Object.entries(sectionNames).forEach(([value, label]) => { const input=document.createElement('input');input.type='checkbox';input.value=value;input.checked=selected.has(value);choices.append(formRow(label,input)); }); let sessionChartSpecs=[];try{sessionChartSpecs=JSON.parse(sessionStorage.getItem('healthDoctorChartSelections')||'[]');if(!Array.isArray(sessionChartSpecs))sessionChartSpecs=[];}catch(_error){sessionChartSpecs=[];}sessionChartSpecs=sessionChartSpecs.slice(-4); const chartNames={labs:'Laborverlauf',sleep_hrv:'Schlaf und HRV',resting_hr:'Ruhepuls',symptoms:'Beschwerden/Aphten',histamine:'Histamin-Zuordnungsindex',nutrition_factors:'Zucker, Ballaststoffe und gesättigte Fettsäuren'};sessionChartSpecs.forEach((spec,index)=>{chartNames[`session_${index}`]=`Vorgemerkt: ${String(spec.title||'Explorer-Grafik')}`;}); const defaultCharts=sessionChartSpecs.length?sessionChartSpecs.map((_spec,index)=>`session_${index}`):['sleep_hrv','resting_hr','symptoms','nutrition_factors']; const selectedCharts=new Set(filters.charts || defaultCharts); const chartChoices=element('fieldset',undefined,'record-report-charts');chartChoices.append(element('legend','Grafiken auswählen und sortieren (maximal vier)')); const chartOrder=[...(filters.charts||[]),...Object.keys(chartNames).filter(key=>!(filters.charts||[]).includes(key))].filter((key,index,array)=>chartNames[key]&&array.indexOf(key)===index);const chartList=element('div',undefined,'doctor-chart-choice-list'); const renderChartChoices=()=>{chartList.replaceChildren();chartOrder.forEach((value,index)=>{const row=element('div',undefined,'doctor-chart-choice');const input=document.createElement('input');input.type='checkbox';input.value=value;input.dataset.reportChartChoice='';input.checked=selectedCharts.has(value);input.addEventListener('change',()=>{if(input.checked&&chartList.querySelectorAll('input:checked').length>4){input.checked=false;status.textContent='Bitte höchstens vier Grafiken auswählen.';}input.checked?selectedCharts.add(value):selectedCharts.delete(value);});const label=element('label');label.append(input,document.createTextNode(chartNames[value]));const controls=element('div',undefined,'doctor-chart-choice-actions');const up=button('↑',()=>{if(index<1)return;[chartOrder[index-1],chartOrder[index]]=[chartOrder[index],chartOrder[index-1]];renderChartChoices();});up.type='button';up.disabled=index===0;up.setAttribute('aria-label',`${chartNames[value]} nach oben`);const down=button('↓',()=>{if(index>=chartOrder.length-1)return;[chartOrder[index+1],chartOrder[index]]=[chartOrder[index],chartOrder[index+1]];renderChartChoices();});down.type='button';down.disabled=index===chartOrder.length-1;down.setAttribute('aria-label',`${chartNames[value]} nach unten`);const remove=button('Entfernen',()=>{selectedCharts.delete(value);renderChartChoices();});remove.type='button';controls.append(up,down,remove);row.append(label,controls);chartList.append(row);});};renderChartChoices();chartChoices.append(chartList); form.append(presets,formRow('Von',from),formRow('Bis',to),choices,chartChoices,button('Vorschau aktualisieren',()=>form.requestSubmit())); form.addEventListener('submit',event=>{event.preventDefault();const sections=[...choices.querySelectorAll('input:checked')].map(input=>input.value);const charts=[...chartChoices.querySelectorAll('input:checked')].map(input=>input.value).slice(0,4);render('report',{push:true,filters:{from:from.value,to:to.value,sections,charts}});}); node.append(form); if (!filters.from || !filters.to) return node; const data=await request(`/api/v1/doctor-report?${query({from:filters.from,to:filters.to,sections:(filters.sections||[]).join(',')})}`,current); const printable=element('article',undefined,'record-report-printable doctor-summary'); printable.append(element('h2','Arzt-Zusammenfassung'),element('p',`${data.from} bis ${data.to}`,'doctor-summary-period'),element('p',`Erstellt: ${String(data.created_at||'').replace('T',' ').slice(0,16)}`,'v5-meta'),element('p','Diskussionsgrundlage für ein Arztgespräch. Dokumentierte Beobachtungen; keine Diagnose-, Kausalitäts- oder Therapieaussage.')); const section=(title)=>{const part=element('section',undefined,'doctor-summary-section');part.append(element('h3',title));printable.append(part);return part;}; const empty=target=>target.append(element('p','Für den gewählten Zeitraum nicht dokumentiert.','v5-empty')); const personalDraws=[]; if(data.selected_sections.includes('overview')){const part=section('Datenabdeckung und fehlende Bereiche');const rows=Object.entries(data.completeness||{}).map(([key,item])=>[sectionNames[key]||key,item.status==='documented'?'Daten dokumentiert':'Nicht dokumentiert',item.truncated?'Aus Sicherheitsgründen gekürzt':'Vollständige begrenzte Antwort']);part.append(table(['Bereich','Abdeckung','Umfang'],rows));} if(data.selected_sections.includes('symptoms')){const part=section('Beschwerden und Symptomereignisse');const rows=[...(data.additional_symptoms||[]).map(item=>[item.occurred_at||item.date,item.symptom,item.severity??'unbekannt',item.note||'']),...(data.symptoms||[]).map(item=>[item.date,'7-Dimensionen-Tageswert',item.symptoms?.total??'unvollständig',''])];rows.length?part.append(table(['Zeitpunkt','Beschwerde','Schweregrad','Notiz'],rows)):empty(part);} if(data.selected_sections.includes('medications')){const part=section('Medikamentenverlauf');[['Tatsächlich verabreicht','administered'],['Ausgelassen','missed'],['Korrigiert','corrected'],['Geplant – separat','planned'],['Status unbekannt – nicht interpretiert','unknown']].forEach(([label,key])=>{part.append(element('h4',label));const rows=(data.medications?.[key]||[]).map(item=>{const marker=item.superseded_by_correction?'Durch spätere Korrektur ersetzt · ':(item.is_correction&&item.is_latest_effective?'Wirksame Korrektur · ':'');const detail=item.status==='corrected'?`Zielstatus: ${item.effective_status||'unbekannt'} · ${medicationDose(item)}`:medicationDose(item);return[item.date,item.name,`${marker}${detail}`];});rows.length?part.append(table(['Datum','Medikament','Dokumentierte Angabe'],rows)):empty(part);});} if(data.selected_sections.includes('supplements')){const part=section('Supplemente');[['Tatsächlich eingenommen','administered'],['Ausgelassen','missed'],['Korrigiert','corrected'],['Geplant – separat','planned']].forEach(([label,key])=>{part.append(element('h4',label));const rows=(data.supplements?.[key]||[]).map(item=>[item.date,item.product,`${item.amount} ${item.unit} ${item.nutrient}`,item.composition_source,item.assignment_reliability]);rows.length?part.append(table(['Datum','Präparat','Dokumentierte Menge','Zusammensetzungsquelle','Zuordnung'],rows)):empty(part);});part.append(element('p','Dokumentierte Einnahme; keine Dosierungsempfehlung oder Aussage über einen Bedarf.','doctor-summary-disclaimer'));} if(data.selected_sections.includes('labs')){const part=section('Relevante Laborverläufe');const rows=(data.labs||[]).map(item=>[item.date,item.parameter,`${text(item.display_value ?? `${item.operator||''}${item.value}`)} ${text(item.unit)}`]);rows.length?part.append(table(['Datum','Parameter','Wert'],rows)):empty(part);} if(data.selected_sections.includes('observations')){const part=section('Schlaf, Kreislauf und Aktivität');(data.observations||[]).forEach(metric=>{part.append(element('h4',metric.label||'Körperwert'));const rows=(metric.points||[]).filter(point=>point.value!==null&&point.value!==undefined).map(point=>[point.date,`${point.value} ${metric.unit||''}`.trim()]);rows.length?part.append(table(['Datum','Dokumentierter Wert'],rows)):empty(part);});if(!(data.observations||[]).length)empty(part);} if(data.selected_sections.includes('personal_observations')){const part=section('Persönliche Fragestellungen');(data.personal_observations||[]).forEach(observation=>{const article=element('article',undefined,'doctor-summary-observation');article.append(element('h4',observation.title),element('p',observation.question||'Persönliche Fragestellung'),element('p',`${observation.from} bis ${observation.to} · erwartetes Lag ${observation.lag_min}–${observation.lag_max} Tage`,'v5-meta'));const rows=observation.phases.map(phase=>[phase.name,`${phase.start_date} bis ${phase.end_date}`,`${phase.checkin_days}/${phase.expected_days} Tage`,phase.summary_label,phase.event_count,phase.known_factor_count]);article.append(table(['Phase','Zeitraum','Abdeckung','Auswertung','Ereignisse','Begleitfaktoren'],rows));(observation.charts||[]).slice(0,2).forEach(spec=>{const block=element('div',undefined,'doctor-summary-chart');block.append(element('h5',`${spec.label} (${spec.unit||'ohne Einheit'})`),element('p',`Quelle: ${spec.source} · ${observation.from} bis ${observation.to}`,'v5-meta'));const chartNode=element('div',undefined,'doctor-summary-echart');block.append(chartNode);article.append(block);personalDraws.push({node:chartNode,spec,observation});});article.append(element('p',observation.medical_statement,'doctor-summary-disclaimer'));part.append(article);});if(!(data.personal_observations||[]).length)empty(part);setTimeout(()=>personalDraws.forEach(({node,spec,observation})=>{if(!window.echarts||!node.isConnected)return;const instance=window.echarts.init(node,null,{renderer:'canvas'});const areas=(spec.phases||[]).map((phase,index)=>[{name:phase.name,xAxis:phase.start_date,itemStyle:{color:['rgba(71,120,140,.08)','rgba(183,131,90,.08)','rgba(83,107,88,.08)','rgba(112,96,145,.08)'][index%4]}},{xAxis:phase.end_date}]);instance.setOption({animation:false,aria:{enabled:true,description:`${spec.label}; persönliche zeitliche Beobachtung ohne Kausalitätsaussage.`},tooltip:{trigger:'axis'},grid:{left:60,right:28,top:58,bottom:44,containLabel:true},xAxis:{type:'time',name:'Datum'},yAxis:{type:'value',name:spec.unit||'',scale:true},series:[{name:spec.label,type:'line',connectNulls:false,showSymbol:true,data:(spec.phases||[]).flatMap(phase=>phase.points.map(point=>[point.date,point.value])),markArea:{silent:true,data:areas}}]});},{once:true}),0);} if(data.selected_sections.includes('nutrition')){const part=section('Ernährung');const summary=data.nutrition_summary||{};part.append(element('p',`${summary.documented_days||0} dokumentierte Tage · ${summary.fully_mapped_days||0} vollständig zugeordnet · ${summary.days_with_open_mappings||0} mit offenen Lebensmittelzuordnungen.`));const rows=(data.nutrition||[]).map(item=>[item.date,item.item_count,item.histamine?.status==='complete'?'Histamin-Zuordnungsindex vollständig':'Histamin-Zuordnungsindex unvollständig',item.histamine?.score??'nicht ausgewiesen',item.histamine?.mapping_coverage===undefined?'unbekannt':`${Math.round(100*item.histamine.mapping_coverage)} %`,item.nutrients?.['nutrient.protein']?.value??'unbekannt',item.nutrients?.['nutrient.carb']?.value??'unbekannt',item.nutrients?.['nutrient.fat']?.value??'unbekannt']);rows.length?part.append(table(['Datum','Einträge','Histaminstatus','Index','Mappingabdeckung','Protein (g)','Kohlenhydrate (g)','Fett (g)'],rows)):empty(part);if(data.nutrition_factor_statement)part.append(element('p',data.nutrition_factor_statement,'doctor-summary-disclaimer'));const factors=(data.nutrition_factors||[]).map(item=>[item.label,item.arithmetic_mean,item.unit,item.documented_days,`${item.present_days}/${item.expected_days}`,item.period?`${item.period.from} bis ${item.period.to}`:'–']);if(factors.length)part.append(table(['Dokumentierter Einzelfaktor','Arithmetischer Mittelwert pro dokumentiertem Tag','Einheit','n','Vorhanden/erwartet','Zeitraum'],factors));} if(data.selected_sections.includes('appointments')){const part=section('Termine und Untersuchungen');const rows=(data.appointments||[]).map(item=>[item.date,item.institution,item.reason]);rows.length?part.append(table(['Datum','Institution','Anlass'],rows)):empty(part);} if(data.selected_sections.includes('documents')){const part=section('Relevante Dokumente');const list=element('ul');(data.documents||[]).forEach(item=>{const li=element('li');const link=document.createElement('a');link.textContent=`${item.document_date||'Datum unbekannt'} · ${item.category||'Dokument'}`;link.href=`?view=record&tab=documents&document=${encodeURIComponent(item.id)}`;li.append(link);list.append(li);});list.children.length?part.append(list):empty(part);} try { const observation=JSON.parse(sessionStorage.getItem('healthDoctorAssociationObservation')||'null'); if(observation&&typeof observation==='object'){const part=section('Ausgewählte explorative Beobachtung');part.append(element('p',String(observation.question||'Zeitliche Beobachtung')));part.append(table(['Zeitraum','Zeitversatz','Gepaarte Tage','Status','Explorative Rangkorrelation'],[[`${observation.period?.from||'–'} bis ${observation.period?.to||'–'}`,String(observation.lag_label||'–'),String(observation.quality?.paired_days??'–'),String(observation.quality?.status||'–'),observation.coefficient===null?'nicht berechnet':String(observation.coefficient)]]));part.append(element('p','Zeitliche Nähe; explorative Auswertung. Keine Kausalitätsaussage und keine Therapieempfehlung.','doctor-summary-disclaimer'));} } catch(_error) { /* session-only optional observation stays absent */ } const temporal=section('Zeitliche Beobachtungen');temporal.append(element('p','Explorative zeitliche Nähe im Fenster von drei Tagen vor bis drei Tagen nach einem Symptomereignis. Keine Kausalitätsaussage.','doctor-summary-disclaimer'));const temporalRows=(data.temporal_observations||[]).flatMap(item=>item.nearby.map(nearby=>[item.symptom_date,nearby.date,nearby.offset_days,nearby.label]));temporalRows.length&&temporal.append(table(['Symptomtag','Nahes Ereignis','Tage Abstand','Dokumentiertes Ereignis'],temporalRows));const nutritionNear=(data.nutrition_proximity||[]).map(item=>[item.nutrition_date,(item.meals||[]).join(', '),item.symptom_date,item.offset_days]);if(nutritionNear.length){temporal.append(element('h4','Ernährungstage in zeitlicher Nähe'));temporal.append(table(['Ernährungstag','Zusammengefasste Mahlzeiten','Dokumentiertes Symptom am','Tage Abstand'],nutritionNear));}if(!temporalRows.length&&!nutritionNear.length)empty(temporal); if(data.selected_sections.includes('timeline')){const part=section('Chronologische Ereignisübersicht');const rows=(data.timeline||[]).map(item=>[item.date,item.label||'Dokumentiertes Ereignis']);rows.length?part.append(table(['Datum','Ereignis'],rows)):empty(part);} const selectedChartKeys=(filters.charts||[]).slice(0,4); if(selectedChartKeys.length){const chartSection=section('Ausgewählte Verlaufsgrafiken');chartSection.classList.add('doctor-summary-charts');const observations=Object.fromEntries((data.observations||[]).map(item=>[item.metric,item]));const labParameter=(data.labs||[])[0]?.parameter;const specs={ labs:{title:labParameter?`Labor: ${labParameter}`:'Laborverlauf',unit:(data.labs||[]).find(item=>item.parameter===labParameter)?.unit||'',context:'Laborreferenz ausschließlich aus dem jeweiligen Originalbericht; Vergleichsoperatoren bleiben tabellarisch',series:labParameter?[{name:labParameter,points:(data.labs||[]).filter(item=>item.parameter===labParameter&&item.value_kind==='numeric'&&!item.operator).map(item=>[item.date,item.value])}]:[]}, sleep_hrv:{title:'Schlaf und HRV',unit:'getrennte Einheiten',context:'Persönlicher Vergleich; kein Normalbereich',series:[observations['apple.sleep'],observations['apple.hrv']].filter(Boolean).map(item=>({name:item.label,unit:item.unit,points:(item.points||[]).map(point=>[point.date,point.value])}))}, resting_hr:{title:'Ruhepuls',unit:observations['apple.resting_heart_rate']?.unit||'',context:'Persönlicher Vergleich; kein Normalbereich',series:observations['apple.resting_heart_rate']?[{name:observations['apple.resting_heart_rate'].label,points:(observations['apple.resting_heart_rate'].points||[]).map(point=>[point.date,point.value])}]:[]}, symptoms:{title:'Beschwerden und Aphten',unit:'Dokumentierter Score',context:'Persönlicher Vergleich; keine generische medizinische Bewertung',series:[{name:'Symptomscore',points:(data.symptoms||[]).map(item=>[item.date,item.symptoms?.total??null])}]}, histamine:{title:'Histamin-Zuordnungsindex',unit:'Index',context:'Dokumentierte Zuordnung; keine Verträglichkeits- oder Entzündungsbewertung',series:[{name:'Index',points:(data.nutrition||[]).map(item=>[item.date,item.histamine?.score??null])}]}, nutrition_factors:{title:'Zucker, Ballaststoffe und gesättigte Fettsäuren',unit:'g',context:'Ernährungsorientierung; YAZIO bleibt Primärquelle',series:[['Zucker','nutrient.sugar'],['Ballaststoffe','nutrient.dietaryfiber'],['Gesättigte Fettsäuren','nutrient.saturated']].map(([name,key])=>({name,points:(data.nutrition||[]).map(item=>[item.date,item.nutrients?.[key]?.total_value??item.nutrients?.[key]?.value??null])}))} - };sessionChartSpecs.forEach((stored,index)=>{specs[`session_${index}`]={title:String(stored.title||'Explorer-Grafik'),unit:String(stored.unit||''),context:String(stored.context||'Zeitliche Beobachtung ohne Kausalitätsaussage'),period:stored.period||null,coverage:stored.coverage||null,markers:Array.isArray(stored.markers)?stored.markers:[],series:Array.isArray(stored.series)?stored.series.map(item=>({name:String(item.name||'Dokumentierter Wert'),unit:String(item.unit||stored.unit||''),points:Array.isArray(item.points)?item.points:[],baseline:Array.isArray(item.baseline)?item.baseline:[]})):[]};});const markers=[...(data.medications?.administered||[]).map(item=>({date:item.date,label:'Medikament'})),...(data.supplements?.administered||[]).map(item=>({date:item.date,label:'Supplement'}))];const draws=[];selectedChartKeys.forEach(key=>{const spec=specs[key];if(!spec)return;const article=element('article',undefined,'doctor-summary-chart');const title=element('h4',spec.title);const period=spec.period?.from&&spec.period?.to?spec.period:{from:data.from,to:data.to};const documentedPoints=spec.series.flatMap(item=>item.points).filter(point=>point[1]!==null&&point[1]!==undefined);const pointCount=documentedPoints.length;const documentedDays=spec.coverage?.observed_days??new Set(documentedPoints.map(point=>point[0])).size;const expectedDays=spec.coverage?.expected_days??Math.max(1,Math.round((new Date(`${period.to}T12:00:00Z`)-new Date(`${period.from}T12:00:00Z`))/86400000)+1);const gapCount=Math.max(0,expectedDays-documentedDays);article.dataset.reportChartKey=key;article.append(title,element('p',`Einheit: ${spec.unit||'Nicht dokumentiert'} · Zeitraum: ${period.from} bis ${period.to} · Punkte: ${pointCount} · Abdeckung: ${documentedDays}/${expectedDays} Tage · Datenlücken: ${gapCount} Tage · Kontext: ${spec.context}`,'v5-meta'),element('p','Zeitliche Beobachtung; keine Kausalitätsaussage.','doctor-summary-disclaimer'));const chart=element('div',undefined,'doctor-summary-echart');chart.setAttribute('role','img');chart.setAttribute('aria-label',`${spec.title}, ${period.from} bis ${period.to}`);article.append(chart);chartSection.append(article);draws.push({chart,spec,period});});setTimeout(()=>draws.forEach(({chart,spec,period})=>{if(!window.echarts||!chart.isConnected)return;const instance=window.echarts.init(chart,null,{renderer:'canvas'});const units=[...new Set(spec.series.map(item=>item.unit||spec.unit||''))];instance.setOption({animation:!window.matchMedia('(prefers-reduced-motion: reduce)').matches,animationDuration:window.matchMedia('(prefers-reduced-motion: reduce)').matches?0:280,animationDurationUpdate:window.matchMedia('(prefers-reduced-motion: reduce)').matches?0:240,aria:{enabled:true,description:`${spec.title}; dokumentierter Verlauf; keine Kausalitätsaussage.`},title:{text:spec.title,subtext:`${period.from} bis ${period.to}`},legend:{show:true,top:42},tooltip:{trigger:'axis'},grid:{left:64,right:36,top:84,bottom:48,containLabel:true},xAxis:{type:'time',name:'Datum'},yAxis:units.map((unit,index)=>({type:'value',name:unit||spec.unit||'',position:index?'right':'left',scale:true})),series:spec.series.flatMap((item,index)=>{const axis=Math.max(0,units.indexOf(item.unit||spec.unit||''));const main={name:item.name,type:'line',connectNulls:false,showSymbol:true,yAxisIndex:axis,data:item.points,markLine:{silent:true,symbol:'none',lineStyle:{type:'dashed',color:'#6b7280'},data:(spec.markers?.length?spec.markers:markers).map(marker=>({xAxis:marker.date,name:'Dokumentiertes Ereignis'}))}};const baseline=Array.isArray(item.baseline)&&item.baseline.length?{name:`${item.name} · persönliche Baseline`,type:'line',connectNulls:false,showSymbol:false,silent:true,yAxisIndex:axis,data:item.baseline,lineStyle:{type:'dashed',color:'#5c5484'}}:null;return baseline?[main,baseline]:[main];})});window.addEventListener('beforeprint',()=>instance.resize(),{once:true});}),0);} + };sessionChartSpecs.forEach((stored,index)=>{specs[`session_${index}`]={title:String(stored.title||'Explorer-Grafik'),unit:String(stored.unit||''),context:String(stored.context||'Zeitliche Beobachtung ohne Kausalitätsaussage'),period:stored.period||null,coverage:stored.coverage||null,markers:Array.isArray(stored.markers)?stored.markers:[],series:Array.isArray(stored.series)?stored.series.map(item=>({name:String(item.name||'Dokumentierter Wert'),unit:String(item.unit||stored.unit||''),points:Array.isArray(item.points)?item.points:[],baseline:Array.isArray(item.baseline)?item.baseline:[]})):[]};});const effectiveMedicationMarkers=[...(data.medications?.administered||[]),...(data.medications?.corrected||[])].filter(item=>!item.superseded_by_correction&&(item.status==='administered'||(item.status==='corrected'&&item.effective_status==='administered'))).map(item=>({date:item.date,label:'Medikament'}));const markers=[...effectiveMedicationMarkers,...(data.supplements?.administered||[]).map(item=>({date:item.date,label:'Supplement'}))];const draws=[];selectedChartKeys.forEach(key=>{const spec=specs[key];if(!spec)return;const article=element('article',undefined,'doctor-summary-chart');const title=element('h4',spec.title);const period=spec.period?.from&&spec.period?.to?spec.period:{from:data.from,to:data.to};const documentedPoints=spec.series.flatMap(item=>item.points).filter(point=>point[1]!==null&&point[1]!==undefined);const pointCount=documentedPoints.length;const documentedDays=spec.coverage?.observed_days??new Set(documentedPoints.map(point=>point[0])).size;const expectedDays=spec.coverage?.expected_days??Math.max(1,Math.round((new Date(`${period.to}T12:00:00Z`)-new Date(`${period.from}T12:00:00Z`))/86400000)+1);const gapCount=Math.max(0,expectedDays-documentedDays);article.dataset.reportChartKey=key;article.append(title,element('p',`Einheit: ${spec.unit||'Nicht dokumentiert'} · Zeitraum: ${period.from} bis ${period.to} · Punkte: ${pointCount} · Abdeckung: ${documentedDays}/${expectedDays} Tage · Datenlücken: ${gapCount} Tage · Kontext: ${spec.context}`,'v5-meta'),element('p','Zeitliche Beobachtung; keine Kausalitätsaussage.','doctor-summary-disclaimer'));const chart=element('div',undefined,'doctor-summary-echart');chart.setAttribute('role','img');chart.setAttribute('aria-label',`${spec.title}, ${period.from} bis ${period.to}`);article.append(chart);chartSection.append(article);draws.push({chart,spec,period});});setTimeout(()=>draws.forEach(({chart,spec,period})=>{if(!window.echarts||!chart.isConnected)return;const instance=window.echarts.init(chart,null,{renderer:'canvas'});const units=[...new Set(spec.series.map(item=>item.unit||spec.unit||''))];instance.setOption({animation:!window.matchMedia('(prefers-reduced-motion: reduce)').matches,animationDuration:window.matchMedia('(prefers-reduced-motion: reduce)').matches?0:280,animationDurationUpdate:window.matchMedia('(prefers-reduced-motion: reduce)').matches?0:240,aria:{enabled:true,description:`${spec.title}; dokumentierter Verlauf; keine Kausalitätsaussage.`},title:{text:spec.title,subtext:`${period.from} bis ${period.to}`},legend:{show:true,top:42},tooltip:{trigger:'axis'},grid:{left:64,right:36,top:84,bottom:48,containLabel:true},xAxis:{type:'time',name:'Datum'},yAxis:units.map((unit,index)=>({type:'value',name:unit||spec.unit||'',position:index?'right':'left',scale:true})),series:spec.series.flatMap((item,index)=>{const axis=Math.max(0,units.indexOf(item.unit||spec.unit||''));const main={name:item.name,type:'line',connectNulls:false,showSymbol:true,yAxisIndex:axis,data:item.points,markLine:{silent:true,symbol:'none',lineStyle:{type:'dashed',color:'#6b7280'},data:(spec.markers?.length?spec.markers:markers).map(marker=>({xAxis:marker.date,name:'Dokumentiertes Ereignis'}))}};const baseline=Array.isArray(item.baseline)&&item.baseline.length?{name:`${item.name} · persönliche Baseline`,type:'line',connectNulls:false,showSymbol:false,silent:true,yAxisIndex:axis,data:item.baseline,lineStyle:{type:'dashed',color:'#5c5484'}}:null;return baseline?[main,baseline]:[main];})});window.addEventListener('beforeprint',()=>instance.resize(),{once:true});}),0);} const print=button('Drucken / als PDF speichern',()=>window.print());print.className='doctor-summary-print';node.append(printable,print);return node; } async function renderOverview(current) { const data = await request('/api/v1/record-summary', current); const node = card('Übersicht'); node.classList.add('record-overview'); const quick=element('nav',undefined,'record-quick-actions');quick.setAttribute('aria-label','Direkte Aktenbereiche');[['Labore','labs'],['Medikamente','medications'],['Termine','appointments'],['Dokumente','documents'],['Offene Dokumentprüfungen','documents']].forEach(([label,tab])=>quick.append(button(label,()=>render(tab,{push:true,filters:label.startsWith('Offene')?{queue:'now_reviewable'}:{}}))));node.append(quick); const kpis = element('div', undefined, 'record-kpi-grid'); const addKpi = (label, primary, secondary) => { const item=element('article',undefined,'record-kpi'); item.append(element('span',label),element('strong',primary||'Nicht dokumentiert'),element('small',secondary||'')); kpis.append(item); }; const lab=data.labs?.[0], administered=data.latest_administered?.[0], planned=data.next_planned?.[0], appointment=data.appointments?.[0], documentItem=data.documents?.[0]; addKpi('Letzte verifizierte Laboruntersuchung',lab?.date,lab?.parameter); addKpi('Letzte tatsächlich verabreichte Medikation',administered?.date,administered?.name); addKpi('Nächste dokumentierte Planung',planned?.date,planned?.name); addKpi('Dokumentierter Termin',appointment?.date,appointment?.institution); addKpi('Neuestes Dokument',documentItem?.document_date,documentItem?.category); addKpi('Ungeprüfte Dokumente',String(data.documents_not_reviewed ?? 0),'Metadaten bleiben sichtbar'); node.append(kpis); const groups=element('div',undefined,'record-overview-groups'); const addGroup=(title,items,formatter,tab)=>{const section=element('section',undefined,'v5-surface record-overview-group');const heading=element('div',undefined,'section-heading');heading.append(element('h4',title),button('Alle anzeigen',()=>render(tab,{push:true})));section.append(heading);if(!items.length)appendEmpty(section);else setList(section,items.slice(0,3),formatter);groups.append(section);}; addGroup('Labor',data.labs||[],item=>`${item.date} · ${item.parameter} · ${text(item.display_value ?? `${item.operator||''}${item.value}`)} ${text(item.unit)}`,'labs'); addGroup('Medikamente',[...(data.latest_administered||[]),...(data.next_planned||[])],item=>`${item.date} · ${item.name} · ${item.dose||'Dosis nicht dokumentiert'}`,'medications'); addGroup('Termine',data.appointments||[],item=>`${item.date} · ${item.institution||'Institution nicht dokumentiert'}`,'appointments'); addGroup('Dokumente',data.documents||[],item=>`${item.document_date||'Datum unbekannt'} · ${item.category||'Dokument'} · ${item.review_status==='geprueft'?'Geprüft':'Ungeprüft'}`,'documents'); node.append(groups); return node; } async function render(tab, options = {}) { if (!allowedTabs.has(tab)) return; const current = ++version; if (options.restore) restoreFilters(options.restore); const filters = sanitizeFilters(tab, options.filters ?? filtersByTab[tab]); filtersByTab[tab] = filters; if (options.push) setRoute(tab, { filters }); else if (options.replace) setRoute(tab, { filters, replace: true }); activate(tab, Boolean(options.focus)); content.replaceChildren(); status.textContent = 'Akte wird geladen …'; try { let node; if (tab === 'overview') node = await renderOverview(current); if (tab === 'labs') node = await renderLabs(filters, current); if (tab === 'medications') node = await renderMedications(filters, current); if (tab === 'appointments') node = await renderAppointments(filters, current); if (tab === 'documents') node = await renderDocuments(filters, current, options.selectedDocument || null, options.preview || null); if (tab === 'report') node = await renderReport(filters, current); if (current !== version || !node) return; content.replaceChildren(node); status.textContent = ''; } catch (error) { if (error !== STALE_REQUEST && current === version) { content.replaceChildren(errorCard(tab, error, () => render(tab, { filters, focus: true }))); status.textContent = `${tabLabels[tab] || 'Bereich'} konnte nicht geladen werden.`; } } } window.healthRecordOpenTab = (tab, filters = {}) => { if (allowedTabs.has(tab)) render(tab, { push: true, focus: true, filters }); }; window.healthRecordOpenDocumentUpload = () => { render('documents',{push:true,focus:true}).then(()=>{if(uploadDialog&&!uploadDialog.open){uploadDialog.showModal();uploadForm?.querySelector('[data-document-upload-file]')?.focus();}}); }; window.healthRecordOpenDocument = id => { if (/^api-document-[a-f0-9]{24}$/.test(String(id || ''))) { setRoute('documents', { documentId: id, filters: filtersByTab.documents || {} }); render('documents', { filters: filtersByTab.documents || {}, selectedDocument: id, focus: true }); } }; tabs.forEach(node => node.addEventListener('click', () => render(node.dataset.recordTab, { push: true, focus: true }))); shell.querySelector('[data-record-back]')?.addEventListener('click', () => render('overview', { push: true, focus: true })); document.querySelector('[data-dashboard-view="doctor"]')?.addEventListener('click', () => render('overview', { replace: true, focus: true })); window.addEventListener('popstate', () => { const state = route(); if (state) { restoreFilters(history.state); if (Object.keys(state.filters || {}).length) filtersByTab[state.tab] = state.filters; const filters = filtersByTab[state.tab]; if (state.tab === 'documents' && state.documentId) render('documents', { filters, selectedDocument: state.documentId, preview: state.preview }); else if (state.documentId) openDocument(state.documentId, state.tab, { write: false, filters }); else render(state.tab, { filters }); } else { version += 1; diff --git a/tests/test_dashboard_v5_sprint6b_api.py b/tests/test_dashboard_v5_sprint6b_api.py index e3a9704..15eabe9 100644 --- a/tests/test_dashboard_v5_sprint6b_api.py +++ b/tests/test_dashboard_v5_sprint6b_api.py @@ -480,160 +480,161 @@ def test_planned_medication_rows_cannot_hide_a_later_actual_administration(tmp_p database = api_database(tmp_path) connection = sqlite3.connect(database) try: connection.executemany( """INSERT INTO medication_administrations(datum,medication_name,event_type) VALUES(?,?,?)""", [(ANCHOR.isoformat(), f"Plan-{index:05d}", "planned") for index in range(10001)], ) connection.execute( """INSERT INTO medication_administrations(datum,medication_name,event_type) VALUES(?,?,?)""", (ANCHOR.isoformat(), "Synthetic actual", "administered"), ) connection.commit() finally: connection.close() response = dispatch_api( database, "/api/v1/events", f"from={ANCHOR.isoformat()}&to={ANCHOR.isoformat()}&types=medication_administered", ) assert any(item["label"] == "Synthetic actual" for item in response["events"]) def test_sqlite_query_deadline_is_enforced_as_a_controlled_api_error(tmp_path, monkeypatch): database = api_database(tmp_path) monkeypatch.setattr(read_api, "QUERY_TIMEOUT_SECONDS", -1.0) with pytest.raises(APIError, match="data_unavailable"): dispatch_api(database, "/api/v1/metric-catalog", "") def test_availability_matches_incomplete_symptom_and_ambiguous_or_unreferenced_labs(tmp_path): database = api_database(tmp_path) incomplete_day = (ANCHOR - timedelta(days=9)).isoformat() connection = sqlite3.connect(database) lab_columns = """parameter_name,wert,einheit,reference_min,reference_max, abnahme_datum,wert_original,validierungsstatus,source_type, reference_range_source,verified_against_original,provenance_note""" try: connection.execute("DELETE FROM symptom_log WHERE datum<>?", (incomplete_day,)) connection.execute("DELETE FROM laborwerte") connection.executemany( f"INSERT INTO laborwerte({lab_columns}) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", [ ("CRP", value, "mg/L", "0", "5", "2026-05-01", value, "validiert", "scanned_original", "scanned_original", 1, "synthetic_duplicate") for value in ("1", "2") ], ) connection.commit() finally: connection.close() symptom_catalog = dispatch_api(database, "/api/v1/metric-catalog", "q=Symptome") symptom_metric = next( item for item in symptom_catalog["groups"]["metrics"] if item["id"] == "symptom.total" ) assert symptom_metric["availability"] == {"status": "supported_no_data", "observations": 0} assert dispatch_api(database, "/api/v1/series", "metric=symptom.total&resolution=day")["points"] == [] crp = dispatch_api(database, "/api/v1/metric-catalog", "q=CRP")["groups"]["metrics"][0] assert crp["availability"] == {"status": "supported_no_data", "observations": 0} assert dispatch_api(database, "/api/v1/labs", "")["observations"] == [] connection = sqlite3.connect(database) try: connection.execute("DELETE FROM laborwerte") connection.execute( f"INSERT INTO laborwerte({lab_columns}) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", ("CRP", "1", "mg/L", "9" * 400, "<5", "2026-05-01", "1", "validiert", "scanned_original", "scanned_original", 1, "synthetic_bad_reference"), ) connection.commit() finally: connection.close() crp = dispatch_api(database, "/api/v1/metric-catalog", "q=CRP")["groups"]["metrics"][0] assert crp["availability"] == {"status": "supported_no_data", "observations": 0} assert dispatch_api(database, "/api/v1/labs", "")["observations"] == [] def load_server(name: str, dashboard: Path, database: Path, token_file: Path, monkeypatch): monkeypatch.delenv("HEALTH_DASHBOARD_TEST_INSTANCE_ID", raising=False) monkeypatch.delenv("HEALTH_DASHBOARD_ACTION_INBOX", raising=False) + monkeypatch.delenv("HEALTH_DASHBOARD_BASIC_PASSWORD_FILE", raising=False) monkeypatch.setenv("HEALTH_DASHBOARD_FILE", str(dashboard)) monkeypatch.setenv("HEALTH_DASHBOARD_V5_FILE", str(dashboard)) monkeypatch.setenv("HEALTH_DASHBOARD_DB", str(database)) monkeypatch.setenv("HEALTH_DASHBOARD_API_TOKEN_FILE", str(token_file)) spec = importlib.util.spec_from_file_location(name, SERVER) assert spec and spec.loader module = importlib.util.module_from_spec(spec) sys.modules[name] = module spec.loader.exec_module(module) return module def test_http_api_requires_private_bearer_token_and_never_caches(tmp_path, monkeypatch): database = api_database(tmp_path) dashboard = tmp_path / "dashboard.html" dashboard.write_text("", encoding="utf-8") token = "synthetic_api_token_0123456789abcdef" token_file = tmp_path / "api-token" token_file.write_text(token, encoding="ascii") token_file.chmod(0o600) module = load_server("health_dashboard_server_sprint6b", dashboard, database, token_file, monkeypatch) assert module.API_DB == database.resolve() assert module.DB != database.resolve() server = module.ThreadingHTTPServer(("127.0.0.1", 0), module.Handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() base = f"http://127.0.0.1:{server.server_port}" try: bad_host = urllib.request.Request( base + "/api/v1/labs", headers={"Host": "attacker.invalid"}, ) with pytest.raises(urllib.error.HTTPError) as rejected_host: urllib.request.urlopen(bad_host, timeout=5) assert rejected_host.value.code == 421 assert rejected_host.value.headers["Cache-Control"] == "no-store" with pytest.raises(urllib.error.HTTPError) as missing: urllib.request.urlopen(base + "/api/v1/metric-catalog?q=HRV", timeout=5) assert missing.value.code == 401 assert missing.value.headers["Cache-Control"] == "no-store" bad = urllib.request.Request( base + "/api/v1/metric-catalog?q=HRV", headers={"Authorization": "Bearer wrong-token-0123456789abcdef"}, ) with pytest.raises(urllib.error.HTTPError) as denied: urllib.request.urlopen(bad, timeout=5) assert denied.value.code == 401 request = urllib.request.Request( base + "/api/v1/metric-catalog?q=HRV", headers={"Authorization": f"Bearer {token}"}, ) response = urllib.request.urlopen(request, timeout=5) payload = json.loads(response.read()) assert response.status == 200 assert response.headers["Cache-Control"] == "no-store" assert response.headers["Content-Type"] == "application/json; charset=utf-8" assert response.headers["X-Content-Type-Options"] == "nosniff" foreign_origin = urllib.request.Request( base + "/api/v1/search?q=HRV", headers={ "Authorization": f"Bearer {token}", "Origin": "https://attacker.invalid", }, ) with pytest.raises(urllib.error.HTTPError) as cross_origin: urllib.request.urlopen(foreign_origin, timeout=5) assert cross_origin.value.code == 403 assert cross_origin.value.headers["Cache-Control"] == "no-store" cross_site = urllib.request.Request( base + "/api/v1/search?q=HRV", headers={ "Authorization": f"Bearer {token}", "Sec-Fetch-Site": "cross-site", }, ) with pytest.raises(urllib.error.HTTPError) as cross_site_error: urllib.request.urlopen(cross_site, timeout=5) assert cross_site_error.value.code == 403 assert payload["groups"]["metrics"][0]["id"] == "apple.hrv" assert_no_forbidden_fields(payload) document_request = urllib.request.Request( @@ -681,107 +682,108 @@ def test_http_api_requires_private_bearer_token_and_never_caches(tmp_path, monke def raw_method(target: str, host: str) -> bytes: with socket.create_connection(("127.0.0.1", server.server_port), timeout=5) as client: client.sendall( f"PROPFIND {target} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n".encode("ascii") ) chunks = [] while chunk := client.recv(65536): chunks.append(chunk) return b"".join(chunks) absolute = raw_method(f"http://127.0.0.1:{server.server_port}/api/v1/labs", f"127.0.0.1:{server.server_port}") assert b" 405 " in absolute.split(b"\r\n", 1)[0] assert b"Content-Type: application/json; charset=utf-8" in absolute assert b"Cache-Control: no-store" in absolute invalid_absolute_host = raw_method( f"http://127.0.0.1:{server.server_port}/api/v1/labs", "attacker.invalid" ) assert b" 421 " in invalid_absolute_host.split(b"\r\n", 1)[0] assert b"Cache-Control: no-store" in invalid_absolute_host head = urllib.request.Request( base + "/api/v1/labs", method="HEAD", headers={"Authorization": f"Bearer {token}"}, ) assert urllib.request.urlopen(head, timeout=5).read() == b"" original_dispatch = getattr(module, "dispatch_api") setattr(module, "dispatch_api", lambda *_args: {"data": "x" * 512_001}) try: with pytest.raises(urllib.error.HTTPError) as oversized: urllib.request.urlopen(request, timeout=5) assert oversized.value.code == 422 assert json.load(oversized.value) == {"error": {"code": "response_too_large"}} assert oversized.value.headers["Cache-Control"] == "no-store" finally: setattr(module, "dispatch_api", original_dispatch) setattr(module, "dispatch_api", lambda *_args: (_ for _ in ()).throw(RuntimeError("synthetic"))) try: with pytest.raises(urllib.error.HTTPError) as internal: urllib.request.urlopen(request, timeout=5) assert internal.value.code == 500 assert json.load(internal.value) == {"error": {"code": "internal_error"}} assert internal.value.headers["Cache-Control"] == "no-store" finally: setattr(module, "dispatch_api", original_dispatch) setattr(module, "dispatch_api", lambda *_args: {"not_serializable": {1, 2}}) try: with pytest.raises(urllib.error.HTTPError) as serialization: urllib.request.urlopen(request, timeout=5) assert serialization.value.code == 500 assert json.load(serialization.value) == {"error": {"code": "internal_error"}} assert serialization.value.headers["Cache-Control"] == "no-store" finally: setattr(module, "dispatch_api", original_dispatch) token_file.chmod(0o644) with pytest.raises(urllib.error.HTTPError) as insecure: urllib.request.urlopen(request, timeout=5) assert insecure.value.code == 503 token_file.chmod(0o600) token_alias = tmp_path / "api-token-link" token_alias.symlink_to(token_file) setattr(module, "API_TOKEN_FILE", token_alias) with pytest.raises(urllib.error.HTTPError) as symlinked: urllib.request.urlopen(request, timeout=5) assert symlinked.value.code == 503 finally: server.shutdown() server.server_close() thread.join(timeout=5) assert stat.S_IMODE(token_file.stat().st_mode) == 0o600 def test_http_api_is_fail_closed_without_explicit_database_and_token_paths(tmp_path, monkeypatch): dashboard = tmp_path / "dashboard.html" dashboard.write_text("", encoding="utf-8") monkeypatch.delenv("HEALTH_DASHBOARD_TEST_INSTANCE_ID", raising=False) monkeypatch.delenv("HEALTH_DASHBOARD_ACTION_INBOX", raising=False) + monkeypatch.delenv("HEALTH_DASHBOARD_BASIC_PASSWORD_FILE", raising=False) monkeypatch.setenv("HEALTH_DASHBOARD_FILE", str(dashboard)) monkeypatch.setenv("HEALTH_DASHBOARD_V5_FILE", str(dashboard)) monkeypatch.delenv("HEALTH_DASHBOARD_DB", raising=False) monkeypatch.delenv("HEALTH_DASHBOARD_API_TOKEN_FILE", raising=False) spec = importlib.util.spec_from_file_location("health_dashboard_server_sprint6b_disabled", SERVER) assert spec and spec.loader module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) server = module.ThreadingHTTPServer(("127.0.0.1", 0), module.Handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: with urllib.request.urlopen( f"http://127.0.0.1:{server.server_port}/health-dashboard-v5", timeout=5 ) as response: assert response.status == 200 with pytest.raises(urllib.error.HTTPError) as disabled: urllib.request.urlopen( f"http://127.0.0.1:{server.server_port}/api/v1/labs", timeout=5 ) assert disabled.value.code == 503 assert disabled.value.headers["Cache-Control"] == "no-store" finally: server.shutdown() server.server_close() thread.join(timeout=5) diff --git a/tests/test_dashboard_v5_sprint7c_f1.py b/tests/test_dashboard_v5_sprint7c_f1.py index 991947a..81dcd37 100644 --- a/tests/test_dashboard_v5_sprint7c_f1.py +++ b/tests/test_dashboard_v5_sprint7c_f1.py @@ -122,168 +122,299 @@ def test_exact_unknown_status_historical_hyrimoz_capture_and_replay(tmp_path: Pa monkeypatch.setattr(worker, "DASHBOARD_DB", database) monkeypatch.setattr(worker, "ACTION_INBOX", action_dir) monkeypatch.setattr(worker, "CAPTURE_MEDIA", media_dir) monkeypatch.setattr(worker, "CAPTURE_QUARANTINE", quarantine_dir) first = worker.apply_capture_action(payload) second = worker.apply_capture_action(payload) assert first == second action_hash = hashlib.sha256(worker._canonical_json(payload).encode()).hexdigest() worker.write_capture_receipt(payload["idempotency_key"], "processed", action_hash) receipt = json.loads((action_dir / "capture-receipts" / f"{payload['idempotency_key']}.json").read_text()) assert receipt == { "version": 2, "status": "processed", "action_hash": action_hash, "processed_at": receipt["processed_at"], } connection = sqlite3.connect(database) connection.row_factory = sqlite3.Row rows = connection.execute( """SELECT occurred_at,actual_quantity_value,actual_dosage_form,actual_strength, route_normalized,injection_region,injection_side,injection_detail,notes FROM medication_administrations WHERE source='dashboard_medication_action_version_two'""" ).fetchall() assert connection.execute("SELECT count(*) FROM medication_administrations").fetchone()[0] == before + 1 assert len(rows) == 1 assert dict(rows[0]) == { "occurred_at": "2026-08-17T12:32", "actual_quantity_value": "1", "actual_dosage_form": "Spritze", "actual_strength": "40 mg/0,4 ml", "route_normalized": "subcutaneous", "injection_region": "Oberschenkel", "injection_side": "right", "injection_detail": "rechtes Bein", "notes": "Spritze wie üblich.", } status = connection.execute( "SELECT prescription_status FROM medikamente WHERE medikament_name='Hyrimoz / Adalimumab'" ).fetchone()[0] assert status == "unknown" day = dispatch_api(database, "/api/v1/day/2026-08-17", "") assert day["medications"]["administered"] == [{ "name": "Hyrimoz / Adalimumab", "dose": "1 Spritze · 40 mg/0,4 ml", "route": "subkutan", "note": "Spritze wie üblich.", "source": "dashboard_medication_action_version_two", }] calendar = dispatch_api(database, "/api/v1/calendar", "from=2026-08-17&to=2026-08-17") assert calendar["days"][0]["counts"]["medications"] >= 1 assert "has_medications" in calendar["days"][0]["codes"] report = dispatch_api( database, "/api/v1/doctor-report", "from=2026-08-17&to=2026-08-17§ions=medications", ) assert len(report["medications"]["administered"]) == 1 assert report["medications"]["administered"][0]["actual_strength"] == "40 mg/0,4 ml" def test_preset_match_needs_no_deviation_but_actual_deviation_does(tmp_path: Path) -> None: database, _, rx = _case(tmp_path) matching = validate_capture_payload(_payload(rx)) connection = sqlite3.connect(database) connection.row_factory = sqlite3.Row assert resolve_action_preview(connection, matching)[0] changed = validate_capture_payload(_payload(rx, data={"strength": "80 mg/0,8 ml"})) with pytest.raises(RuntimeError, match="differs from verified preset"): resolve_action_preview(connection, changed) confirmed = validate_capture_payload(_payload(rx, data={"strength": "80 mg/0,8 ml", "deviation_confirmed": True})) assert resolve_action_preview(connection, confirmed)[0] route_changed = validate_capture_payload(_payload(rx, data={"route_original": "s.c."})) with pytest.raises(RuntimeError, match="differs from verified preset"): resolve_action_preview(connection, route_changed) connection.execute( "UPDATE medikamente SET administration_preset_strength='80 mg/0,8 ml' WHERE medikament_name='Hyrimoz / Adalimumab'" ) connection.commit() with pytest.raises(RuntimeError, match="preset is no longer available"): resolve_action_preview(connection, matching) with pytest.raises(MedicationFieldError) as invalid: validate_capture_payload(_payload(rx, data={"strength": "/home/private"})) assert invalid.value.field == "strength" + with pytest.raises(MedicationFieldError) as invalid_quantity: + validate_capture_payload(_payload(rx, data={"quantity_value": "1 Spritze"})) + assert invalid_quantity.value.field == "quantity_value" + + +def test_structured_correction_is_append_only_idempotent_and_effective_once( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + database, _, rx = _case(tmp_path) + connection = sqlite3.connect(database) + connection.row_factory = sqlite3.Row + selected_id = connection.execute( + """SELECT id FROM medikamente + WHERE medikament_name=? AND administration_preset_revision IS NOT NULL""", + (rx["name"],), + ).fetchone()[0] + preset_columns = ( + "administration_preset_quantity_value=NULL,administration_preset_dosage_form=NULL," + "administration_preset_strength=NULL,administration_preset_route_original=NULL," + "administration_preset_route=NULL,administration_preset_source=NULL," + "administration_preset_provenance=NULL,administration_preset_revision=NULL" + ) + connection.execute(f"UPDATE medikamente SET {preset_columns} WHERE id=?", (selected_id,)) + connection.execute( + "INSERT INTO medikamente(medikament_name,anwendungsform,business_revision) VALUES(?,?,?)", + ("Hyrimoz / Adalimumab", "subkutan", "f" * 64), + ) + duplicate_id = connection.execute("SELECT last_insert_rowid()").fetchone()[0] + connection.commit() + connection.close() + + for name in ("actions", "media", "quarantine"): + (tmp_path / name).mkdir(mode=0o700) + monkeypatch.setattr(worker, "DASHBOARD_DB", database) + monkeypatch.setattr(worker, "ACTION_INBOX", tmp_path / "actions") + monkeypatch.setattr(worker, "CAPTURE_MEDIA", tmp_path / "media") + monkeypatch.setattr(worker, "CAPTURE_QUARANTINE", tmp_path / "quarantine") + + original = validate_capture_payload( + _payload( + rx, + data={ + "strength": "40 mg", "route_original": "", "injection_region": "", + "injection_detail": "Rechtes Bein", "note": "", "preset_revision": "", + }, + ) + ) + connection = sqlite3.connect(database); connection.row_factory = sqlite3.Row + original["data"]["preview_revision"] = resolve_action_preview(connection, original)[0] + connection.close() + original_entry = worker.apply_capture_action(original) + + api = dispatch_api(database, "/api/v1/medications", "from=2026-08-17&to=2026-08-17") + target = next(item for item in api["administered"] if item["name"] == rx["name"]) + assert target["capture_entry_ref"] == original_entry + baseline = target["correction_administration_preset"] + correction = validate_capture_payload( + _payload( + rx, + idempotency_key="fedcba9876543210fedcba9876543210", + corrects_entry_id=original_entry, + data={ + "mode": "correction", "status": "corrected", "quantity_value": "1", + "dosage_form": "Spritze", "strength": "40 mg/0,4 ml", + "route_original": "", "route_normalized": "subcutaneous", + "injection_region": "", "injection_side": "right", + "injection_detail": "Rechtes Bein", "note": "", + "preset_revision": baseline["preset_revision"], + "correction_target_ref": target["id"], + "correction_reason": "Strukturierte Menge und vollständige Stärke berichtigt.", + "bind_verified_preset": True, "deviation_confirmed": True, + }, + ) + ) + connection = sqlite3.connect(database); connection.row_factory = sqlite3.Row + revision, resolved_rx, planned, resolved_target = resolve_action_preview(connection, correction) + assert planned is None and int(resolved_rx["id"]) == selected_id + assert int(resolved_target["id"]) > 0 + correction["data"]["preview_revision"] = revision + connection.close() + + corrected_entry = worker.apply_capture_action(correction) + assert worker.apply_capture_action(correction) == corrected_entry + + connection = sqlite3.connect(database); connection.row_factory = sqlite3.Row + rows = connection.execute( + """SELECT event_type,actual_quantity_value,actual_dosage_form,actual_strength, + route_original,route_normalized,injection_region,injection_side,injection_detail, + corrects_event_id,corrected_target_status + FROM medication_administrations + WHERE source='dashboard_medication_action_version_two' ORDER BY id""" + ).fetchall() + assert [row["event_type"] for row in rows] == ["administered", "corrected"] + assert tuple(rows[0][1:5]) == ("1", "Spritze", "40 mg", None) + assert tuple(rows[1][1:9]) == ( + "1", "Spritze", "40 mg/0,4 ml", None, "subcutaneous", None, "right", "Rechtes Bein" + ) + assert rows[1]["corrects_event_id"] is not None + assert rows[1]["corrected_target_status"] == "administered" + assert connection.execute( + "SELECT administration_preset_strength FROM medikamente WHERE id=?", (selected_id,) + ).fetchone()[0] == "40 mg/0,4 ml" + assert connection.execute( + "SELECT administration_preset_strength FROM medikamente WHERE id=?", (duplicate_id,) + ).fetchone()[0] is None + assert connection.execute("PRAGMA integrity_check").fetchone()[0] == "ok" + assert connection.execute("PRAGMA foreign_key_check").fetchall() == [] + connection.close() + + api = dispatch_api(database, "/api/v1/medications", "from=2026-08-17&to=2026-08-17") + administered_history = [item for item in api["administered"] if item["name"] == rx["name"]] + corrected_history = [item for item in api["corrected"] if item["name"] == rx["name"]] + assert len(administered_history) == len(corrected_history) == 1 + assert administered_history[0]["superseded_by_correction"] is True + effective = corrected_history[0] + assert effective["status"] == "corrected" and effective["effective_status"] == "administered" + assert effective["route_original"] == "" + assert (effective["actual_quantity_value"], effective["actual_dosage_form"], effective["actual_strength"]) == ("1", "Spritze", "40 mg/0,4 ml") + day = dispatch_api(database, "/api/v1/day/2026-08-17", "") + day_rows = [item for item in day["medications"]["administered"] if item["name"] == rx["name"]] + assert len(day_rows) == 1 and day_rows[0]["dose"] == "1 Spritze · 40 mg/0,4 ml" + events = dispatch_api(database, "/api/v1/events", "from=2026-08-17&to=2026-08-17&types=medication,medication_administered") + assert len([item for item in events["events"] if item["label"] == rx["name"]]) == 1 + report = dispatch_api(database, "/api/v1/doctor-report", "from=2026-08-17&to=2026-08-17§ions=medications") + assert len([item for item in report["medications"]["administered"] if item["name"] == rx["name"]]) == 1 def test_historical_and_real_plan_modes_are_fail_closed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: database, _, rx = _case(tmp_path) historical = validate_capture_payload(_payload(rx)) connection = sqlite3.connect(database) connection.row_factory = sqlite3.Row assert resolve_action_preview(connection, historical)[2] is None with pytest.raises(ValueError, match="requires a real plan"): validate_capture_payload(_payload(rx, data={"mode": "planned"})) with pytest.raises(ValueError, match="cannot reference a plan"): validate_capture_payload(_payload(rx, data={"planned_event_ref": "medevt_" + "a" * 24})) connection.execute( """UPDATE medication_administrations SET planned_quantity_value='1',planned_dosage_form='Tablette',planned_strength='10 mg', route_original='oral',route_normalized='oral' WHERE lower(event_type)='planned'""" ) connection.commit() planned_api = dispatch_api(database, "/api/v1/medications", "") plan = planned_api["planned"][0] active = next(item for item in planned_api["prescriptions"] if item["id"] == plan["prescription_id"]) planned_payload = validate_capture_payload( _payload( rx, data={ "mode": "planned", "medication_ref": active["id"], "planned_event_ref": plan["id"], "name": active["name"], "quantity_value": "1", "dosage_form": "Tablette", "strength": "10 mg", "route_original": "oral", "route_normalized": "oral", "injection_region": "", "injection_side": "", "injection_detail": "", "preset_revision": plan["administration_preset"]["preset_revision"], }, ) ) revision, _, resolved_plan, _ = resolve_action_preview(connection, planned_payload) assert revision and resolved_plan is not None planned_payload["data"]["preview_revision"] = revision connection.close() media_dir=tmp_path/"planned-media";quarantine_dir=tmp_path/"planned-quarantine";media_dir.mkdir();quarantine_dir.mkdir() monkeypatch.setattr(worker,"DASHBOARD_DB",database) monkeypatch.setattr(worker,"CAPTURE_MEDIA",media_dir) monkeypatch.setattr(worker,"CAPTURE_QUARANTINE",quarantine_dir) worker.apply_capture_action(planned_payload) connection=sqlite3.connect(database) snapshot=connection.execute( """SELECT planned_quantity_value,planned_dosage_form,planned_strength, actual_quantity_value,actual_dosage_form,actual_strength FROM medication_administrations WHERE source='dashboard_medication_action_version_two' AND planned_event_id IS NOT NULL""" ).fetchone() assert snapshot == ("1","Tablette","10 mg","1","Tablette","10 mg") def test_ui_contract_separates_fields_modes_and_repairs_day_symptom_button() -> None: record = (ROOT / "scripts/health/assets/health-assets/dashboard-v5-record.js").read_text(encoding="utf-8") capture = record[record.index("function administrationCaptureDialog"):record.index("async function renderMedications")] for label in ("Menge", "Darreichungsform", "Wirkstoffstärke", "Applikationsweg", "Injektionsstelle (optional)", "Notiz (optional)"): assert label in capture assert "health.medication_action.v2" in capture assert "Historische manuelle Gabe ohne Plantermin" in capture assert "Aus bestehender Medikamentenplanung" not in capture assert "Planwert bewusst übernommen" not in capture assert "Abweichung vom Plan bestätigt" not in capture assert "Geplanten Termin dokumentieren" in record assert "Historische Einnahme/Gabe erfassen" in record assert "aria-labelledby','medication-administration-title" in record assert "baseline?.route_original||''" in record assert "route.value==='subcutaneous'?'subkutan'" not in record + assert "quantity.type='number'" in capture + assert "['Spritze','Spritze']" in capture + assert "correction_administration_preset" in capture + assert "superseded_by_correction" in record + assert "medicationRouteLabel" in record and "subcutaneous:'Subkutan'" in record + assert "medicationSideLabel" in record and "right:'Rechts'" in record assert "Tatsächliche Wirkstoffstärke" in record assert "fetch('/health-actions/capture'" in capture assert "/api/v1/capture/status/${statusKey}/${statusToken}" in capture assert "Eingaben und identische Aktion bleiben erhalten" in capture main = (ROOT / "scripts/health/assets/health-assets/dashboard-v5.js").read_text(encoding="utf-8") day = (ROOT / "scripts/health/assets/health-assets/dashboard-v5-day-controller.js").read_text(encoding="utf-8") assert "window.healthOpenSymptomCheckin = openSymptomCheckin" in main assert 'dialog.querySelector("input[name=date]")' in main assert 'input[name=date]' in main assert "let currentDay = dayRoute(new URLSearchParams(location.search)) || today" in day assert "currentDay=input.date" in day assert "if(!routed)currentDay=today" in day assert "window.healthOpenSymptomCheckin(date)" in day assert "#open-checkin" not in day capture_hub = (ROOT / "scripts/health/assets/health-assets/dashboard-v5-capture.js").read_text(encoding="utf-8") assert "openMedicationRecord" in capture_hub assert "window.healthRecordOpenTab('medications')" in capture_hub __HERMES_CWD_8d46a20096ed__/home/agent/.hermes/repos/HealthManager__HERMES_CWD_8d46a20096ed__