From c25b6e0c803de91910426494576d4b4fc791ae5b Mon Sep 17 00:00:00 2001 From: amantahiliani Date: Fri, 20 Feb 2026 23:59:46 -0500 Subject: [PATCH] UI Upgrades --- app/db/clinical.go | 297 +++++++++++++- app/db/clinical_test.go | 408 +++++++++++++++++++ app/db/db.go | 102 ++++- app/fhir/fhir.go | 405 +++++++++++++++++-- app/fhir/fhir_test.go | 229 +++++++++++ app/handlers/dashboard.go | 73 +++- app/handlers/handler.go | 193 ++++++++- app/handlers/sync.go | 58 ++- app/models/models.go | 78 +++- app/static/css/styles.css | 681 +++++++++++++------------------ app/templates/base.html | 7 +- app/templates/dashboard.html | 756 ++++++++++++++++++----------------- app/templates/error.html | 2 +- app/templates/index.html | 2 +- app/templates/patients.html | 95 +++-- 15 files changed, 2494 insertions(+), 892 deletions(-) diff --git a/app/db/clinical.go b/app/db/clinical.go index a4748ef..521707a 100644 --- a/app/db/clinical.go +++ b/app/db/clinical.go @@ -401,22 +401,25 @@ func (s *Store) UpsertAllergyIntolerance(a *models.AllergyIntolerance) (string, if err == nil { _, err = s.db.Exec(` UPDATE allergy_intolerances SET - patient_fhir_id = ?, - clinical_status = ?, - verification_status = ?, - type = ?, - category = ?, - criticality = ?, - code_text = ?, - code_system = ?, - code_code = ?, - recorded_date = ?, - synced_at = ? + patient_fhir_id = ?, + clinical_status = ?, + verification_status = ?, + type = ?, + category = ?, + criticality = ?, + code_text = ?, + code_system = ?, + code_code = ?, + recorded_date = ?, + reaction_severity = ?, + reaction_manifestation = ?, + synced_at = ? WHERE id = ?`, a.PatientFHIRID, a.ClinicalStatus, a.VerificationStatus, a.Type, a.Category, a.Criticality, a.CodeText, a.CodeSystem, a.CodeCode, - a.RecordedDate, now, existingID, + a.RecordedDate, a.ReactionSeverity, a.ReactionManifestation, + now, existingID, ) if err != nil { return "", fmt.Errorf("db: update allergy_intolerance %s: %w", existingID, err) @@ -429,11 +432,13 @@ func (s *Store) UpsertAllergyIntolerance(a *models.AllergyIntolerance) (string, INSERT INTO allergy_intolerances ( id, fhir_id, ehr_url, patient_fhir_id, clinical_status, verification_status, type, category, criticality, - code_text, code_system, code_code, recorded_date, synced_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + code_text, code_system, code_code, recorded_date, + reaction_severity, reaction_manifestation, synced_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, a.FHIRID, a.EHRURL, a.PatientFHIRID, a.ClinicalStatus, a.VerificationStatus, a.Type, a.Category, a.Criticality, - a.CodeText, a.CodeSystem, a.CodeCode, a.RecordedDate, now, + a.CodeText, a.CodeSystem, a.CodeCode, a.RecordedDate, + a.ReactionSeverity, a.ReactionManifestation, now, ) if err != nil { return "", fmt.Errorf("db: insert allergy_intolerance fhir_id=%s: %w", a.FHIRID, err) @@ -446,7 +451,8 @@ func (s *Store) ListAllergyIntolerances(patientFHIRID, ehrURL string) ([]models. rows, err := s.db.Query(` SELECT id, fhir_id, ehr_url, patient_fhir_id, clinical_status, verification_status, type, category, criticality, - code_text, code_system, code_code, recorded_date, synced_at + code_text, code_system, code_code, recorded_date, + reaction_severity, reaction_manifestation, synced_at FROM allergy_intolerances WHERE patient_fhir_id = ? AND ehr_url = ? ORDER BY recorded_date DESC`, @@ -463,7 +469,8 @@ func (s *Store) ListAllergyIntolerances(patientFHIRID, ehrURL string) ([]models. if err := rows.Scan( &a.ID, &a.FHIRID, &a.EHRURL, &a.PatientFHIRID, &a.ClinicalStatus, &a.VerificationStatus, &a.Type, &a.Category, &a.Criticality, - &a.CodeText, &a.CodeSystem, &a.CodeCode, &a.RecordedDate, &a.SyncedAt, + &a.CodeText, &a.CodeSystem, &a.CodeCode, &a.RecordedDate, + &a.ReactionSeverity, &a.ReactionManifestation, &a.SyncedAt, ); err != nil { return nil, fmt.Errorf("db: scan allergy_intolerance: %w", err) } @@ -472,6 +479,262 @@ func (s *Store) ListAllergyIntolerances(patientFHIRID, ehrURL string) ([]models. return out, rows.Err() } +// --------------------------------------------------------------------------- +// Immunization +// --------------------------------------------------------------------------- + +// UpsertImmunization inserts or updates an Immunization record keyed on (fhir_id, ehr_url). +func (s *Store) UpsertImmunization(imm *models.Immunization) (string, error) { + now := time.Now().UTC() + + var existingID string + err := s.db.QueryRow( + `SELECT id FROM immunizations WHERE fhir_id = ? AND ehr_url = ?`, + imm.FHIRID, imm.EHRURL, + ).Scan(&existingID) + + if err == nil { + _, err = s.db.Exec(` + UPDATE immunizations SET + patient_fhir_id = ?, + status = ?, + vaccine_text = ?, + vaccine_system = ?, + vaccine_code = ?, + occurrence_date = ?, + primary_source = ?, + lot_number = ?, + synced_at = ? + WHERE id = ?`, + imm.PatientFHIRID, imm.Status, + imm.VaccineText, imm.VaccineSystem, imm.VaccineCode, + imm.OccurrenceDate, imm.PrimarySource, imm.LotNumber, + now, existingID, + ) + if err != nil { + return "", fmt.Errorf("db: update immunization %s: %w", existingID, err) + } + return existingID, nil + } + + id := uuid.NewString() + _, err = s.db.Exec(` + INSERT INTO immunizations ( + id, fhir_id, ehr_url, patient_fhir_id, + status, vaccine_text, vaccine_system, vaccine_code, + occurrence_date, primary_source, lot_number, synced_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, imm.FHIRID, imm.EHRURL, imm.PatientFHIRID, + imm.Status, imm.VaccineText, imm.VaccineSystem, imm.VaccineCode, + imm.OccurrenceDate, imm.PrimarySource, imm.LotNumber, now, + ) + if err != nil { + return "", fmt.Errorf("db: insert immunization fhir_id=%s: %w", imm.FHIRID, err) + } + return id, nil +} + +// ListImmunizations returns all Immunizations for the given patient, newest first. +func (s *Store) ListImmunizations(patientFHIRID, ehrURL string) ([]models.Immunization, error) { + rows, err := s.db.Query(` + SELECT id, fhir_id, ehr_url, patient_fhir_id, + status, vaccine_text, vaccine_system, vaccine_code, + occurrence_date, primary_source, lot_number, synced_at + FROM immunizations + WHERE patient_fhir_id = ? AND ehr_url = ? + ORDER BY occurrence_date DESC`, + patientFHIRID, ehrURL, + ) + if err != nil { + return nil, fmt.Errorf("db: list immunizations: %w", err) + } + defer rows.Close() + + var out []models.Immunization + for rows.Next() { + var imm models.Immunization + if err := rows.Scan( + &imm.ID, &imm.FHIRID, &imm.EHRURL, &imm.PatientFHIRID, + &imm.Status, &imm.VaccineText, &imm.VaccineSystem, &imm.VaccineCode, + &imm.OccurrenceDate, &imm.PrimarySource, &imm.LotNumber, &imm.SyncedAt, + ); err != nil { + return nil, fmt.Errorf("db: scan immunization: %w", err) + } + out = append(out, imm) + } + return out, rows.Err() +} + +// --------------------------------------------------------------------------- +// Procedure +// --------------------------------------------------------------------------- + +// UpsertProcedure inserts or updates a Procedure record keyed on (fhir_id, ehr_url). +func (s *Store) UpsertProcedure(p *models.Procedure) (string, error) { + now := time.Now().UTC() + + var existingID string + err := s.db.QueryRow( + `SELECT id FROM procedures WHERE fhir_id = ? AND ehr_url = ?`, + p.FHIRID, p.EHRURL, + ).Scan(&existingID) + + if err == nil { + _, err = s.db.Exec(` + UPDATE procedures SET + patient_fhir_id = ?, + status = ?, + code_text = ?, + code_system = ?, + code_code = ?, + performed_date = ?, + reason_text = ?, + outcome = ?, + synced_at = ? + WHERE id = ?`, + p.PatientFHIRID, p.Status, + p.CodeText, p.CodeSystem, p.CodeCode, + p.PerformedDate, p.ReasonText, p.Outcome, + now, existingID, + ) + if err != nil { + return "", fmt.Errorf("db: update procedure %s: %w", existingID, err) + } + return existingID, nil + } + + id := uuid.NewString() + _, err = s.db.Exec(` + INSERT INTO procedures ( + id, fhir_id, ehr_url, patient_fhir_id, + status, code_text, code_system, code_code, + performed_date, reason_text, outcome, synced_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, p.FHIRID, p.EHRURL, p.PatientFHIRID, + p.Status, p.CodeText, p.CodeSystem, p.CodeCode, + p.PerformedDate, p.ReasonText, p.Outcome, now, + ) + if err != nil { + return "", fmt.Errorf("db: insert procedure fhir_id=%s: %w", p.FHIRID, err) + } + return id, nil +} + +// ListProcedures returns all Procedures for the given patient, newest first. +func (s *Store) ListProcedures(patientFHIRID, ehrURL string) ([]models.Procedure, error) { + rows, err := s.db.Query(` + SELECT id, fhir_id, ehr_url, patient_fhir_id, + status, code_text, code_system, code_code, + performed_date, reason_text, outcome, synced_at + FROM procedures + WHERE patient_fhir_id = ? AND ehr_url = ? + ORDER BY performed_date DESC`, + patientFHIRID, ehrURL, + ) + if err != nil { + return nil, fmt.Errorf("db: list procedures: %w", err) + } + defer rows.Close() + + var out []models.Procedure + for rows.Next() { + var p models.Procedure + if err := rows.Scan( + &p.ID, &p.FHIRID, &p.EHRURL, &p.PatientFHIRID, + &p.Status, &p.CodeText, &p.CodeSystem, &p.CodeCode, + &p.PerformedDate, &p.ReasonText, &p.Outcome, &p.SyncedAt, + ); err != nil { + return nil, fmt.Errorf("db: scan procedure: %w", err) + } + out = append(out, p) + } + return out, rows.Err() +} + +// --------------------------------------------------------------------------- +// Encounter +// --------------------------------------------------------------------------- + +// UpsertEncounter inserts or updates an Encounter record keyed on (fhir_id, ehr_url). +func (s *Store) UpsertEncounter(e *models.Encounter) (string, error) { + now := time.Now().UTC() + + var existingID string + err := s.db.QueryRow( + `SELECT id FROM encounters WHERE fhir_id = ? AND ehr_url = ?`, + e.FHIRID, e.EHRURL, + ).Scan(&existingID) + + if err == nil { + _, err = s.db.Exec(` + UPDATE encounters SET + patient_fhir_id = ?, + status = ?, + class = ?, + type_text = ?, + period_start = ?, + period_end = ?, + reason_text = ?, + synced_at = ? + WHERE id = ?`, + e.PatientFHIRID, e.Status, e.Class, e.TypeText, + e.PeriodStart, e.PeriodEnd, e.ReasonText, + now, existingID, + ) + if err != nil { + return "", fmt.Errorf("db: update encounter %s: %w", existingID, err) + } + return existingID, nil + } + + id := uuid.NewString() + _, err = s.db.Exec(` + INSERT INTO encounters ( + id, fhir_id, ehr_url, patient_fhir_id, + status, class, type_text, + period_start, period_end, reason_text, synced_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, e.FHIRID, e.EHRURL, e.PatientFHIRID, + e.Status, e.Class, e.TypeText, + e.PeriodStart, e.PeriodEnd, e.ReasonText, now, + ) + if err != nil { + return "", fmt.Errorf("db: insert encounter fhir_id=%s: %w", e.FHIRID, err) + } + return id, nil +} + +// ListEncounters returns all Encounters for the given patient, newest first. +func (s *Store) ListEncounters(patientFHIRID, ehrURL string) ([]models.Encounter, error) { + rows, err := s.db.Query(` + SELECT id, fhir_id, ehr_url, patient_fhir_id, + status, class, type_text, + period_start, period_end, reason_text, synced_at + FROM encounters + WHERE patient_fhir_id = ? AND ehr_url = ? + ORDER BY period_start DESC`, + patientFHIRID, ehrURL, + ) + if err != nil { + return nil, fmt.Errorf("db: list encounters: %w", err) + } + defer rows.Close() + + var out []models.Encounter + for rows.Next() { + var e models.Encounter + if err := rows.Scan( + &e.ID, &e.FHIRID, &e.EHRURL, &e.PatientFHIRID, + &e.Status, &e.Class, &e.TypeText, + &e.PeriodStart, &e.PeriodEnd, &e.ReasonText, &e.SyncedAt, + ); err != nil { + return nil, fmt.Errorf("db: scan encounter: %w", err) + } + out = append(out, e) + } + return out, rows.Err() +} + // --------------------------------------------------------------------------- // PatientSync // --------------------------------------------------------------------------- diff --git a/app/db/clinical_test.go b/app/db/clinical_test.go index a253def..0de6da6 100644 --- a/app/db/clinical_test.go +++ b/app/db/clinical_test.go @@ -271,6 +271,414 @@ func TestListDocumentReferences_Empty(t *testing.T) { } } +// --------------------------------------------------------------------------- +// MedicationRequest tests +// --------------------------------------------------------------------------- + +func TestUpsertMedicationRequest_NewAndUpdate(t *testing.T) { + store := newTestStore(t) + + med := &models.MedicationRequest{ + FHIRID: "med-001", + EHRURL: testEHRURL, + PatientFHIRID: testPatientID, + Status: "active", + Intent: "order", + MedCodeText: "Metformin 500mg", + MedCodeSystem: "http://www.nlm.nih.gov/research/umls/rxnorm", + MedCodeCode: "860975", + AuthoredOn: "2024-01-10", + RequesterDisplay: "Dr. Smith", + DosageText: "1 tablet twice daily", + } + + id1, err := store.UpsertMedicationRequest(med) + if err != nil { + t.Fatalf("initial UpsertMedicationRequest: %v", err) + } + if id1 == "" { + t.Fatal("expected non-empty ID") + } + + med.Status = "stopped" + id2, err := store.UpsertMedicationRequest(med) + if err != nil { + t.Fatalf("update UpsertMedicationRequest: %v", err) + } + if id1 != id2 { + t.Errorf("ID changed on upsert: was %q, got %q", id1, id2) + } + + rows, err := store.ListMedicationRequests(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("ListMedicationRequests: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 med, got %d", len(rows)) + } + if rows[0].Status != "stopped" { + t.Errorf("Status: got %q, want stopped", rows[0].Status) + } +} + +func TestListMedicationRequests_Empty(t *testing.T) { + store := newTestStore(t) + rows, err := store.ListMedicationRequests("no-such-patient", testEHRURL) + if err != nil { + t.Fatalf("ListMedicationRequests: %v", err) + } + if len(rows) != 0 { + t.Errorf("expected 0, got %d", len(rows)) + } +} + +// --------------------------------------------------------------------------- +// AllergyIntolerance tests (including reaction fields) +// --------------------------------------------------------------------------- + +func TestUpsertAllergyIntolerance_WithReaction(t *testing.T) { + store := newTestStore(t) + + allergy := &models.AllergyIntolerance{ + FHIRID: "allergy-001", + EHRURL: testEHRURL, + PatientFHIRID: testPatientID, + ClinicalStatus: "active", + VerificationStatus: "confirmed", + Type: "allergy", + Category: "medication", + Criticality: "high", + CodeText: "Penicillin", + CodeSystem: "http://www.nlm.nih.gov/research/umls/rxnorm", + CodeCode: "7980", + RecordedDate: "2018-05-01", + ReactionSeverity: "severe", + ReactionManifestation: "Anaphylaxis", + } + + id1, err := store.UpsertAllergyIntolerance(allergy) + if err != nil { + t.Fatalf("initial UpsertAllergyIntolerance: %v", err) + } + if id1 == "" { + t.Fatal("expected non-empty ID") + } + + // Update reaction. + allergy.ReactionSeverity = "moderate" + allergy.ReactionManifestation = "Rash" + id2, err := store.UpsertAllergyIntolerance(allergy) + if err != nil { + t.Fatalf("update UpsertAllergyIntolerance: %v", err) + } + if id1 != id2 { + t.Errorf("ID changed on upsert: was %q, got %q", id1, id2) + } + + rows, err := store.ListAllergyIntolerances(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("ListAllergyIntolerances: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 allergy, got %d", len(rows)) + } + got := rows[0] + if got.ReactionSeverity != "moderate" { + t.Errorf("ReactionSeverity: got %q, want moderate", got.ReactionSeverity) + } + if got.ReactionManifestation != "Rash" { + t.Errorf("ReactionManifestation: got %q, want Rash", got.ReactionManifestation) + } +} + +func TestUpsertAllergyIntolerance_NoReaction(t *testing.T) { + store := newTestStore(t) + + allergy := &models.AllergyIntolerance{ + FHIRID: "allergy-no-rxn", + EHRURL: testEHRURL, + PatientFHIRID: testPatientID, + ClinicalStatus: "active", + CodeText: "Latex", + } + _, err := store.UpsertAllergyIntolerance(allergy) + if err != nil { + t.Fatalf("UpsertAllergyIntolerance (no reaction): %v", err) + } + + rows, err := store.ListAllergyIntolerances(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("ListAllergyIntolerances: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1, got %d", len(rows)) + } + if rows[0].ReactionSeverity != "" { + t.Errorf("expected empty ReactionSeverity, got %q", rows[0].ReactionSeverity) + } +} + +// --------------------------------------------------------------------------- +// Immunization tests +// --------------------------------------------------------------------------- + +func TestUpsertImmunization_NewAndUpdate(t *testing.T) { + store := newTestStore(t) + + imm := &models.Immunization{ + FHIRID: "imm-001", + EHRURL: testEHRURL, + PatientFHIRID: testPatientID, + Status: "completed", + VaccineText: "Influenza, seasonal", + VaccineSystem: "http://hl7.org/fhir/sid/cvx", + VaccineCode: "141", + OccurrenceDate: "2023-10-01", + PrimarySource: true, + LotNumber: "LOT123", + } + + id1, err := store.UpsertImmunization(imm) + if err != nil { + t.Fatalf("initial UpsertImmunization: %v", err) + } + if id1 == "" { + t.Fatal("expected non-empty ID") + } + + imm.LotNumber = "LOT456" + id2, err := store.UpsertImmunization(imm) + if err != nil { + t.Fatalf("update UpsertImmunization: %v", err) + } + if id1 != id2 { + t.Errorf("ID changed on upsert: was %q, got %q", id1, id2) + } + + rows, err := store.ListImmunizations(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("ListImmunizations: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 immunization, got %d", len(rows)) + } + if rows[0].LotNumber != "LOT456" { + t.Errorf("LotNumber: got %q, want LOT456", rows[0].LotNumber) + } + if !rows[0].PrimarySource { + t.Error("PrimarySource should be true") + } +} + +func TestListImmunizations_Empty(t *testing.T) { + store := newTestStore(t) + rows, err := store.ListImmunizations("no-such-patient", testEHRURL) + if err != nil { + t.Fatalf("ListImmunizations: %v", err) + } + if len(rows) != 0 { + t.Errorf("expected 0, got %d", len(rows)) + } +} + +func TestListImmunizations_OrderedNewestFirst(t *testing.T) { + store := newTestStore(t) + + for _, item := range []struct { + id string + date string + }{ + {"imm-a", "2022-09-01"}, + {"imm-b", "2023-10-15"}, + {"imm-c", "2021-03-01"}, + } { + _, err := store.UpsertImmunization(&models.Immunization{ + FHIRID: item.id, + EHRURL: testEHRURL, + PatientFHIRID: testPatientID, + Status: "completed", + OccurrenceDate: item.date, + }) + if err != nil { + t.Fatalf("UpsertImmunization %s: %v", item.id, err) + } + } + + rows, err := store.ListImmunizations(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("ListImmunizations: %v", err) + } + if len(rows) != 3 { + t.Fatalf("expected 3, got %d", len(rows)) + } + if rows[0].FHIRID != "imm-b" { + t.Errorf("first: got %q, want imm-b", rows[0].FHIRID) + } + if rows[2].FHIRID != "imm-c" { + t.Errorf("last: got %q, want imm-c", rows[2].FHIRID) + } +} + +// --------------------------------------------------------------------------- +// Procedure tests +// --------------------------------------------------------------------------- + +func TestUpsertProcedure_NewAndUpdate(t *testing.T) { + store := newTestStore(t) + + proc := &models.Procedure{ + FHIRID: "proc-001", + EHRURL: testEHRURL, + PatientFHIRID: testPatientID, + Status: "completed", + CodeText: "Appendectomy", + CodeSystem: "http://snomed.info/sct", + CodeCode: "80146002", + PerformedDate: "2019-06-15", + ReasonText: "Acute appendicitis", + Outcome: "Successful procedure", + } + + id1, err := store.UpsertProcedure(proc) + if err != nil { + t.Fatalf("initial UpsertProcedure: %v", err) + } + if id1 == "" { + t.Fatal("expected non-empty ID") + } + + proc.Outcome = "Procedure completed without complications" + id2, err := store.UpsertProcedure(proc) + if err != nil { + t.Fatalf("update UpsertProcedure: %v", err) + } + if id1 != id2 { + t.Errorf("ID changed on upsert: was %q, got %q", id1, id2) + } + + rows, err := store.ListProcedures(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("ListProcedures: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 procedure, got %d", len(rows)) + } + if rows[0].Outcome != "Procedure completed without complications" { + t.Errorf("Outcome: got %q", rows[0].Outcome) + } +} + +func TestListProcedures_Empty(t *testing.T) { + store := newTestStore(t) + rows, err := store.ListProcedures("no-such-patient", testEHRURL) + if err != nil { + t.Fatalf("ListProcedures: %v", err) + } + if len(rows) != 0 { + t.Errorf("expected 0, got %d", len(rows)) + } +} + +// --------------------------------------------------------------------------- +// Encounter tests +// --------------------------------------------------------------------------- + +func TestUpsertEncounter_NewAndUpdate(t *testing.T) { + store := newTestStore(t) + + enc := &models.Encounter{ + FHIRID: "enc-001", + EHRURL: testEHRURL, + PatientFHIRID: testPatientID, + Status: "finished", + Class: "AMB", + TypeText: "Office visit", + PeriodStart: "2024-03-10", + PeriodEnd: "2024-03-10", + ReasonText: "Annual physical", + } + + id1, err := store.UpsertEncounter(enc) + if err != nil { + t.Fatalf("initial UpsertEncounter: %v", err) + } + if id1 == "" { + t.Fatal("expected non-empty ID") + } + + enc.Status = "cancelled" + id2, err := store.UpsertEncounter(enc) + if err != nil { + t.Fatalf("update UpsertEncounter: %v", err) + } + if id1 != id2 { + t.Errorf("ID changed on upsert: was %q, got %q", id1, id2) + } + + rows, err := store.ListEncounters(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("ListEncounters: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 encounter, got %d", len(rows)) + } + if rows[0].Status != "cancelled" { + t.Errorf("Status: got %q, want cancelled", rows[0].Status) + } + if rows[0].Class != "AMB" { + t.Errorf("Class: got %q, want AMB", rows[0].Class) + } +} + +func TestListEncounters_Empty(t *testing.T) { + store := newTestStore(t) + rows, err := store.ListEncounters("no-such-patient", testEHRURL) + if err != nil { + t.Fatalf("ListEncounters: %v", err) + } + if len(rows) != 0 { + t.Errorf("expected 0, got %d", len(rows)) + } +} + +func TestListEncounters_OrderedNewestFirst(t *testing.T) { + store := newTestStore(t) + + for _, item := range []struct { + id string + start string + }{ + {"enc-a", "2023-01-01"}, + {"enc-b", "2024-06-01"}, + {"enc-c", "2022-12-01"}, + } { + _, err := store.UpsertEncounter(&models.Encounter{ + FHIRID: item.id, + EHRURL: testEHRURL, + PatientFHIRID: testPatientID, + Status: "finished", + PeriodStart: item.start, + }) + if err != nil { + t.Fatalf("UpsertEncounter %s: %v", item.id, err) + } + } + + rows, err := store.ListEncounters(testPatientID, testEHRURL) + if err != nil { + t.Fatalf("ListEncounters: %v", err) + } + if len(rows) != 3 { + t.Fatalf("expected 3, got %d", len(rows)) + } + if rows[0].FHIRID != "enc-b" { + t.Errorf("first: got %q, want enc-b", rows[0].FHIRID) + } + if rows[2].FHIRID != "enc-c" { + t.Errorf("last: got %q, want enc-c", rows[2].FHIRID) + } +} + // --------------------------------------------------------------------------- // PatientSync tests // --------------------------------------------------------------------------- diff --git a/app/db/db.go b/app/db/db.go index dde2aea..075d246 100644 --- a/app/db/db.go +++ b/app/db/db.go @@ -239,12 +239,81 @@ var migrations = []migration{ CREATE INDEX IF NOT EXISTS idx_allergy_intolerances_patient ON allergy_intolerances(patient_fhir_id, ehr_url); `, }, - // Future migrations: append new entries here with incrementing version numbers. - // Example: - // { - // version: 2, - // sql: `ALTER TABLE users ADD COLUMN phone TEXT NOT NULL DEFAULT '';`, - // }, + { + version: 5, + sql: ` + ALTER TABLE allergy_intolerances ADD COLUMN reaction_severity TEXT NOT NULL DEFAULT ''; + ALTER TABLE allergy_intolerances ADD COLUMN reaction_manifestation TEXT NOT NULL DEFAULT ''; + `, + }, + { + version: 6, + sql: ` + CREATE TABLE IF NOT EXISTS immunizations ( + id TEXT PRIMARY KEY, + fhir_id TEXT NOT NULL, + ehr_url TEXT NOT NULL, + patient_fhir_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT '', + vaccine_text TEXT NOT NULL DEFAULT '', + vaccine_system TEXT NOT NULL DEFAULT '', + vaccine_code TEXT NOT NULL DEFAULT '', + occurrence_date TEXT NOT NULL DEFAULT '', + primary_source INTEGER NOT NULL DEFAULT 0, + lot_number TEXT NOT NULL DEFAULT '', + synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(fhir_id, ehr_url) + ); + CREATE INDEX IF NOT EXISTS idx_immunizations_patient ON immunizations(patient_fhir_id, ehr_url); + `, + }, + { + version: 7, + sql: ` + CREATE TABLE IF NOT EXISTS procedures ( + id TEXT PRIMARY KEY, + fhir_id TEXT NOT NULL, + ehr_url TEXT NOT NULL, + patient_fhir_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT '', + code_text TEXT NOT NULL DEFAULT '', + code_system TEXT NOT NULL DEFAULT '', + code_code TEXT NOT NULL DEFAULT '', + performed_date TEXT NOT NULL DEFAULT '', + reason_text TEXT NOT NULL DEFAULT '', + outcome TEXT NOT NULL DEFAULT '', + synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(fhir_id, ehr_url) + ); + CREATE INDEX IF NOT EXISTS idx_procedures_patient ON procedures(patient_fhir_id, ehr_url); + `, + }, + { + version: 8, + sql: ` + CREATE TABLE IF NOT EXISTS encounters ( + id TEXT PRIMARY KEY, + fhir_id TEXT NOT NULL, + ehr_url TEXT NOT NULL, + patient_fhir_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT '', + class TEXT NOT NULL DEFAULT '', + type_text TEXT NOT NULL DEFAULT '', + period_start TEXT NOT NULL DEFAULT '', + period_end TEXT NOT NULL DEFAULT '', + reason_text TEXT NOT NULL DEFAULT '', + synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(fhir_id, ehr_url) + ); + CREATE INDEX IF NOT EXISTS idx_encounters_patient ON encounters(patient_fhir_id, ehr_url); + `, + }, + { + version: 9, + sql: ` + ALTER TABLE users ADD COLUMN mrn TEXT NOT NULL DEFAULT ''; + `, + }, } // migrate applies any migrations that have not yet been run, in order. @@ -315,6 +384,7 @@ func (s *Store) UpsertUser(u *models.User) (string, error) { first_name = ?, middle_name = ?, last_name = ?, + mrn = ?, dob = ?, gender = ?, email = ?, @@ -322,7 +392,7 @@ func (s *Store) UpsertUser(u *models.User) (string, error) { role = ?, updated_at = ? WHERE id = ?`, - u.FirstName, u.MiddleName, u.LastName, + u.FirstName, u.MiddleName, u.LastName, u.MRN, u.DOB, u.Gender, u.Email, u.FHIRResourceType, string(u.Role), now, existingID, @@ -342,11 +412,11 @@ func (s *Store) UpsertUser(u *models.User) (string, error) { _, err = s.db.Exec(` INSERT INTO users ( id, fhir_resource_type, fhir_id, ehr_url, role, - first_name, middle_name, last_name, dob, gender, email, + first_name, middle_name, last_name, mrn, dob, gender, email, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, u.FHIRResourceType, u.FHIRID, u.EHRURL, string(u.Role), - u.FirstName, u.MiddleName, u.LastName, u.DOB, u.Gender, u.Email, + u.FirstName, u.MiddleName, u.LastName, u.MRN, u.DOB, u.Gender, u.Email, now, now, ) if err != nil { @@ -361,13 +431,13 @@ func (s *Store) GetUserByFHIRID(fhirID, ehrURL string) (*models.User, error) { u := &models.User{} err := s.db.QueryRow(` SELECT id, fhir_resource_type, fhir_id, ehr_url, role, - first_name, middle_name, last_name, dob, gender, email, + first_name, middle_name, last_name, mrn, dob, gender, email, created_at, updated_at FROM users WHERE fhir_id = ? AND ehr_url = ?`, fhirID, ehrURL, ).Scan( &u.ID, &u.FHIRResourceType, &u.FHIRID, &u.EHRURL, &u.Role, - &u.FirstName, &u.MiddleName, &u.LastName, &u.DOB, &u.Gender, &u.Email, + &u.FirstName, &u.MiddleName, &u.LastName, &u.MRN, &u.DOB, &u.Gender, &u.Email, &u.CreatedAt, &u.UpdatedAt, ) if err != nil { @@ -381,12 +451,12 @@ func (s *Store) GetUserByID(id string) (*models.User, error) { u := &models.User{} err := s.db.QueryRow(` SELECT id, fhir_resource_type, fhir_id, ehr_url, role, - first_name, middle_name, last_name, dob, gender, email, + first_name, middle_name, last_name, mrn, dob, gender, email, created_at, updated_at FROM users WHERE id = ?`, id, ).Scan( &u.ID, &u.FHIRResourceType, &u.FHIRID, &u.EHRURL, &u.Role, - &u.FirstName, &u.MiddleName, &u.LastName, &u.DOB, &u.Gender, &u.Email, + &u.FirstName, &u.MiddleName, &u.LastName, &u.MRN, &u.DOB, &u.Gender, &u.Email, &u.CreatedAt, &u.UpdatedAt, ) if err != nil { @@ -399,7 +469,7 @@ func (s *Store) GetUserByID(id string) (*models.User, error) { func (s *Store) ListUsersByRole(role models.Role, ehrURL string) ([]models.User, error) { rows, err := s.db.Query(` SELECT id, fhir_resource_type, fhir_id, ehr_url, role, - first_name, middle_name, last_name, dob, gender, email, + first_name, middle_name, last_name, mrn, dob, gender, email, created_at, updated_at FROM users WHERE role = ? AND ehr_url = ? ORDER BY last_name ASC, first_name ASC`, @@ -415,7 +485,7 @@ func (s *Store) ListUsersByRole(role models.Role, ehrURL string) ([]models.User, var u models.User if err := rows.Scan( &u.ID, &u.FHIRResourceType, &u.FHIRID, &u.EHRURL, &u.Role, - &u.FirstName, &u.MiddleName, &u.LastName, &u.DOB, &u.Gender, &u.Email, + &u.FirstName, &u.MiddleName, &u.LastName, &u.MRN, &u.DOB, &u.Gender, &u.Email, &u.CreatedAt, &u.UpdatedAt, ); err != nil { return nil, fmt.Errorf("db: scan user: %w", err) diff --git a/app/fhir/fhir.go b/app/fhir/fhir.go index 8cb864a..63d852f 100644 --- a/app/fhir/fhir.go +++ b/app/fhir/fhir.go @@ -114,6 +114,16 @@ type Meta struct { // https://www.hl7.org/fhir/patient.html // --------------------------------------------------------------------------- +// Extension represents the FHIR Extension data type (R4). +// Extensions are used for US Core race/ethnicity and other modifiers. +type Extension struct { + URL string `json:"url"` + ValueCode string `json:"valueCode,omitempty"` + ValueString string `json:"valueString,omitempty"` + ValueCoding *Coding `json:"valueCoding,omitempty"` + Extension []Extension `json:"extension,omitempty"` +} + // Patient represents a FHIR R4 Patient resource. // Fields are a curated subset of the full specification — add new fields // here as the platform needs them, without breaking existing code. @@ -129,6 +139,7 @@ type Patient struct { BirthDate string `json:"birthDate"` Address []Address `json:"address"` MaritalStatus CodeableConcept `json:"maritalStatus"` + Extension []Extension `json:"extension"` } // ResourceType implements the Resource interface. @@ -275,23 +286,87 @@ type MedicationRequest struct { func (m *MedicationRequest) ResourceType() string { return "MedicationRequest" } +// AllergyReaction represents a reaction event in a FHIR AllergyIntolerance. +type AllergyReaction struct { + Manifestation []CodeableConcept `json:"manifestation"` + Severity string `json:"severity"` // mild | moderate | severe +} + // AllergyIntolerance represents a FHIR R4 AllergyIntolerance resource. // https://www.hl7.org/fhir/allergyintolerance.html type AllergyIntolerance struct { - ResourceTypeField string `json:"resourceType"` - ID string `json:"id"` - ClinicalStatus CodeableConcept `json:"clinicalStatus"` - VerificationStatus CodeableConcept `json:"verificationStatus"` - Type string `json:"type"` - Category []string `json:"category"` - Criticality string `json:"criticality"` - Code CodeableConcept `json:"code"` - Patient Reference `json:"patient"` - RecordedDate string `json:"recordedDate"` + ResourceTypeField string `json:"resourceType"` + ID string `json:"id"` + ClinicalStatus CodeableConcept `json:"clinicalStatus"` + VerificationStatus CodeableConcept `json:"verificationStatus"` + Type string `json:"type"` + Category []string `json:"category"` + Criticality string `json:"criticality"` + Code CodeableConcept `json:"code"` + Patient Reference `json:"patient"` + RecordedDate string `json:"recordedDate"` + Reaction []AllergyReaction `json:"reaction"` } func (a *AllergyIntolerance) ResourceType() string { return "AllergyIntolerance" } +// Period represents the FHIR Period data type (R4). +type Period struct { + Start string `json:"start"` + End string `json:"end"` +} + +// Immunization represents a FHIR R4 Immunization resource. +// https://www.hl7.org/fhir/immunization.html +type Immunization struct { + ResourceTypeField string `json:"resourceType"` + ID string `json:"id"` + Status string `json:"status"` + VaccineCode CodeableConcept `json:"vaccineCode"` + Patient Reference `json:"patient"` + OccurrenceDateTime string `json:"occurrenceDateTime"` + PrimarySource bool `json:"primarySource"` + LotNumber string `json:"lotNumber"` +} + +func (i *Immunization) ResourceType() string { return "Immunization" } + +// Procedure represents a FHIR R4 Procedure resource. +// https://www.hl7.org/fhir/procedure.html +type Procedure struct { + ResourceTypeField string `json:"resourceType"` + ID string `json:"id"` + Status string `json:"status"` + Code CodeableConcept `json:"code"` + Subject Reference `json:"subject"` + PerformedDateTime string `json:"performedDateTime"` + PerformedPeriod *Period `json:"performedPeriod,omitempty"` + ReasonCode []CodeableConcept `json:"reasonCode"` + Outcome CodeableConcept `json:"outcome"` +} + +func (p *Procedure) ResourceType() string { return "Procedure" } + +// EncounterClass represents the coded class of an Encounter (V3 ActCode). +type EncounterClass struct { + Code string `json:"code"` +} + +// Encounter represents a FHIR R4 Encounter resource. +// https://www.hl7.org/fhir/encounter.html +type Encounter struct { + ResourceTypeField string `json:"resourceType"` + ID string `json:"id"` + Status string `json:"status"` + Class EncounterClass `json:"class"` + Type []CodeableConcept `json:"type"` + Subject Reference `json:"subject"` + Period *Period `json:"period,omitempty"` + ReasonCode []CodeableConcept `json:"reasonCode"` +} + +func (e *Encounter) ResourceType() string { return "Encounter" } + // Quantity represents the FHIR Quantity data type. type Quantity struct { Value float64 `json:"value"` @@ -565,7 +640,7 @@ func (c *Client) GetDocumentReferences(patientID, since string) ([]DocumentRefer // If since is non-empty, only fetches requests modified after that timestamp (RFC3339). func (c *Client) GetMedicationRequests(patientID, since string) ([]MedicationRequest, error) { var bundle Bundle - path := fmt.Sprintf("MedicationRequest?patient=%s&status=active&_sort=-date", patientID) + path := fmt.Sprintf("MedicationRequest?patient=%s&_sort=-date", patientID) if since != "" { path += fmt.Sprintf("&_lastUpdated=ge%s", since) } @@ -615,6 +690,87 @@ func (c *Client) GetAllergyIntolerances(patientID, since string) ([]AllergyIntol return allergies, nil } +// GetImmunizations fetches Immunization resources for a specific patient. +// If since is non-empty, only fetches immunizations modified after that timestamp (RFC3339). +func (c *Client) GetImmunizations(patientID, since string) ([]Immunization, error) { + var bundle Bundle + path := fmt.Sprintf("Immunization?patient=%s&_sort=-date", patientID) + if since != "" { + path += fmt.Sprintf("&_lastUpdated=ge%s", since) + } + if err := c.get(path, &bundle); err != nil { + return nil, err + } + + entries, err := c.fetchAllBundlePages(&bundle, 10) + if err != nil { + return nil, err + } + + var immunizations []Immunization + for _, entry := range entries { + var imm Immunization + if err := json.Unmarshal(entry, &imm); err == nil { + immunizations = append(immunizations, imm) + } + } + return immunizations, nil +} + +// GetProcedures fetches Procedure resources for a specific patient. +// If since is non-empty, only fetches procedures modified after that timestamp (RFC3339). +func (c *Client) GetProcedures(patientID, since string) ([]Procedure, error) { + var bundle Bundle + path := fmt.Sprintf("Procedure?patient=%s&_sort=-date", patientID) + if since != "" { + path += fmt.Sprintf("&_lastUpdated=ge%s", since) + } + if err := c.get(path, &bundle); err != nil { + return nil, err + } + + entries, err := c.fetchAllBundlePages(&bundle, 10) + if err != nil { + return nil, err + } + + var procedures []Procedure + for _, entry := range entries { + var p Procedure + if err := json.Unmarshal(entry, &p); err == nil { + procedures = append(procedures, p) + } + } + return procedures, nil +} + +// GetEncounters fetches Encounter resources for a specific patient. +// If since is non-empty, only fetches encounters modified after that timestamp (RFC3339). +func (c *Client) GetEncounters(patientID, since string) ([]Encounter, error) { + var bundle Bundle + path := fmt.Sprintf("Encounter?patient=%s&_sort=-date", patientID) + if since != "" { + path += fmt.Sprintf("&_lastUpdated=ge%s", since) + } + if err := c.get(path, &bundle); err != nil { + return nil, err + } + + entries, err := c.fetchAllBundlePages(&bundle, 10) + if err != nil { + return nil, err + } + + var encounters []Encounter + for _, entry := range entries { + var e Encounter + if err := json.Unmarshal(entry, &e); err == nil { + encounters = append(encounters, e) + } + } + return encounters, nil +} + // GetSmartConfiguration fetches and parses the SMART discovery document // for this FHIR server. func GetSmartConfiguration(issURL string) (*SmartConfiguration, error) { @@ -665,6 +821,30 @@ func primaryEmail(telecom []ContactPoint) string { return "" } +// extractMRN attempts to find a Medical Record Number in a FHIR Identifier slice. +// It looks for identifiers with a system containing "mrn" or the first identifier +// if none specifically match. +func extractMRN(identifiers []Identifier) string { + for _, id := range identifiers { + // Look for common MRN system patterns + system := strings.ToLower(id.System) + if strings.Contains(system, "mrn") || strings.Contains(system, "medical-record") { + return id.Value + } + // Also check the type code if present + for _, coding := range id.Type.Coding { + if strings.ToUpper(coding.Code) == "MR" { + return id.Value + } + } + } + // Fallback to the first identifier if we haven't found a definitive MRN + if len(identifiers) > 0 { + return identifiers[0].Value + } + return "" +} + // ExtractUserFromPatient converts a FHIR Patient resource into a platform // models.User with Role=RolePatient. The ehrURL is the originating FHIR // server base URL. @@ -685,6 +865,7 @@ func ExtractUserFromPatient(p *Patient, ehrURL string) *models.User { FirstName: first, MiddleName: middle, LastName: name.Family, + MRN: extractMRN(p.Identifier), DOB: p.BirthDate, Gender: p.Gender, Email: primaryEmail(p.Telecom), @@ -747,6 +928,20 @@ func firstCategoryText(cats []CodeableConcept) string { return "" } +// firstCategoryCode returns the first coding code of the first element in a +// []CodeableConcept. Preferred over firstCategoryText when machine-readable +// values (e.g. "problem-list-item", "vital-signs") are needed for filtering. +func firstCategoryCode(cats []CodeableConcept) string { + if len(cats) == 0 { + return "" + } + c := cats[0] + if len(c.Coding) > 0 && c.Coding[0].Code != "" { + return c.Coding[0].Code + } + return c.Text +} + // ExtractObservation maps a FHIR Observation to a models.Observation ready // for upsert. patientFHIRID and ehrURL are injected by the caller because // they are session-level context, not encoded inside the FHIR resource. @@ -830,6 +1025,8 @@ func ExtractObservation(o *Observation, patientFHIRID, ehrURL string) *models.Ob } // ExtractCondition maps a FHIR Condition to a models.Condition ready for upsert. +// Category is stored as a machine-readable code (e.g. "problem-list-item", +// "encounter-diagnosis") for reliable client-side filtering. func ExtractCondition(c *Condition, patientFHIRID, ehrURL string) *models.Condition { coding := firstCoding(c.Code) clinicalStatus := firstCoding(c.ClinicalStatus) @@ -840,7 +1037,7 @@ func ExtractCondition(c *Condition, patientFHIRID, ehrURL string) *models.Condit PatientFHIRID: patientFHIRID, ClinicalStatus: clinicalStatus.Code, VerificationStatus: verificationStatus.Code, - Category: firstCategoryText(c.Category), + Category: firstCategoryCode(c.Category), CodeText: c.Code.Text, CodeSystem: coding.System, CodeCode: coding.Code, @@ -913,20 +1110,178 @@ func ExtractAllergyIntolerance(a *AllergyIntolerance, patientFHIRID, ehrURL stri if len(a.Category) > 0 { category = a.Category[0] } - return &models.AllergyIntolerance{ - FHIRID: a.ID, - EHRURL: strings.TrimRight(ehrURL, "/"), - PatientFHIRID: patientFHIRID, - ClinicalStatus: clinicalStatus.Code, - VerificationStatus: verificationStatus.Code, - Type: a.Type, - Category: category, - Criticality: a.Criticality, - CodeText: a.Code.Text, - CodeSystem: codeCoding.System, - CodeCode: codeCoding.Code, - RecordedDate: a.RecordedDate, + + // Extract first reaction severity and manifestation for safety display. + var reactionSeverity, reactionManifestation string + if len(a.Reaction) > 0 { + rxn := a.Reaction[0] + reactionSeverity = rxn.Severity + if len(rxn.Manifestation) > 0 { + m := rxn.Manifestation[0] + if m.Text != "" { + reactionManifestation = m.Text + } else if len(m.Coding) > 0 { + if m.Coding[0].Display != "" { + reactionManifestation = m.Coding[0].Display + } else { + reactionManifestation = m.Coding[0].Code + } + } + } } + + return &models.AllergyIntolerance{ + FHIRID: a.ID, + EHRURL: strings.TrimRight(ehrURL, "/"), + PatientFHIRID: patientFHIRID, + ClinicalStatus: clinicalStatus.Code, + VerificationStatus: verificationStatus.Code, + Type: a.Type, + Category: category, + Criticality: a.Criticality, + CodeText: a.Code.Text, + CodeSystem: codeCoding.System, + CodeCode: codeCoding.Code, + RecordedDate: a.RecordedDate, + ReactionSeverity: reactionSeverity, + ReactionManifestation: reactionManifestation, + } +} + +// ExtractImmunization maps a FHIR Immunization to a models.Immunization ready for upsert. +func ExtractImmunization(imm *Immunization, patientFHIRID, ehrURL string) *models.Immunization { + coding := firstCoding(imm.VaccineCode) + return &models.Immunization{ + FHIRID: imm.ID, + EHRURL: strings.TrimRight(ehrURL, "/"), + PatientFHIRID: patientFHIRID, + Status: imm.Status, + VaccineText: imm.VaccineCode.Text, + VaccineSystem: coding.System, + VaccineCode: coding.Code, + OccurrenceDate: imm.OccurrenceDateTime, + PrimarySource: imm.PrimarySource, + LotNumber: imm.LotNumber, + } +} + +// ExtractProcedure maps a FHIR Procedure to a models.Procedure ready for upsert. +func ExtractProcedure(p *Procedure, patientFHIRID, ehrURL string) *models.Procedure { + coding := firstCoding(p.Code) + + // Prefer performedDateTime; fall back to period start. + performedDate := p.PerformedDateTime + if performedDate == "" && p.PerformedPeriod != nil { + performedDate = p.PerformedPeriod.Start + } + + // First reason code text/display. + var reasonText string + if len(p.ReasonCode) > 0 { + rc := p.ReasonCode[0] + if rc.Text != "" { + reasonText = rc.Text + } else if len(rc.Coding) > 0 { + reasonText = rc.Coding[0].Display + } + } + + // Outcome text. + var outcome string + if p.Outcome.Text != "" { + outcome = p.Outcome.Text + } else if len(p.Outcome.Coding) > 0 { + outcome = p.Outcome.Coding[0].Display + } + + return &models.Procedure{ + FHIRID: p.ID, + EHRURL: strings.TrimRight(ehrURL, "/"), + PatientFHIRID: patientFHIRID, + Status: p.Status, + CodeText: p.Code.Text, + CodeSystem: coding.System, + CodeCode: coding.Code, + PerformedDate: performedDate, + ReasonText: reasonText, + Outcome: outcome, + } +} + +// ExtractEncounter maps a FHIR Encounter to a models.Encounter ready for upsert. +func ExtractEncounter(e *Encounter, patientFHIRID, ehrURL string) *models.Encounter { + // Type text from first type entry. + var typeText string + if len(e.Type) > 0 { + t := e.Type[0] + if t.Text != "" { + typeText = t.Text + } else if len(t.Coding) > 0 { + typeText = t.Coding[0].Display + } + } + + var periodStart, periodEnd string + if e.Period != nil { + periodStart = e.Period.Start + periodEnd = e.Period.End + } + + // First reason code text/display. + var reasonText string + if len(e.ReasonCode) > 0 { + rc := e.ReasonCode[0] + if rc.Text != "" { + reasonText = rc.Text + } else if len(rc.Coding) > 0 { + reasonText = rc.Coding[0].Display + } + } + + return &models.Encounter{ + FHIRID: e.ID, + EHRURL: strings.TrimRight(ehrURL, "/"), + PatientFHIRID: patientFHIRID, + Status: e.Status, + Class: e.Class.Code, + TypeText: typeText, + PeriodStart: periodStart, + PeriodEnd: periodEnd, + ReasonText: reasonText, + } + +} + +// ExtractUSCoreRaceText returns the US Core race text extension value from a Patient, +// or empty string if not present. +func ExtractUSCoreRaceText(p *Patient) string { + const raceURL = "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race" + for _, ext := range p.Extension { + if ext.URL == raceURL { + for _, nested := range ext.Extension { + if nested.URL == "text" { + return nested.ValueString + } + } + } + } + return "" +} + +// ExtractUSCoreEthnicityText returns the US Core ethnicity text extension value from a Patient, +// or empty string if not present. +func ExtractUSCoreEthnicityText(p *Patient) string { + const ethnicityURL = "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity" + for _, ext := range p.Extension { + if ext.URL == ethnicityURL { + for _, nested := range ext.Extension { + if nested.URL == "text" { + return nested.ValueString + } + } + } + } + return "" } // ParseFHIRUserFromIDToken attempts to extract a FHIR resource reference diff --git a/app/fhir/fhir_test.go b/app/fhir/fhir_test.go index ad8171c..980aff8 100644 --- a/app/fhir/fhir_test.go +++ b/app/fhir/fhir_test.go @@ -119,6 +119,235 @@ func TestExtractUserFromPractitioner_FullRecord(t *testing.T) { assertEqual(t, "Email", "dr.chen@hospital.org", u.Email) } +// --------------------------------------------------------------------------- +// ExtractAllergyIntolerance tests +// --------------------------------------------------------------------------- + +func TestExtractAllergyIntolerance_WithReaction(t *testing.T) { + a := &fhir.AllergyIntolerance{ + ID: "allergy-001", + Code: fhir.CodeableConcept{ + Text: "Penicillin", + Coding: []fhir.Coding{{System: "http://rxnorm", Code: "7980"}}, + }, + ClinicalStatus: fhir.CodeableConcept{Coding: []fhir.Coding{{Code: "active"}}}, + VerificationStatus: fhir.CodeableConcept{Coding: []fhir.Coding{{Code: "confirmed"}}}, + Criticality: "high", + RecordedDate: "2018-05-01", + Reaction: []fhir.AllergyReaction{ + { + Severity: "severe", + Manifestation: []fhir.CodeableConcept{ + {Text: "Anaphylaxis"}, + }, + }, + }, + } + + m := fhir.ExtractAllergyIntolerance(a, "patient-001", "https://ehr.example.com/fhir") + + assertEqual(t, "ReactionSeverity", "severe", m.ReactionSeverity) + assertEqual(t, "ReactionManifestation", "Anaphylaxis", m.ReactionManifestation) + assertEqual(t, "ClinicalStatus", "active", m.ClinicalStatus) + assertEqual(t, "Criticality", "high", m.Criticality) +} + +func TestExtractAllergyIntolerance_NoReaction(t *testing.T) { + a := &fhir.AllergyIntolerance{ + ID: "allergy-002", + Code: fhir.CodeableConcept{Text: "Latex"}, + } + + m := fhir.ExtractAllergyIntolerance(a, "patient-001", "https://ehr.example.com/fhir") + + if m.ReactionSeverity != "" { + t.Errorf("ReactionSeverity: expected empty, got %q", m.ReactionSeverity) + } + if m.ReactionManifestation != "" { + t.Errorf("ReactionManifestation: expected empty, got %q", m.ReactionManifestation) + } +} + +// --------------------------------------------------------------------------- +// ExtractImmunization tests +// --------------------------------------------------------------------------- + +func TestExtractImmunization_Full(t *testing.T) { + imm := &fhir.Immunization{ + ID: "imm-001", + Status: "completed", + VaccineCode: fhir.CodeableConcept{ + Text: "Influenza, seasonal", + Coding: []fhir.Coding{{System: "http://hl7.org/fhir/sid/cvx", Code: "141"}}, + }, + OccurrenceDateTime: "2023-10-01", + PrimarySource: true, + LotNumber: "LOT123", + } + + m := fhir.ExtractImmunization(imm, "patient-001", "https://ehr.example.com/fhir") + + assertEqual(t, "FHIRID", "imm-001", m.FHIRID) + assertEqual(t, "Status", "completed", m.Status) + assertEqual(t, "VaccineText", "Influenza, seasonal", m.VaccineText) + assertEqual(t, "VaccineCode", "141", m.VaccineCode) + assertEqual(t, "OccurrenceDate", "2023-10-01", m.OccurrenceDate) + assertEqual(t, "LotNumber", "LOT123", m.LotNumber) + if !m.PrimarySource { + t.Error("PrimarySource should be true") + } +} + +// --------------------------------------------------------------------------- +// ExtractProcedure tests +// --------------------------------------------------------------------------- + +func TestExtractProcedure_WithDatetime(t *testing.T) { + p := &fhir.Procedure{ + ID: "proc-001", + Status: "completed", + Code: fhir.CodeableConcept{ + Text: "Appendectomy", + Coding: []fhir.Coding{{System: "http://snomed.info/sct", Code: "80146002"}}, + }, + PerformedDateTime: "2019-06-15", + ReasonCode: []fhir.CodeableConcept{ + {Text: "Acute appendicitis"}, + }, + Outcome: fhir.CodeableConcept{Text: "Successful"}, + } + + m := fhir.ExtractProcedure(p, "patient-001", "https://ehr.example.com/fhir") + + assertEqual(t, "FHIRID", "proc-001", m.FHIRID) + assertEqual(t, "Status", "completed", m.Status) + assertEqual(t, "CodeText", "Appendectomy", m.CodeText) + assertEqual(t, "PerformedDate", "2019-06-15", m.PerformedDate) + assertEqual(t, "ReasonText", "Acute appendicitis", m.ReasonText) + assertEqual(t, "Outcome", "Successful", m.Outcome) +} + +func TestExtractProcedure_FallsBackToPeriodStart(t *testing.T) { + p := &fhir.Procedure{ + ID: "proc-002", + Status: "completed", + Code: fhir.CodeableConcept{Text: "Colonoscopy"}, + PerformedPeriod: &fhir.Period{ + Start: "2022-03-01", + End: "2022-03-01", + }, + } + + m := fhir.ExtractProcedure(p, "patient-001", "https://ehr.example.com/fhir") + assertEqual(t, "PerformedDate (from period)", "2022-03-01", m.PerformedDate) +} + +// --------------------------------------------------------------------------- +// ExtractEncounter tests +// --------------------------------------------------------------------------- + +func TestExtractEncounter_Full(t *testing.T) { + e := &fhir.Encounter{ + ID: "enc-001", + Status: "finished", + Class: fhir.EncounterClass{Code: "AMB"}, + Type: []fhir.CodeableConcept{ + {Text: "Office visit"}, + }, + Period: &fhir.Period{Start: "2024-03-10", End: "2024-03-10"}, + ReasonCode: []fhir.CodeableConcept{ + {Text: "Annual physical"}, + }, + } + + m := fhir.ExtractEncounter(e, "patient-001", "https://ehr.example.com/fhir") + + assertEqual(t, "FHIRID", "enc-001", m.FHIRID) + assertEqual(t, "Status", "finished", m.Status) + assertEqual(t, "Class", "AMB", m.Class) + assertEqual(t, "TypeText", "Office visit", m.TypeText) + assertEqual(t, "PeriodStart", "2024-03-10", m.PeriodStart) + assertEqual(t, "PeriodEnd", "2024-03-10", m.PeriodEnd) + assertEqual(t, "ReasonText", "Annual physical", m.ReasonText) +} + +// --------------------------------------------------------------------------- +// ExtractCondition category code tests +// --------------------------------------------------------------------------- + +func TestExtractCondition_UsesCategoryCode(t *testing.T) { + c := &fhir.Condition{ + ID: "cond-001", + Code: fhir.CodeableConcept{ + Text: "Hypertension", + Coding: []fhir.Coding{{System: "http://snomed.info/sct", Code: "38341003"}}, + }, + ClinicalStatus: fhir.CodeableConcept{Coding: []fhir.Coding{{Code: "active"}}}, + VerificationStatus: fhir.CodeableConcept{Coding: []fhir.Coding{{Code: "confirmed"}}}, + Category: []fhir.CodeableConcept{ + { + Coding: []fhir.Coding{{System: "http://terminology.hl7.org/CodeSystem/condition-category", Code: "problem-list-item", Display: "Problem List Item"}}, + Text: "Problem List Item", + }, + }, + } + + m := fhir.ExtractCondition(c, "patient-001", "https://ehr.example.com/fhir") + + // Must store the code ("problem-list-item"), not the display text ("Problem List Item"). + assertEqual(t, "Category code", "problem-list-item", m.Category) +} + +// --------------------------------------------------------------------------- +// ExtractUSCoreRace/Ethnicity tests +// --------------------------------------------------------------------------- + +func TestExtractUSCoreRaceText(t *testing.T) { + p := &fhir.Patient{ + ID: "patient-race", + Extension: []fhir.Extension{ + { + URL: "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race", + Extension: []fhir.Extension{ + {URL: "text", ValueString: "White"}, + {URL: "ombCategory", ValueCoding: &fhir.Coding{Code: "2106-3", Display: "White"}}, + }, + }, + }, + } + + got := fhir.ExtractUSCoreRaceText(p) + if got != "White" { + t.Errorf("ExtractUSCoreRaceText: got %q, want White", got) + } +} + +func TestExtractUSCoreEthnicityText(t *testing.T) { + p := &fhir.Patient{ + ID: "patient-eth", + Extension: []fhir.Extension{ + { + URL: "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity", + Extension: []fhir.Extension{ + {URL: "text", ValueString: "Not Hispanic or Latino"}, + }, + }, + }, + } + + got := fhir.ExtractUSCoreEthnicityText(p) + if got != "Not Hispanic or Latino" { + t.Errorf("ExtractUSCoreEthnicityText: got %q, want Not Hispanic or Latino", got) + } +} + +func TestExtractUSCoreRaceText_Missing(t *testing.T) { + p := &fhir.Patient{ID: "patient-no-race"} + if got := fhir.ExtractUSCoreRaceText(p); got != "" { + t.Errorf("expected empty string for patient without race extension, got %q", got) + } +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/app/handlers/dashboard.go b/app/handlers/dashboard.go index 5833fdb..c541b24 100644 --- a/app/handlers/dashboard.go +++ b/app/handlers/dashboard.go @@ -3,12 +3,60 @@ package handlers import ( "log" "net/http" + "time" "github.com/AmanTahiliani/FHIR-Sandbox/app/fhir" "github.com/AmanTahiliani/FHIR-Sandbox/app/middleware" "github.com/AmanTahiliani/FHIR-Sandbox/app/models" ) +// ClinicalSummary holds pre-computed summary values for the Summary tab. +type ClinicalSummary struct { + LatestVitals []models.Observation + ActiveCondCount int + ActiveMedCount int + AbnormalLabCount int + AbnormalLabsRecent []models.Observation +} + +// buildClinicalSummary computes the clinical summary from existing in-memory data. +// No additional DB or FHIR calls are made. +func buildClinicalSummary(obs []models.Observation, conds []models.Condition, meds []models.MedicationRequest) ClinicalSummary { + // Latest value per vital code. + latestVitals := latestObsPerCode(filterObsByCategory(obs, "vital-signs")) + + activeConds := 0 + for _, c := range conds { + if c.ClinicalStatus == "active" { + activeConds++ + } + } + + activeMeds := 0 + for _, m := range meds { + if m.Status == "active" { + activeMeds++ + } + } + + // Abnormal labs within the last 30 days. + cutoff := time.Now().AddDate(0, 0, -30).Format("2006-01-02") + var abnormalLabs []models.Observation + for _, o := range obs { + if isAbnormalInterp(o.Interpretation) && o.EffectiveDate >= cutoff { + abnormalLabs = append(abnormalLabs, o) + } + } + + return ClinicalSummary{ + LatestVitals: latestVitals, + ActiveCondCount: activeConds, + ActiveMedCount: activeMeds, + AbnormalLabCount: len(abnormalLabs), + AbnormalLabsRecent: abnormalLabs, + } +} + // HandleDashboard renders the stable patient dashboard. // On first load (no prior sync), automatically triggers a sync to populate data. // All other clinical data is read from the local database; no live FHIR calls are @@ -63,7 +111,6 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) { observations, err := h.store.ListObservations(patientID, ehrURL) if err != nil { log.Printf("handlers: dashboard ListObservations Patient/%s: %v", patientID, err) - // Non-fatal; render with empty slice. } conditions, err := h.store.ListConditions(patientID, ehrURL) @@ -86,6 +133,22 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) { log.Printf("handlers: dashboard ListAllergyIntolerances Patient/%s: %v", patientID, err) } + immunizations, err := h.store.ListImmunizations(patientID, ehrURL) + if err != nil { + log.Printf("handlers: dashboard ListImmunizations Patient/%s: %v", patientID, err) + } + + procedures, err := h.store.ListProcedures(patientID, ehrURL) + if err != nil { + log.Printf("handlers: dashboard ListProcedures Patient/%s: %v", patientID, err) + } + + encounters, err := h.store.ListEncounters(patientID, ehrURL) + if err != nil { + log.Printf("handlers: dashboard ListEncounters Patient/%s: %v", patientID, err) + } + + summary := buildClinicalSummary(observations, conditions, medications) synced := r.URL.Query().Get("synced") == "true" h.render(w, "dashboard.html", dashboardData{ @@ -97,6 +160,10 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) { DocumentReferences: docRefs, Medications: medications, Allergies: allergies, + Immunizations: immunizations, + Procedures: procedures, + Encounters: encounters, + Summary: summary, LatestSync: latestSync, Session: sess, Synced: synced, @@ -113,6 +180,10 @@ type dashboardData struct { DocumentReferences []models.DocumentReference Medications []models.MedicationRequest Allergies []models.AllergyIntolerance + Immunizations []models.Immunization + Procedures []models.Procedure + Encounters []models.Encounter + Summary ClinicalSummary LatestSync *models.PatientSync Session *models.Session Synced bool diff --git a/app/handlers/handler.go b/app/handlers/handler.go index 307881d..8ed5868 100644 --- a/app/handlers/handler.go +++ b/app/handlers/handler.go @@ -21,14 +21,18 @@ package handlers import ( "crypto/rand" "encoding/hex" + "fmt" "html/template" "io/fs" "log" "net/http" + "sort" + "strings" "time" "github.com/AmanTahiliani/FHIR-Sandbox/app/config" "github.com/AmanTahiliani/FHIR-Sandbox/app/db" + "github.com/AmanTahiliani/FHIR-Sandbox/app/fhir" "github.com/AmanTahiliani/FHIR-Sandbox/app/models" ) @@ -111,20 +115,74 @@ func generateState() (string, error) { return hex.EncodeToString(b), nil } -// templateFuncs returns the custom template function map. +// --------------------------------------------------------------------------- +// Private helpers used by both template funcs and buildClinicalSummary. +// --------------------------------------------------------------------------- + +// filterObsByCategory returns observations whose category matches cat +// using a normalised (lowercase, spaces→hyphens) comparison. +func filterObsByCategory(obs []models.Observation, cat string) []models.Observation { + want := strings.ToLower(strings.ReplaceAll(cat, " ", "-")) + var out []models.Observation + for _, o := range obs { + got := strings.ToLower(strings.ReplaceAll(o.Category, " ", "-")) + if got == want { + out = append(out, o) + } + } + return out +} + +// latestObsPerCode returns the most-recent observation per LOINC code (or code +// text when code is absent), sorted by code text for stable display. +func latestObsPerCode(obs []models.Observation) []models.Observation { + latest := make(map[string]models.Observation) + for _, o := range obs { + key := o.CodeCode + if key == "" { + key = o.CodeText + } + if existing, ok := latest[key]; !ok || o.EffectiveDate > existing.EffectiveDate { + latest[key] = o + } + } + out := make([]models.Observation, 0, len(latest)) + for _, o := range latest { + out = append(out, o) + } + sort.Slice(out, func(i, j int) bool { return out[i].CodeText < out[j].CodeText }) + return out +} + +// isAbnormalInterp returns true for interpretation codes that indicate an +// out-of-range or critical result. +func isAbnormalInterp(interp string) bool { + switch strings.ToUpper(strings.TrimSpace(interp)) { + case "H", "HH", "L", "LL", "A", "AA", "HIGH", "LOW", "ABNORMAL", "CRITICAL": + return true + } + return false +} + +// --------------------------------------------------------------------------- +// TemplateFuncs returns the custom template function map. // Defined here so it is available to both main.go (for wiring) and // handler tests. +// --------------------------------------------------------------------------- func TemplateFuncs() template.FuncMap { return template.FuncMap{ "formatDate": func(s string) string { if s == "" { return "—" } - t, err := time.Parse("2006-01-02", s) - if err != nil { - return s + // Try full datetime first (FHIR dateTime), then plain date. + for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05Z0700", "2006-01-02"} { + t, err := time.Parse(layout, s) + if err == nil { + return t.Format("Jan 2, 2006") + } } - return t.Format("January 2, 2006") + return s }, "formatDateTime": func(t time.Time) string { if t.IsZero() { @@ -153,6 +211,7 @@ func TemplateFuncs() template.FuncMap { } return s }, + // groupByCategory is kept for backward compatibility. "groupByCategory": func(obs []models.Observation) map[string][]models.Observation { m := make(map[string][]models.Observation) for _, o := range obs { @@ -172,5 +231,129 @@ func TemplateFuncs() template.FuncMap { } return false }, + // --- T1.2 Vitals/Labs --- + "filterObsByCategory": func(obs []models.Observation, cat string) []models.Observation { + return filterObsByCategory(obs, cat) + }, + "latestObPerCode": func(obs []models.Observation) []models.Observation { + return latestObsPerCode(obs) + }, + "isAbnormal": func(interp string) bool { + return isAbnormalInterp(interp) + }, + // --- T1.3 Medication history --- + "filterMedsByStatus": func(meds []models.MedicationRequest, status string) []models.MedicationRequest { + if status == "" || status == "all" { + return meds + } + var out []models.MedicationRequest + for _, m := range meds { + if strings.EqualFold(m.Status, status) { + out = append(out, m) + } + } + return out + }, + // --- T1.1 Demographics --- + "calculateAge": func(dob string) string { + if dob == "" { + return "" + } + t, err := time.Parse("2006-01-02", dob) + if err != nil { + return "" + } + now := time.Now() + years := now.Year() - t.Year() + if now.Month() < t.Month() || (now.Month() == t.Month() && now.Day() < t.Day()) { + years-- + } + return fmt.Sprintf("%d", years) + }, + "primaryPhone": func(p *fhir.Patient) string { + if p == nil { + return "" + } + for _, tc := range p.Telecom { + if tc.System == "phone" && tc.Value != "" { + return tc.Value + } + } + return "" + }, + "primaryAddress": func(p *fhir.Patient) string { + if p == nil || len(p.Address) == 0 { + return "" + } + addr := p.Address[0] + var parts []string + if len(addr.Line) > 0 { + parts = append(parts, addr.Line[0]) + } + if addr.City != "" { + parts = append(parts, addr.City) + } + if addr.State != "" { + parts = append(parts, addr.State) + } + if addr.PostalCode != "" { + parts = append(parts, addr.PostalCode) + } + return strings.Join(parts, ", ") + }, + "usRace": func(p *fhir.Patient) string { + if p == nil { + return "" + } + return fhir.ExtractUSCoreRaceText(p) + }, + "usEthnicity": func(p *fhir.Patient) string { + if p == nil { + return "" + } + return fhir.ExtractUSCoreEthnicityText(p) + }, + // --- T2.3 Encounters --- + "encounterClassBadge": func(class string) string { + switch strings.ToUpper(class) { + case "AMB": + return "badge-info" + case "EMER": + return "badge-danger" + case "IMP", "INPATIENT": + return "badge-warning" + default: + return "badge-neutral" + } + }, + "encounterClassLabel": func(class string) string { + switch strings.ToUpper(class) { + case "AMB": + return "Ambulatory" + case "EMER": + return "Emergency" + case "IMP": + return "Inpatient" + case "VR": + return "Virtual" + default: + if class == "" { + return "Visit" + } + return class + } + }, + "split": func(s, sep string) []string { + if s == "" { + return nil + } + return strings.Split(s, sep) + }, + "min": func(a, b int) int { + if a < b { + return a + } + return b + }, } } diff --git a/app/handlers/sync.go b/app/handlers/sync.go index 9fc44b3..fa105c5 100644 --- a/app/handlers/sync.go +++ b/app/handlers/sync.go @@ -143,6 +143,60 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) { allergyCount++ } + // ----------------------------------------------------------------- + // Fetch Immunizations + // ----------------------------------------------------------------- + rawImmunizations, err := client.GetImmunizations(patientID, sinceTime) + if err != nil { + log.Printf("handlers: sync GetImmunizations for Patient/%s: %v", patientID, err) + } + + immunizationCount := 0 + for i := range rawImmunizations { + m := fhir.ExtractImmunization(&rawImmunizations[i], patientID, ehrURL) + if _, err := h.store.UpsertImmunization(m); err != nil { + log.Printf("handlers: sync UpsertImmunization fhir_id=%s: %v", m.FHIRID, err) + continue + } + immunizationCount++ + } + + // ----------------------------------------------------------------- + // Fetch Procedures + // ----------------------------------------------------------------- + rawProcedures, err := client.GetProcedures(patientID, sinceTime) + if err != nil { + log.Printf("handlers: sync GetProcedures for Patient/%s: %v", patientID, err) + } + + procedureCount := 0 + for i := range rawProcedures { + m := fhir.ExtractProcedure(&rawProcedures[i], patientID, ehrURL) + if _, err := h.store.UpsertProcedure(m); err != nil { + log.Printf("handlers: sync UpsertProcedure fhir_id=%s: %v", m.FHIRID, err) + continue + } + procedureCount++ + } + + // ----------------------------------------------------------------- + // Fetch Encounters + // ----------------------------------------------------------------- + rawEncounters, err := client.GetEncounters(patientID, sinceTime) + if err != nil { + log.Printf("handlers: sync GetEncounters for Patient/%s: %v", patientID, err) + } + + encounterCount := 0 + for i := range rawEncounters { + m := fhir.ExtractEncounter(&rawEncounters[i], patientID, ehrURL) + if _, err := h.store.UpsertEncounter(m); err != nil { + log.Printf("handlers: sync UpsertEncounter fhir_id=%s: %v", m.FHIRID, err) + continue + } + encounterCount++ + } + // ----------------------------------------------------------------- // Record the sync event // ----------------------------------------------------------------- @@ -150,8 +204,8 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) { log.Printf("handlers: sync RecordSync Patient/%s: %v", patientID, err) } - log.Printf("handlers: sync complete for Patient/%s — obs=%d cond=%d docs=%d med=%d allergy=%d", - patientID, obsCount, condCount, docCount, medCount, allergyCount) + log.Printf("handlers: sync complete for Patient/%s — obs=%d cond=%d docs=%d med=%d allergy=%d imm=%d proc=%d enc=%d", + patientID, obsCount, condCount, docCount, medCount, allergyCount, immunizationCount, procedureCount, encounterCount) dashboardURL := "/dashboard?synced=true" if overrideID := r.URL.Query().Get("patient_id"); overrideID != "" { diff --git a/app/models/models.go b/app/models/models.go index 5eb4bfc..76e55d2 100644 --- a/app/models/models.go +++ b/app/models/models.go @@ -48,6 +48,7 @@ type User struct { FirstName string `json:"first_name" db:"first_name"` MiddleName string `json:"middle_name" db:"middle_name"` LastName string `json:"last_name" db:"last_name"` + MRN string `json:"mrn" db:"mrn"` // Medical Record Number DOB string `json:"dob" db:"dob"` // ISO 8601 date, e.g. "1990-04-22" Gender string `json:"gender" db:"gender"` // FHIR value set: male|female|other|unknown @@ -182,20 +183,69 @@ type MedicationRequest struct { // AllergyIntolerance is the persisted representation of a FHIR R4 AllergyIntolerance. type AllergyIntolerance struct { - ID string `json:"id" db:"id"` - FHIRID string `json:"fhir_id" db:"fhir_id"` - EHRURL string `json:"ehr_url" db:"ehr_url"` - PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"` - ClinicalStatus string `json:"clinical_status" db:"clinical_status"` - VerificationStatus string `json:"verification_status" db:"verification_status"` - Type string `json:"type" db:"type"` - Category string `json:"category" db:"category"` - Criticality string `json:"criticality" db:"criticality"` - CodeText string `json:"code_text" db:"code_text"` - CodeSystem string `json:"code_system" db:"code_system"` - CodeCode string `json:"code_code" db:"code_code"` - RecordedDate string `json:"recorded_date" db:"recorded_date"` - SyncedAt time.Time `json:"synced_at" db:"synced_at"` + ID string `json:"id" db:"id"` + FHIRID string `json:"fhir_id" db:"fhir_id"` + EHRURL string `json:"ehr_url" db:"ehr_url"` + PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"` + ClinicalStatus string `json:"clinical_status" db:"clinical_status"` + VerificationStatus string `json:"verification_status" db:"verification_status"` + Type string `json:"type" db:"type"` + Category string `json:"category" db:"category"` + Criticality string `json:"criticality" db:"criticality"` + CodeText string `json:"code_text" db:"code_text"` + CodeSystem string `json:"code_system" db:"code_system"` + CodeCode string `json:"code_code" db:"code_code"` + RecordedDate string `json:"recorded_date" db:"recorded_date"` + ReactionSeverity string `json:"reaction_severity" db:"reaction_severity"` + ReactionManifestation string `json:"reaction_manifestation" db:"reaction_manifestation"` + SyncedAt time.Time `json:"synced_at" db:"synced_at"` +} + +// Immunization is the persisted representation of a FHIR R4 Immunization. +type Immunization struct { + ID string `json:"id" db:"id"` + FHIRID string `json:"fhir_id" db:"fhir_id"` + EHRURL string `json:"ehr_url" db:"ehr_url"` + PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"` + Status string `json:"status" db:"status"` + VaccineText string `json:"vaccine_text" db:"vaccine_text"` + VaccineSystem string `json:"vaccine_system" db:"vaccine_system"` + VaccineCode string `json:"vaccine_code" db:"vaccine_code"` + OccurrenceDate string `json:"occurrence_date" db:"occurrence_date"` + PrimarySource bool `json:"primary_source" db:"primary_source"` + LotNumber string `json:"lot_number" db:"lot_number"` + SyncedAt time.Time `json:"synced_at" db:"synced_at"` +} + +// Procedure is the persisted representation of a FHIR R4 Procedure. +type Procedure struct { + ID string `json:"id" db:"id"` + FHIRID string `json:"fhir_id" db:"fhir_id"` + EHRURL string `json:"ehr_url" db:"ehr_url"` + PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"` + Status string `json:"status" db:"status"` + CodeText string `json:"code_text" db:"code_text"` + CodeSystem string `json:"code_system" db:"code_system"` + CodeCode string `json:"code_code" db:"code_code"` + PerformedDate string `json:"performed_date" db:"performed_date"` + ReasonText string `json:"reason_text" db:"reason_text"` + Outcome string `json:"outcome" db:"outcome"` + SyncedAt time.Time `json:"synced_at" db:"synced_at"` +} + +// Encounter is the persisted representation of a FHIR R4 Encounter. +type Encounter struct { + ID string `json:"id" db:"id"` + FHIRID string `json:"fhir_id" db:"fhir_id"` + EHRURL string `json:"ehr_url" db:"ehr_url"` + PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"` + Status string `json:"status" db:"status"` + Class string `json:"class" db:"class"` + TypeText string `json:"type_text" db:"type_text"` + PeriodStart string `json:"period_start" db:"period_start"` + PeriodEnd string `json:"period_end" db:"period_end"` + ReasonText string `json:"reason_text" db:"reason_text"` + SyncedAt time.Time `json:"synced_at" db:"synced_at"` } // PatientSync records a completed FHIR sync event for a patient. diff --git a/app/static/css/styles.css b/app/static/css/styles.css index 1da89c2..68a58bf 100644 --- a/app/static/css/styles.css +++ b/app/static/css/styles.css @@ -1,453 +1,324 @@ :root { - /* Brand Colors */ - --primary-color: #2563EB; - --primary-hover: #1D4ED8; - --secondary-color: #0D9488; - --secondary-hover: #0F766E; - - /* State Colors */ - --success-color: #10B981; - --success-bg: #D1FAE5; - --warning-color: #F59E0B; - --warning-bg: #FEF3C7; - --danger-color: #EF4444; - --danger-bg: #FEE2E2; - --info-color: #3B82F6; - --info-bg: #DBEAFE; - - /* Neutral Colors */ - --background-color: #F3F4F6; - --surface-color: #FFFFFF; - --text-primary: #111827; - --text-secondary: #4B5563; - --text-muted: #9CA3AF; - --border-color: #E5E7EB; - - /* Spacing */ - --spacing-xs: 0.25rem; - --spacing-sm: 0.5rem; - --spacing-md: 1rem; - --spacing-lg: 1.5rem; - --spacing-xl: 2rem; - - /* Typography */ - --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + /* Ultra-refined Palette (Anthropic / Apple inspired) */ + --bg-app: #FCFCFC; /* Very subtle warm off-white for main background */ + --bg-panel: #FFFFFF; /* Pure white for cards */ + --bg-hover: #F4F4F5; /* Zinc 100 for subtle hovers */ - /* Effects */ - --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); - --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); - --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); - --radius: 0.5rem; - --transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); -} - -/* Reset & Base */ -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; + --border-soft: #F4F4F5; /* Zinc 100 */ + --border-hard: #E4E4E7; /* Zinc 200 */ + --border-focus: #A1A1AA; /* Zinc 400 */ + + --text-main: #18181B; /* Zinc 900 - Almost black */ + --text-muted: #71717A; /* Zinc 500 - Refined gray */ + --text-faint: #A1A1AA; /* Zinc 400 - For placeholders/meta */ + + --brand-dark: #09090B; /* Anthropic style primary action */ + --brand-light: #F4F4F5; + + /* Semantic Colors - Muted & Sophisticated */ + --accent-blue: #0284C7; /* Sky 600 */ + --accent-blue-bg: #F0F9FF; /* Sky 50 */ + --danger: #E11D48; /* Rose 600 */ + --danger-bg: #FFF1F2; /* Rose 50 */ + --warning: #D97706; /* Amber 600 */ + --warning-bg: #FFFBEB; /* Amber 50 */ + --success: #059669; /* Emerald 600 */ + --success-bg: #ECFDF5; /* Emerald 50 */ + + /* Shapes & Metrics */ + --radius-sm: 6px; + --radius-md: 12px; + --radius-lg: 20px; + --radius-full: 9999px; + + /* Shadows - Apple-style diffused */ + --shadow-subtle: 0 2px 8px -2px rgba(0, 0, 0, 0.04), 0 1px 2px -1px rgba(0, 0, 0, 0.02); + --shadow-float: 0 12px 32px -4px rgba(0, 0, 0, 0.08), 0 4px 12px -2px rgba(0, 0, 0, 0.04); + + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-mono: "SF Mono", ui-monospace, Menlo, Monaco, Consolas, monospace; } +/* Reset & Typography */ +* { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: var(--font-sans); - background-color: var(--background-color); - color: var(--text-primary); - line-height: 1.5; + background-color: var(--bg-app); + color: var(--text-main); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; min-height: 100vh; display: flex; flex-direction: column; } -h1, h2, h3, h4, h5, h6 { - font-weight: 600; - color: var(--text-primary); - line-height: 1.25; -} - -a { - color: var(--primary-color); - text-decoration: none; - transition: var(--transition); -} - -a:hover { - color: var(--primary-hover); - text-decoration: underline; -} +h1, h2, h3, h4 { font-weight: 600; letter-spacing: -0.02em; color: var(--text-main); line-height: 1.2; } +a { color: var(--text-main); text-decoration: none; transition: color 0.2s; } +a:hover { color: var(--text-muted); } /* Layout */ -.container { - max-width: 1200px; - margin: 0 auto; - padding: 0 var(--spacing-md); - width: 100%; -} - -main { - flex: 1; - padding: var(--spacing-xl) 0; -} +.container { max-width: 1280px; margin: 0 auto; padding: 0 2rem; width: 100%; } +main { flex: 1; padding: 3rem 0; } /* Navbar */ .navbar { - background-color: var(--surface-color); - border-bottom: 1px solid var(--border-color); - padding: var(--spacing-md) 0; - position: sticky; - top: 0; - z-index: 50; - box-shadow: var(--shadow-sm); -} - -.navbar-content { - display: flex; - align-items: center; - justify-content: space-between; -} - -.brand { - font-size: 1.25rem; - font-weight: 700; - color: var(--primary-color); - display: flex; - align-items: center; - gap: var(--spacing-sm); -} - -.brand span { - font-weight: 400; - color: var(--text-primary); -} - -.nav-links { - display: flex; - gap: var(--spacing-md); - align-items: center; -} - -.nav-link { - color: var(--text-secondary); - font-weight: 500; - padding: var(--spacing-sm) var(--spacing-md); - border-radius: var(--radius); -} - -.nav-link:hover { - background-color: var(--background-color); - color: var(--text-primary); - text-decoration: none; + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border-hard); + position: sticky; top: 0; z-index: 50; + padding: 1rem 0; } +.navbar-content { display: flex; justify-content: space-between; align-items: center; } +.brand { font-size: 1.125rem; font-weight: 700; letter-spacing: -0.03em; color: var(--text-main); display: flex; align-items: center; gap: 0.5rem; } +.brand .brand-light { font-weight: 400; color: var(--text-muted); } +.nav-links { display: flex; align-items: center; gap: 1.5rem; font-size: 0.875rem; font-weight: 500; } +.nav-link { color: var(--text-muted); } +.nav-link:hover { color: var(--text-main); } /* Buttons */ .btn { - display: inline-flex; - align-items: center; - justify-content: center; - padding: var(--spacing-sm) var(--spacing-md); - border-radius: var(--radius); - font-weight: 500; - cursor: pointer; - border: 1px solid transparent; - transition: var(--transition); - font-size: 0.875rem; - gap: var(--spacing-sm); + display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; + padding: 0.5rem 1rem; border-radius: var(--radius-sm); font-size: 0.875rem; font-weight: 500; + cursor: pointer; transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); border: 1px solid transparent; } +.btn-primary { background: var(--brand-dark); color: white; box-shadow: 0 1px 2px rgba(0,0,0,0.1); } +.btn-primary:hover { background: #27272A; transform: translateY(-1px); box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); } +.btn-secondary, .btn-outline { background: white; color: var(--text-main); border-color: var(--border-hard); box-shadow: 0 1px 2px rgba(0,0,0,0.02); } +.btn-secondary:hover, .btn-outline:hover { background: var(--bg-hover); } +.btn-danger { background: var(--danger-bg); color: var(--danger); } +.btn-danger:hover { background: #FFE4E6; } +.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.8125rem; } +.btn-icon { padding: 0.375rem; border-radius: var(--radius-sm); } -.btn-primary { - background-color: var(--primary-color); - color: white; +/* Panels / Cards */ +.panel { + background: var(--bg-panel); border-radius: var(--radius-md); + border: 1px solid var(--border-soft); box-shadow: var(--shadow-subtle); } +.panel-padded { padding: 2rem; } -.btn-primary:hover { - background-color: var(--primary-hover); - text-decoration: none; -} - -.btn-secondary { - background-color: var(--surface-color); - border-color: var(--border-color); - color: var(--text-secondary); -} - -.btn-secondary:hover { - background-color: var(--background-color); - text-decoration: none; -} - -.btn-danger { - background-color: var(--danger-color); - color: white; -} - -.btn-danger:hover { - background-color: #DC2626; - text-decoration: none; -} - -.btn-outline { - background-color: transparent; - border-color: var(--border-color); - color: var(--text-secondary); -} - -.btn-outline:hover, .btn-outline.active { - background-color: var(--background-color); - border-color: var(--text-secondary); - color: var(--text-primary); - text-decoration: none; -} - -/* Cards */ -.card { - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: var(--radius); - box-shadow: var(--shadow); - padding: var(--spacing-lg); - margin-bottom: var(--spacing-lg); - overflow: hidden; -} - -.card-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--spacing-md); - padding-bottom: var(--spacing-md); - border-bottom: 1px solid var(--border-color); -} - -.card-title { - font-size: 1.125rem; - font-weight: 600; - color: var(--text-primary); -} - -.card-subtitle { - font-size: 0.875rem; - color: var(--text-secondary); -} - -/* Grid System */ -.grid { - display: grid; - gap: var(--spacing-lg); -} - -.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } -.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } -.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } -.grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } - -@media (max-width: 768px) { - .grid-cols-2, .grid-cols-3, .grid-cols-4 { - grid-template-columns: 1fr; - } -} - -/* Details List */ -.details-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: var(--spacing-md); -} - -.detail-item { - display: flex; - flex-direction: column; -} - -.detail-label { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-muted); - font-weight: 600; - margin-bottom: var(--spacing-xs); -} - -.detail-value { - font-size: 1rem; - font-weight: 500; - color: var(--text-primary); -} - -/* Tables */ -.table-container { - width: 100%; - overflow-x: auto; - border-radius: var(--radius); - border: 1px solid var(--border-color); -} - -table { - width: 100%; - border-collapse: collapse; - font-size: 0.875rem; - background-color: var(--surface-color); -} - -th { - text-align: left; - padding: var(--spacing-md); - background-color: var(--background-color); - color: var(--text-secondary); - font-weight: 600; - border-bottom: 1px solid var(--border-color); - white-space: nowrap; -} - -td { - padding: var(--spacing-md); - border-bottom: 1px solid var(--border-color); - color: var(--text-secondary); - vertical-align: top; -} - -tr:last-child td { - border-bottom: none; -} - -tr:hover td { - background-color: #F9FAFB; +/* Typography Utilities */ +.text-main { color: var(--text-main); } +.text-muted { color: var(--text-muted); } +.text-faint { color: var(--text-faint); } +.text-danger { color: var(--danger); } +.text-success { color: var(--success); } +.text-blue { color: var(--accent-blue); } +.text-xs { font-size: 0.75rem; } +.text-sm { font-size: 0.875rem; } +.text-lg { font-size: 1.125rem; } +.text-xl { font-size: 1.25rem; } +.text-2xl { font-size: 1.5rem; letter-spacing: -0.03em; } +.text-3xl { font-size: 1.875rem; letter-spacing: -0.04em; } +.font-medium { font-weight: 500; } +.font-semibold { font-weight: 600; } +.font-mono { font-family: var(--font-mono); } +.uppercase { text-transform: uppercase; letter-spacing: 0.05em; } + +/* Layout Utilities */ +.flex { display: flex; } +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } +.flex-col { flex-direction: column; } +.gap-1 { gap: 0.25rem; } +.gap-2 { gap: 0.5rem; } +.gap-3 { gap: 0.75rem; } +.gap-4 { gap: 1rem; } +.gap-6 { gap: 1.5rem; } +.gap-8 { gap: 2rem; } +.w-full { width: 100%; } + +/* Avatars */ +.avatar { + display: flex; align-items: center; justify-content: center; + border-radius: var(--radius-full); font-weight: 600; + background: var(--bg-hover); color: var(--text-main); border: 1px solid var(--border-hard); } +.avatar-sm { width: 32px; height: 32px; font-size: 0.875rem; } +.avatar-md { width: 48px; height: 48px; font-size: 1.125rem; } +.avatar-lg { width: 80px; height: 80px; font-size: 2rem; background: var(--bg-panel); box-shadow: var(--shadow-subtle); border-color: var(--border-soft); } /* Badges */ .badge { - display: inline-flex; - align-items: center; - padding: 0.125rem 0.5rem; - border-radius: 9999px; - font-size: 0.75rem; - font-weight: 600; - text-transform: capitalize; + display: inline-flex; align-items: center; padding: 0.125rem 0.5rem; + border-radius: var(--radius-full); font-size: 0.75rem; font-weight: 500; } +.badge-neutral { background: var(--bg-hover); color: var(--text-muted); border: 1px solid var(--border-hard); } +.badge-blue { background: var(--accent-blue-bg); color: var(--accent-blue); border: 1px solid #BAE6FD; } +.badge-danger { background: var(--danger-bg); color: var(--danger); border: 1px solid #FECDD3; } +.badge-warning { background: var(--warning-bg); color: var(--warning); border: 1px solid #FDE68A; } +.badge-success { background: var(--success-bg); color: var(--success); border: 1px solid #A7F3D0; } -.badge-success { background-color: var(--success-bg); color: var(--success-color); } -.badge-warning { background-color: var(--warning-bg); color: var(--warning-color); } -.badge-danger { background-color: var(--danger-bg); color: var(--danger-color); } -.badge-info { background-color: var(--info-bg); color: var(--info-color); } -.badge-neutral { background-color: var(--background-color); color: var(--text-secondary); border: 1px solid var(--border-color); } - -/* Tabs */ -.tabs { - display: flex; - border-bottom: 1px solid var(--border-color); - margin-bottom: var(--spacing-lg); - overflow-x: auto; +/* Tables - Elegant & Airy */ +.table-wrapper { width: 100%; overflow-x: auto; } +table { width: 100%; border-collapse: separate; border-spacing: 0; } +th { + font-weight: 500; color: var(--text-muted); font-size: 0.75rem; text-transform: uppercase; + letter-spacing: 0.05em; border-bottom: 1px solid var(--border-hard); + padding: 1rem 0.75rem; text-align: left; background: var(--bg-panel); } +td { + padding: 1.25rem 0.75rem; border-bottom: 1px solid var(--border-soft); + color: var(--text-main); font-size: 0.875rem; vertical-align: top; +} +tr:last-child td { border-bottom: none; } +tr:hover td { background-color: #FAFAFB; } +.td-sub { color: var(--text-muted); font-size: 0.8125rem; margin-top: 0.25rem; } +/* Tabs - Minimal Underline */ +.tabs { display: flex; gap: 2rem; border-bottom: 1px solid var(--border-hard); overflow-x: auto; scrollbar-width: none; } +.tabs::-webkit-scrollbar { display: none; } .tab-btn { - padding: var(--spacing-md) var(--spacing-lg); - border: none; - background: none; - cursor: pointer; - color: var(--text-secondary); - font-weight: 500; - border-bottom: 2px solid transparent; - transition: var(--transition); - white-space: nowrap; + background: none; border: none; padding: 0 0 1rem 0; margin-bottom: -1px; + color: var(--text-muted); font-size: 0.875rem; font-weight: 500; + border-bottom: 2px solid transparent; cursor: pointer; transition: all 0.2s; white-space: nowrap; +} +.tab-btn:hover { color: var(--text-main); } +.tab-btn.active { color: var(--text-main); border-bottom-color: var(--text-main); } +.tab-content { display: none; animation: fadeIn 0.3s ease; } +.tab-content.active { display: block; } +@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } + +/* Inputs / Search */ +.input-base { + width: 100%; padding: 0.625rem 1rem; border-radius: var(--radius-sm); + border: 1px solid var(--border-hard); background: var(--bg-panel); + color: var(--text-main); font-size: 0.875rem; outline: none; transition: border-color 0.2s; +} +.input-base:focus { border-color: var(--border-focus); box-shadow: 0 0 0 3px rgba(161, 161, 170, 0.1); } +.search-wrapper { position: relative; display: flex; align-items: center; max-width: 480px; } +.search-icon { position: absolute; left: 1rem; color: var(--text-faint); pointer-events: none; } +.search-input { padding-left: 2.75rem; border-radius: var(--radius-full); background: var(--bg-hover); border-color: transparent; } +.search-input:focus { background: var(--bg-panel); border-color: var(--border-hard); } + +/* Key-Value Lists */ +.kv-list { display: flex; flex-direction: column; gap: 0.75rem; } +.kv-row { display: flex; flex-direction: column; gap: 0.125rem; } +.kv-label { font-size: 0.75rem; color: var(--text-muted); font-weight: 500; } +.kv-value { font-size: 0.875rem; color: var(--text-main); } + +/* Animation Utils */ +.animate-spin { animation: spin 1s linear infinite; } +@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } + +/* Grid Utilities */ +.grid { display: grid; } +.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } +@media (max-width: 1024px) { + .lg\:flex-col { flex-direction: column; } + .lg\:grid-cols-1 { grid-template-columns: minmax(0, 1fr); } } -.tab-btn:hover { - color: var(--primary-color); +/* Page Headers */ +.page-header { margin-bottom: 2rem; } +.page-title { font-size: 1.875rem; font-weight: 600; color: var(--text-main); margin: 0; letter-spacing: -0.04em; } +.page-subtitle { font-size: 1rem; color: var(--text-muted); margin-top: 0.25rem; } + +/* Dashboard Specific Layout */ +.dashboard-layout { display: flex; gap: 1.5rem; align-items: flex-start; } +.dashboard-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1.5rem; } +.dashboard-sidebar { width: 320px; flex-shrink: 0; display: flex; flex-direction: column; gap: 1rem; } +@media (max-width: 1024px) { + .dashboard-layout { flex-direction: column; } + .dashboard-sidebar { width: 100%; order: 2; } + .dashboard-main { order: 1; } } -.tab-btn.active { - color: var(--primary-color); - border-bottom-color: var(--primary-color); +/* Custom UI Components for App */ +.flash-message { + background: var(--success-bg); color: var(--success); + padding: 1rem; border-radius: var(--radius-md); border: 1px solid #A7F3D0; + display: flex; align-items: center; justify-content: space-between; } -.tab-content { - display: none; - animation: fadeIn 0.2s ease-in-out; +.mrn-badge { + background: var(--bg-hover); color: var(--text-muted); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); + font-size: 0.875rem; font-weight: 500; font-family: var(--font-mono); border: 1px solid var(--border-hard); } -.tab-content.active { - display: block; -} +.patient-meta-strip { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-top: 0.5rem; } +.meta-item { font-size: 0.875rem; color: var(--text-muted); } +.meta-item strong { color: var(--text-main); font-weight: 500; } -@keyframes fadeIn { - from { opacity: 0; transform: translateY(4px); } - to { opacity: 1; transform: translateY(0); } -} +.stat-card { text-align: center; } +.stat-value { font-size: 2rem; font-weight: 600; line-height: 1; margin-bottom: 0.25rem; letter-spacing: -0.04em; } +.stat-label { font-size: 0.75rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; } +.stat-card.danger { border-top: 3px solid var(--danger); } -/* Avatar */ -.avatar { - width: 48px; - height: 48px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-weight: 700; - color: white; - font-size: 1.25rem; -} +/* Vitals strip & grid */ +.vitals-strip { display: flex; gap: 0.75rem; overflow-x: auto; padding-bottom: 0.5rem; } +.vital-item { min-width: 140px; background: var(--bg-hover); padding: 1rem; border-radius: var(--radius-md); display: flex; flex-direction: column; gap: 0.25rem; border: 1px solid var(--border-soft); } +.vital-label { font-size: 0.75rem; color: var(--text-muted); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.vital-value { font-size: 1.25rem; font-weight: 600; color: var(--text-main); } +.vital-value small { font-size: 0.75rem; font-weight: 500; color: var(--text-muted); } +.vital-date { font-size: 0.65rem; color: var(--text-muted); } +.vital-abnormal { background: var(--danger-bg); border-color: #FECDD3; } +.vital-abnormal .vital-value { color: var(--danger); } -.avatar-practitioner { background-color: var(--secondary-color); } -.avatar-patient { background-color: var(--primary-color); } +.vitals-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; } -/* Utility */ -.text-right { text-align: right; } +.alert-card { border-left: 3px solid var(--danger); } +.alert-item { display: flex; flex-direction: column; gap: 0.125rem; padding: 0.5rem 0; } +.alert-code { font-weight: 600; color: var(--danger); font-size: 0.875rem; } +.alert-detail { font-size: 0.75rem; color: var(--text-muted); } + +.inspector-card { background: var(--bg-hover); border: 1px dashed var(--border-focus); } +.inspector-content { margin-top: 1rem; display: flex; flex-direction: column; gap: 1rem; } +.inspect-item .label { font-size: 0.75rem; font-weight: 600; color: var(--text-muted); display: block; margin-bottom: 0.25rem; } +.scopes-cloud { display: flex; flex-wrap: wrap; gap: 0.25rem; } +.scope-tag { font-size: 0.75rem; background: var(--bg-panel); border: 1px solid var(--border-hard); padding: 0.125rem 0.375rem; border-radius: var(--radius-sm); color: var(--text-muted); font-family: var(--font-mono); } + +.practitioner-mini-card { display: flex; align-items: center; gap: 0.5rem; background: var(--bg-hover); padding: 0.25rem 0.75rem 0.25rem 0.25rem; border-radius: var(--radius-full); border: 1px solid var(--border-soft); } +.empty-state { text-align: center; padding: 3rem; color: var(--text-muted); font-style: italic; } +.row-abnormal { background: var(--danger-bg); } + +/* Note cards */ +.notes-list { display: flex; flex-direction: column; gap: 1rem; } +.note-item { display: flex; flex-direction: column; gap: 0.5rem; } +.note-header { display: flex; align-items: center; gap: 0.75rem; } +.note-date { font-weight: 500; font-size: 0.875rem; color: var(--text-main); } +.note-type { font-size: 0.875rem; color: var(--text-muted); } +.note-desc { font-size: 0.875rem; color: var(--text-main); line-height: 1.5; } + +/* Directory toolbar */ +.directory-toolbar { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.5rem; background: var(--bg-hover); border-bottom: 1px solid var(--border-hard); } + +/* Additional Utilities */ .text-center { text-align: center; } -.mt-4 { margin-top: var(--spacing-md); } -.mb-4 { margin-bottom: var(--spacing-md); } -.w-full { width: 100%; } -.flex { display: flex; } -.gap-2 { gap: var(--spacing-sm); } -.gap-4 { gap: var(--spacing-md); } -.items-center { align-items: center; } -.justify-between { justify-content: space-between; } -.code-block { - font-family: var(--font-mono); - background-color: var(--background-color); - padding: var(--spacing-sm); - border-radius: var(--radius); - font-size: 0.75rem; - word-break: break-all; - color: var(--text-primary); -} +.text-right { text-align: right; } +.pt-4 { padding-top: 1rem; } +.pb-4 { padding-bottom: 1rem; } +.py-16 { padding-top: 4rem; padding-bottom: 4rem; } +.mt-4 { margin-top: 1rem; } +.mb-2 { margin-bottom: 0.5rem; } +.mb-4 { margin-bottom: 1rem; } +.mb-6 { margin-bottom: 1.5rem; } +.min-h-\[400px\] { min-height: 400px; } +.tracking-wider { letter-spacing: 0.05em; } +.break-all { word-break: break-all; } -/* Flash Message */ -.flash-message { - padding: var(--spacing-md); - border-radius: var(--radius); - margin-bottom: var(--spacing-lg); - display: flex; - justify-content: space-between; - align-items: center; -} +/* Tailwind-like utilities used in templates */ +.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; } +.px-4 { padding-left: 1rem; padding-right: 1rem; } +.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; } +.border-b { border-bottom-width: 1px; border-bottom-style: solid; } +.border-hard { border-color: var(--border-hard); } +.bg-panel { background-color: var(--bg-panel); } -.flash-success { - background-color: var(--success-bg); - border: 1px solid var(--success-color); - color: #065F46; -} - -/* Accordion/Details */ -details > summary { - list-style: none; - cursor: pointer; - padding: var(--spacing-sm) 0; - font-weight: 600; - color: var(--primary-color); - display: flex; - align-items: center; - gap: var(--spacing-sm); -} - -details > summary::-webkit-details-marker { - display: none; -} - -details > summary::before { - content: '▶'; - font-size: 0.75rem; - transition: transform 0.2s; -} - -details[open] > summary::before { - transform: rotate(90deg); -} +/* Additional Spacing & Layout Utilities */ +.m-0 { margin: 0; } +.p-0 { padding: 0; } +.p-5 { padding: 1.25rem; } +.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; } +.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; } +.shrink-0 { flex-shrink: 0; } +.overflow-hidden { overflow: hidden; } +.cursor-pointer { cursor: pointer; } +.outline-none { outline: 2px solid transparent; outline-offset: 2px; } +.max-w-\[500px\] { max-width: 500px; } +.align-middle { vertical-align: middle; } +.border-l-brand { border-left: 6px solid var(--brand-dark); } +.border-b { border-bottom-width: 1px; border-bottom-style: solid; } +.py-8 { padding-top: 2rem; padding-bottom: 2rem; } diff --git a/app/templates/base.html b/app/templates/base.html index 03b9420..a482ad9 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -6,16 +6,13 @@ FHIR Health Platform -