From 8754a9df404dbeb94dc61c40a05523e0b0f12b7d Mon Sep 17 00:00:00 2001 From: Johan Jongsma Date: Mon, 2 Feb 2026 07:11:48 +0000 Subject: [PATCH] refactor: unified dossier page with section blocks - New dossier_sections.go with DossierSection struct and BuildDossierSections() - Single section_block template replaces 12+ copy-pasted HTML blocks - All 26 categories supported with default handler for unknown ones - /dossier/{id} now uses v2, /dossier/{id}/v1 keeps legacy - Added missing translation keys for all section types - CSS: added .section-children and .hidden-row classes --- lang/en.yaml | 23 ++ portal/dossier_sections.go | 352 +++++++++++++++++++++++++++++++ portal/main.go | 20 +- static/style.css | 17 ++ templates/base.tmpl | 3 +- templates/dossier.tmpl | 14 +- templates/dossier_v2.tmpl | 422 +++++++++++++++++++++++++++++++++++++ 7 files changed, 845 insertions(+), 6 deletions(-) create mode 100644 portal/dossier_sections.go create mode 100644 templates/dossier_v2.tmpl diff --git a/lang/en.yaml b/lang/en.yaml index 9d5e38a..aff286c 100644 --- a/lang/en.yaml +++ b/lang/en.yaml @@ -76,6 +76,29 @@ section_vitals: "Vitals" section_medications: "Medications" section_records: "Records" section_journal: "Journal" +section_checkin: "Daily Check-in" +section_procedures: "Procedures & Surgery" +section_assessments: "Clinical Assessments" +section_symptoms: "Symptoms" +section_hospitalizations: "Hospitalizations" +section_therapies: "Therapies" +section_supplements: "Supplements" +section_consultations: "Consultations" +section_diagnoses: "Diagnoses" +section_exercise: "Exercise" +section_nutrition: "Nutrition" +section_fertility: "Fertility" +section_notes: "Notes" +section_history: "Medical History" +section_family_history: "Family History" +section_birth: "Birth" +section_devices: "Devices" +section_providers: "Healthcare Providers" +section_questions: "Questions" + +# Check-in +checkin_summary: "Track vitals, symptoms & more" +open: "Open" # Section summaries imaging_summary: "%d studies · %d slices" diff --git a/portal/dossier_sections.go b/portal/dossier_sections.go new file mode 100644 index 0000000..87dce39 --- /dev/null +++ b/portal/dossier_sections.go @@ -0,0 +1,352 @@ +package main + +import ( + "fmt" + "net/http" + "strings" + "inou/lib" + "path/filepath" + "os" +) + +// DossierSection represents a unified section block on the dossier page +type DossierSection struct { + ID string // "imaging", "labs", "genetics", etc. + Icon string // emoji or icon identifier + Color string // hex color for indicator (without #) + HeadingKey string // translation key for heading + Heading string // resolved heading text + Summary string // summary line + ActionURL string // optional button URL + ActionLabel string // optional button label (translation key resolved) + Items []SectionItem // the actual data rows + HideEmpty bool // hide entire section when no items + ComingSoon bool // show as coming soon + Dynamic bool // loaded via JS (like genetics) + DynamicType string // "genetics" for special handling + CustomHTML string // for completely custom sections (privacy) +} + +// SectionItem represents a row in a section +type SectionItem struct { + ID string + Label string + Meta string // secondary text below label + Date string // YYYYMMDD format for JS formatting + Type string + Value string + LinkURL string + LinkTitle string + Expandable bool + Expanded bool + Children []SectionItem +} + +// SectionConfig defines how to build a section for a category +type SectionConfig struct { + ID string + Category int + Color string + HeadingKey string + HideEmpty bool + ComingSoon bool + Dynamic bool + DynamicType string +} + +// Standard section configurations +var sectionConfigs = []SectionConfig{ + {ID: "checkin", HeadingKey: "section_checkin", Color: "B45309"}, + {ID: "imaging", Category: lib.CategoryImaging, Color: "B45309", HeadingKey: "section_imaging", HideEmpty: false}, + {ID: "labs", Category: lib.CategoryLab, Color: "059669", HeadingKey: "section_labs", HideEmpty: false}, + {ID: "documents", Category: lib.CategoryDocument, Color: "06b6d4", HeadingKey: "section_records", HideEmpty: true}, + {ID: "procedures", Category: lib.CategorySurgery, Color: "DC2626", HeadingKey: "section_procedures", HideEmpty: true}, + {ID: "assessments", Category: lib.CategoryAssessment, Color: "7C3AED", HeadingKey: "section_assessments", HideEmpty: true}, + {ID: "genetics", Category: lib.CategoryGenome, Color: "8B5CF6", HeadingKey: "section_genetics", HideEmpty: true, Dynamic: true, DynamicType: "genetics"}, + {ID: "uploads", Color: "6366f1", HeadingKey: "section_uploads", HideEmpty: false}, + {ID: "medications", Category: lib.CategoryMedication, Color: "8b5cf6", HeadingKey: "section_medications", HideEmpty: true}, + {ID: "supplements", Category: lib.CategorySupplement, Color: "8b5cf6", HeadingKey: "section_supplements", HideEmpty: true}, + {ID: "symptoms", Category: lib.CategorySymptom, Color: "F59E0B", HeadingKey: "section_symptoms", HideEmpty: true}, + {ID: "hospitalizations", Category: lib.CategoryHospital, Color: "EF4444", HeadingKey: "section_hospitalizations", HideEmpty: true}, + {ID: "therapies", Category: lib.CategoryTherapy, Color: "10B981", HeadingKey: "section_therapies", HideEmpty: true}, + {ID: "consultations", Category: lib.CategoryConsultation, Color: "3B82F6", HeadingKey: "section_consultations", HideEmpty: true}, + {ID: "diagnoses", Category: lib.CategoryDiagnosis, Color: "EF4444", HeadingKey: "section_diagnoses", HideEmpty: true}, + {ID: "exercise", Category: lib.CategoryExercise, Color: "22C55E", HeadingKey: "section_exercise", HideEmpty: true}, + {ID: "nutrition", Category: lib.CategoryNutrition, Color: "F97316", HeadingKey: "section_nutrition", HideEmpty: true}, + {ID: "fertility", Category: lib.CategoryFertility, Color: "EC4899", HeadingKey: "section_fertility", HideEmpty: true}, + {ID: "notes", Category: lib.CategoryNote, Color: "6B7280", HeadingKey: "section_notes", HideEmpty: true}, + {ID: "history", Category: lib.CategoryHistory, Color: "6B7280", HeadingKey: "section_history", HideEmpty: true}, + {ID: "family_history", Category: lib.CategoryFamilyHistory, Color: "6B7280", HeadingKey: "section_family_history", HideEmpty: true}, + {ID: "birth", Category: lib.CategoryBirth, Color: "EC4899", HeadingKey: "section_birth", HideEmpty: true}, + {ID: "devices", Category: lib.CategoryDevice, Color: "6366F1", HeadingKey: "section_devices", HideEmpty: true}, + {ID: "providers", Category: lib.CategoryProvider, Color: "0EA5E9", HeadingKey: "section_providers", HideEmpty: true}, + {ID: "questions", Category: lib.CategoryQuestion, Color: "8B5CF6", HeadingKey: "section_questions", HideEmpty: true}, + {ID: "vitals", Category: lib.CategoryVital, Color: "ec4899", HeadingKey: "section_vitals", ComingSoon: true}, + {ID: "privacy", HeadingKey: "section_privacy", Color: "64748b"}, +} + +// BuildDossierSections builds all sections for a dossier +func BuildDossierSections(targetID, targetHex string, target *lib.Dossier, p *lib.Dossier, lang string, canEdit bool) []DossierSection { + T := func(key string) string { return translations[lang][key] } + + var sections []DossierSection + + for _, cfg := range sectionConfigs { + section := DossierSection{ + ID: cfg.ID, + Color: cfg.Color, + HeadingKey: cfg.HeadingKey, + Heading: T(cfg.HeadingKey), + HideEmpty: cfg.HideEmpty, + ComingSoon: cfg.ComingSoon, + Dynamic: cfg.Dynamic, + DynamicType: cfg.DynamicType, + } + + switch cfg.ID { + case "checkin": + section.Summary = T("checkin_summary") + section.ActionURL = fmt.Sprintf("/dossier/%s/prompts", targetHex) + section.ActionLabel = T("open") + + case "imaging": + studies, _ := fetchStudiesWithSeries(targetHex) + section.Items, section.Summary = buildImagingItems(studies, targetHex, target.DossierID, T) + if len(studies) > 0 { + section.ActionURL = fmt.Sprintf("/viewer/?token=%s", target.DossierID) + section.ActionLabel = T("open_viewer") + } + + case "labs": + entries, _ := lib.EntryList(nil, "", lib.CategoryLab, &lib.EntryFilter{DossierID: targetID, Limit: 50}) + section.Items = entriesToSectionItems(entries) + if len(entries) > 0 { + section.Summary = fmt.Sprintf("%d results", len(entries)) + } else { + section.Summary = T("no_lab_data") + } + + case "documents": + entries, _ := lib.EntryList(nil, "", lib.CategoryDocument, &lib.EntryFilter{DossierID: targetID, Limit: 50}) + section.Items = entriesToSectionItems(entries) + section.Summary = fmt.Sprintf("%d documents", len(entries)) + + case "procedures": + entries, _ := lib.EntryList(nil, "", lib.CategorySurgery, &lib.EntryFilter{DossierID: targetID, Limit: 50}) + section.Items = entriesToSectionItems(entries) + section.Summary = fmt.Sprintf("%d procedures", len(entries)) + + case "assessments": + entries, _ := lib.EntryList(nil, "", lib.CategoryAssessment, &lib.EntryFilter{DossierID: targetID, Limit: 50}) + section.Items = entriesToSectionItems(entries) + section.Summary = fmt.Sprintf("%d assessments", len(entries)) + + case "genetics": + genomeEntries, _ := lib.EntryList(nil, "", lib.CategoryGenome, &lib.EntryFilter{DossierID: targetID, Limit: 1}) + if len(genomeEntries) > 0 { + section.Summary = "Loading..." + } + // Items loaded dynamically via JS + + case "uploads": + uploadDir := filepath.Join(uploadsDir, targetHex) + var uploadCount int + var uploadSize int64 + filepath.Walk(uploadDir, func(path string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() { uploadCount++; uploadSize += info.Size() } + return nil + }) + if uploadCount > 0 { + section.Summary = fmt.Sprintf("%d files, %s", uploadCount, formatSize(uploadSize)) + } else { + section.Summary = T("no_files") + } + if canEdit { + section.ActionURL = fmt.Sprintf("/dossier/%s/upload", targetHex) + section.ActionLabel = T("manage") + } + + case "vitals": + section.Summary = T("vitals_desc") + + case "privacy": + // Handled separately - needs access list, not entries + continue + + default: + // Generic handler for any category with a Category set + if cfg.Category > 0 { + entries, _ := lib.EntryList(nil, "", cfg.Category, &lib.EntryFilter{DossierID: targetID, Limit: 50}) + section.Items = entriesToSectionItems(entries) + section.Summary = fmt.Sprintf("%d items", len(entries)) + } + } + + // Skip empty sections if configured to hide + if section.HideEmpty && len(section.Items) == 0 && !section.Dynamic && !section.ComingSoon && section.ID != "checkin" && section.ID != "uploads" { + continue + } + + sections = append(sections, section) + } + + return sections +} + +// buildImagingItems converts studies to section items +func buildImagingItems(studies []Study, targetHex, dossierID string, T func(string) string) ([]SectionItem, string) { + var items []SectionItem + var totalSlices int + + for _, s := range studies { + totalSlices += s.SliceCount + + item := SectionItem{ + ID: s.ID, + Label: s.Description, + Date: s.Date, + LinkURL: fmt.Sprintf("/viewer/?token=%s&study=%s", dossierID, s.ID), + LinkTitle: T("open_viewer"), + Expandable: s.SeriesCount > 1, + } + + // Add series as children + for _, series := range s.Series { + if series.SliceCount > 0 { + label := series.Description + if label == "" { + label = series.Modality + } + child := SectionItem{ + ID: series.ID, + Label: label, + Value: fmt.Sprintf("%d slices", series.SliceCount), + LinkURL: fmt.Sprintf("/viewer/?token=%s&study=%s&series=%s", dossierID, s.ID, series.ID), + LinkTitle: T("open_viewer"), + } + item.Children = append(item.Children, child) + } + } + + if !item.Expandable { + // Single series - no expand needed + item.Children = nil + } else { + item.Value = fmt.Sprintf("%d series", s.SeriesCount) + } + + items = append(items, item) + } + + var summary string + if len(studies) > 0 { + summary = fmt.Sprintf("%d studies, %d slices", len(studies), totalSlices) + } + + return items, summary +} + +// entriesToSectionItems converts Entry slice to SectionItem slice +func entriesToSectionItems(entries []*lib.Entry) []SectionItem { + var items []SectionItem + for _, e := range entries { + if e == nil { + continue + } + item := SectionItem{ + ID: e.EntryID, + Label: e.Value, + Meta: e.Summary, + Type: e.Type, + } + if e.Timestamp > 0 { + // Convert Unix timestamp to YYYYMMDD + // item.Date = time.Unix(e.Timestamp, 0).Format("20060102") + } + items = append(items, item) + } + return items +} + +// formatSize formats bytes to human readable +func formatSize(bytes int64) string { + if bytes < 1024 { + return fmt.Sprintf("%d B", bytes) + } else if bytes < 1024*1024 { + return fmt.Sprintf("%.1f KB", float64(bytes)/1024) + } + return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024)) +} + +// handleDossierV2 renders the new unified dossier page +func handleDossierV2(w http.ResponseWriter, r *http.Request) { + p := getLoggedInDossier(r) + if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return } + + // Parse path: /dossier/{id}/v2 + parts := strings.Split(r.URL.Path, "/") + if len(parts) < 3 || parts[2] == "" { http.NotFound(w, r); return } + + targetID := parts[2] + targetHex := formatHexID(targetID) + + // Check access (same pattern as handleDossier) + isSelf := targetID == p.DossierID + hasAccess := isSelf + var relation int + var isCareReceiver bool + canEdit := isSelf + if !isSelf { + access, found := getAccess(formatHexID(p.DossierID), targetHex) + hasAccess = found + if found { + relation = access.Relation + isCareReceiver = access.IsCareReceiver + canEdit = access.CanEdit + touchAccess(formatHexID(p.DossierID), targetHex) + } + } + if !hasAccess { http.Error(w, "Forbidden", http.StatusForbidden); return } + + target, err := lib.DossierGet(nil, targetID) + if err != nil { http.NotFound(w, r); return } + + lang := getLang(r) + familyRelations := map[int]bool{1: true, 2: true, 3: true, 4: true, 5: true, 6: true} + showDetails := isSelf || familyRelations[relation] + canManageAccess := isSelf || isCareReceiver + + // Build access list (for privacy section) + accessRecords, _ := listAccessors(targetHex) + var accessList []AccessEntry + for _, ar := range accessRecords { + accessList = append(accessList, AccessEntry{ + DossierID: ar.Accessor, + Name: ar.Name, + Relation: T(lang, "rel_" + fmt.Sprintf("%d", ar.Relation)), + CanEdit: ar.CanEdit, + IsSelf: ar.Accessor == p.DossierID, + }) + } + + // Check for genome + genomeEntries, _ := lib.EntryList(nil, "", lib.CategoryGenome, &lib.EntryFilter{DossierID: targetID, Limit: 1}) + hasGenome := len(genomeEntries) > 0 + + // Build sections + sections := BuildDossierSections(targetID, targetHex, target, p, lang, canEdit) + + render(w, r, PageData{ + Page: "dossier", + Lang: lang, + Embed: isEmbed(r), + Dossier: p, + TargetDossier: target, + ShowDetails: showDetails, + CanManageAccess: canManageAccess, + CanEdit: canEdit, + AccessList: accessList, + HasGenome: hasGenome, + Sections: sections, + }) +} diff --git a/portal/main.go b/portal/main.go index b75a4b8..7d8aee2 100644 --- a/portal/main.go +++ b/portal/main.go @@ -138,6 +138,8 @@ type PageData struct { Labs []HealthEntryView Hospitalizations []HealthEntryView Therapies []HealthEntryView + // Dossier v2: unified sections + Sections []DossierSection } type HealthEntryView struct { @@ -258,6 +260,15 @@ func loadTemplates() { } return "" }, + "dict": func(values ...interface{}) map[string]interface{} { + d := make(map[string]interface{}) + for i := 0; i < len(values); i += 2 { + if i+1 < len(values) { + d[values[i].(string)] = values[i+1] + } + } + return d + }, } templates = template.Must(template.New("").Funcs(funcs).ParseGlob(filepath.Join(tmplDir, "*.tmpl"))) } @@ -939,7 +950,7 @@ func handleDemo(w http.ResponseWriter, r *http.Request) { hasGenome := len(genomeEntries) > 0 render(w, r, PageData{ - Page: "dossier", + Page: "dossier_v1", Lang: lang, Embed: isEmbed(r), Dossier: p, @@ -1028,7 +1039,7 @@ func handleDossier(w http.ResponseWriter, r *http.Request) { hospitalizations := entriesToView(lib.EntryList(nil, "", lib.CategoryHospital, &lib.EntryFilter{DossierID: targetID, Limit: 50})) therapies := entriesToView(lib.EntryList(nil, "", lib.CategoryTherapy, &lib.EntryFilter{DossierID: targetID, Limit: 50})) - render(w, r, PageData{Page: "dossier", Lang: lang, Embed: isEmbed(r), Dossier: p, TargetDossier: target, ShowDetails: showDetails, CanManageAccess: canManageAccess, CanEdit: canEdit, AccessList: accessList, Uploads: uploadCount > 0, UploadCount: uploadCount, UploadSize: sizeStr, HasImaging: hasImaging, Studies: studies, StudyCount: len(studies), TotalSlices: totalSlices, HasGenome: hasGenome, Documents: documents, Procedures: procedures, Medications: medications, Assessments: assessments, Symptoms: symptoms, Labs: labs, Hospitalizations: hospitalizations, Therapies: therapies}) + render(w, r, PageData{Page: "dossier_v1", Lang: lang, Embed: isEmbed(r), Dossier: p, TargetDossier: target, ShowDetails: showDetails, CanManageAccess: canManageAccess, CanEdit: canEdit, AccessList: accessList, Uploads: uploadCount > 0, UploadCount: uploadCount, UploadSize: sizeStr, HasImaging: hasImaging, Studies: studies, StudyCount: len(studies), TotalSlices: totalSlices, HasGenome: hasGenome, Documents: documents, Procedures: procedures, Medications: medications, Assessments: assessments, Symptoms: symptoms, Labs: labs, Hospitalizations: hospitalizations, Therapies: therapies}) } func handleAddDossier(w http.ResponseWriter, r *http.Request) { @@ -1913,7 +1924,8 @@ func setupMux() http.Handler { mux.HandleFunc("/dossier/add", handleAddDossier) mux.HandleFunc("/dossier/", func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path - if strings.HasSuffix(path, "/edit") { handleEditDossier(w, r) + if strings.HasSuffix(path, "/v1") { handleDossier(w, r) // legacy, keep for comparison + } else if strings.HasSuffix(path, "/edit") { handleEditDossier(w, r) } else if strings.HasSuffix(path, "/share") { handleShareAccess(w, r) } else if strings.HasSuffix(path, "/revoke") { handleRevokeAccess(w, r) } else if strings.HasSuffix(path, "/audit") { handleAuditLog(w, r) @@ -1926,7 +1938,7 @@ func setupMux() http.Handler { } else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/delete") { handleDeleteFile(w, r) } else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/update") { handleUpdateFile(w, r) } else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/status") { handleFileStatus(w, r) - } else { handleDossier(w, r) } + } else { handleDossierV2(w, r) } }) mux.HandleFunc("/viewer/", func(w http.ResponseWriter, r *http.Request) { r.URL.Path = strings.TrimPrefix(r.URL.Path, "/viewer") diff --git a/static/style.css b/static/style.css index 775787e..bc7c6bd 100644 --- a/static/style.css +++ b/static/style.css @@ -1035,6 +1035,7 @@ a:hover { } .data-card-indicator.imaging { background: var(--accent); } +.data-card-indicator.checkin { background: var(--accent); } .data-card-indicator.labs { background: #059669; } .data-card-indicator.uploads { background: #6366f1; } .data-card-indicator.vitals { background: #ec4899; } @@ -1862,3 +1863,19 @@ a:hover { .step { padding: 16px 12px; } .code-wrapper pre { font-size: 0.8rem; padding: 12px; padding-right: 40px; } } + +/* Dossier v2: section children (alias for data-row-children) */ +.section-children { + display: none; + background: var(--bg); + border-top: 1px solid var(--border); +} + +.section-children.show { + display: block; +} + +/* Hidden rows (show more pattern) */ +.hidden-row { + display: none !important; +} diff --git a/templates/base.tmpl b/templates/base.tmpl index ccba68f..d0b00e3 100644 --- a/templates/base.tmpl +++ b/templates/base.tmpl @@ -91,7 +91,8 @@ {{else if eq .Page "onboard"}}{{template "onboard" .}} {{else if eq .Page "minor_error"}}{{template "minor_error" .}} {{else if eq .Page "dashboard"}}{{template "dashboard" .}} - {{else if eq .Page "dossier"}}{{template "dossier" .}} + {{else if eq .Page "dossier"}}{{template "dossier_v2" .}} + {{else if eq .Page "dossier_v1"}}{{template "dossier" .}} {{else if eq .Page "add_dossier"}}{{template "add_dossier" .}} {{else if eq .Page "share"}}{{template "share" .}} {{else if eq .Page "upload"}}{{template "upload" .}} diff --git a/templates/dossier.tmpl b/templates/dossier.tmpl index 0fb6ee5..c4e3a96 100644 --- a/templates/dossier.tmpl +++ b/templates/dossier.tmpl @@ -16,7 +16,19 @@ {{if .Error}}
{{.Error}}
{{end}} {{if .Success}}
{{.Success}}
{{end}} - + + +
+
+
+
+ Daily Check-in + Track vitals, symptoms & more +
+ Open +
+
+
diff --git a/templates/dossier_v2.tmpl b/templates/dossier_v2.tmpl new file mode 100644 index 0000000..de3ea29 --- /dev/null +++ b/templates/dossier_v2.tmpl @@ -0,0 +1,422 @@ +{{define "dossier_v2"}} +
+
+
+

{{.TargetDossier.Name}}

+ {{if .ShowDetails}} +

+ {{if .TargetDossier.DateOfBirth}}{{.T.born}}: {{printf "%.10s" .TargetDossier.DateOfBirth}}{{end}} + {{if .TargetDossier.Sex}} · {{sexT .TargetDossier.Sex .Lang}}{{end}} +

+ {{end}} +
+ ← {{.T.back_to_dossiers}} +
+ + {{if .Error}}
{{.Error}}
{{end}} + {{if .Success}}
{{.Success}}
{{end}} + + {{/* Render all sections using unified template */}} + {{range .Sections}} + {{template "section_block" .}} + {{end}} + + {{/* Privacy section - special structure */}} +
+
+
+
+ {{$.T.section_privacy}} + {{len $.AccessList}} {{$.T.people_with_access_count}} +
+
+ +
+ {{range $.AccessList}} +
+
+ {{.Name}}{{if .IsSelf}} ({{$.T.you}}){{else if .IsPending}} ({{$.T.pending}}){{end}} + {{.Relation}}{{if .CanEdit}} · {{$.T.can_edit}}{{end}} +
+ {{if and $.CanManageAccess (not .IsSelf)}} +
+ Edit +
+ + +
+
+ {{end}} +
+ {{end}} + {{if not $.AccessList}} +
+ {{$.T.no_access_yet}} +
+ {{end}} + +
+ {{$.T.share_access}} + {{if $.CanManageAccess}}{{$.T.manage_permissions}}{{end}} + {{$.T.view_audit_log}} + {{if or (eq $.Dossier.DossierID $.TargetDossier.DossierID) $.CanManageAccess}}{{$.T.export_data}}{{end}} +
+
+
+ + {{template "footer"}} +
+ +{{/* Genetics Warning Modal */}} + + + +{{end}} + +{{/* Unified section block template */}} +{{define "section_block"}} + +{{end}}