mirror of
https://github.com/AmanTahiliani/FHIR-Sandbox.git
synced 2026-08-07 11:53:56 -04:00
Patient Match and CGM Demo
This commit is contained in:
@@ -33,6 +33,18 @@ type AppConfig struct {
|
|||||||
// PatientMatchRemoteAPIKey is the API key sent in the X-Api-Key header
|
// PatientMatchRemoteAPIKey is the API key sent in the X-Api-Key header
|
||||||
// when calling the remote (Rimidi/Provider) patient match API.
|
// when calling the remote (Rimidi/Provider) patient match API.
|
||||||
PatientMatchRemoteAPIKey string
|
PatientMatchRemoteAPIKey string
|
||||||
|
|
||||||
|
// RimidiAppID is the identifier for the Rimidi app instance.
|
||||||
|
// Used when storing confirmed patient matches.
|
||||||
|
RimidiAppID string
|
||||||
|
|
||||||
|
// RimidiCGMAPIURL is the base URL for the Rimidi CGM preview API.
|
||||||
|
// e.g., "http://localhost:2222/cshub/privateadmin/patients"
|
||||||
|
RimidiCGMAPIURL string
|
||||||
|
|
||||||
|
// RimidiInternalAPIKey is the API key used to authenticate with the Rimidi
|
||||||
|
// CGM API (INTERNAL_APP_SYNC_KEY from Provider settings).
|
||||||
|
RimidiInternalAPIKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerConfig holds HTTP server settings.
|
// ServerConfig holds HTTP server settings.
|
||||||
|
|||||||
144
app/db/db.go
144
app/db/db.go
@@ -314,6 +314,25 @@ var migrations = []migration{
|
|||||||
ALTER TABLE users ADD COLUMN mrn TEXT NOT NULL DEFAULT '';
|
ALTER TABLE users ADD COLUMN mrn TEXT NOT NULL DEFAULT '';
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
version: 10,
|
||||||
|
sql: `
|
||||||
|
CREATE TABLE IF NOT EXISTS patient_matches (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
hrs_patient_fhir_id TEXT NOT NULL,
|
||||||
|
hrs_ehr_url TEXT NOT NULL,
|
||||||
|
rimidi_app_id TEXT NOT NULL,
|
||||||
|
rimidi_patient_pk TEXT NOT NULL,
|
||||||
|
rimidi_patient_ref TEXT NOT NULL,
|
||||||
|
confirmed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(hrs_patient_fhir_id, hrs_ehr_url, rimidi_app_id, rimidi_patient_pk)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_patient_matches_fhir ON patient_matches(hrs_patient_fhir_id, hrs_ehr_url);
|
||||||
|
`,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrate applies any migrations that have not yet been run, in order.
|
// migrate applies any migrations that have not yet been run, in order.
|
||||||
@@ -597,3 +616,128 @@ func (s *Store) DeleteExpiredSessions() (int64, error) {
|
|||||||
n, _ := res.RowsAffected()
|
n, _ := res.RowsAffected()
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Patient match operations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// UpsertPatientMatch inserts a new patient match or updates an existing one.
|
||||||
|
// The natural key is (hrs_patient_fhir_id, hrs_ehr_url, rimidi_app_id, rimidi_patient_pk).
|
||||||
|
func (s *Store) UpsertPatientMatch(match *models.PatientMatch) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// Check if match already exists
|
||||||
|
var existingID string
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`SELECT id FROM patient_matches
|
||||||
|
WHERE hrs_patient_fhir_id = ? AND hrs_ehr_url = ? AND rimidi_app_id = ? AND rimidi_patient_pk = ?`,
|
||||||
|
match.HRSPatientFHIRID, match.HRSEHRURL, match.RimidiAppID, match.RimidiPatientPK,
|
||||||
|
).Scan(&existingID)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
// Match exists — update it
|
||||||
|
_, err = s.db.Exec(`
|
||||||
|
UPDATE patient_matches SET
|
||||||
|
rimidi_patient_ref = ?,
|
||||||
|
confirmed_at = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
match.RimidiPatientRef, now, now, existingID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: update patient match %s: %w", existingID, err)
|
||||||
|
}
|
||||||
|
match.ID = existingID
|
||||||
|
match.ConfirmedAt = now
|
||||||
|
match.UpdatedAt = now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
return fmt.Errorf("db: lookup patient match: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New match — generate a fresh internal UUID
|
||||||
|
match.ID = uuid.NewString()
|
||||||
|
match.ConfirmedAt = now
|
||||||
|
match.CreatedAt = now
|
||||||
|
match.UpdatedAt = now
|
||||||
|
|
||||||
|
_, err = s.db.Exec(`
|
||||||
|
INSERT INTO patient_matches (
|
||||||
|
id, hrs_patient_fhir_id, hrs_ehr_url, rimidi_app_id,
|
||||||
|
rimidi_patient_pk, rimidi_patient_ref, confirmed_at, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
match.ID, match.HRSPatientFHIRID, match.HRSEHRURL, match.RimidiAppID,
|
||||||
|
match.RimidiPatientPK, match.RimidiPatientRef, match.ConfirmedAt, match.CreatedAt, match.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: insert patient match: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPatientMatchByFHIRID retrieves the most recent confirmed match for a patient
|
||||||
|
// by their FHIR ID and EHR URL. Returns sql.ErrNoRows if no match is found.
|
||||||
|
func (s *Store) GetPatientMatchByFHIRID(fhirID, ehrURL string) (*models.PatientMatch, error) {
|
||||||
|
m := &models.PatientMatch{}
|
||||||
|
err := s.db.QueryRow(`
|
||||||
|
SELECT id, hrs_patient_fhir_id, hrs_ehr_url, rimidi_app_id,
|
||||||
|
rimidi_patient_pk, rimidi_patient_ref, confirmed_at, created_at, updated_at
|
||||||
|
FROM patient_matches
|
||||||
|
WHERE hrs_patient_fhir_id = ? AND hrs_ehr_url = ?
|
||||||
|
ORDER BY confirmed_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
fhirID, ehrURL,
|
||||||
|
).Scan(
|
||||||
|
&m.ID, &m.HRSPatientFHIRID, &m.HRSEHRURL, &m.RimidiAppID,
|
||||||
|
&m.RimidiPatientPK, &m.RimidiPatientRef, &m.ConfirmedAt, &m.CreatedAt, &m.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPatientMatchesByFHIRID retrieves all confirmed matches for a patient
|
||||||
|
// by their FHIR ID and EHR URL, ordered by most recent first.
|
||||||
|
func (s *Store) ListPatientMatchesByFHIRID(fhirID, ehrURL string) ([]models.PatientMatch, error) {
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT id, hrs_patient_fhir_id, hrs_ehr_url, rimidi_app_id,
|
||||||
|
rimidi_patient_pk, rimidi_patient_ref, confirmed_at, created_at, updated_at
|
||||||
|
FROM patient_matches
|
||||||
|
WHERE hrs_patient_fhir_id = ? AND hrs_ehr_url = ?
|
||||||
|
ORDER BY confirmed_at DESC`,
|
||||||
|
fhirID, ehrURL,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db: list patient matches: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var matches []models.PatientMatch
|
||||||
|
for rows.Next() {
|
||||||
|
var m models.PatientMatch
|
||||||
|
if err := rows.Scan(
|
||||||
|
&m.ID, &m.HRSPatientFHIRID, &m.HRSEHRURL, &m.RimidiAppID,
|
||||||
|
&m.RimidiPatientPK, &m.RimidiPatientRef, &m.ConfirmedAt, &m.CreatedAt, &m.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("db: scan patient match: %w", err)
|
||||||
|
}
|
||||||
|
matches = append(matches, m)
|
||||||
|
}
|
||||||
|
return matches, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePatientMatch removes the confirmed match for a patient.
|
||||||
|
func (s *Store) DeletePatientMatch(fhirID, ehrURL string) error {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
DELETE FROM patient_matches
|
||||||
|
WHERE hrs_patient_fhir_id = ? AND hrs_ehr_url = ?`,
|
||||||
|
fhirID, ehrURL,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: delete patient match: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
125
app/handlers/cgm.go
Normal file
125
app/handlers/cgm.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
// Package handlers — cgm.go implements the CGM preview proxy endpoint.
|
||||||
|
//
|
||||||
|
// GET /api/cgm-preview?patient_id=<fhir_id>
|
||||||
|
//
|
||||||
|
// This endpoint proxies requests to the Rimidi Provider CGM API to display
|
||||||
|
// CGM data for a patient that has been matched between HRS and Rimidi.
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/FHIR-Sandbox/app/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// proxyTimeout is the HTTP client timeout for the outbound CGM API call.
|
||||||
|
const cgmProxyTimeout = 15 * time.Second
|
||||||
|
|
||||||
|
// HandleCGMPreview handles GET /api/cgm-preview requests.
|
||||||
|
// It requires a valid session and looks up the confirmed match for the patient,
|
||||||
|
// then proxies the request to the Rimidi Provider CGM API.
|
||||||
|
func (h *Handler) HandleCGMPreview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := middleware.SessionFromContext(r.Context())
|
||||||
|
if sess == nil {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resolve patient from query param or session ────────────────
|
||||||
|
patientFHIRID := r.URL.Query().Get("patient_id")
|
||||||
|
if patientFHIRID == "" {
|
||||||
|
patientFHIRID = sess.PatientFHIRID
|
||||||
|
}
|
||||||
|
|
||||||
|
if patientFHIRID == "" {
|
||||||
|
http.Error(w, `{"error":"no patient in context"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Look up confirmed match for this patient ───────────────────
|
||||||
|
match, err := h.store.GetPatientMatchByFHIRID(patientFHIRID, sess.EHRURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("handlers: cgm-preview GetPatientMatchByFHIRID(%s, %s): %v", patientFHIRID, sess.EHRURL, err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"error": "No confirmed patient match found. Please confirm a match first.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build Rimidi CGM API URL ───────────────────────────────────
|
||||||
|
rimidiURL := h.cfg.RimidiCGMAPIURL
|
||||||
|
rimidiKey := h.cfg.RimidiInternalAPIKey
|
||||||
|
|
||||||
|
if rimidiURL == "" || rimidiKey == "" {
|
||||||
|
log.Printf("handlers: cgm-preview Rimidi CGM API not configured")
|
||||||
|
http.Error(w, `{"error":"CGM API not configured"}`, http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the full URL: {base_url}/{patient_pk}/cgm-preview/
|
||||||
|
cgmURL := rimidiURL + "/" + match.RimidiPatientPK + "/cgm-preview/"
|
||||||
|
|
||||||
|
// ── Call Rimidi CGM API ────────────────────────────────────────
|
||||||
|
client := &http.Client{Timeout: cgmProxyTimeout}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, cgmURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("handlers: cgm-preview new request: %v", err)
|
||||||
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Api-Key", rimidiKey)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("handlers: cgm-preview remote call failed: %v", err)
|
||||||
|
http.Error(w, `{"error":"could not reach Rimidi CGM API"}`, http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("handlers: cgm-preview read response: %v", err)
|
||||||
|
http.Error(w, `{"error":"failed to read remote response"}`, http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
bodyLen := len(respBody)
|
||||||
|
if bodyLen > 500 {
|
||||||
|
bodyLen = 500
|
||||||
|
}
|
||||||
|
log.Printf("handlers: cgm-preview remote returned %d: %s", resp.StatusCode, string(respBody[:bodyLen]))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
w.Write(respBody)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parse and forward the response ──────────────────────────────
|
||||||
|
var cgmData map[string]interface{}
|
||||||
|
if err := json.Unmarshal(respBody, &cgmData); err != nil {
|
||||||
|
log.Printf("handlers: cgm-preview unmarshal response: %v", err)
|
||||||
|
http.Error(w, `{"error":"invalid response from Rimidi CGM API"}`, http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reports now come with presigned S3 URLs, no transformation needed
|
||||||
|
|
||||||
|
// Forward the response as-is
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(cgmData)
|
||||||
|
}
|
||||||
101
app/handlers/cgm_report_proxy.go
Normal file
101
app/handlers/cgm_report_proxy.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const cgmReportProxyTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// HandleCGMReportProxy proxies CGM report requests (PDFs and images) from Rimidi Provider.
|
||||||
|
// This allows the browser to access reports without needing to handle API keys directly.
|
||||||
|
func (h *Handler) HandleCGMReportProxy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the target URL from query parameter
|
||||||
|
targetURL := r.URL.Query().Get("url")
|
||||||
|
if targetURL == "" {
|
||||||
|
http.Error(w, "Missing 'url' query parameter", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that the URL is from the configured Rimidi CGM API
|
||||||
|
rimidiBaseURL := h.cfg.RimidiCGMAPIURL
|
||||||
|
if rimidiBaseURL == "" {
|
||||||
|
http.Error(w, "CGM API not configured", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract base URL (remove /{patient_pk}/cgm-preview/)
|
||||||
|
baseURL := rimidiBaseURL
|
||||||
|
if len(baseURL) > 20 {
|
||||||
|
// Remove trailing path components to get base URL
|
||||||
|
lastSlash := -1
|
||||||
|
for i := len(baseURL) - 1; i >= 0; i-- {
|
||||||
|
if baseURL[i] == '/' {
|
||||||
|
lastSlash = i
|
||||||
|
if i > 0 && baseURL[i-1] != '/' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastSlash > 0 {
|
||||||
|
baseURL = baseURL[:lastSlash]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove /privateadmin/patients if present
|
||||||
|
if len(baseURL) > 20 && baseURL[len(baseURL)-20:] == "/privateadmin/patients" {
|
||||||
|
baseURL = baseURL[:len(baseURL)-20]
|
||||||
|
}
|
||||||
|
// Add /cshub if not present
|
||||||
|
if len(baseURL) < 7 || baseURL[len(baseURL)-7:] != "/cshub" {
|
||||||
|
baseURL = baseURL + "/cshub"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if target URL starts with the expected base
|
||||||
|
expectedPrefix := baseURL + "/api/cgm-report-proxy/"
|
||||||
|
if len(targetURL) < len(expectedPrefix) || targetURL[:len(expectedPrefix)] != expectedPrefix {
|
||||||
|
log.Printf("handlers: cgm-report-proxy invalid URL prefix: %s (expected: %s)", targetURL, expectedPrefix)
|
||||||
|
http.Error(w, "Invalid URL", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call Rimidi Provider API with internal API key
|
||||||
|
client := &http.Client{Timeout: cgmReportProxyTimeout}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, targetURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("handlers: cgm-report-proxy new request: %v", err)
|
||||||
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Api-Key", h.cfg.RimidiInternalAPIKey)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("handlers: cgm-report-proxy remote call failed: %v", err)
|
||||||
|
http.Error(w, "Could not fetch report", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Copy response headers
|
||||||
|
for key, values := range resp.Header {
|
||||||
|
for _, value := range values {
|
||||||
|
w.Header().Add(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set status code
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
|
||||||
|
// Copy response body
|
||||||
|
if _, err := io.Copy(w, resp.Body); err != nil {
|
||||||
|
log.Printf("handlers: cgm-report-proxy copy response: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/fhir"
|
"github.com/AmanTahiliani/FHIR-Sandbox/app/fhir"
|
||||||
@@ -151,6 +154,16 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
summary := buildClinicalSummary(observations, conditions, medications)
|
summary := buildClinicalSummary(observations, conditions, medications)
|
||||||
synced := r.URL.Query().Get("synced") == "true"
|
synced := r.URL.Query().Get("synced") == "true"
|
||||||
|
|
||||||
|
// ── Check for confirmed Rimidi patient match ────────────────────
|
||||||
|
var rimidiMatch *models.PatientMatch
|
||||||
|
var rimidiPatientKey string
|
||||||
|
match, err := h.store.GetPatientMatchByFHIRID(patientID, ehrURL)
|
||||||
|
if err == nil {
|
||||||
|
rimidiMatch = match
|
||||||
|
// Fetch the signed patient key from Rimidi
|
||||||
|
rimidiPatientKey = h.fetchRimidiPatientKey(match.RimidiPatientPK)
|
||||||
|
}
|
||||||
|
|
||||||
h.render(w, "dashboard.html", dashboardData{
|
h.render(w, "dashboard.html", dashboardData{
|
||||||
Patient: patientUser,
|
Patient: patientUser,
|
||||||
Practitioner: practitionerUser,
|
Practitioner: practitionerUser,
|
||||||
@@ -167,6 +180,8 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
LatestSync: latestSync,
|
LatestSync: latestSync,
|
||||||
Session: sess,
|
Session: sess,
|
||||||
Synced: synced,
|
Synced: synced,
|
||||||
|
RimidiMatch: rimidiMatch,
|
||||||
|
RimidiPatientKey: rimidiPatientKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +202,75 @@ type dashboardData struct {
|
|||||||
LatestSync *models.PatientSync
|
LatestSync *models.PatientSync
|
||||||
Session *models.Session
|
Session *models.Session
|
||||||
Synced bool
|
Synced bool
|
||||||
|
RimidiMatch *models.PatientMatch
|
||||||
|
RimidiPatientKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchRimidiPatientKey fetches the signed patient key from Rimidi Provider API.
|
||||||
|
// Returns empty string if the fetch fails (non-blocking).
|
||||||
|
func (h *Handler) fetchRimidiPatientKey(patientPK string) string {
|
||||||
|
if patientPK == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the Rimidi patient key API URL
|
||||||
|
// PatientMatchRemoteURL is like "http://localhost:2222/cshub/api/patient-match/"
|
||||||
|
rimidiBaseURL := h.cfg.PatientMatchRemoteURL
|
||||||
|
if rimidiBaseURL == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace "/api/patient-match/" with "/api/patient-key/{pk}/"
|
||||||
|
keyURL := strings.Replace(rimidiBaseURL, "/api/patient-match/", "/api/patient-key/"+patientPK+"/", 1)
|
||||||
|
log.Printf("handlers: fetchRimidiPatientKey constructing URL: %s (from base: %s)", keyURL, rimidiBaseURL)
|
||||||
|
|
||||||
|
// Call Rimidi API to get signed patient key
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, keyURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("handlers: fetchRimidiPatientKey new request: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Use internal API key (same as CGM API) for consistency
|
||||||
|
req.Header.Set("X-Api-Key", h.cfg.RimidiInternalAPIKey)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("handlers: fetchRimidiPatientKey remote call failed: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("handlers: fetchRimidiPatientKey read response: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
bodyPreview := ""
|
||||||
|
if len(body) > 0 {
|
||||||
|
if len(body) > 200 {
|
||||||
|
bodyPreview = string(body[:200])
|
||||||
|
} else {
|
||||||
|
bodyPreview = string(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("handlers: fetchRimidiPatientKey remote returned %d for URL %s, body: %s", resp.StatusCode, keyURL, bodyPreview)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyData map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &keyData); err != nil {
|
||||||
|
log.Printf("handlers: fetchRimidiPatientKey unmarshal response: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if patientKey, ok := keyData["patient_key"].(string); ok {
|
||||||
|
return patientKey
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUnauthorized redirects to root for dashboard requests.
|
// handleUnauthorized redirects to root for dashboard requests.
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/FHIR-Sandbox/app/middleware"
|
||||||
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
|
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// patientMatchRequest is the inbound JSON shape for a match query.
|
// patientMatchRequest is the inbound JSON shape for a match query.
|
||||||
@@ -203,3 +206,144 @@ func (h *Handler) HandlePatientMatch(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
json.NewEncoder(w).Encode(resp)
|
json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// confirmMatchRequest is the inbound JSON shape for a match confirmation.
|
||||||
|
type confirmMatchRequest struct {
|
||||||
|
RimidiPatientRef string `json:"rimidi_patient_ref"`
|
||||||
|
RimidiPatientPK string `json:"rimidi_patient_pk"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirmMatchResponse is the response shape for match confirmation.
|
||||||
|
type confirmMatchResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Match *models.PatientMatch `json:"match,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleConfirmMatch processes POST /api/patient-match/confirm requests.
|
||||||
|
// It requires a valid session and stores the confirmed match in the database.
|
||||||
|
func (h *Handler) HandleConfirmMatch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := middleware.SessionFromContext(r.Context())
|
||||||
|
if sess == nil {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resolve patient from the session or query param ─────────────
|
||||||
|
patientFHIRID := sess.PatientFHIRID
|
||||||
|
if override := r.URL.Query().Get("patient_id"); override != "" {
|
||||||
|
patientFHIRID = override
|
||||||
|
}
|
||||||
|
|
||||||
|
if patientFHIRID == "" {
|
||||||
|
http.Error(w, `{"error":"no patient in context"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decode request body ─────────────────────────────────────────
|
||||||
|
var req confirmMatchRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid JSON body"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validate required fields ────────────────────────────────────
|
||||||
|
if strings.TrimSpace(req.RimidiPatientRef) == "" {
|
||||||
|
http.Error(w, `{"error":"rimidi_patient_ref is required"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.RimidiPatientPK) == "" {
|
||||||
|
http.Error(w, `{"error":"rimidi_patient_pk is required"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get Rimidi app ID from config ──────────────────────────────
|
||||||
|
rimidiAppID := h.cfg.RimidiAppID
|
||||||
|
if rimidiAppID == "" {
|
||||||
|
rimidiAppID = "demo-app" // Default for demo
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create patient match record ─────────────────────────────────
|
||||||
|
match := &models.PatientMatch{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
HRSPatientFHIRID: patientFHIRID,
|
||||||
|
HRSEHRURL: sess.EHRURL,
|
||||||
|
RimidiAppID: rimidiAppID,
|
||||||
|
RimidiPatientPK: req.RimidiPatientPK,
|
||||||
|
RimidiPatientRef: req.RimidiPatientRef,
|
||||||
|
ConfirmedAt: time.Now().UTC(),
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Store match in database ────────────────────────────────────
|
||||||
|
if err := h.store.UpsertPatientMatch(match); err != nil {
|
||||||
|
log.Printf("handlers: confirm-match UpsertPatientMatch failed: %v", err)
|
||||||
|
http.Error(w, `{"error":"failed to store match"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("handlers: confirmed patient match: hrs_patient=%s rimidi_pk=%s", patientFHIRID, req.RimidiPatientPK)
|
||||||
|
|
||||||
|
// ── Write success response ─────────────────────────────────────
|
||||||
|
resp := confirmMatchResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Patient match confirmed successfully",
|
||||||
|
Match: match,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUnlinkMatch processes DELETE /api/patient-match/unlink requests.
|
||||||
|
// It requires a valid session and removes the confirmed match for the patient.
|
||||||
|
func (h *Handler) HandleUnlinkMatch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodDelete && r.Method != http.MethodPost {
|
||||||
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := middleware.SessionFromContext(r.Context())
|
||||||
|
if sess == nil {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resolve patient from query param or session ────────────────
|
||||||
|
patientFHIRID := r.URL.Query().Get("patient_id")
|
||||||
|
if patientFHIRID == "" {
|
||||||
|
patientFHIRID = sess.PatientFHIRID
|
||||||
|
}
|
||||||
|
|
||||||
|
if patientFHIRID == "" {
|
||||||
|
http.Error(w, `{"error":"no patient in context"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete the match ──────────────────────────────────────────
|
||||||
|
err := h.store.DeletePatientMatch(patientFHIRID, sess.EHRURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("handlers: unlink-match DeletePatientMatch(%s, %s): %v", patientFHIRID, sess.EHRURL, err)
|
||||||
|
http.Error(w, `{"error":"failed to unlink patient"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("handlers: unlinked patient match: hrs_patient=%s", patientFHIRID)
|
||||||
|
|
||||||
|
// ── Write success response ─────────────────────────────────────
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": "Patient unlinked successfully",
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|||||||
17
app/main.go
17
app/main.go
@@ -53,6 +53,13 @@ func main() {
|
|||||||
// Remote (Rimidi/Provider) patient match integration.
|
// Remote (Rimidi/Provider) patient match integration.
|
||||||
PatientMatchRemoteURL: "http://localhost:2222/cshub/api/patient-match/",
|
PatientMatchRemoteURL: "http://localhost:2222/cshub/api/patient-match/",
|
||||||
PatientMatchRemoteAPIKey: "pMaTcH.XkR9wQzL5vJ3nT7hB2fY8dU4mA6sC1eP0gW",
|
PatientMatchRemoteAPIKey: "pMaTcH.XkR9wQzL5vJ3nT7hB2fY8dU4mA6sC1eP0gW",
|
||||||
|
|
||||||
|
// Rimidi app identifier for patient matches.
|
||||||
|
RimidiAppID: "demo-app",
|
||||||
|
|
||||||
|
// Rimidi CGM API integration.
|
||||||
|
RimidiCGMAPIURL: "http://localhost:2222/cshub/privateadmin/patients",
|
||||||
|
RimidiInternalAPIKey: "PYRXAcHV.ltOJRxYjoSrBNKXZNhu5fWalWXJesvbx",
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -112,6 +119,16 @@ func main() {
|
|||||||
// Session-required API — "Find in Rimidi" proxy for the dashboard UI.
|
// Session-required API — "Find in Rimidi" proxy for the dashboard UI.
|
||||||
mux.Handle("/api/patient-match-proxy", sessionMW.RequireSession(http.HandlerFunc(h.HandlePatientMatchProxy)))
|
mux.Handle("/api/patient-match-proxy", sessionMW.RequireSession(http.HandlerFunc(h.HandlePatientMatchProxy)))
|
||||||
|
|
||||||
|
// Session-required API — Patient match confirmation.
|
||||||
|
mux.Handle("/api/patient-match/confirm", sessionMW.RequireSession(http.HandlerFunc(h.HandleConfirmMatch)))
|
||||||
|
|
||||||
|
// Session-required API — Patient match unlink.
|
||||||
|
mux.Handle("/api/patient-match/unlink", sessionMW.RequireSession(http.HandlerFunc(h.HandleUnlinkMatch)))
|
||||||
|
|
||||||
|
// Session-required API — CGM preview proxy.
|
||||||
|
mux.Handle("/api/cgm-preview", sessionMW.RequireSession(http.HandlerFunc(h.HandleCGMPreview)))
|
||||||
|
mux.Handle("/api/cgm-report-proxy", sessionMW.RequireSession(http.HandlerFunc(h.HandleCGMReportProxy)))
|
||||||
|
|
||||||
// Apply the soft session loader to every request so templates can always
|
// Apply the soft session loader to every request so templates can always
|
||||||
// read the current user from context.
|
// read the current user from context.
|
||||||
root := sessionMW.LoadSession(mux)
|
root := sessionMW.LoadSession(mux)
|
||||||
|
|||||||
@@ -258,3 +258,16 @@ type PatientSync struct {
|
|||||||
CondCount int `json:"cond_count" db:"cond_count"`
|
CondCount int `json:"cond_count" db:"cond_count"`
|
||||||
DocCount int `json:"doc_count" db:"doc_count"`
|
DocCount int `json:"doc_count" db:"doc_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PatientMatch records a confirmed patient match between HRS (FHIR Sandbox) and Rimidi Provider.
|
||||||
|
type PatientMatch struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
HRSPatientFHIRID string `json:"hrs_patient_fhir_id" db:"hrs_patient_fhir_id"`
|
||||||
|
HRSEHRURL string `json:"hrs_ehr_url" db:"hrs_ehr_url"`
|
||||||
|
RimidiAppID string `json:"rimidi_app_id" db:"rimidi_app_id"`
|
||||||
|
RimidiPatientPK string `json:"rimidi_patient_pk" db:"rimidi_patient_pk"`
|
||||||
|
RimidiPatientRef string `json:"rimidi_patient_ref" db:"rimidi_patient_ref"`
|
||||||
|
ConfirmedAt time.Time `json:"confirmed_at" db:"confirmed_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -177,6 +177,31 @@ tr:hover td { background-color: #FAFAFB; }
|
|||||||
}
|
}
|
||||||
.tab-btn:hover { color: var(--text-main); }
|
.tab-btn:hover { color: var(--text-main); }
|
||||||
.tab-btn.active { color: var(--text-main); border-bottom-color: var(--text-main); }
|
.tab-btn.active { color: var(--text-main); border-bottom-color: var(--text-main); }
|
||||||
|
.tab-btn.cgm-tab {
|
||||||
|
color: #e91e63;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.tab-btn.cgm-tab:hover {
|
||||||
|
color: #c2185b;
|
||||||
|
border-bottom-color: rgba(233, 30, 99, 0.4);
|
||||||
|
}
|
||||||
|
.tab-btn.cgm-tab.active {
|
||||||
|
color: #e91e63;
|
||||||
|
border-bottom-color: #e91e63;
|
||||||
|
font-weight: 600;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.tab-btn.cgm-tab.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -3px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, #e91e63 0%, #f06292 50%, #e91e63 100%);
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
}
|
||||||
.tab-content { display: none; animation: fadeIn 0.3s ease; }
|
.tab-content { display: none; animation: fadeIn 0.3s ease; }
|
||||||
.tab-content.active { display: block; }
|
.tab-content.active { display: block; }
|
||||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
|||||||
@@ -63,10 +63,28 @@
|
|||||||
Refresh Chart
|
Refresh Chart
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{{if .RimidiMatch}}
|
||||||
|
{{if .RimidiPatientKey}}
|
||||||
|
<a href="http://localhost:2222/providers/v2/diab/patient/labs/{{.RimidiPatientKey}}/" target="_blank" class="btn" style="background-color:#e91e63;color:white;border:none;" title="Launch patient in Rimidi">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||||
|
Launch in Rimidi
|
||||||
|
</a>
|
||||||
|
{{else}}
|
||||||
|
<button class="btn" style="background-color:#e91e63;color:white;border:none;opacity:0.6;cursor:not-allowed;" title="Loading patient key..." disabled>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||||
|
Launch in Rimidi
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
<button class="btn btn-outline" type="button" onclick="unlinkPatient('{{.Patient.FHIRID}}')" title="Unlink this patient from Rimidi" style="color:var(--color-danger);border-color:var(--color-danger);">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18"/><path d="M6 6l12 12"/></svg>
|
||||||
|
Unlink
|
||||||
|
</button>
|
||||||
|
{{else}}
|
||||||
<button class="btn btn-outline" type="button" onclick="findInRimidi('{{.Patient.FHIRID}}')" title="Search for this patient in the Rimidi system">
|
<button class="btn btn-outline" type="button" onclick="findInRimidi('{{.Patient.FHIRID}}')" title="Search for this patient in the Rimidi system">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
Find in Rimidi
|
Find in Rimidi
|
||||||
</button>
|
</button>
|
||||||
|
{{end}}
|
||||||
<span class="text-xs text-muted">
|
<span class="text-xs text-muted">
|
||||||
Last Synced: {{if .LatestSync}}{{formatDateTime .LatestSync.SyncedAt}}{{else}}Never{{end}}
|
Last Synced: {{if .LatestSync}}{{formatDateTime .LatestSync.SyncedAt}}{{else}}Never{{end}}
|
||||||
</span>
|
</span>
|
||||||
@@ -99,6 +117,9 @@
|
|||||||
<div class="bg-panel px-4 pt-4 border-b border-hard">
|
<div class="bg-panel px-4 pt-4 border-b border-hard">
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab-btn active" onclick="openTab(event, 'summary')">Summary</button>
|
<button class="tab-btn active" onclick="openTab(event, 'summary')">Summary</button>
|
||||||
|
{{if .RimidiMatch}}
|
||||||
|
<button class="tab-btn cgm-tab" onclick="openTab(event, 'cgm')">CGM</button>
|
||||||
|
{{end}}
|
||||||
<button class="tab-btn" onclick="openTab(event, 'vitals')">Vitals ({{len (latestObPerCode (filterObsByCategory .Observations "vital-signs"))}})</button>
|
<button class="tab-btn" onclick="openTab(event, 'vitals')">Vitals ({{len (latestObPerCode (filterObsByCategory .Observations "vital-signs"))}})</button>
|
||||||
<button class="tab-btn" onclick="openTab(event, 'labs')">Labs ({{len (filterObsByCategory .Observations "laboratory")}})</button>
|
<button class="tab-btn" onclick="openTab(event, 'labs')">Labs ({{len (filterObsByCategory .Observations "laboratory")}})</button>
|
||||||
<button class="tab-btn" onclick="openTab(event, 'conditions')">Conditions ({{len .Conditions}})</button>
|
<button class="tab-btn" onclick="openTab(event, 'conditions')">Conditions ({{len .Conditions}})</button>
|
||||||
@@ -161,6 +182,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{/* ---- CGM Tab ---- */}}
|
||||||
|
{{if .RimidiMatch}}
|
||||||
|
<div id="cgm" class="tab-content">
|
||||||
|
<div id="cgmTabContent" style="min-height:400px;"></div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{/* ---- Vitals Tab ---- */}}
|
{{/* ---- Vitals Tab ---- */}}
|
||||||
<div id="vitals" class="tab-content">
|
<div id="vitals" class="tab-content">
|
||||||
{{$vitals := latestObPerCode (filterObsByCategory .Observations "vital-signs")}}
|
{{$vitals := latestObPerCode (filterObsByCategory .Observations "vital-signs")}}
|
||||||
@@ -461,6 +489,15 @@ function openTab(evt, tabName) {
|
|||||||
var target = document.getElementById(tabName);
|
var target = document.getElementById(tabName);
|
||||||
setTimeout(() => target.classList.add("active"), 10);
|
setTimeout(() => target.classList.add("active"), 10);
|
||||||
evt.currentTarget.classList.add("active");
|
evt.currentTarget.classList.add("active");
|
||||||
|
|
||||||
|
// Load CGM data when CGM tab is opened
|
||||||
|
if (tabName === 'cgm') {
|
||||||
|
var cgmContent = document.getElementById('cgmTabContent');
|
||||||
|
if (cgmContent && !cgmContent.dataset.loaded) {
|
||||||
|
var patientFHIRID = '{{.Patient.FHIRID}}';
|
||||||
|
loadCGMData(patientFHIRID);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSyncClick() {
|
function onSyncClick() {
|
||||||
@@ -501,7 +538,7 @@ function findInRimidi(patientFHIRID) {
|
|||||||
return resp.json();
|
return resp.json();
|
||||||
})
|
})
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
renderRimidiMatchResults(body, data);
|
renderRimidiMatchResults(body, data, patientFHIRID);
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
body.innerHTML =
|
body.innerHTML =
|
||||||
@@ -520,21 +557,25 @@ function escapeHtml(s) {
|
|||||||
return d.innerHTML;
|
return d.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRimidiMatchResults(container, data) {
|
function renderRimidiMatchResults(container, data, patientFHIRID) {
|
||||||
var matches = data.matches || [];
|
var matches = data.matches || [];
|
||||||
var local = data.local_patient || {};
|
var local = data.local_patient || {};
|
||||||
|
patientFHIRID = patientFHIRID || '';
|
||||||
|
|
||||||
if (matches.length === 0) {
|
if (matches.length === 0) {
|
||||||
container.innerHTML =
|
container.innerHTML =
|
||||||
'<div style="padding:20px;text-align:center;color:var(--color-text-muted);">' +
|
'<div style="padding:40px;text-align:center;color:var(--text-muted);">' +
|
||||||
'No matching patients found in the Rimidi system.</div>';
|
'<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin:0 auto 16px;opacity:0.5;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>' +
|
||||||
|
'<p style="margin:0;font-size:16px;">No matching patients found in the Rimidi system.</p></div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var html = '<div style="padding:16px;">';
|
var html = '<div style="padding:24px;">';
|
||||||
html += '<p style="margin-bottom:16px;color:var(--color-text-muted);">' +
|
html += '<div style="margin-bottom:24px;padding:16px;background:var(--accent-blue-bg);border-radius:var(--radius-md);border:1px solid #BAE6FD;">';
|
||||||
'Found <strong>' + matches.length + '</strong> potential match' +
|
html += '<p style="margin:0;color:var(--accent-blue);font-size:14px;font-weight:500;">' +
|
||||||
|
'Found <strong style="font-weight:600;">' + matches.length + '</strong> potential match' +
|
||||||
(matches.length > 1 ? 'es' : '') + ' in Rimidi.</p>';
|
(matches.length > 1 ? 'es' : '') + ' in Rimidi.</p>';
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
var fieldLabels = {
|
var fieldLabels = {
|
||||||
first_name: 'First Name', last_name: 'Last Name',
|
first_name: 'First Name', last_name: 'Last Name',
|
||||||
@@ -545,21 +586,26 @@ function renderRimidiMatchResults(container, data) {
|
|||||||
for (var i = 0; i < matches.length; i++) {
|
for (var i = 0; i < matches.length; i++) {
|
||||||
var m = matches[i];
|
var m = matches[i];
|
||||||
var f = m.fields || {};
|
var f = m.fields || {};
|
||||||
var borderColor = m.score >= 4 ? 'var(--color-success)' : (m.score >= 3 ? 'var(--color-warning, #f0ad4e)' : 'var(--color-brand)');
|
var borderColor = m.score >= 4 ? 'var(--success)' : (m.score >= 3 ? 'var(--warning)' : 'var(--accent-blue)');
|
||||||
var badgeClass = m.score >= 4 ? 'badge-success' : (m.score >= 3 ? 'badge-warning' : 'badge-blue');
|
var badgeClass = m.score >= 4 ? 'badge-success' : (m.score >= 3 ? 'badge-warning' : 'badge-blue');
|
||||||
|
var matchQuality = m.score >= 4 ? 'High' : (m.score >= 3 ? 'Medium' : 'Low');
|
||||||
|
|
||||||
html += '<div class="panel" style="margin-bottom:16px;border-left:4px solid ' + borderColor + ';padding:0;">';
|
html += '<div class="panel" style="margin-bottom:20px;border-left:5px solid ' + borderColor + ';padding:0;overflow:hidden;box-shadow:var(--shadow-subtle);">';
|
||||||
html += '<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--color-border-soft);">';
|
html += '<div style="display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--border-hard);background:var(--bg-panel);">';
|
||||||
html += '<strong>Match #' + (i + 1) + '</strong>';
|
html += '<div style="display:flex;align-items:center;gap:12px;">';
|
||||||
html += '<span class="badge ' + badgeClass + '">' + m.score + '/5 fields match</span>';
|
html += '<strong style="font-size:16px;font-weight:600;color:var(--text-main);">Match #' + (i + 1) + '</strong>';
|
||||||
|
html += '<span class="badge ' + badgeClass + '" style="font-size:12px;padding:4px 10px;">' + m.score + '/5 fields match</span>';
|
||||||
|
html += '<span style="font-size:12px;color:var(--text-muted);">(' + matchQuality + ' confidence)</span>';
|
||||||
|
html += '</div>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
html += '<table style="width:100%;border-collapse:collapse;">';
|
html += '<div style="overflow-x:auto;">';
|
||||||
html += '<thead><tr style="background:var(--color-bg-panel);">';
|
html += '<table style="width:100%;border-collapse:separate;border-spacing:0;">';
|
||||||
html += '<th style="padding:8px 12px;text-align:left;width:25%;font-size:12px;text-transform:uppercase;color:var(--color-text-muted);">Field</th>';
|
html += '<thead><tr style="background:var(--bg-hover);">';
|
||||||
html += '<th style="padding:8px 12px;text-align:left;width:30%;font-size:12px;text-transform:uppercase;color:var(--color-text-muted);">HRS (Local)</th>';
|
html += '<th style="padding:12px 16px;text-align:left;width:20%;font-size:11px;text-transform:uppercase;color:var(--text-muted);font-weight:600;letter-spacing:0.05em;border-bottom:2px solid var(--border-hard);">Field</th>';
|
||||||
html += '<th style="padding:8px 12px;text-align:left;width:30%;font-size:12px;text-transform:uppercase;color:var(--color-text-muted);">Rimidi (Remote)</th>';
|
html += '<th style="padding:12px 16px;text-align:left;width:32%;font-size:11px;text-transform:uppercase;color:var(--text-muted);font-weight:600;letter-spacing:0.05em;border-bottom:2px solid var(--border-hard);">HRS (Local)</th>';
|
||||||
html += '<th style="padding:8px 12px;text-align:center;width:15%;font-size:12px;text-transform:uppercase;color:var(--color-text-muted);">Match</th>';
|
html += '<th style="padding:12px 16px;text-align:left;width:32%;font-size:11px;text-transform:uppercase;color:var(--text-muted);font-weight:600;letter-spacing:0.05em;border-bottom:2px solid var(--border-hard);">Rimidi (Remote)</th>';
|
||||||
|
html += '<th style="padding:12px 16px;text-align:center;width:16%;font-size:11px;text-transform:uppercase;color:var(--text-muted);font-weight:600;letter-spacing:0.05em;border-bottom:2px solid var(--border-hard);">Status</th>';
|
||||||
html += '</tr></thead><tbody>';
|
html += '</tr></thead><tbody>';
|
||||||
|
|
||||||
for (var j = 0; j < fieldOrder.length; j++) {
|
for (var j = 0; j < fieldOrder.length; j++) {
|
||||||
@@ -568,42 +614,661 @@ function renderRimidiMatchResults(container, data) {
|
|||||||
var localVal = local[key] || '—';
|
var localVal = local[key] || '—';
|
||||||
var remoteVal = fld.value || '—';
|
var remoteVal = fld.value || '—';
|
||||||
var isMatch = fld.match === true;
|
var isMatch = fld.match === true;
|
||||||
var icon = isMatch
|
|
||||||
? '<span style="color:var(--color-success);font-size:16px;">✓</span>'
|
// Enhanced visual indicators
|
||||||
: '<span style="color:var(--color-danger);font-size:16px;">✗</span>';
|
var matchIcon = isMatch
|
||||||
var rowBg = isMatch ? '' : 'background:rgba(240,173,78,0.1);';
|
? '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--success)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:middle;"><polyline points="20 6 9 17 4 12"/></svg>'
|
||||||
|
: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:middle;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||||||
|
|
||||||
|
var rowStyle = isMatch
|
||||||
|
? 'background:var(--success-bg);border-left:3px solid var(--success);'
|
||||||
|
: 'background:var(--warning-bg);border-left:3px solid var(--warning);';
|
||||||
|
|
||||||
|
var matchBadge = isMatch
|
||||||
|
? '<span style="display:inline-flex;align-items:center;gap:4px;padding:4px 8px;background:var(--success-bg);color:var(--success);border:1px solid #A7F3D0;border-radius:var(--radius-sm);font-size:11px;font-weight:600;">Match</span>'
|
||||||
|
: '<span style="display:inline-flex;align-items:center;gap:4px;padding:4px 8px;background:var(--danger-bg);color:var(--danger);border:1px solid #FECDD3;border-radius:var(--radius-sm);font-size:11px;font-weight:600;">Mismatch</span>';
|
||||||
|
|
||||||
html += '<tr style="border-top:1px solid var(--color-border-soft);' + rowBg + '">';
|
html += '<tr style="' + rowStyle + 'border-top:1px solid var(--border-soft);transition:background 0.2s;">';
|
||||||
html += '<td style="padding:8px 12px;font-weight:600;">' + fieldLabels[key] + '</td>';
|
html += '<td style="padding:14px 16px;font-weight:600;color:var(--text-main);font-size:13px;">' + fieldLabels[key] + '</td>';
|
||||||
html += '<td style="padding:8px 12px;">' + escapeHtml(localVal) + '</td>';
|
html += '<td style="padding:14px 16px;color:var(--text-main);font-size:13px;font-family:var(--font-mono);background:rgba(255,255,255,0.5);">' + escapeHtml(localVal) + '</td>';
|
||||||
html += '<td style="padding:8px 12px;">' + escapeHtml(remoteVal) + '</td>';
|
html += '<td style="padding:14px 16px;color:var(--text-main);font-size:13px;font-family:var(--font-mono);background:rgba(255,255,255,0.5);">' + escapeHtml(remoteVal) + '</td>';
|
||||||
html += '<td style="padding:8px 12px;text-align:center;">' + icon + '</td>';
|
html += '<td style="padding:14px 16px;text-align:center;vertical-align:middle;">' + matchBadge + '</td>';
|
||||||
html += '</tr>';
|
html += '</tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
html += '</tbody></table></div>';
|
html += '</tbody></table>';
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Add Confirm Match button
|
||||||
|
var patientPK = m.patient_pk || m.patient_ref;
|
||||||
|
html += '<div style="padding:16px 20px;border-top:1px solid var(--border-hard);text-align:right;background:var(--bg-panel);">';
|
||||||
|
html += '<button class="btn btn-primary" id="confirm-btn-' + i + '" onclick="confirmMatch(\'' + escapeHtml(patientFHIRID) + '\', \'' + escapeHtml(m.patient_ref) + '\', \'' + escapeHtml(patientPK) + '\', ' + i + ')" style="font-weight:600;">';
|
||||||
|
html += '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:text-bottom;margin-right:6px;"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||||
|
html += 'Confirm Match';
|
||||||
|
html += '</button>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function confirmMatch(patientFHIRID, rimidiPatientRef, rimidiPatientPK, matchIndex) {
|
||||||
|
var btn = document.getElementById('confirm-btn-' + matchIndex);
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
var originalText = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span style="display:inline-block;width:14px;height:14px;border:2px solid #fff;border-top-color:transparent;border-radius:50%;animation:spin 0.6s linear infinite;margin-right:6px;"></span>Confirming...';
|
||||||
|
|
||||||
|
fetch('/api/patient-match/confirm?patient_id=' + encodeURIComponent(patientFHIRID), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
rimidi_patient_ref: rimidiPatientRef,
|
||||||
|
rimidi_patient_pk: rimidiPatientPK
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(function(resp) {
|
||||||
|
if (!resp.ok) {
|
||||||
|
return resp.json().then(function(d) { throw new Error(d.error || 'HTTP ' + resp.status); });
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
})
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success) {
|
||||||
|
// Show success message
|
||||||
|
var body = document.getElementById('rimidiMatchBody');
|
||||||
|
body.innerHTML =
|
||||||
|
'<div style="padding:40px;text-align:center;">' +
|
||||||
|
'<div style="color:var(--color-success);font-size:48px;margin-bottom:16px;">✓</div>' +
|
||||||
|
'<h3 style="margin:0 0 8px 0;">Match Confirmed!</h3>' +
|
||||||
|
'<p style="color:var(--color-text-muted);margin-bottom:24px;">The patient match has been successfully confirmed.</p>' +
|
||||||
|
'<button class="btn btn-primary" onclick="closeRimidiMatch(); window.location.reload();">Close & Refresh</button>' +
|
||||||
|
'</div>';
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message || 'Confirmation failed');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
alert('Error confirming match: ' + err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Rimidi Patient Match Modal Overlay -->
|
<!-- Rimidi Patient Match Modal Overlay -->
|
||||||
<div id="rimidiMatchOverlay" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;">
|
<div id="rimidiMatchOverlay" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.65);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);align-items:center;justify-content:center;">
|
||||||
<div style="background:var(--color-bg-main);border-radius:12px;width:90%;max-width:700px;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);">
|
<div style="background:#FFFFFF;border-radius:var(--radius-md);width:90%;max-width:800px;max-height:85vh;display:flex;flex-direction:column;box-shadow:0 25px 80px rgba(0,0,0,0.4),0 8px 16px rgba(0,0,0,0.2);border:1px solid var(--border-soft);overflow:hidden;">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--color-border-soft);">
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:20px 24px;border-bottom:1px solid var(--border-hard);background:var(--bg-panel);">
|
||||||
<h3 style="margin:0;font-size:18px;">
|
<h3 style="margin:0;font-size:18px;font-weight:600;color:var(--text-main);display:flex;align-items:center;gap:8px;">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:text-bottom;margin-right:6px;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
Find Patient in Rimidi
|
Find Patient in Rimidi
|
||||||
</h3>
|
</h3>
|
||||||
<button onclick="closeRimidiMatch()" style="background:none;border:none;font-size:24px;cursor:pointer;color:var(--color-text-muted);padding:0 4px;">×</button>
|
<button onclick="closeRimidiMatch()" style="background:none;border:none;font-size:28px;cursor:pointer;color:var(--text-muted);padding:0;width:32px;height:32px;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-sm);transition:all 0.2s;" onmouseover="this.style.background='var(--bg-hover)';this.style.color='var(--text-main)';" onmouseout="this.style.background='none';this.style.color='var(--text-muted)';" title="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="rimidiMatchBody" style="overflow-y:auto;flex:1;">
|
<div id="rimidiMatchBody" style="overflow-y:auto;flex:1;background:var(--bg-panel);">
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:12px 20px;border-top:1px solid var(--color-border-soft);text-align:right;">
|
<div style="padding:16px 24px;border-top:1px solid var(--border-hard);text-align:right;background:var(--bg-panel);">
|
||||||
<button class="btn btn-secondary" onclick="closeRimidiMatch()">Close</button>
|
<button class="btn btn-secondary" onclick="closeRimidiMatch()">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- CGM Preview Modal Overlay -->
|
||||||
|
<div id="cgmPreviewOverlay" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;">
|
||||||
|
<div style="background:var(--color-bg-main);border-radius:12px;width:95%;max-width:1200px;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--color-border-soft);">
|
||||||
|
<h3 style="margin:0;font-size:18px;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:text-bottom;margin-right:6px;"><path d="M3 3v18h18"/><path d="M7 12h10M7 8h10M7 16h4"/></svg>
|
||||||
|
CGM Data from Rimidi
|
||||||
|
</h3>
|
||||||
|
<button onclick="closeCGMPreview()" style="background:none;border:none;font-size:24px;cursor:pointer;color:var(--color-text-muted);padding:0 4px;">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="cgmPreviewBody" style="overflow-y:auto;flex:1;padding:20px;">
|
||||||
|
</div>
|
||||||
|
<div style="padding:12px 20px;border-top:1px solid var(--color-border-soft);text-align:right;">
|
||||||
|
<button class="btn btn-secondary" onclick="closeCGMPreview()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* ── CGM Preview Functions ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
var cgmChart = null;
|
||||||
|
|
||||||
|
function loadCGMData(patientFHIRID) {
|
||||||
|
var container = document.getElementById('cgmTabContent');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Mark as loading to prevent duplicate loads
|
||||||
|
if (container.dataset.loaded === 'loading') return;
|
||||||
|
container.dataset.loaded = 'loading';
|
||||||
|
|
||||||
|
container.innerHTML =
|
||||||
|
'<div style="text-align:center;padding:40px;">' +
|
||||||
|
'<div class="animate-spin" style="width:24px;height:24px;border:3px solid #ddd;border-top-color:#0d6efd;border-radius:50%;display:inline-block;"></div>' +
|
||||||
|
'<p style="margin-top:12px;color:var(--color-text-muted);">Loading CGM data…</p></div>';
|
||||||
|
|
||||||
|
var url = '/api/cgm-preview?patient_id=' + encodeURIComponent(patientFHIRID);
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
.then(function(resp) {
|
||||||
|
if (!resp.ok) {
|
||||||
|
return resp.json().then(function(d) { throw new Error(d.error || 'HTTP ' + resp.status); });
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
})
|
||||||
|
.then(function(data) {
|
||||||
|
container.dataset.loaded = 'true';
|
||||||
|
renderCGMPreview(container, data);
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
container.dataset.loaded = 'error';
|
||||||
|
container.innerHTML =
|
||||||
|
'<div style="padding:20px;color:var(--color-danger);background:var(--color-danger-bg);border-radius:8px;margin:20px;">' +
|
||||||
|
'<strong>Error:</strong> ' + escapeHtml(err.message) + '</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the old function for backward compatibility (in case it's called from elsewhere)
|
||||||
|
function loadCGMPreview(patientFHIRID) {
|
||||||
|
loadCGMData(patientFHIRID);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCGMPreview() {
|
||||||
|
document.getElementById('cgmPreviewOverlay').style.display = 'none';
|
||||||
|
if (cgmChart) {
|
||||||
|
cgmChart.destroy();
|
||||||
|
cgmChart = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlinkPatient(patientFHIRID) {
|
||||||
|
if (!confirm('Are you sure you want to unlink this patient from Rimidi? This will remove the connection and you will need to match again to view CGM data.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = '/api/patient-match/unlink?patient_id=' + encodeURIComponent(patientFHIRID);
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
.then(function(resp) {
|
||||||
|
if (!resp.ok) {
|
||||||
|
return resp.json().then(function(d) { throw new Error(d.error || 'HTTP ' + resp.status); });
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
})
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success) {
|
||||||
|
// Reload the page to update the UI
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message || 'Unlink failed');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
alert('Error unlinking patient: ' + err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCGMPreview(container, data) {
|
||||||
|
var html = '<div style="display:flex;flex-direction:column;gap:32px;">';
|
||||||
|
|
||||||
|
// Key Metrics - Modern Grid Layout
|
||||||
|
if (data.metrics) {
|
||||||
|
html += '<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(180px, 1fr));gap:20px;margin-bottom:8px;">';
|
||||||
|
|
||||||
|
if (data.metrics.average_glucose !== undefined) {
|
||||||
|
html += '<div style="padding:20px;background:linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);border-radius:12px;color:white;box-shadow:0 4px 12px rgba(129,140,248,0.2);">';
|
||||||
|
html += '<div style="font-size:12px;opacity:0.9;margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px;">Average Glucose</div>';
|
||||||
|
html += '<div style="font-size:32px;font-weight:700;line-height:1.2;">' + escapeHtml(data.metrics.average_glucose.toFixed(1)) + '<span style="font-size:18px;font-weight:400;opacity:0.9;"> mg/dL</span></div>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.metrics.gmi !== undefined) {
|
||||||
|
html += '<div style="padding:20px;background:linear-gradient(135deg, #f9a8d4 0%, #fb7185 100%);border-radius:12px;color:white;box-shadow:0 4px 12px rgba(249,168,212,0.2);">';
|
||||||
|
html += '<div style="font-size:12px;opacity:0.9;margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px;">GMI</div>';
|
||||||
|
html += '<div style="font-size:32px;font-weight:700;line-height:1.2;">' + escapeHtml(data.metrics.gmi.toFixed(1)) + '<span style="font-size:18px;font-weight:400;opacity:0.9;">%</span></div>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.metrics.time_in_range) {
|
||||||
|
var tir = data.metrics.time_in_range;
|
||||||
|
html += '<div style="padding:20px;background:linear-gradient(135deg, #60a5fa 0%, #38bdf8 100%);border-radius:12px;color:white;box-shadow:0 4px 12px rgba(96,165,250,0.2);">';
|
||||||
|
html += '<div style="font-size:12px;opacity:0.9;margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px;">Time in Range</div>';
|
||||||
|
html += '<div style="font-size:32px;font-weight:700;line-height:1.2;">' + escapeHtml((tir.target || 0).toFixed(1)) + '<span style="font-size:18px;font-weight:400;opacity:0.9;">%</span></div>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.gri_score && data.gri_score !== '-' && data.gri_score !== 'NA') {
|
||||||
|
html += '<div style="padding:20px;background:linear-gradient(135deg, #fb9ab8 0%, #fde68a 100%);border-radius:12px;color:white;box-shadow:0 4px 12px rgba(251,154,184,0.2);">';
|
||||||
|
html += '<div style="font-size:12px;opacity:0.9;margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px;">GRI Score</div>';
|
||||||
|
html += '<div style="font-size:32px;font-weight:700;line-height:1.2;">' + escapeHtml(data.gri_score) + '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Device Info - Inline with subtle styling
|
||||||
|
if (data.device_info) {
|
||||||
|
html += '<div style="display:flex;align-items:center;gap:16px;padding:16px 20px;background:var(--bg-panel);border-radius:8px;border:1px solid var(--color-border-soft);">';
|
||||||
|
html += '<div style="flex:1;">';
|
||||||
|
html += '<div style="font-size:12px;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">Device</div>';
|
||||||
|
html += '<div style="font-size:16px;font-weight:600;color:var(--text-main);">' + escapeHtml(data.device_info.type || 'Unknown') + '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '<div style="padding-left:20px;border-left:1px solid var(--color-border-soft);">';
|
||||||
|
html += '<div style="font-size:12px;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">Status</div>';
|
||||||
|
html += '<div style="display:flex;align-items:center;gap:6px;">';
|
||||||
|
var statusColor = data.device_info.enabled ? 'var(--color-success)' : 'var(--color-text-muted)';
|
||||||
|
html += '<span style="width:8px;height:8px;border-radius:50%;background:' + statusColor + ';"></span>';
|
||||||
|
html += '<span style="font-size:14px;font-weight:500;color:var(--text-main);">' + (data.device_info.enabled ? 'Active' : 'Inactive') + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time in Range Breakdown - Modern Visual Bars
|
||||||
|
if (data.metrics.time_in_range) {
|
||||||
|
var tir = data.metrics.time_in_range;
|
||||||
|
html += '<div style="padding:24px;background:var(--bg-panel);border-radius:12px;border:1px solid var(--color-border-soft);">';
|
||||||
|
html += '<h3 style="margin:0 0 20px 0;font-size:16px;font-weight:600;color:var(--text-main);">Time in Range Breakdown</h3>';
|
||||||
|
html += '<div style="display:flex;flex-direction:column;gap:12px;">';
|
||||||
|
|
||||||
|
// Very Low
|
||||||
|
if (tir.very_low !== undefined && tir.very_low > 0) {
|
||||||
|
html += '<div>';
|
||||||
|
html += '<div style="display:flex;justify-content:space-between;margin-bottom:6px;">';
|
||||||
|
html += '<span style="font-size:13px;font-weight:500;color:#dc2626;">Very Low (<54)</span>';
|
||||||
|
html += '<span style="font-size:13px;font-weight:600;color:var(--text-main);">' + (tir.very_low || 0).toFixed(1) + '%</span>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '<div style="height:8px;background:#fee2e2;border-radius:4px;overflow:hidden;">';
|
||||||
|
html += '<div style="height:100%;width:' + (tir.very_low || 0) + '%;background:#dc2626;transition:width 0.3s ease;"></div>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Low
|
||||||
|
if (tir.low !== undefined && tir.low > 0) {
|
||||||
|
html += '<div>';
|
||||||
|
html += '<div style="display:flex;justify-content:space-between;margin-bottom:6px;">';
|
||||||
|
html += '<span style="font-size:13px;font-weight:500;color:#f59e0b;">Low (54-70)</span>';
|
||||||
|
html += '<span style="font-size:13px;font-weight:600;color:var(--text-main);">' + (tir.low || 0).toFixed(1) + '%</span>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '<div style="height:8px;background:#fef3c7;border-radius:4px;overflow:hidden;">';
|
||||||
|
html += '<div style="height:100%;width:' + (tir.low || 0) + '%;background:#f59e0b;transition:width 0.3s ease;"></div>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target
|
||||||
|
if (tir.target !== undefined && tir.target > 0) {
|
||||||
|
html += '<div>';
|
||||||
|
html += '<div style="display:flex;justify-content:space-between;margin-bottom:6px;">';
|
||||||
|
html += '<span style="font-size:13px;font-weight:500;color:#10b981;">Target (70-180)</span>';
|
||||||
|
html += '<span style="font-size:13px;font-weight:600;color:var(--text-main);">' + (tir.target || 0).toFixed(1) + '%</span>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '<div style="height:8px;background:#d1fae5;border-radius:4px;overflow:hidden;">';
|
||||||
|
html += '<div style="height:100%;width:' + (tir.target || 0) + '%;background:#10b981;transition:width 0.3s ease;"></div>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// High
|
||||||
|
if (tir.high !== undefined && tir.high > 0) {
|
||||||
|
html += '<div>';
|
||||||
|
html += '<div style="display:flex;justify-content:space-between;margin-bottom:6px;">';
|
||||||
|
html += '<span style="font-size:13px;font-weight:500;color:#f59e0b;">High (180-250)</span>';
|
||||||
|
html += '<span style="font-size:13px;font-weight:600;color:var(--text-main);">' + (tir.high || 0).toFixed(1) + '%</span>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '<div style="height:8px;background:#fef3c7;border-radius:4px;overflow:hidden;">';
|
||||||
|
html += '<div style="height:100%;width:' + (tir.high || 0) + '%;background:#f59e0b;transition:width 0.3s ease;"></div>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Very High
|
||||||
|
if (tir.very_high !== undefined && tir.very_high > 0) {
|
||||||
|
html += '<div>';
|
||||||
|
html += '<div style="display:flex;justify-content:space-between;margin-bottom:6px;">';
|
||||||
|
html += '<span style="font-size:13px;font-weight:500;color:#dc2626;">Very High (>250)</span>';
|
||||||
|
html += '<span style="font-size:13px;font-weight:600;color:var(--text-main);">' + (tir.very_high || 0).toFixed(1) + '%</span>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '<div style="height:8px;background:#fee2e2;border-radius:4px;overflow:hidden;">';
|
||||||
|
html += '<div style="height:100%;width:' + (tir.very_high || 0) + '%;background:#dc2626;transition:width 0.3s ease;"></div>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart
|
||||||
|
if (data.chart_data && data.chart_data.length > 0) {
|
||||||
|
html += '<div style="padding:24px;background:var(--bg-panel);border-radius:12px;border:1px solid var(--color-border-soft);">';
|
||||||
|
html += '<h3 style="margin:0 0 20px 0;font-size:16px;font-weight:600;color:var(--text-main);">Glucose Trends (Last 14 Days)</h3>';
|
||||||
|
html += '<div style="position:relative;height:350px;">';
|
||||||
|
html += '<canvas id="cgmChart"></canvas>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reports - Carousel Format
|
||||||
|
if (data.reports && data.reports.length > 0) {
|
||||||
|
html += '<div style="padding:24px;background:var(--bg-panel);border-radius:12px;border:1px solid var(--color-border-soft);">';
|
||||||
|
html += '<h3 style="margin:0 0 24px 0;font-size:16px;font-weight:600;color:var(--text-main);">Reports (' + data.reports.length + ')</h3>';
|
||||||
|
|
||||||
|
// Carousel Container
|
||||||
|
html += '<div id="cgmReportsCarousel" style="position:relative;width:100%;overflow:hidden;">';
|
||||||
|
|
||||||
|
// Carousel Wrapper
|
||||||
|
html += '<div id="cgmReportsCarouselWrapper" style="display:flex;width:' + (data.reports.length * 100) + '%;transition:transform 0.3s ease-in-out;">';
|
||||||
|
|
||||||
|
// Generate report slides
|
||||||
|
for (var i = 0; i < data.reports.length; i++) {
|
||||||
|
var report = data.reports[i];
|
||||||
|
var reportType = report.type || 'Unknown';
|
||||||
|
var reportTypeLabel = reportType.charAt(0).toUpperCase() + reportType.slice(1);
|
||||||
|
var dateRange = '';
|
||||||
|
if (report.start_date && report.end_date) {
|
||||||
|
dateRange = report.start_date + ' - ' + report.end_date;
|
||||||
|
} else if (report.start_date) {
|
||||||
|
dateRange = report.start_date;
|
||||||
|
} else if (report.end_date) {
|
||||||
|
dateRange = report.end_date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report Slide - each slide is 100% of container width
|
||||||
|
var slideWidthPercent = 100 / data.reports.length;
|
||||||
|
html += '<div class="cgm-report-slide" data-slide-index="' + i + '" style="width:' + slideWidthPercent + '%;flex-shrink:0;display:flex;flex-direction:column;padding:24px;box-sizing:border-box;">';
|
||||||
|
|
||||||
|
// Report Header
|
||||||
|
html += '<div style="margin-bottom:16px;">';
|
||||||
|
html += '<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">';
|
||||||
|
html += '<span style="font-weight:600;font-size:18px;">' + escapeHtml(reportTypeLabel) + ' Report</span>';
|
||||||
|
html += '</div>';
|
||||||
|
if (dateRange) {
|
||||||
|
html += '<span style="color:var(--color-text-muted);font-size:14px;">' + escapeHtml(dateRange) + '</span>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Preview Image
|
||||||
|
if (report.image_url) {
|
||||||
|
html += '<div style="flex:1;display:flex;align-items:center;justify-content:center;margin-bottom:20px;background:#f8f9fa;border-radius:8px;padding:16px;min-height:400px;">';
|
||||||
|
html += '<img src="' + escapeHtml(report.image_url) + '" alt="' + escapeHtml(reportTypeLabel) + ' Report Preview" style="max-width:100%;max-height:500px;object-fit:contain;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,0.1);">';
|
||||||
|
html += '</div>';
|
||||||
|
} else {
|
||||||
|
html += '<div style="flex:1;display:flex;align-items:center;justify-content:center;margin-bottom:20px;background:#f8f9fa;border-radius:8px;padding:16px;min-height:400px;color:var(--color-text-muted);">';
|
||||||
|
html += '<p>No preview image available</p>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDF Download Link
|
||||||
|
if (report.pdf_url) {
|
||||||
|
html += '<div style="text-align:center;padding-top:12px;border-top:1px solid var(--color-border-soft);">';
|
||||||
|
html += '<a href="' + escapeHtml(report.pdf_url) + '" target="_blank" class="btn btn-primary" style="text-decoration:none;display:inline-block;">';
|
||||||
|
html += '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:text-bottom;margin-right:6px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||||||
|
html += 'Download PDF';
|
||||||
|
html += '</a>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>'; // End report slide
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>'; // End carousel wrapper
|
||||||
|
|
||||||
|
// Navigation Arrows
|
||||||
|
if (data.reports.length > 1) {
|
||||||
|
html += '<button id="cgmCarouselPrev" onclick="navigateCGMCarousel(-1)" style="position:absolute;left:10px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.9);border:1px solid var(--color-border-soft);border-radius:50%;width:40px;height:40px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,0.1);z-index:10;transition:all 0.2s;" onmouseover="this.style.background=\'white\';this.style.boxShadow=\'0 4px 12px rgba(0,0,0,0.15)\';" onmouseout="this.style.background=\'rgba(255,255,255,0.9)\';this.style.boxShadow=\'0 2px 8px rgba(0,0,0,0.1)\';">';
|
||||||
|
html += '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>';
|
||||||
|
html += '</button>';
|
||||||
|
|
||||||
|
html += '<button id="cgmCarouselNext" onclick="navigateCGMCarousel(1)" style="position:absolute;right:10px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.9);border:1px solid var(--color-border-soft);border-radius:50%;width:40px;height:40px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,0.1);z-index:10;transition:all 0.2s;" onmouseover="this.style.background=\'white\';this.style.boxShadow=\'0 4px 12px rgba(0,0,0,0.15)\';" onmouseout="this.style.background=\'rgba(255,255,255,0.9)\';this.style.boxShadow=\'0 2px 8px rgba(0,0,0,0.1)\';">';
|
||||||
|
html += '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>';
|
||||||
|
html += '</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination Dots
|
||||||
|
if (data.reports.length > 1) {
|
||||||
|
html += '<div style="display:flex;justify-content:center;gap:8px;margin-top:16px;">';
|
||||||
|
for (var i = 0; i < data.reports.length; i++) {
|
||||||
|
var activeClass = i === 0 ? 'active' : '';
|
||||||
|
html += '<button class="cgm-carousel-dot ' + activeClass + '" onclick="goToCGMReport(' + i + ')" data-dot-index="' + i + '" style="width:10px;height:10px;border-radius:50%;border:none;background:' + (i === 0 ? 'var(--color-brand)' : 'var(--color-border-soft)') + ';cursor:pointer;transition:all 0.2s;padding:0;"></button>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>'; // End carousel container
|
||||||
|
html += '</div>'; // End panel
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
// Initialize carousel after rendering (with delay to ensure DOM is ready)
|
||||||
|
setTimeout(function() {
|
||||||
|
initCGMCarousel();
|
||||||
|
// Handle window resize to recalculate carousel
|
||||||
|
var resizeTimeout;
|
||||||
|
window.addEventListener('resize', function() {
|
||||||
|
clearTimeout(resizeTimeout);
|
||||||
|
resizeTimeout = setTimeout(function() {
|
||||||
|
updateCGMCarouselPosition();
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
// Render chart if data exists
|
||||||
|
if (data.chart_data && data.chart_data.length > 0 && typeof Chart !== 'undefined') {
|
||||||
|
setTimeout(function() {
|
||||||
|
renderCGMChart(data.chart_data, data.thresholds);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCGMChart(chartData, thresholds) {
|
||||||
|
var ctx = document.getElementById('cgmChart');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// Destroy existing chart
|
||||||
|
if (cgmChart) {
|
||||||
|
cgmChart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare data
|
||||||
|
var labels = [];
|
||||||
|
var values = [];
|
||||||
|
for (var i = 0; i < chartData.length; i++) {
|
||||||
|
var point = chartData[i];
|
||||||
|
labels.push(point.timestamp);
|
||||||
|
values.push(point.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thresholds
|
||||||
|
var lowThreshold = (thresholds && thresholds.low) || 70;
|
||||||
|
var highThreshold = (thresholds && thresholds.high) || 180;
|
||||||
|
|
||||||
|
cgmChart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Glucose (mg/dL)',
|
||||||
|
data: values,
|
||||||
|
borderColor: 'rgb(59, 130, 246)',
|
||||||
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||||
|
tension: 0.1,
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: false,
|
||||||
|
suggestedMin: 50,
|
||||||
|
suggestedMax: 300
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
type: 'time',
|
||||||
|
time: {
|
||||||
|
unit: 'day'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
annotation: {
|
||||||
|
annotations: {
|
||||||
|
lowLine: {
|
||||||
|
type: 'line',
|
||||||
|
yMin: lowThreshold,
|
||||||
|
yMax: lowThreshold,
|
||||||
|
borderColor: 'rgb(239, 68, 68)',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderDash: [5, 5],
|
||||||
|
label: {
|
||||||
|
content: 'Low: ' + lowThreshold + ' mg/dL',
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
highLine: {
|
||||||
|
type: 'line',
|
||||||
|
yMin: highThreshold,
|
||||||
|
yMax: highThreshold,
|
||||||
|
borderColor: 'rgb(239, 68, 68)',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderDash: [5, 5],
|
||||||
|
label: {
|
||||||
|
content: 'High: ' + highThreshold + ' mg/dL',
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CGM Reports Carousel Navigation ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
var cgmCarouselCurrentIndex = 0;
|
||||||
|
var cgmCarouselTotalSlides = 0;
|
||||||
|
|
||||||
|
function initCGMCarousel() {
|
||||||
|
var wrapper = document.getElementById('cgmReportsCarouselWrapper');
|
||||||
|
if (!wrapper) {
|
||||||
|
console.log('CGM Carousel: Wrapper not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var slides = wrapper.querySelectorAll('.cgm-report-slide');
|
||||||
|
cgmCarouselTotalSlides = slides.length;
|
||||||
|
cgmCarouselCurrentIndex = 0;
|
||||||
|
|
||||||
|
console.log('CGM Carousel: Initialized with', cgmCarouselTotalSlides, 'slides');
|
||||||
|
|
||||||
|
if (cgmCarouselTotalSlides > 0) {
|
||||||
|
updateCGMCarouselPosition();
|
||||||
|
updateCGMCarouselDots();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateCGMCarousel(direction) {
|
||||||
|
var wrapper = document.getElementById('cgmReportsCarouselWrapper');
|
||||||
|
if (!wrapper) return;
|
||||||
|
|
||||||
|
var slides = wrapper.querySelectorAll('.cgm-report-slide');
|
||||||
|
if (slides.length === 0) return;
|
||||||
|
|
||||||
|
cgmCarouselCurrentIndex += direction;
|
||||||
|
|
||||||
|
// Wrap around
|
||||||
|
if (cgmCarouselCurrentIndex < 0) {
|
||||||
|
cgmCarouselCurrentIndex = slides.length - 1;
|
||||||
|
} else if (cgmCarouselCurrentIndex >= slides.length) {
|
||||||
|
cgmCarouselCurrentIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCGMCarouselPosition();
|
||||||
|
updateCGMCarouselDots();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToCGMReport(index) {
|
||||||
|
var wrapper = document.getElementById('cgmReportsCarouselWrapper');
|
||||||
|
if (!wrapper) return;
|
||||||
|
|
||||||
|
var slides = wrapper.querySelectorAll('.cgm-report-slide');
|
||||||
|
if (index < 0 || index >= slides.length) return;
|
||||||
|
|
||||||
|
cgmCarouselCurrentIndex = index;
|
||||||
|
updateCGMCarouselPosition();
|
||||||
|
updateCGMCarouselDots();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCGMCarouselPosition() {
|
||||||
|
var wrapper = document.getElementById('cgmReportsCarouselWrapper');
|
||||||
|
if (!wrapper) {
|
||||||
|
console.log('CGM Carousel: Wrapper not found in updateCGMCarouselPosition');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var slides = wrapper.querySelectorAll('.cgm-report-slide');
|
||||||
|
if (slides.length === 0) {
|
||||||
|
console.log('CGM Carousel: No slides found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the container (parent of wrapper) to calculate pixel offset
|
||||||
|
var container = wrapper.parentElement;
|
||||||
|
if (!container) {
|
||||||
|
console.log('CGM Carousel: Container not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var containerWidth = container.offsetWidth || container.clientWidth;
|
||||||
|
if (containerWidth === 0) {
|
||||||
|
console.log('CGM Carousel: Container width is 0, retrying...');
|
||||||
|
setTimeout(function() {
|
||||||
|
updateCGMCarouselPosition();
|
||||||
|
}, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate offset in pixels: move by container width * index
|
||||||
|
// Since each slide is 100% of container width, we move by container width for each slide
|
||||||
|
var offsetPixels = -cgmCarouselCurrentIndex * containerWidth;
|
||||||
|
wrapper.style.transform = 'translateX(' + offsetPixels + 'px)';
|
||||||
|
|
||||||
|
console.log('CGM Carousel: Moved to slide', cgmCarouselCurrentIndex, 'offset:', offsetPixels + 'px', 'container width:', containerWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCGMCarouselDots() {
|
||||||
|
var dots = document.querySelectorAll('.cgm-carousel-dot');
|
||||||
|
for (var i = 0; i < dots.length; i++) {
|
||||||
|
if (i === cgmCarouselCurrentIndex) {
|
||||||
|
dots[i].style.background = 'var(--color-brand)';
|
||||||
|
dots[i].classList.add('active');
|
||||||
|
} else {
|
||||||
|
dots[i].style.background = 'var(--color-border-soft)';
|
||||||
|
dots[i].classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user