package main import ( "encoding/json" "fmt" "html/template" "net/http" "strings" "time" "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) Searchable bool // show search/filter box in header // Checkin-specific: show "build your profile" prompt ShowBuildPrompt bool // true if trackable categories are empty TrackableStats map[string]int // counts for trackable categories TrackerButtons []TrackerButton // buttons for empty trackable categories } // TrackerButton for the "build your profile" section type TrackerButton struct { Label string Icon string URL string } // 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 Time string // "20:06 -0400" — shown alongside Date when present 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.ActionURL = fmt.Sprintf("/dossier/%s/prompts", targetHex) section.ActionLabel = T("open") // Count trackable categories stats := make(map[string]int) vitals, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryVital, &lib.EntryFilter{DossierID: targetID, Limit: 1}) meds, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryMedication, &lib.EntryFilter{DossierID: targetID, Limit: 1}) supps, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategorySupplement, &lib.EntryFilter{DossierID: targetID, Limit: 1}) exercise, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryExercise, &lib.EntryFilter{DossierID: targetID, Limit: 1}) symptoms, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategorySymptom, &lib.EntryFilter{DossierID: targetID, Limit: 1}) stats["vitals"] = len(vitals) stats["medications"] = len(meds) stats["supplements"] = len(supps) stats["exercise"] = len(exercise) stats["symptoms"] = len(symptoms) section.TrackableStats = stats // Check if any trackable data exists hasData := len(vitals) > 0 || len(meds) > 0 || len(supps) > 0 || len(exercise) > 0 || len(symptoms) > 0 if hasData { // Compact summary showing what's tracked section.Summary = T("checkin_summary") } else { // Show build profile prompt section.ShowBuildPrompt = true section.Summary = T("checkin_build_profile") promptsURL := fmt.Sprintf("/dossier/%s/prompts", targetHex) section.TrackerButtons = []TrackerButton{ {Label: T("btn_vitals"), URL: promptsURL + "?add=vital"}, {Label: T("btn_medications"), URL: promptsURL + "?add=medication"}, {Label: T("btn_supplements"), URL: promptsURL + "?add=supplement"}, {Label: T("btn_exercise"), URL: promptsURL + "?add=exercise"}, } } case "imaging": studies, _ := fetchStudiesWithSeries(targetHex) section.Items, section.Summary = buildImagingItems(studies, targetHex, target.DossierID, lang, T) if len(studies) > 0 { section.ActionURL = fmt.Sprintf("/viewer/?token=%s", target.DossierID) section.ActionLabel = T("open_viewer") } case "labs": section.Items, section.Summary = buildLabItems(targetID, lang, T) section.Searchable = len(section.Items) > 5 case "documents": entries, _ := lib.EntryList(lib.SystemAccessorID, "", 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(lib.SystemAccessorID, "", 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(lib.SystemAccessorID, "", 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(lib.SystemAccessorID, "", 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(lib.SystemAccessorID, "", cfg.Category, &lib.EntryFilter{DossierID: targetID, Limit: 50}) section.Items = entriesToSectionItems(entries) // Use section ID for summary (e.g., "2 medications" not "2 items") section.Summary = fmt.Sprintf("%d %s", len(entries), cfg.ID) } } // 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 } // pluralT returns a translated plural string like "3 slices" using translation keys. // Keys: "{key}_one", "{key}_few" (Russian 2-4), "{key}_other". // Falls back: few->other, missing key->English. func pluralT(n int, key, lang string) string { T := func(k string) string { if t, ok := translations[lang]; ok { if s, ok := t[k]; ok { return s } } if t, ok := translations["en"]; ok { if s, ok := t[k]; ok { return s } } return k } form := pluralForm(n, lang) s := T(key + "_" + form) return fmt.Sprintf(s, n) } func pluralForm(n int, lang string) string { switch lang { case "ru": mod10, mod100 := n%10, n%100 if mod10 == 1 && mod100 != 11 { return "one" } if mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) { return "few" } return "other" default: if n == 1 { return "one" } return "other" } } func offsetToTZName(offset int) string { switch offset { case -4 * 3600: return "EDT" case -5 * 3600: return "EST" case -6 * 3600: return "CST" case -7 * 3600: return "MST" case -8 * 3600: return "PST" case 0: return "UTC" case 1 * 3600: return "CET" case 2 * 3600: return "CEST" case 3 * 3600: return "MSK" default: h := offset / 3600 m := (offset % 3600) / 60 if m < 0 { m = -m } return fmt.Sprintf("%+03d%02d", h, m) } } // buildImagingItems converts studies to section items func buildImagingItems(studies []Study, targetHex, dossierID, lang 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: pluralT(series.SliceCount, "slice", lang), 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 - show slice count, no expand item.Children = nil item.Value = pluralT(s.SliceCount, "slice", lang) } else { item.Value = pluralT(s.SeriesCount, "series", lang) } items = append(items, item) } var summary string if len(studies) > 0 { summary = fmt.Sprintf("%d studies, %d slices", len(studies), totalSlices) } return items, summary } // buildLabItems creates parent/child lab section items func buildLabItems(dossierID, lang string, T func(string) string) ([]SectionItem, string) { // Get lab orders (parents) orders, _ := lib.EntryQuery(dossierID, lib.CategoryLab, "lab_order") // Also get standalone lab results (no parent) allLabs, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryLab, &lib.EntryFilter{DossierID: dossierID, Limit: 5000}) var standalones []*lib.Entry for _, e := range allLabs { if e.ParentID == "" && e.Type != "lab_order" { standalones = append(standalones, e) } } if len(orders) == 0 && len(standalones) == 0 { return nil, T("no_lab_data") } var items []SectionItem var totalTests int // Process lab orders with children for _, order := range orders { // Get children for this order children, _ := lib.EntryChildren(dossierID, order.EntryID) totalTests += len(children) item := SectionItem{ ID: order.EntryID, Label: order.Value, Expandable: len(children) > 0, } // Use original local_time from Data JSON if available var data struct { LocalTime string `json:"local_time"` } if json.Unmarshal([]byte(order.Data), &data) == nil && data.LocalTime != "" { if t, err := time.Parse(time.RFC3339, data.LocalTime); err == nil { item.Date = t.Format("20060102") if t.Hour() != 0 || t.Minute() != 0 { _, offset := t.Zone() item.Time = fmt.Sprintf("%02d:%02d %s", t.Hour(), t.Minute(), offsetToTZName(offset)) } } else { fmt.Printf("[DEBUG] Failed to parse local_time for %s: %s (err: %v)\n", order.EntryID, data.LocalTime, err) } } // Fallback: if date still not set, use timestamp if item.Date == "" && order.Timestamp > 0 { t := time.Unix(order.Timestamp, 0) item.Date = t.Format("20060102") fmt.Printf("[DEBUG] Set date from timestamp for %s: %s -> %s\n", order.EntryID, order.Value, item.Date) } if item.Date == "" { fmt.Printf("[DEBUG] No date set for order %s: %s (timestamp: %d, local_time: %s)\n", order.EntryID, order.Value, order.Timestamp, data.LocalTime) } if len(children) > 0 { item.Value = pluralT(len(children), "result", lang) for _, c := range children { // Extract LOINC for precise matching var childData struct { Loinc string `json:"loinc"` } json.Unmarshal([]byte(c.Data), &childData) child := SectionItem{ Label: c.Summary, Type: childData.Loinc, // Store LOINC in Type field } item.Children = append(item.Children, child) } } items = append(items, item) } // Add standalone lab results for _, standalone := range standalones { var data struct { Loinc string `json:"loinc"` } json.Unmarshal([]byte(standalone.Data), &data) item := SectionItem{ ID: standalone.EntryID, Label: standalone.Summary, Type: data.Loinc, // Store LOINC for search matching } // Set date from timestamp if standalone.Timestamp > 0 { t := time.Unix(standalone.Timestamp, 0) item.Date = t.Format("20060102") } items = append(items, item) totalTests++ } summary := fmt.Sprintf("%s, %s", pluralT(len(orders), "order", lang), pluralT(totalTests, "result", lang)) 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 } // buildLoincNameMap builds a JSON map of LOINC code → full test name // for displaying full names in charts. func buildLoincNameMap() string { var tests []lib.LabTest if err := lib.Query("SELECT loinc_id, name FROM lab_test", nil, &tests); err != nil { return "{}" } // Map: loinc → name nameMap := make(map[string]string) for _, test := range tests { nameMap[test.LoincID] = test.Name } b, _ := json.Marshal(nameMap) return string(b) } // buildLabSearchIndex builds a JSON map of search terms → LOINC codes // for client-side lab result filtering. Keys are lowercase test names and abbreviations. func buildLabSearchIndex() string { var tests []lib.LabTest if err := lib.Query("SELECT loinc_id, name FROM lab_test", nil, &tests); err != nil { return "{}" } // Map: lowercase name → []loinc index := make(map[string][]string) // Index by test names from lab_test table for _, test := range tests { name := strings.ToLower(test.Name) words := strings.Fields(name) // Index by full name if !contains(index[name], test.LoincID) { index[name] = append(index[name], test.LoincID) } // Index by individual words (for partial matching) for _, word := range words { // Strip parentheses from words like "(MCV)" -> "mcv" word = strings.Trim(word, "()") if len(word) >= 3 { // Skip short words if !contains(index[word], test.LoincID) { index[word] = append(index[word], test.LoincID) } } } } // Also index by abbreviations from actual lab entries // Get unique LOINC+abbreviation pairs from all lab entries with parent var entries []*lib.Entry if err := lib.Query("SELECT entry_id, data FROM entries WHERE category = ? AND parent_id != ''", []any{lib.CategoryLab}, &entries); err == nil { seen := make(map[string]bool) // Track LOINC+abbr pairs to avoid duplicates for _, e := range entries { var data struct { Loinc string `json:"loinc"` Abbreviation string `json:"abbreviation"` } if json.Unmarshal([]byte(e.Data), &data) == nil && data.Loinc != "" && data.Abbreviation != "" { key := data.Loinc + "|" + data.Abbreviation if !seen[key] { // Strip parentheses and convert to lowercase abbr := strings.ToLower(strings.Trim(data.Abbreviation, "()")) if abbr != "" && !contains(index[abbr], data.Loinc) { index[abbr] = append(index[abbr], data.Loinc) } seen[key] = true } } } } b, _ := json.Marshal(index) return string(b) } func contains(slice []string, item string) bool { for _, s := range slice { if s == item { return true } } return false } // buildLabRefData builds a JSON map of test abbreviation → reference data // for the chart JS to use. Keys are abbreviations (e.g. "WBC", "Hgb"). func buildLabRefData(dossierID string, dob time.Time, sex int) string { type refInfo struct { Direction string `json:"direction"` RefLow float64 `json:"refLow"` RefHigh float64 `json:"refHigh"` } result := make(map[string]refInfo) // Load all lab child entries to get unique loinc → abbreviation mappings entries, err := lib.EntryQuery(dossierID, lib.CategoryLab, "") if err != nil { return "{}" } // Build loinc → abbreviation + si_factor map from entries type testInfo struct { abbr string siFactor float64 } loincMap := make(map[string]testInfo) // loinc → {abbr, siFactor} for _, e := range entries { if e.ParentID == "" { continue } var data struct { Loinc string `json:"loinc"` Abbr string `json:"abbreviation"` SIFactor float64 `json:"si_factor"` } if json.Unmarshal([]byte(e.Data), &data) != nil || data.Loinc == "" || data.Abbr == "" { continue } if _, exists := loincMap[data.Loinc]; !exists { factor := data.SIFactor if factor == 0 { factor = 1.0 } loincMap[data.Loinc] = testInfo{abbr: data.Abbr, siFactor: factor} } } // Patient's current age in days (for reference range lookup) ageDays := int64(0) if !dob.IsZero() { ageDays = lib.AgeDays(dob.Unix(), time.Now().Unix()) } // Convert sex int to CALIPER format sexStr := "" switch sex { case 1: sexStr = "M" case 2: sexStr = "F" } // For each unique loinc, look up lab_test (direction) and lab_reference (ranges) for loinc, info := range loincMap { test, err := lib.LabTestGet(loinc) if err != nil || test == nil { continue } ref, err := lib.LabRefLookup(loinc, sexStr, ageDays) if err != nil || ref == nil { continue } // Convert SI reference values back to original unit for chart display low := lib.FromLabScale(ref.RefLow) / info.siFactor high := lib.FromLabScale(ref.RefHigh) / info.siFactor result[info.abbr] = refInfo{ Direction: test.Direction, RefLow: low, RefHigh: high, } } b, _ := json.Marshal(result) return string(b) } // 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(lib.SystemAccessorID, "", lib.CategoryGenome, &lib.EntryFilter{DossierID: targetID, Limit: 1}) hasGenome := len(genomeEntries) > 0 // Build sections sections := BuildDossierSections(targetID, targetHex, target, p, lang, canEdit) // Build lab reference data for charts labRefJSON := template.JS(buildLabRefData(targetID, target.DOB, target.Sex)) labSearchJSON := template.JS(buildLabSearchIndex()) loincNameJSON := template.JS(buildLoincNameMap()) 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, LabRefJSON: labRefJSON, LabSearchJSON: labSearchJSON, LoincNameJSON: loincNameJSON, }) }