mirror of
https://github.com/AmanTahiliani/FHIR-Sandbox.git
synced 2026-08-07 19:56:17 -04:00
UI Upgrades
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
Reference in New Issue
Block a user