diff --git a/database/schema.sql b/database/schema.sql index c512305..58cd7b1 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -239,65 +239,78 @@ CREATE TABLE medication_administrations ( datum TEXT NOT NULL, medication_name TEXT NOT NULL, dose TEXT, route TEXT, event_type TEXT, scheduled_next_date TEXT, notes TEXT, source TEXT DEFAULT 'manual', created_at TEXT DEFAULT CURRENT_TIMESTAMP, occurred_at TEXT, medication_id INTEGER REFERENCES medikamente(id), planned_event_id INTEGER REFERENCES medication_administrations(id), planned_dose_value TEXT, planned_dose_unit TEXT, actual_dose_value TEXT, actual_dose_unit TEXT, route_original TEXT, route_normalized TEXT, injection_region TEXT, injection_side TEXT, injection_detail TEXT, lot_number TEXT, corrects_event_id INTEGER REFERENCES medication_administrations(id), corrected_target_status TEXT, correction_reason TEXT, - business_revision TEXT + business_revision TEXT, + planned_quantity_value TEXT, + planned_dosage_form TEXT, + planned_strength TEXT, + actual_quantity_value TEXT, + actual_dosage_form TEXT, + actual_strength TEXT ); CREATE TABLE medikamente ( id INTEGER PRIMARY KEY AUTOINCREMENT, dokument_id INTEGER, medikament_name TEXT NOT NULL, dosierung TEXT, anwendungsform TEXT, erhaltungsform TEXT, ermittlung_datum TIMESTAMP DEFAULT CURRENT_TIMESTAMP, prescription_status TEXT, prescription_status_source TEXT, prescription_status_provenance TEXT, business_revision TEXT, + administration_preset_quantity_value TEXT, + administration_preset_dosage_form TEXT, + administration_preset_strength TEXT, + administration_preset_route TEXT, + administration_preset_source TEXT, + administration_preset_provenance TEXT, + administration_preset_revision TEXT, FOREIGN KEY (dokument_id) REFERENCES dokumente(id) ); CREATE TABLE nutrition_action_recommendations ( id INTEGER PRIMARY KEY AUTOINCREMENT, priority INTEGER NOT NULL, recommendation_type TEXT NOT NULL, title TEXT NOT NULL, rationale TEXT, next_step TEXT, status TEXT DEFAULT 'open' CHECK(status IN ('open','done','dismissed')), computed_at TEXT DEFAULT CURRENT_TIMESTAMP, UNIQUE(recommendation_type,title) ); CREATE TABLE nutrition_correlation_results ( id INTEGER PRIMARY KEY AUTOINCREMENT, metric TEXT NOT NULL, target TEXT NOT NULL, lag_days INTEGER NOT NULL, n INTEGER NOT NULL, correlation REAL, method TEXT DEFAULT 'pearson', interpretation TEXT, computed_at TEXT DEFAULT CURRENT_TIMESTAMP, diff --git a/scripts/health/assets/health-assets/dashboard-v5-record.js b/scripts/health/assets/health-assets/dashboard-v5-record.js index 4ab3635..f789f92 100644 --- a/scripts/health/assets/health-assets/dashboard-v5-record.js +++ b/scripts/health/assets/health-assets/dashboard-v5-record.js @@ -716,52 +716,54 @@ actions.append(button('Verlauf öffnen', () => openHistory(latest))); if (latest.date) actions.append(button('Untersuchungstag öffnen', () => window.healthDayRouter?.open({ date: latest.date, source: 'record_lab' }))); if (latest.document_id) actions.append(button('Quelldokument öffnen', () => openDocument(latest.document_id, 'labs', { filters: safeFilters }))); article.append(heading, facts, actions); const older = element('details', undefined, 'lab-observation-history'); older.append(element('summary', `Einzelbeobachtungen (${observations.length})`)); const olderList = element('div', undefined, 'lab-observation-list'); observations.forEach(item => { const row = element('article', undefined, 'lab-observation-card'); row.append(element('strong', `${item.date} · ${item.display_value ?? `${item.operator||''}${item.value}`} ${item.unit}`), element('p', referenceText(item), 'v5-meta')); const rowActions = element('div', undefined, 'record-actions'); rowActions.append(button('Vollständigen Verlauf öffnen', () => openHistory(item))); if (item.date) rowActions.append(button('Untersuchungstag öffnen', () => window.healthDayRouter?.open({ date: item.date, source: 'record_lab_observation' }))); if (item.document_id) rowActions.append(button('Quelldokument öffnen', () => openDocument(item.document_id, 'labs', { filters: safeFilters }))); row.append(rowActions); olderList.append(row); }); older.append(olderList); article.append(older); list.append(article); }); node.append(list); } node.append(element('p', `Datenvollständigkeit: ${text(data.completeness)}${data.truncated ? ' · gekürzt' : ''}`, 'v5-meta')); return node; } const medicationDose = item => { - const actual = [item.actual_dose_value, item.actual_dose_unit].filter(Boolean).join(' '); - const planned = [item.planned_dose_value, item.planned_dose_unit].filter(Boolean).join(' '); + 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'; @@ -789,63 +791,107 @@ 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 plannedMode=captureMode==='planned'; + if(plannedMode&&!eventItem)return; + const baseline=plannedMode?eventItem?.administration_preset:prescription.administration_preset; + const dialog=document.createElement('dialog');dialog.className='medication-action-dialog'; + const form=document.createElement('form');form.method='dialog'; + form.append(element('h2',plannedMode?'Geplanten Termin dokumentieren':'Historische Einnahme/Gabe erfassen'),element('p','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 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=route.value==='subcutaneous'?'subkutan':''; + 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'; + 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)); + 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,route.value!==(baseline.route_normalized||'unknown')].some(Boolean); + const syncDeviation=()=>{deviationRow.hidden=!differs();if(deviationRow.hidden)deviation.checked=false;}; + [quantity,dosageForm,strength,route].forEach(input=>input.addEventListener('input',syncDeviation));route.addEventListener('change',()=>{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}; + 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;if(field==='medication')quantity.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; + } + 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(); + } + 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('Jetzt als eingenommen / verabreicht dokumentieren',()=>medicationActionDialog(item)));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.','v5-meta'));otherSection.append(article);});if(!(data.other_prescriptions||[]).length)appendEmpty(otherSection);node.append(otherSection,element('h4','Verlauf')); + 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 Dosis',[item.planned_dose_value,item.planned_dose_unit].filter(Boolean).join(' ')||'Nicht strukturiert dokumentiert'],['Tatsächliche Dosis',[item.actual_dose_value,item.actual_dose_unit].filter(Boolean).join(' ')||'Nicht strukturiert dokumentiert'],['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){article.append(button('Als eingenommen / verabreicht dokumentieren',()=>medicationActionDialog(prescription,item,'administered')),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 Dosis',[item.planned_dose_value,item.planned_dose_unit].filter(Boolean).join(' ')||'Nicht strukturiert dokumentiert'],['Tatsächliche Dosis',[item.actual_dose_value,item.actual_dose_unit].filter(Boolean).join(' ')||'Nicht strukturiert dokumentiert'],['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){article.append(button('Geplanten Termin dokumentieren',()=>administrationCaptureDialog(prescription,item,'planned')),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); }); 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 || '{}'); diff --git a/scripts/health/dashboard_v5/read_api.py b/scripts/health/dashboard_v5/read_api.py index 62ef74c..602130e 100644 --- a/scripts/health/dashboard_v5/read_api.py +++ b/scripts/health/dashboard_v5/read_api.py @@ -64,50 +64,52 @@ from dashboard_v5.nutrition_mapping_review import ( build_review as build_mapping_review, ) from dashboard_v5.association_engine import ( MAX_ANALYSIS_DAYS, analyze as analyze_association, catalog as association_catalog, event_days as association_event_days, ) from dashboard_v5.comparison_contract import ( MAX_DAYS as MAX_COMPARISON_DAYS, build as build_comparison, catalog as comparison_catalog, ) from dashboard_v5.reference_contract import public_reference_context from dashboard_v5.supplement_read import supplement_nutrient_totals, supplements from dashboard_v5.source_status import build_source_status from dashboard_v5.observation_contract import PUBLIC_CONTRACT_VERSION, STATUS as OBSERVATION_STATUS, templates as observation_templates from dashboard_v5.observation_engine import evaluate_observation, evaluate_plan, list_observations, observation_detail from dashboard_v5.medication_schema import assert_schema as assert_medication_schema from dashboard_v5.medication_contract import ( PUBLIC_CONTRACT_VERSION as MEDICATION_HISTORY_CONTRACT, event_ref as medication_event_ref, list_events as medication_event_rows, list_prescriptions as medication_prescription_rows, medication_ref as medication_prescription_ref, + prescription_preset as medication_prescription_preset, + planned_event_preset as medication_planned_event_preset, public_action_context_token as medication_public_context_token, public_identity_key as medication_public_identity_key, plan_is_consumed as medication_plan_is_consumed, ) TZ_NAME = "Europe/Zurich" LOCAL_TZ = ZoneInfo(TZ_NAME) MAX_RANGE_DAYS = 3660 MAX_SERIES_ROWS = 3660 MAX_RAW_APPLE_ROWS = 100000 MAX_RAW_LAB_ROWS = 10000 MAX_EVENT_SOURCE_ROWS = 10000 MAX_DAILY_SOURCE_ROWS = 30000 MAX_EVENT_ROWS = 1000 MAX_LAB_ROWS = 100 MAX_SEARCH_ROWS_PER_GROUP = 20 MAX_CALENDAR_DAYS = 62 MAX_NUTRITION_DAYS = 180 MAX_NUTRITION_ITEMS = 200 MAX_NUTRITION_QUEUE = 50 MAX_FUTURE_DAYS = 366 MAX_QUERY_BYTES = 512 QUERY_TIMEOUT_SECONDS = 1.0 ALLOWED_RESOLUTIONS = frozenset({"day", "week"}) ALLOWED_EVENT_TYPES = frozenset( @@ -3552,50 +3554,52 @@ def _record_medications( public_prescriptions = [] for row in prescriptions: name = safe_metadata_text(row["medikament_name"], 120) dose = safe_metadata_text(row["dosierung"], 80, allow_empty=True) route = safe_metadata_text(row["anwendungsform"], 80, allow_empty=True) source = safe_metadata_text(row["prescription_status_source"], 80, allow_empty=True) provenance = safe_metadata_text(row["prescription_status_provenance"], 160, allow_empty=True) status = str(row["prescription_status"] or "unknown") trusted_status = bool( status in {"active", "ended", "paused"} and source and provenance and row["business_revision"] ) if not trusted_status or None in (name, dose, route): status = "unknown" item = { "id": medication_prescription_ref(row, identity_key), "name": name, "documented_dose": dose, "documented_route": route, "status": status, "status_source": source, "status_provenance": provenance, "preview_revision": medication_public_context_token(identity_key, row), + "administration_preset": medication_prescription_preset(row, identity_key), + "historical_capture_allowed": True, } public_prescriptions.append(item) empty["prescriptions"] = public_prescriptions empty["current_prescriptions"] = [item for item in public_prescriptions if item["status"] == "active"] empty["other_prescriptions"] = [item for item in public_prescriptions if item["status"] != "active"] rows = medication_event_rows(connection) corrected_origins = { int(row["corrects_event_id"]) for row in rows if str(row["event_type"] or "").strip().casefold() == "corrected" and row["corrects_event_id"] is not None } aliases = { "oral": "oral", "subkutan": "subcutaneous", "subcutaneous": "subcutaneous", "intravenös": "intravenous", "intravenous": "intravenous", "intramuskulär": "intramuscular", "intramuscular": "intramuscular", "äußerlich": "topical", "topical": "topical", "inhalativ": "inhaled", "inhaled": "inhaled", } sources: set[str] = set() truncated_sections: set[str] = set() for row in rows: raw_status = str(row["event_type"] or "").strip().casefold() if raw_status in {"planned", "scheduled", "geplant"}: bucket = "planned" elif raw_status in ADMINISTERED: @@ -3641,50 +3645,57 @@ def _record_medications( planned_context = None if row["planned_event_id"] is not None: planned_context = next((candidate for candidate in rows if int(candidate["id"]) == int(row["planned_event_id"])), None) correction_context = None if row["corrects_event_id"] is not None: correction_context = next((candidate for candidate in rows if int(candidate["id"]) == int(row["corrects_event_id"])), None) plan_consumed = ( medication_plan_is_consumed(connection, int(row["id"])) if bucket == "planned" else False ) item = { "id": medication_event_ref(row, identity_key), "date": effective_day, "recorded_date": recorded_day, "occurred_at": safe_metadata_text(row["occurred_at"], 40, allow_empty=True), "scheduled_next_date": scheduled, "name": name, "status": bucket, "effective_status": safe_metadata_text(row["corrected_target_status"], 20, allow_empty=True) if bucket == "corrected" else bucket, "legacy_dose": safe_metadata_text(row["dose"], 80, allow_empty=True), "planned_dose_value": safe_metadata_text(row["planned_dose_value"], 40, allow_empty=True), "planned_dose_unit": safe_metadata_text(row["planned_dose_unit"], 30, allow_empty=True), "actual_dose_value": safe_metadata_text(row["actual_dose_value"], 40, allow_empty=True), "actual_dose_unit": safe_metadata_text(row["actual_dose_unit"], 30, allow_empty=True), + "planned_quantity_value": safe_metadata_text(row["planned_quantity_value"], 40, allow_empty=True), + "planned_dosage_form": safe_metadata_text(row["planned_dosage_form"], 40, allow_empty=True), + "planned_strength": safe_metadata_text(row["planned_strength"], 80, allow_empty=True), + "actual_quantity_value": safe_metadata_text(row["actual_quantity_value"], 40, allow_empty=True), + "actual_dosage_form": safe_metadata_text(row["actual_dosage_form"], 40, allow_empty=True), + "actual_strength": safe_metadata_text(row["actual_strength"], 80, allow_empty=True), + "administration_preset": medication_planned_event_preset(row, identity_key) if bucket == "planned" else None, "route_original": route_original, "route_normalized": route_normalized or "unknown", "injection_region": safe_metadata_text(row["injection_region"], 80, allow_empty=True), "injection_side": safe_metadata_text(row["injection_side"], 20, allow_empty=True), "injection_detail": safe_metadata_text(row["injection_detail"], 120, allow_empty=True), "lot_number": safe_metadata_text(row["lot_number"], 80, allow_empty=True), "note": safe_metadata_text(row["notes"], 300, allow_empty=True), "source": source, "prescription_id": medication_prescription_ref(prescription, identity_key) if prescription is not None else None, "planned_event_id": medication_event_ref(planned_context, identity_key) if planned_context is not None else None, "corrects_event_id": medication_event_ref(correction_context, identity_key) if correction_context is not None else None, "correction_reason": safe_metadata_text(row["correction_reason"], 300, allow_empty=True), "superseded_by_correction": int(row["id"]) in corrected_origins, "plan_consumed": plan_consumed, "administration_preview_revision": medication_public_context_token( identity_key, prescription, row, None, planned_consumed=plan_consumed, ) if prescription is not None and bucket == "planned" and not plan_consumed else None, "correction_preview_revision": medication_public_context_token(identity_key, prescription, None, row) if prescription is not None and int(row["id"]) not in corrected_origins else None, } if len(empty[bucket]) >= RECORD_MAX_ROWS: truncated_sections.add(bucket) @@ -4196,51 +4207,53 @@ def _doctor_report( start, end = parse_range(params, required=True) assert start and end selected = set(filter(None, params.get("sections", "").split(","))) or set( REPORT_SECTIONS ) if not selected <= REPORT_SECTIONS: raise APIError(400, "section_not_allowed") lab_page = ( _record_labs( connection, {"from": start.isoformat(), "to": end.isoformat()}, ) if "labs" in selected else {"observations": [], "truncated": False} ) labs = lab_page["observations"] medication_record = ( _record_medications( connection, {"from": start.isoformat(), "to": end.isoformat()} ) if "medications" in selected else {"planned": [], "administered": [], "missed": [], "corrected": [], "unknown": [], "truncated": False} ) medication_report_fields = ( "date", "name", "status", "effective_status", "scheduled_next_date", "planned_dose_value", - "planned_dose_unit", "actual_dose_value", "actual_dose_unit", "legacy_dose", + "planned_dose_unit", "actual_dose_value", "actual_dose_unit", + "planned_quantity_value", "planned_dosage_form", "planned_strength", + "actual_quantity_value", "actual_dosage_form", "actual_strength", "legacy_dose", "superseded_by_correction", ) medications: dict[str, Any] = { kind: [ { **{field: item.get(field) for field in medication_report_fields}, "is_correction": kind == "corrected", "is_latest_effective": not bool(item.get("superseded_by_correction")), } for item in medication_record.get(kind, []) ] for kind in ("planned", "administered", "missed", "corrected", "unknown") } medications["truncated"] = bool(medication_record.get("truncated")) supplement_report = ( supplements(connection, start, end) if "supplements" in selected else {"planned": [], "administered": [], "missed": [], "corrected": []} ) appointments = ( _record_appointments( connection, {"from": start.isoformat(), "to": end.isoformat(), "order": "asc"}, )["items"] if "appointments" in selected __HERMES_CWD_8d46a20096ed__/home/agent/.hermes/repos/HealthManager__HERMES_CWD_8d46a20096ed__