From 2171991dc89542af9785544ef45d3918d16e0884 Mon Sep 17 00:00:00 2001 From: amantahiliani Date: Fri, 20 Feb 2026 17:46:34 -0500 Subject: [PATCH] UI/UX enhancements --- README.md | 73 ++-- app/handlers/handler.go | 5 - app/main.go | 1 + app/static/css/styles.css | 453 ++++++++++++++++++++ app/templates/base.html | 234 +---------- app/templates/dashboard.html | 788 ++++++++++++++++------------------- app/templates/error.html | 15 +- app/templates/index.html | 60 ++- app/templates/patients.html | 135 +++--- 9 files changed, 1028 insertions(+), 736 deletions(-) create mode 100644 app/static/css/styles.css diff --git a/README.md b/README.md index 455055f..df0477c 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,34 @@ # FHIR-Sandbox: SMART on FHIR Healthcare Platform -A production-quality Go-based platform for integrating with Electronic Health Record (EHR) systems using the SMART on FHIR protocol. This sandbox demonstrates authentication, persistence, and dashboarding for patient and practitioner data. +A production-quality Go-based platform for integrating with Electronic Health Record (EHR) systems using the SMART on FHIR protocol. This sandbox demonstrates authentication, persistence, and dashboarding for patient and practitioner data, serving as a robust starting point for healthcare applications. ## Features -- **SMART on FHIR Launch:** Supports the full SMART App Launch flow (EHR launch and standalone). +- **SMART on FHIR Launch:** Supports the full SMART App Launch flow (EHR launch and standalone) with OAuth2 code exchange. - **Identity Resolution:** Correctly handles practitioner identification from both `practitioner` and `user` (Practitioner/ID) fields in OAuth2 token responses. -- **SQLite Persistence:** Persists patient and practitioner data upon successful launch using a pure-Go SQLite driver (no CGO required). -- **Session Management:** Server-side sessions stored in SQLite with secure, HttpOnly cookies. -- **Responsive Dashboard:** A modern UI built with Go `html/template` that displays patient demographics and practitioner details. -- **Extensible Architecture:** Clean package separation (`handlers`, `db`, `fhir`, `models`, `middleware`, `config`) designed for growth. +- **Comprehensive FHIR Sync:** Automatically synchronizes and persists key clinical data: + - Patient Demographics + - Observations (Vitals, Labs) + - Conditions (Problems) + - DocumentReferences + - MedicationRequests + - AllergyIntolerances +- **SQLite Persistence:** Persists all synced data using a pure-Go SQLite driver (`modernc.org/sqlite`), requiring no CGO or external database server. +- **Secure Session Management:** Server-side sessions stored in SQLite with secure, HttpOnly cookies. +- **Responsive Dashboard:** A modern UI built with Go `html/template` that displays patient demographics, clinical data, and practitioner details. +- **Extensible Architecture:** Modular design with clean separation of concerns (`handlers`, `db`, `fhir`, `models`, `middleware`, `config`). ## Architecture The project is structured into modular packages under `/app`: -- `/db`: Database schema, migrations, and CRUD operations using `modernc.org/sqlite`. -- `/fhir`: FHIR R4 resource definitions and SMART discovery/client logic. -- `/handlers`: HTTP request handlers and template rendering. -- `/middleware`: Session loading and authentication guards. -- `/models`: Shared data structures. -- `/templates`: HTML templates with layout inheritance. + +- `/config`: Configuration structures and URL normalization. +- `/db`: Database schema, versioned migrations, and CRUD operations using `modernc.org/sqlite`. +- `/fhir`: FHIR R4 resource definitions, SMART discovery, and FHIR client logic. +- `/handlers`: HTTP request handlers (launch, auth, dashboard, sync) and template rendering. +- `/middleware`: Session management middleware (loading and hard-gate protection). +- `/models`: Core domain models and context keys. +- `/templates`: Embedded HTML templates with layout inheritance. ## Prerequisites @@ -44,24 +53,30 @@ The project is structured into modular packages under `/app`: Use the [SMART Health IT Sandbox](https://launch.smarthealthit.org/): - **App Launch URL:** `http://localhost:8080/launch` - **Redirect URL:** `http://localhost:8080/auth-redirect` - - The default configuration in `main.go` is pre-set to work with the SmartHealthIT sandbox. + + The default configuration in `main.go` is pre-set to work with the SmartHealthIT sandbox. ## Configuration -Configuration is currently managed in `app/main.go` via `config.AppConfig`. You can define multiple EHRs, set your redirect URI, and required scopes. +Configuration is currently managed in `app/main.go` via the `config.AppConfig` struct. You can define multiple EHRs, set your redirect URI, and required scopes directly in the code. ```go +// Example configuration in app/main.go cfg := &config.AppConfig{ DBPath: "fhir_sandbox.db", + Server: config.ServerConfig{ + Port: 8080, + }, SMART: config.SMARTConfig{ RedirectURL: "http://localhost:8080/auth-redirect", Scopes: []string{"openid", "profile", "launch", "patient/*.read", "user/*.read"}, }, EHRs: []config.EHRConfig{ { - Name: "SmartHealthIT Sandbox (R4)", - FHIRURL: "https://launch.smarthealthit.org/v/r4/fhir", - ClientID: "your-client-id", + Name: "SmartHealthIT Sandbox (R4)", + FHIRURL: "https://launch.smarthealthit.org/v/r4/fhir", + ClientID: "your-client-id", + ClientSecret: "your-client-secret", // Optional, depending on EHR }, }, } @@ -69,15 +84,23 @@ cfg := &config.AppConfig{ ## Testing -The project includes unit tests for database logic and FHIR parsing. +The project includes comprehensive unit tests for database logic, FHIR parsing, and clinical data handling. -```bash -go test ./... -``` +- **Run all tests:** + ```bash + go test ./... + ``` + +- **Run tests with coverage:** + ```bash + go test -cover ./... + ``` ## Future Improvements -- [ ] Support for Observations, Conditions, and Encounters. -- [ ] Move configuration to a YAML/TOML file. -- [ ] Add structured logging (slog). -- [ ] Implement Refresh Token handling. +- [ ] **Configuration Loading:** Implement a robust configuration loader (e.g., `spf13/viper`) to load settings from files or environment variables. +- [ ] **Structured Logging:** Migrate to Go 1.21's `log/slog` for structured, leveled logging. +- [ ] **Refresh Tokens:** Implement OAuth2 refresh token logic to maintain long-lived sessions. +- [ ] **Additional Resources:** Add support for Encounters, Procedures, Immunizations, etc. +- [ ] **Frontend Enhancement:** Evolve the UI with HTMX or a modern JS framework for better interactivity. +- [ ] **FHIR Type Safety:** Adopt a comprehensive FHIR library (e.g., `google/fhir/go`) for stricter type safety. diff --git a/app/handlers/handler.go b/app/handlers/handler.go index 4f920e3..307881d 100644 --- a/app/handlers/handler.go +++ b/app/handlers/handler.go @@ -172,10 +172,5 @@ func TemplateFuncs() template.FuncMap { } return false }, - "last": func(slice interface{}) interface{} { - // Helper function to get the last element of a slice - // Used in template logic - return nil - }, } } diff --git a/app/main.go b/app/main.go index 9038050..baa3c0c 100644 --- a/app/main.go +++ b/app/main.go @@ -92,6 +92,7 @@ func main() { mux.HandleFunc("/", h.HandleRoot) mux.HandleFunc("/launch", h.HandleLaunch) mux.HandleFunc("/auth-redirect", h.HandleAuthRedirect) + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("app/static")))) // Session-required routes — wrapped with the hard-gate middleware. mux.Handle("/dashboard", sessionMW.RequireSession(http.HandlerFunc(h.HandleDashboard))) diff --git a/app/static/css/styles.css b/app/static/css/styles.css new file mode 100644 index 0000000..1da89c2 --- /dev/null +++ b/app/static/css/styles.css @@ -0,0 +1,453 @@ +:root { + /* Brand Colors */ + --primary-color: #2563EB; + --primary-hover: #1D4ED8; + --secondary-color: #0D9488; + --secondary-hover: #0F766E; + + /* State Colors */ + --success-color: #10B981; + --success-bg: #D1FAE5; + --warning-color: #F59E0B; + --warning-bg: #FEF3C7; + --danger-color: #EF4444; + --danger-bg: #FEE2E2; + --info-color: #3B82F6; + --info-bg: #DBEAFE; + + /* Neutral Colors */ + --background-color: #F3F4F6; + --surface-color: #FFFFFF; + --text-primary: #111827; + --text-secondary: #4B5563; + --text-muted: #9CA3AF; + --border-color: #E5E7EB; + + /* Spacing */ + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + + /* Typography */ + --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + + /* Effects */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --radius: 0.5rem; + --transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Reset & Base */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-sans); + background-color: var(--background-color); + color: var(--text-primary); + line-height: 1.5; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +h1, h2, h3, h4, h5, h6 { + font-weight: 600; + color: var(--text-primary); + line-height: 1.25; +} + +a { + color: var(--primary-color); + text-decoration: none; + transition: var(--transition); +} + +a:hover { + color: var(--primary-hover); + text-decoration: underline; +} + +/* Layout */ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 var(--spacing-md); + width: 100%; +} + +main { + flex: 1; + padding: var(--spacing-xl) 0; +} + +/* Navbar */ +.navbar { + background-color: var(--surface-color); + border-bottom: 1px solid var(--border-color); + padding: var(--spacing-md) 0; + position: sticky; + top: 0; + z-index: 50; + box-shadow: var(--shadow-sm); +} + +.navbar-content { + display: flex; + align-items: center; + justify-content: space-between; +} + +.brand { + font-size: 1.25rem; + font-weight: 700; + color: var(--primary-color); + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.brand span { + font-weight: 400; + color: var(--text-primary); +} + +.nav-links { + display: flex; + gap: var(--spacing-md); + align-items: center; +} + +.nav-link { + color: var(--text-secondary); + font-weight: 500; + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius); +} + +.nav-link:hover { + background-color: var(--background-color); + color: var(--text-primary); + text-decoration: none; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius); + font-weight: 500; + cursor: pointer; + border: 1px solid transparent; + transition: var(--transition); + font-size: 0.875rem; + gap: var(--spacing-sm); +} + +.btn-primary { + background-color: var(--primary-color); + color: white; +} + +.btn-primary:hover { + background-color: var(--primary-hover); + text-decoration: none; +} + +.btn-secondary { + background-color: var(--surface-color); + border-color: var(--border-color); + color: var(--text-secondary); +} + +.btn-secondary:hover { + background-color: var(--background-color); + text-decoration: none; +} + +.btn-danger { + background-color: var(--danger-color); + color: white; +} + +.btn-danger:hover { + background-color: #DC2626; + text-decoration: none; +} + +.btn-outline { + background-color: transparent; + border-color: var(--border-color); + color: var(--text-secondary); +} + +.btn-outline:hover, .btn-outline.active { + background-color: var(--background-color); + border-color: var(--text-secondary); + color: var(--text-primary); + text-decoration: none; +} + +/* Cards */ +.card { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: var(--spacing-lg); + margin-bottom: var(--spacing-lg); + overflow: hidden; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-md); + padding-bottom: var(--spacing-md); + border-bottom: 1px solid var(--border-color); +} + +.card-title { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); +} + +.card-subtitle { + font-size: 0.875rem; + color: var(--text-secondary); +} + +/* Grid System */ +.grid { + display: grid; + gap: var(--spacing-lg); +} + +.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } +.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } + +@media (max-width: 768px) { + .grid-cols-2, .grid-cols-3, .grid-cols-4 { + grid-template-columns: 1fr; + } +} + +/* Details List */ +.details-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: var(--spacing-md); +} + +.detail-item { + display: flex; + flex-direction: column; +} + +.detail-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + font-weight: 600; + margin-bottom: var(--spacing-xs); +} + +.detail-value { + font-size: 1rem; + font-weight: 500; + color: var(--text-primary); +} + +/* Tables */ +.table-container { + width: 100%; + overflow-x: auto; + border-radius: var(--radius); + border: 1px solid var(--border-color); +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + background-color: var(--surface-color); +} + +th { + text-align: left; + padding: var(--spacing-md); + background-color: var(--background-color); + color: var(--text-secondary); + font-weight: 600; + border-bottom: 1px solid var(--border-color); + white-space: nowrap; +} + +td { + padding: var(--spacing-md); + border-bottom: 1px solid var(--border-color); + color: var(--text-secondary); + vertical-align: top; +} + +tr:last-child td { + border-bottom: none; +} + +tr:hover td { + background-color: #F9FAFB; +} + +/* Badges */ +.badge { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + text-transform: capitalize; +} + +.badge-success { background-color: var(--success-bg); color: var(--success-color); } +.badge-warning { background-color: var(--warning-bg); color: var(--warning-color); } +.badge-danger { background-color: var(--danger-bg); color: var(--danger-color); } +.badge-info { background-color: var(--info-bg); color: var(--info-color); } +.badge-neutral { background-color: var(--background-color); color: var(--text-secondary); border: 1px solid var(--border-color); } + +/* Tabs */ +.tabs { + display: flex; + border-bottom: 1px solid var(--border-color); + margin-bottom: var(--spacing-lg); + overflow-x: auto; +} + +.tab-btn { + padding: var(--spacing-md) var(--spacing-lg); + border: none; + background: none; + cursor: pointer; + color: var(--text-secondary); + font-weight: 500; + border-bottom: 2px solid transparent; + transition: var(--transition); + white-space: nowrap; +} + +.tab-btn:hover { + color: var(--primary-color); +} + +.tab-btn.active { + color: var(--primary-color); + border-bottom-color: var(--primary-color); +} + +.tab-content { + display: none; + animation: fadeIn 0.2s ease-in-out; +} + +.tab-content.active { + display: block; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Avatar */ +.avatar { + width: 48px; + height: 48px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + color: white; + font-size: 1.25rem; +} + +.avatar-practitioner { background-color: var(--secondary-color); } +.avatar-patient { background-color: var(--primary-color); } + +/* Utility */ +.text-right { text-align: right; } +.text-center { text-align: center; } +.mt-4 { margin-top: var(--spacing-md); } +.mb-4 { margin-bottom: var(--spacing-md); } +.w-full { width: 100%; } +.flex { display: flex; } +.gap-2 { gap: var(--spacing-sm); } +.gap-4 { gap: var(--spacing-md); } +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } +.code-block { + font-family: var(--font-mono); + background-color: var(--background-color); + padding: var(--spacing-sm); + border-radius: var(--radius); + font-size: 0.75rem; + word-break: break-all; + color: var(--text-primary); +} + +/* Flash Message */ +.flash-message { + padding: var(--spacing-md); + border-radius: var(--radius); + margin-bottom: var(--spacing-lg); + display: flex; + justify-content: space-between; + align-items: center; +} + +.flash-success { + background-color: var(--success-bg); + border: 1px solid var(--success-color); + color: #065F46; +} + +/* Accordion/Details */ +details > summary { + list-style: none; + cursor: pointer; + padding: var(--spacing-sm) 0; + font-weight: 600; + color: var(--primary-color); + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +details > summary::-webkit-details-marker { + display: none; +} + +details > summary::before { + content: '▶'; + font-size: 0.75rem; + transition: transform 0.2s; +} + +details[open] > summary::before { + transform: rotate(90deg); +} diff --git a/app/templates/base.html b/app/templates/base.html index 2213e4e..03b9420 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -4,233 +4,31 @@ FHIR Health Platform + + -
-
FHIR Health Platform
- + -
+
{{block "content" .}}{{end}}
-