diff --git a/src/styles.css b/src/styles.css index 72380a9..15fe8c6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -120,7 +120,7 @@ code { .tab-bar { display: grid; - grid-template-columns: repeat(6, minmax(110px, 1fr)); + grid-template-columns: repeat(7, minmax(110px, 1fr)); width: min(1080px, calc(100% - 40px)); margin: 22px auto 0; overflow-x: auto; @@ -237,6 +237,7 @@ code { .field input, .field select, +.field textarea, .compact-field select { width: 100%; min-height: 46px; @@ -247,6 +248,48 @@ code { background: #fff; } +.field textarea { + min-height: 96px; + resize: vertical; +} + +.history-grid, +.das21-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 18px; +} + +.bmi-readout { + display: flex; + align-items: baseline; + gap: 9px; + margin: -2px 0 22px; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--soft); +} + +.bmi-readout span, +.das21-card > p { + color: var(--muted); +} + +.das21-card { + margin-top: 24px; +} + +.das21-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; +} + +.das21-grid .compact-field { + display: grid; + gap: 7px; +} + .field small { color: var(--muted); line-height: 1.45; @@ -1412,7 +1455,12 @@ pre { } .tab-bar { - grid-template-columns: repeat(6, 105px); + grid-template-columns: repeat(7, 105px); + } + + .history-grid, + .das21-grid { + grid-template-columns: 1fr; } .screen { @@ -1423,6 +1471,11 @@ pre { padding: 17px; } + .drug-ranking__item { grid-template-columns: 30px 1fr; } + .drug-ranking__item > span:last-child { grid-column: 2; } + .quick-guidance { grid-template-columns: 1fr; } + .daily-details__heading { align-items: flex-start; flex-direction: column; } + .action-row, .group-heading, .review-output__heading { diff --git a/src/ui/ValidationConsole.tsx b/src/ui/ValidationConsole.tsx index c341770..7d5dfba 100644 --- a/src/ui/ValidationConsole.tsx +++ b/src/ui/ValidationConsole.tsx @@ -8,7 +8,7 @@ import { type ClinicalReviewItem, type ClinicalReviewResult, } from '../ai/clinical-review' -import { canonicalDrug } from '../data/drug-lexicon' +import { canonicalDrug, DRUG_LEXICON } from '../data/drug-lexicon' import { labelFor } from '../data/openfda' import { OFFICIAL_PHARMCAT_EXAMPLES, @@ -56,7 +56,7 @@ import { sourceIdsForGene, } from '../validation/view-model' -type TabId = 'file' | 'genes' | 'medicines' | 'daily' | 'ai' | 'evidence' +type TabId = 'file' | 'genes' | 'history' | 'medicines' | 'daily' | 'ai' | 'evidence' type InputMode = 'genome' | 'example' | 'report' type RunStatus = 'idle' | 'reading' | 'uploading' | 'analysing' | 'running' | 'complete' | 'error' @@ -106,6 +106,34 @@ const EMPTY_ROUTINE: RoutineAnswers = { eatingDisorderHistory: '', } +interface LifestyleMedicalHistory { + chronicConditions: string + surgicalHistory: string + medications: string + familyHistory: string + ethnicity: string + age: string + heightCm: string + weightKg: string + exerciseHours: string + exerciseDetails: string +} + +const EMPTY_LIFESTYLE_MEDICAL_HISTORY: LifestyleMedicalHistory = { + chronicConditions: '', + surgicalHistory: '', + medications: '', + familyHistory: '', + ethnicity: '', + age: '', + heightCm: '', + weightKg: '', + exerciseHours: '', + exerciseDetails: '', +} + +const DAS21_ITEM_COUNT = 21 + const BASE_CARE_CONTEXT: CareContext = { checkIn: null, goals: [], @@ -512,6 +540,106 @@ function FilePanel({ ) } +function LifestyleMedicalHistoryPanel({ + history, + onHistory, + das21Answers, + onDas21Answers, + onNext, +}: { + history: LifestyleMedicalHistory + onHistory: (history: LifestyleMedicalHistory) => void + das21Answers: string[] + onDas21Answers: (answers: string[]) => void + onNext: () => void +}) { + const height = Number(history.heightCm) + const weight = Number(history.weightKg) + const bmi = height > 0 && weight > 0 ? weight / ((height / 100) ** 2) : null + const update = (key: keyof LifestyleMedicalHistory, value: string) => onHistory({ ...history, [key]: value }) + const updateDas21 = (index: number, value: string) => onDas21Answers(das21Answers.map((answer, answerIndex) => answerIndex === index ? value : answer)) + + return ( + + + Lifestyle and Medical History + Add the context that may be useful when discussing medicine options with your clinician. + + + + + Past medical history — chronic conditions + update('chronicConditions', event.target.value)} placeholder="List any chronic conditions" /> + + + Past surgical history + update('surgicalHistory', event.target.value)} placeholder="List past surgeries, if any" /> + + + Medications, including past medicines and dosage + update('medications', event.target.value)} placeholder="For example: medicine name, dosage, and dates taken" /> + + + Family history Required + update('familyHistory', event.target.value)} placeholder="Relevant family medical history" /> + + + + + Ethnicity + update('ethnicity', event.target.value)} autoComplete="off" /> + + + Age + update('age', event.target.value)} /> + + + Height (cm) + update('heightCm', event.target.value)} /> + + + Weight (kg) + update('weightKg', event.target.value)} /> + + + + BMI{bmi ? bmi.toFixed(1) : 'Enter height and weight to calculate'} + + + + Physical activity (hours per week) + update('exerciseHours', event.target.value)} /> + + + Physical exercise + update('exerciseDetails', event.target.value)} placeholder="For example: walking, gym, swimming" /> + + + + + + DAS21 questionnaire + Record each response using the questionnaire item number and its 0–3 response. A clinician should interpret questionnaire results in context. + + {das21Answers.map((answer, index) => ( + + Item {index + 1} + updateDas21(index, event.target.value)} aria-label={`DAS21 item ${index + 1}`}> + Select + 0 + 1 + 2 + 3 + + + ))} + + + See medicine guidance + + ) +} + function GenesPanel({ result, runManifest, onNext }: { result: AnalysisResult; runManifest?: PharmCATRunManifest; onNext: () => void }) { const reportedGenes = new Set(result.pharmcat.genes.map((gene) => gene.gene)) const missingGenes = ANTIDEPRESSANT_PGX_GENES.filter((gene) => !reportedGenes.has(gene)) @@ -1034,6 +1162,8 @@ export function DailyLifePanel({ onRoutine: (routine: RoutineAnswers) => void onNext: () => void }) { + const [isModalOpen, setModalOpen] = useState(false) + const [showExtended, setShowExtended] = useState(false) const protocol = selectedDrug ? result.protocolsByDrug[selectedDrug] : null const product = selectedDrug ? labelFor(selectedDrug) : undefined const questions = protocol ? relevantRoutineQuestions(protocol) : [] @@ -1154,6 +1284,7 @@ export function DailyLifePanel({ Continue to AI review )} + } ) } @@ -1199,6 +1330,53 @@ function canonicalReviewText(item: ClinicalReviewItem, factsById: Map [ + `${index + 1}. ${capitalise(medicine.drug)}`, + ` PGx guidance: ${capitalise(medicine.headline)}`, + ]), + '', + 'Prototype only. Confirm any medicine decision with a clinician.', + ] + const content = [ + 'BT', + '/F1 18 Tf', + '50 760 Td', + `(${pdfSafeText(lines[0])}) Tj`, + '/F1 11 Tf', + ...lines.slice(1).flatMap((line) => ['0 -22 Td', `(${pdfSafeText(line)}) Tj`]), + 'ET', + ].join('\n') + const objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>', + `<< /Length ${content.length} >>\nstream\n${content}\nendstream`, + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + ] + let pdf = '%PDF-1.4\n' + const offsets = [0] + objects.forEach((object, index) => { + offsets.push(pdf.length) + pdf += `${index + 1} 0 obj\n${object}\nendobj\n` + }) + const xrefOffset = pdf.length + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n` + offsets.slice(1).forEach((offset) => { pdf += `${String(offset).padStart(10, '0')} 00000 n \n` }) + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF` + return new Blob([pdf], { type: 'application/pdf' }) +} + function AiReviewPanel({ result, selectedDrug, @@ -1236,6 +1414,15 @@ function AiReviewPanel({ const connected = modelConfigured && hasAttestedRun const answers = Object.keys(confirmedLifestyle).length + const downloadMockReport = () => { + const url = URL.createObjectURL(buildMockMedicineReport(result)) + const link = document.createElement('a') + link.href = url + link.download = 'mock-top-four-medicines-report.pdf' + link.click() + URL.revokeObjectURL(url) + } + const runReview = async () => { setRunning(true) onReview(null) @@ -1268,6 +1455,8 @@ function AiReviewPanel({ {result.genes.length} gene results · {result.input.currentMedications.length ? `${result.input.currentMedications.length} current medicine${result.input.currentMedications.length === 1 ? '' : 's'}` : 'No current medicines or supplements'} · {selectedDrug ? capitalise(selectedDrug) : 'no medicine selected'} · {answers} routine answer{answers === 1 ? '' : 's'} + Generate most recent report + {connected && !review && ( Only derived facts and source IDs are sent. Raw DNA stays out of the model. @@ -1570,6 +1759,8 @@ export function ValidationConsole() { const [selectedDrug, setSelectedDrug] = useState('') const [lifestyleProductConfirmed, setLifestyleProductConfirmed] = useState(null) const [routine, setRoutine] = useState({ ...EMPTY_ROUTINE }) + const [medicalHistory, setMedicalHistory] = useState({ ...EMPTY_LIFESTYLE_MEDICAL_HISTORY }) + const [das21Answers, setDas21Answers] = useState(() => Array(DAS21_ITEM_COUNT).fill('')) const [clinicalReview, setClinicalReview] = useState(null) const [status, setStatus] = useState('idle') const [error, setError] = useState(null) @@ -1585,6 +1776,8 @@ export function ValidationConsole() { setSelectedDrug('') setLifestyleProductConfirmed(null) setRoutine({ ...EMPTY_ROUTINE }) + setMedicalHistory({ ...EMPTY_LIFESTYLE_MEDICAL_HISTORY }) + setDas21Answers(Array(DAS21_ITEM_COUNT).fill('')) setClinicalReview(null) setError(null) setStatus('idle') @@ -1767,6 +1960,7 @@ export function ValidationConsole() { const tabs: Array<{ id: TabId; label: string; disabled: boolean }> = [ { id: 'file', label: 'DNA', disabled: false }, { id: 'genes', label: 'Gene results', disabled: !result }, + { id: 'history', label: 'Lifestyle & history', disabled: !result }, { id: 'medicines', label: 'Medicines', disabled: !result }, { id: 'daily', label: 'My first weeks', disabled: !result }, { id: 'ai', label: 'AI review', disabled: !result }, @@ -1816,7 +2010,8 @@ export function ValidationConsole() { onRun={() => void run()} /> )} - {tab === 'genes' && result && receipt && setTab('medicines')} />} + {tab === 'genes' && result && receipt && setTab('history')} />} + {tab === 'history' && result && setTab('medicines')} />} {tab === 'medicines' && result && } {tab === 'daily' && result && } {tab === 'ai' && result && receipt && }
Add the context that may be useful when discussing medicine options with your clinician.
Record each response using the questionnaire item number and its 0–3 response. A clinician should interpret questionnaire results in context.