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
This commit is contained in:
parent
1ffa947a52
commit
8754a9df40
23
lang/en.yaml
23
lang/en.yaml
|
|
@ -76,6 +76,29 @@ section_vitals: "Vitals"
|
||||||
section_medications: "Medications"
|
section_medications: "Medications"
|
||||||
section_records: "Records"
|
section_records: "Records"
|
||||||
section_journal: "Journal"
|
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
|
# Section summaries
|
||||||
imaging_summary: "%d studies · %d slices"
|
imaging_summary: "%d studies · %d slices"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -138,6 +138,8 @@ type PageData struct {
|
||||||
Labs []HealthEntryView
|
Labs []HealthEntryView
|
||||||
Hospitalizations []HealthEntryView
|
Hospitalizations []HealthEntryView
|
||||||
Therapies []HealthEntryView
|
Therapies []HealthEntryView
|
||||||
|
// Dossier v2: unified sections
|
||||||
|
Sections []DossierSection
|
||||||
}
|
}
|
||||||
|
|
||||||
type HealthEntryView struct {
|
type HealthEntryView struct {
|
||||||
|
|
@ -258,6 +260,15 @@ func loadTemplates() {
|
||||||
}
|
}
|
||||||
return ""
|
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")))
|
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
|
hasGenome := len(genomeEntries) > 0
|
||||||
|
|
||||||
render(w, r, PageData{
|
render(w, r, PageData{
|
||||||
Page: "dossier",
|
Page: "dossier_v1",
|
||||||
Lang: lang,
|
Lang: lang,
|
||||||
Embed: isEmbed(r),
|
Embed: isEmbed(r),
|
||||||
Dossier: p,
|
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}))
|
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}))
|
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) {
|
func handleAddDossier(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -1913,7 +1924,8 @@ func setupMux() http.Handler {
|
||||||
mux.HandleFunc("/dossier/add", handleAddDossier)
|
mux.HandleFunc("/dossier/add", handleAddDossier)
|
||||||
mux.HandleFunc("/dossier/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/dossier/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
path := r.URL.Path
|
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, "/share") { handleShareAccess(w, r)
|
||||||
} else if strings.HasSuffix(path, "/revoke") { handleRevokeAccess(w, r)
|
} else if strings.HasSuffix(path, "/revoke") { handleRevokeAccess(w, r)
|
||||||
} else if strings.HasSuffix(path, "/audit") { handleAuditLog(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, "/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, "/update") { handleUpdateFile(w, r)
|
||||||
} else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/status") { handleFileStatus(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) {
|
mux.HandleFunc("/viewer/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/viewer")
|
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/viewer")
|
||||||
|
|
|
||||||
|
|
@ -1035,6 +1035,7 @@ a:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-card-indicator.imaging { background: var(--accent); }
|
.data-card-indicator.imaging { background: var(--accent); }
|
||||||
|
.data-card-indicator.checkin { background: var(--accent); }
|
||||||
.data-card-indicator.labs { background: #059669; }
|
.data-card-indicator.labs { background: #059669; }
|
||||||
.data-card-indicator.uploads { background: #6366f1; }
|
.data-card-indicator.uploads { background: #6366f1; }
|
||||||
.data-card-indicator.vitals { background: #ec4899; }
|
.data-card-indicator.vitals { background: #ec4899; }
|
||||||
|
|
@ -1862,3 +1863,19 @@ a:hover {
|
||||||
.step { padding: 16px 12px; }
|
.step { padding: 16px 12px; }
|
||||||
.code-wrapper pre { font-size: 0.8rem; padding: 12px; padding-right: 40px; }
|
.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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,8 @@
|
||||||
{{else if eq .Page "onboard"}}{{template "onboard" .}}
|
{{else if eq .Page "onboard"}}{{template "onboard" .}}
|
||||||
{{else if eq .Page "minor_error"}}{{template "minor_error" .}}
|
{{else if eq .Page "minor_error"}}{{template "minor_error" .}}
|
||||||
{{else if eq .Page "dashboard"}}{{template "dashboard" .}}
|
{{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 "add_dossier"}}{{template "add_dossier" .}}
|
||||||
{{else if eq .Page "share"}}{{template "share" .}}
|
{{else if eq .Page "share"}}{{template "share" .}}
|
||||||
{{else if eq .Page "upload"}}{{template "upload" .}}
|
{{else if eq .Page "upload"}}{{template "upload" .}}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,18 @@
|
||||||
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
||||||
{{if .Success}}<div class="success">{{.Success}}</div>{{end}}
|
{{if .Success}}<div class="success">{{.Success}}</div>{{end}}
|
||||||
|
|
||||||
|
<!-- Daily Check-in Section -->
|
||||||
|
<div class="data-card">
|
||||||
|
<div class="data-card-header">
|
||||||
|
<div class="data-card-indicator checkin"></div>
|
||||||
|
<div class="data-card-title">
|
||||||
|
<span class="section-heading">Daily Check-in</span>
|
||||||
|
<span class="data-card-summary">Track vitals, symptoms & more</span>
|
||||||
|
</div>
|
||||||
|
<a href="/dossier/{{.TargetDossier.DossierID}}/prompts" class="btn btn-small btn-primary">Open</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Imaging Section -->
|
<!-- Imaging Section -->
|
||||||
<div class="data-card">
|
<div class="data-card">
|
||||||
<div class="data-card-header">
|
<div class="data-card-header">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,422 @@
|
||||||
|
{{define "dossier_v2"}}
|
||||||
|
<div class="sg-container">
|
||||||
|
<div class="dossier-header" style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px;">
|
||||||
|
<div>
|
||||||
|
<h1 style="font-size: 2.5rem; font-weight: 700; margin-bottom: 8px;">{{.TargetDossier.Name}}</h1>
|
||||||
|
{{if .ShowDetails}}
|
||||||
|
<p style="font-size: 1.15rem; font-weight: 300; color: var(--text-muted);">
|
||||||
|
{{if .TargetDossier.DateOfBirth}}{{.T.born}}: {{printf "%.10s" .TargetDossier.DateOfBirth}}{{end}}
|
||||||
|
{{if .TargetDossier.Sex}} · {{sexT .TargetDossier.Sex .Lang}}{{end}}
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<a href="/dashboard" class="btn btn-secondary btn-small">← {{.T.back_to_dossiers}}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
||||||
|
{{if .Success}}<div class="success">{{.Success}}</div>{{end}}
|
||||||
|
|
||||||
|
{{/* Render all sections using unified template */}}
|
||||||
|
{{range .Sections}}
|
||||||
|
{{template "section_block" .}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* Privacy section - special structure */}}
|
||||||
|
<div class="data-card">
|
||||||
|
<div class="data-card-header">
|
||||||
|
<div class="data-card-indicator privacy"></div>
|
||||||
|
<div class="data-card-title">
|
||||||
|
<span class="section-heading">{{$.T.section_privacy}}</span>
|
||||||
|
<span class="data-card-summary">{{len $.AccessList}} {{$.T.people_with_access_count}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-table">
|
||||||
|
{{range $.AccessList}}
|
||||||
|
<div class="data-row">
|
||||||
|
<div class="data-row-main">
|
||||||
|
<span class="data-label">{{.Name}}{{if .IsSelf}} ({{$.T.you}}){{else if .IsPending}} ({{$.T.pending}}){{end}}</span>
|
||||||
|
<span class="data-meta">{{.Relation}}{{if .CanEdit}} · {{$.T.can_edit}}{{end}}</span>
|
||||||
|
</div>
|
||||||
|
{{if and $.CanManageAccess (not .IsSelf)}}
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<a href="/dossier/{{$.TargetDossier.DossierID}}/access/{{.DossierID}}" class="btn btn-secondary btn-small">Edit</a>
|
||||||
|
<form action="/dossier/{{$.TargetDossier.DossierID}}/revoke" method="POST" style="display:inline;">
|
||||||
|
<input type="hidden" name="accessor_id" value="{{.DossierID}}">
|
||||||
|
<button type="submit" class="btn btn-danger btn-small" onclick="return confirm('Remove access for {{.Name}} to {{$.TargetDossier.Name}}?')">{{$.T.remove}}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if not $.AccessList}}
|
||||||
|
<div class="data-row">
|
||||||
|
<span class="text-muted">{{$.T.no_access_yet}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="data-row privacy-actions">
|
||||||
|
<a href="/dossier/{{$.TargetDossier.DossierID}}/share" class="privacy-action">{{$.T.share_access}}</a>
|
||||||
|
{{if $.CanManageAccess}}<a href="/dossier/{{$.TargetDossier.DossierID}}/permissions" class="privacy-action">{{$.T.manage_permissions}}</a>{{end}}
|
||||||
|
<a href="/dossier/{{$.TargetDossier.DossierID}}/audit" class="privacy-action">{{$.T.view_audit_log}}</a>
|
||||||
|
{{if or (eq $.Dossier.DossierID $.TargetDossier.DossierID) $.CanManageAccess}}<a href="/dossier/{{$.TargetDossier.DossierID}}/export" class="privacy-action">{{$.T.export_data}}</a>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "footer"}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* Genetics Warning Modal */}}
|
||||||
|
<div id="genetics-warning-modal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-content" style="max-width: 520px;">
|
||||||
|
<h3 style="margin-bottom: 16px;">⚠️ Before you continue</h3>
|
||||||
|
<p style="margin-bottom: 16px;">Here you can browse all your raw genetic variants. However, the real value comes from using <a href="/connect">Claude and other LLMs with your health dossier</a> — they can interpret these variants and correlate them with your labs, imaging, and medical history.</p>
|
||||||
|
<p style="margin-bottom: 12px;"><strong>Keep in mind:</strong></p>
|
||||||
|
<ul style="margin-bottom: 16px; padding-left: 20px; line-height: 1.6;">
|
||||||
|
<li>Many associations are based on early or limited research</li>
|
||||||
|
<li>A "risk variant" means slightly higher odds — not a diagnosis</li>
|
||||||
|
<li>Consumer tests (23andMe, AncestryDNA) can have false positives</li>
|
||||||
|
</ul>
|
||||||
|
<p style="margin-bottom: 20px;">These findings can be a starting point for conversations with your doctor — especially if certain conditions run in your family.</p>
|
||||||
|
<div style="display: flex; gap: 12px; justify-content: flex-end;">
|
||||||
|
<button class="btn btn-secondary" onclick="closeGeneticsWarning()">Close</button>
|
||||||
|
<button class="btn btn-primary" onclick="confirmShowAllGenetics()">I understand, show all</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const dossierGUID = "{{.TargetDossier.DossierID}}";
|
||||||
|
const userLang = "{{.Lang}}";
|
||||||
|
|
||||||
|
// Section expand/collapse
|
||||||
|
function toggleSection(el) {
|
||||||
|
el.classList.toggle('expanded');
|
||||||
|
const icon = el.querySelector('.expand-icon');
|
||||||
|
if (icon) icon.textContent = el.classList.contains('expanded') ? '−' : '+';
|
||||||
|
const children = el.nextElementSibling;
|
||||||
|
if (children && children.classList.contains('section-children')) {
|
||||||
|
children.classList.toggle('show');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format dates from YYYYMMDD
|
||||||
|
document.querySelectorAll('[data-date]').forEach(el => {
|
||||||
|
const d = el.dataset.date;
|
||||||
|
if (d && d.length === 8) {
|
||||||
|
const date = new Date(d.slice(0,4) + '-' + d.slice(4,6) + '-' + d.slice(6,8));
|
||||||
|
el.textContent = date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Genetics dynamic loading (if genetics section exists)
|
||||||
|
{{if .HasGenome}}
|
||||||
|
const i18n = {
|
||||||
|
genomeEnglishOnly: "{{.T.genome_english_only}}",
|
||||||
|
genomeVariants: "{{.T.genome_variants}}",
|
||||||
|
genomeHidden: "{{.T.genome_hidden}}",
|
||||||
|
genomeShowAllCategories: "{{.T.genome_show_all_categories}}"
|
||||||
|
};
|
||||||
|
|
||||||
|
let showAllGenetics = false;
|
||||||
|
let showAllCategories = false;
|
||||||
|
let allCategories = {};
|
||||||
|
|
||||||
|
async function loadGeneticsCategories() {
|
||||||
|
const container = document.getElementById('genetics-content');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/categories?dossier=${dossierGUID}&type=genome`);
|
||||||
|
allCategories = await resp.json();
|
||||||
|
|
||||||
|
const totalHidden = Object.values(allCategories).reduce((sum, c) => sum + (c.hidden || 0), 0);
|
||||||
|
const showAllBtn = document.getElementById('show-all-genetics');
|
||||||
|
if (showAllBtn && totalHidden > 0) {
|
||||||
|
showAllBtn.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderGeneticsCategories();
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<div class="data-row"><span class="text-muted">Error loading genetics</span></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGeneticsCategories() {
|
||||||
|
const container = document.getElementById('genetics-content');
|
||||||
|
const summaryEl = document.getElementById('genetics-summary');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let totalShown = 0, totalHidden = 0;
|
||||||
|
for (const counts of Object.values(allCategories)) {
|
||||||
|
totalShown += counts.shown || 0;
|
||||||
|
totalHidden += counts.hidden || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalShown === 0 && totalHidden === 0) {
|
||||||
|
document.getElementById('section-genetics')?.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = showAllGenetics ? `${totalShown + totalHidden} ${i18n.genomeVariants}` : `${totalShown} ${i18n.genomeVariants}`;
|
||||||
|
if (totalHidden > 0 && !showAllGenetics) {
|
||||||
|
summary += ` <span class="text-muted">(${totalHidden} ${i18n.genomeHidden})</span>`;
|
||||||
|
}
|
||||||
|
if (summaryEl) summaryEl.innerHTML = summary;
|
||||||
|
|
||||||
|
const btn = document.getElementById('show-all-genetics');
|
||||||
|
if (btn) {
|
||||||
|
if (showAllGenetics) {
|
||||||
|
btn.textContent = 'Hide negative';
|
||||||
|
btn.onclick = () => { showAllGenetics = false; renderGeneticsCategories(); return false; };
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Show all';
|
||||||
|
btn.onclick = () => { showGeneticsWarning(); return false; };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryPriority = {
|
||||||
|
'traits': 1, 'metabolism': 2, 'longevity': 3, 'blood': 4, 'cardiovascular': 5,
|
||||||
|
'neurological': 6, 'mental_health': 7, 'autoimmune': 8, 'medication': 9,
|
||||||
|
'fertility': 10, 'disease': 11, 'cancer': 12, 'other': 13
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorted = Object.entries(allCategories).sort((a, b) => {
|
||||||
|
const aPriority = categoryPriority[a[0]] || 99;
|
||||||
|
const bPriority = categoryPriority[b[0]] || 99;
|
||||||
|
return aPriority !== bPriority ? aPriority - bPriority :
|
||||||
|
(showAllGenetics ? (b[1].shown + b[1].hidden) : b[1].shown) -
|
||||||
|
(showAllGenetics ? (a[1].shown + a[1].hidden) : a[1].shown);
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxCategories = 5;
|
||||||
|
const displayCategories = showAllCategories ? sorted : sorted.slice(0, maxCategories);
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
if (userLang !== 'en' && i18n.genomeEnglishOnly) {
|
||||||
|
html += `<div class="data-row" style="background: var(--bg-muted); color: var(--text-muted); font-size: 0.9rem; padding: 12px 16px;">
|
||||||
|
<span>🌐 ${i18n.genomeEnglishOnly}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [cat, counts] of displayCategories) {
|
||||||
|
const shown = counts.shown || 0, hidden = counts.hidden || 0;
|
||||||
|
const displayCount = showAllGenetics ? (shown + hidden) : shown;
|
||||||
|
if (displayCount === 0) continue;
|
||||||
|
|
||||||
|
const label = cat.charAt(0).toUpperCase() + cat.slice(1).replace(/_/g, ' ');
|
||||||
|
let countText = `${displayCount} ${i18n.genomeVariants}`;
|
||||||
|
if (hidden > 0 && !showAllGenetics) {
|
||||||
|
countText = `${shown} ${i18n.genomeVariants} <span class="text-muted">(${hidden} ${i18n.genomeHidden})</span>`;
|
||||||
|
}
|
||||||
|
html += `
|
||||||
|
<div class="data-row expandable" data-category="${cat}" onclick="toggleGeneticsCategory(this, '${cat}')">
|
||||||
|
<div class="data-row-main">
|
||||||
|
<span class="expand-icon">+</span>
|
||||||
|
<span class="data-label">${label}</span>
|
||||||
|
</div>
|
||||||
|
<div class="data-values">
|
||||||
|
<span class="data-meta">${countText}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-children" data-category="${cat}"></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showAllCategories && sorted.length > maxCategories) {
|
||||||
|
html += `<div class="data-row show-more" style="cursor: pointer;" onclick="showAllCategories = true; renderGeneticsCategories();">${i18n.genomeShowAllCategories.replace('%d', sorted.length)} →</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showGeneticsWarning() {
|
||||||
|
document.getElementById('genetics-warning-modal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeGeneticsWarning() {
|
||||||
|
document.getElementById('genetics-warning-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmShowAllGenetics() {
|
||||||
|
closeGeneticsWarning();
|
||||||
|
showAllGenetics = true;
|
||||||
|
renderGeneticsCategories();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleGeneticsCategory(el, category) {
|
||||||
|
el.classList.toggle('expanded');
|
||||||
|
const icon = el.querySelector('.expand-icon');
|
||||||
|
if (icon) icon.textContent = el.classList.contains('expanded') ? '−' : '+';
|
||||||
|
|
||||||
|
const children = el.nextElementSibling;
|
||||||
|
if (!children) return;
|
||||||
|
children.classList.toggle('show');
|
||||||
|
|
||||||
|
if (children.dataset.loaded) return;
|
||||||
|
children.dataset.loaded = 'true';
|
||||||
|
children.innerHTML = '<div class="data-row child"><span class="text-muted">Loading...</span></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const includeHidden = showAllGenetics ? '&include_hidden=true' : '';
|
||||||
|
const resp = await fetch(`/api/genome?dossier=${dossierGUID}&category=${encodeURIComponent(category)}${includeHidden}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (!data.matches || data.matches.length === 0) {
|
||||||
|
children.innerHTML = '<div class="data-row child"><span class="text-muted">No variants found</span></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
data.matches.slice(0, 5).forEach(m => {
|
||||||
|
const mag = m.magnitude ? m.magnitude.toFixed(1) : '0';
|
||||||
|
const allele = m.genotype.replace(/;/g, '').split('').join(';');
|
||||||
|
html += `
|
||||||
|
<div class="data-row child" style="flex-direction: column; align-items: flex-start; gap: 8px; padding: 12px 16px;">
|
||||||
|
<div class="sg-gene-row" style="width: 100%;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; width: 100%;">
|
||||||
|
<div class="sg-gene-main">
|
||||||
|
<span class="sg-gene-name">${m.gene || ''}</span>
|
||||||
|
<span class="sg-gene-rsid">${m.rsid}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<span class="sg-gene-allele">${allele}</span>
|
||||||
|
<span class="badge" style="font-size: 0.7rem;">mag ${mag}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sg-gene-summary">${m.summary || ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.total > 5) {
|
||||||
|
const remaining = data.total - 5;
|
||||||
|
html += `<div class="sg-show-more" data-offset="5" data-total="${data.total}" data-category="${category}" onclick="loadMoreGenetics(this)">${remaining > 50 ? `${remaining} more — <a href="/connect">use Claude</a>` : `Show more (${remaining}) →`}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
children.innerHTML = html;
|
||||||
|
} catch (e) {
|
||||||
|
children.innerHTML = '<div class="data-row child"><span class="text-muted">Error loading</span></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMoreGenetics(el) {
|
||||||
|
const offset = parseInt(el.dataset.offset, 10);
|
||||||
|
const total = parseInt(el.dataset.total, 10);
|
||||||
|
const category = el.dataset.category;
|
||||||
|
el.textContent = 'Loading...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const includeHidden = showAllGenetics ? '&include_hidden=true' : '';
|
||||||
|
const resp = await fetch(`/api/genome?dossier=${dossierGUID}&category=${encodeURIComponent(category)}&offset=${offset}&limit=20${includeHidden}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
data.matches.forEach(m => {
|
||||||
|
const mag = m.magnitude ? m.magnitude.toFixed(1) : '0';
|
||||||
|
const allele = m.genotype.replace(/;/g, '').split('').join(';');
|
||||||
|
html += `
|
||||||
|
<div class="data-row child" style="flex-direction: column; align-items: flex-start; gap: 8px; padding: 12px 16px;">
|
||||||
|
<div class="sg-gene-row" style="width: 100%;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; width: 100%;">
|
||||||
|
<div class="sg-gene-main">
|
||||||
|
<span class="sg-gene-name">${m.gene || ''}</span>
|
||||||
|
<span class="sg-gene-rsid">${m.rsid}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<span class="sg-gene-allele">${allele}</span>
|
||||||
|
<span class="badge" style="font-size: 0.7rem;">mag ${mag}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sg-gene-summary">${m.summary || ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
el.insertAdjacentHTML('beforebegin', html);
|
||||||
|
const newOffset = offset + data.matches.length;
|
||||||
|
const remaining = total - newOffset;
|
||||||
|
|
||||||
|
if (remaining > 0) {
|
||||||
|
el.dataset.offset = newOffset;
|
||||||
|
el.innerHTML = remaining > 50 ? `${remaining} more — <a href="/connect">use Claude</a>` : `Show more (${remaining}) →`;
|
||||||
|
} else {
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
el.textContent = 'Error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadGeneticsCategories();
|
||||||
|
{{end}}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* Unified section block template */}}
|
||||||
|
{{define "section_block"}}
|
||||||
|
<div class="data-card{{if .ComingSoon}} coming-soon{{end}}" id="section-{{.ID}}"{{if and .HideEmpty (eq (len .Items) 0) (not .Dynamic) (not .ComingSoon)}} style="display:none;"{{end}}>
|
||||||
|
<div class="data-card-header">
|
||||||
|
<div class="data-card-indicator" style="background: #{{.Color}};"></div>
|
||||||
|
<div class="data-card-title">
|
||||||
|
<span class="section-heading">{{.Heading}}</span>
|
||||||
|
<span class="data-card-summary"{{if .Dynamic}} id="{{.ID}}-summary"{{end}}>{{.Summary}}</span>
|
||||||
|
</div>
|
||||||
|
{{if .ComingSoon}}
|
||||||
|
<span class="badge-soon">Coming soon</span>
|
||||||
|
{{else if eq .ID "genetics"}}
|
||||||
|
<a href="#" class="btn btn-small" id="show-all-genetics" onclick="showGeneticsWarning(); return false;">Show all</a>
|
||||||
|
{{else if .ActionURL}}
|
||||||
|
<a href="{{.ActionURL}}" {{if eq .ID "imaging"}}target="_blank"{{end}} class="btn btn-small{{if eq .ID "checkin"}} btn-primary{{end}}">{{.ActionLabel}}</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Dynamic}}
|
||||||
|
<div class="data-table" id="{{.ID}}-content"></div>
|
||||||
|
{{else if .Items}}
|
||||||
|
<div class="data-table">
|
||||||
|
{{range $i, $item := .Items}}
|
||||||
|
{{if $item.Expandable}}
|
||||||
|
<div class="data-row expandable{{if gt $i 4}} hidden-row{{end}}" data-index="{{$i}}" onclick="toggleSection(this)">
|
||||||
|
<div class="data-row-main">
|
||||||
|
<span class="expand-icon">+</span>
|
||||||
|
<span class="data-label">{{$item.Label}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="data-values">
|
||||||
|
{{if $item.Value}}<span class="data-value mono">{{$item.Value}}</span>{{end}}
|
||||||
|
{{if $item.Date}}<span class="data-date" data-date="{{$item.Date}}"></span>{{end}}
|
||||||
|
{{if $item.LinkURL}}<a href="{{$item.LinkURL}}" target="_blank" class="btn-icon" onclick="event.stopPropagation()" title="{{$item.LinkTitle}}">→</a>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-children{{if gt $i 4}} hidden-row{{end}}" data-index="{{$i}}">
|
||||||
|
{{range $item.Children}}
|
||||||
|
<div class="data-row child">
|
||||||
|
<span class="data-label">{{.Label}}</span>
|
||||||
|
{{if .Value}}<span class="data-value mono">{{.Value}}</span>{{end}}
|
||||||
|
{{if .LinkURL}}<a href="{{.LinkURL}}" target="_blank" class="btn-icon" title="{{.LinkTitle}}">→</a>{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="data-row{{if gt $i 4}} hidden-row{{end}}" data-index="{{$i}}">
|
||||||
|
<div class="data-row-main">
|
||||||
|
<span class="data-label">{{$item.Label}}</span>
|
||||||
|
{{if $item.Meta}}<span class="data-meta">{{$item.Meta}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="data-values">
|
||||||
|
{{if $item.Type}}<span class="data-value">{{$item.Type}}</span>{{end}}
|
||||||
|
{{if $item.Date}}<span class="data-date" data-date="{{$item.Date}}"></span>{{end}}
|
||||||
|
{{if $item.LinkURL}}<a href="{{$item.LinkURL}}" target="_blank" class="btn-icon" title="{{$item.LinkTitle}}">→</a>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if gt (len .Items) 5}}
|
||||||
|
<div class="data-row show-more" onclick="this.parentElement.querySelectorAll('.hidden-row').forEach(el => el.classList.remove('hidden-row')); this.remove();">
|
||||||
|
<span class="text-muted">Show all {{len .Items}}...</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Loading…
Reference in New Issue