package main import ( "encoding/json" "fmt" "net/http" "sort" "strconv" "strings" "time" "inou/lib" ) // 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 ChartData string // JSON chart data (vitals) // Checkin-specific: show "build your profile" prompt ShowBuildTracker 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 SourceSpansJSON string // JSON-encoded source spans for doc pane highlighting } // 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: "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", HideEmpty: true}, {ID: "privacy", HeadingKey: "section_privacy", Color: "64748b"}, } type chartRef struct { RefLow float64 `json:"refLow"` RefHigh float64 `json:"refHigh"` Direction string `json:"direction,omitempty"` } // vitalRef returns US reference range for a body composition metric by sex. // Sex: 1=male, 2=female (ISO 5218). Returns nil if no reference data. // Direction: "higher_better" = only lower bound matters, "lower_better" = only upper bound, "" = both. func vitalRef(metricType string, sex int) *chartRef { type ref struct{ low, high float64; dir string } // US reference ranges: [male, female] // Sources: WHO (BMI), ACE/ACSM (body fat), Tanita (visceral fat) ranges := map[string][2]ref{ "bmi": {{18.5, 24.9, ""}, {18.5, 24.9, ""}}, "body_fat": {{10, 22, ""}, {20, 33, ""}}, "visceral_fat": {{0, 12, "lower_better"}, {0, 12, "lower_better"}}, "subcutaneous_fat": {{0, 19, "lower_better"}, {0, 28, "lower_better"}}, "water": {{50, 0, "higher_better"}, {45, 0, "higher_better"}}, "muscle": {{33, 0, "higher_better"}, {24, 0, "higher_better"}}, "skeletal_muscle": {{33, 0, "higher_better"}, {24, 0, "higher_better"}}, "bone": {{2.5, 0, "higher_better"}, {1.8, 0, "higher_better"}}, "protein": {{16, 0, "higher_better"}, {16, 0, "higher_better"}}, } r, ok := ranges[metricType] if !ok { return nil } idx := 0 if sex == 2 { idx = 1 } return &chartRef{RefLow: r[idx].low, RefHigh: r[idx].high, Direction: r[idx].dir} } // 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/trackers", 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.ShowBuildTracker = true section.Summary = T("checkin_build_profile") promptsURL := fmt.Sprintf("/dossier/%s/trackers", 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": orders, _ := lib.EntryQueryOld(targetID, lib.CategoryLab, "lab_order") sort.Slice(orders, func(i, j int) bool { return orders[i].Timestamp > orders[j].Timestamp }) section.Searchable = len(orders) > 0 if len(orders) == 0 { section.Summary = T("no_lab_data") } else { section.Heading = pluralT(len(orders), cfg.HeadingKey, lang) for _, order := range orders { item := SectionItem{ ID: order.EntryID, Label: order.Value, Expandable: true, } var odata struct { LocalTime string `json:"local_time"` SummaryTranslated string `json:"summary_translated"` } if json.Unmarshal([]byte(order.Data), &odata) == nil { if odata.LocalTime != "" { if t, err := time.Parse(time.RFC3339, odata.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)) } } } if odata.SummaryTranslated != "" { item.Meta = odata.SummaryTranslated } } if item.Date == "" && order.Timestamp > 0 { item.Date = time.Unix(order.Timestamp, 0).UTC().Format("20060102") } section.Items = append(section.Items, item) } } case "documents": entries, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryDocument, &lib.EntryFilter{DossierID: targetID, Limit: 50}) section.Items = docEntriesToSectionItems(entries) section.Heading = pluralT(len(entries), cfg.HeadingKey, lang) case "procedures": entries, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategorySurgery, &lib.EntryFilter{DossierID: targetID, Limit: 50}) section.Items = entriesToSectionItems(entries) section.Heading = pluralT(len(entries), cfg.HeadingKey, lang) case "assessments": entries, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryAssessment, &lib.EntryFilter{DossierID: targetID, Limit: 50}) section.Items = entriesToSectionItems(entries) section.Heading = pluralT(len(entries), cfg.HeadingKey, lang) case "genetics": genomeEntries, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryGenome, &lib.EntryFilter{DossierID: targetID, Limit: 1}) if len(genomeEntries) > 0 { section.Summary = "Loading..." } else { section.Dynamic = false // no data: let HideEmpty hide the section } // Items loaded dynamically via JS case "vitals": // Load group containers (depth 2) — each is a metric type groups, _ := lib.EntryRead(lib.SystemAccessorID, targetID, &lib.Filter{Category: lib.CategoryVital, Type: "root"}) if len(groups) > 0 { metrics, _ := lib.EntryRead(lib.SystemAccessorID, targetID, &lib.Filter{Category: lib.CategoryVital, ParentID: groups[0].EntryID}) type chartPoint struct { Date int64 `json:"date"` // unix seconds Val float64 `json:"val"` } type chartMetric struct { Name string `json:"name"` Type string `json:"type"` Unit string `json:"unit"` Points []chartPoint `json:"points"` Ref *chartRef `json:"ref,omitempty"` } var chartMetrics []chartMetric for _, g := range metrics { readings, _ := lib.EntryRead(lib.SystemAccessorID, targetID, &lib.Filter{ Category: lib.CategoryVital, Type: "reading", ParentID: g.EntryID, }) latest := "" latestDate := "" var points []chartPoint unit := "" for _, r := range readings { if r.Timestamp > 0 { // Parse numeric value from summary like "94.5 kg" parts := strings.SplitN(r.Summary, " ", 2) if v, err := strconv.ParseFloat(parts[0], 64); err == nil { points = append(points, chartPoint{Date: r.Timestamp, Val: v}) if unit == "" && len(parts) > 1 { unit = parts[1] } } } latest = r.Summary if r.Timestamp > 0 { latestDate = time.Unix(r.Timestamp, 0).UTC().Format("2006-01-02") } } section.Items = append(section.Items, SectionItem{ ID: g.EntryID, Label: g.Summary, Value: latest, Date: latestDate, }) if len(points) > 0 { cm := chartMetric{Name: g.Summary, Type: g.Type, Unit: unit, Points: points} cm.Ref = vitalRef(g.Type, target.Sex) chartMetrics = append(chartMetrics, cm) } } section.Summary = fmt.Sprintf("%d metrics", len(metrics)) if len(chartMetrics) > 0 { if b, err := json.Marshal(chartMetrics); err == nil { section.ChartData = string(b) } } } 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) section.Heading = pluralT(len(entries), cfg.HeadingKey, lang) } } // Skip empty sections if configured to hide if section.HideEmpty && len(section.Items) == 0 && !section.Dynamic && !section.ComingSoon && section.ID != "checkin" { 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) if !strings.Contains(s, "%d") { if n > 1 { return fmt.Sprintf("%d %s", n, s) } return s } 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.EntryQueryOld(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"` SummaryTranslated string `json:"summary_translated"` } if json.Unmarshal([]byte(order.Data), &data) == nil { if 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) } } if data.SummaryTranslated != "" { item.Meta = data.SummaryTranslated } } // Fallback: if date still not set, use timestamp if item.Date == "" && order.Timestamp > 0 { t := time.Unix(order.Timestamp, 0).UTC() 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 { var childData struct { Loinc string `json:"loinc"` SummaryTranslated string `json:"summary_translated"` } json.Unmarshal([]byte(c.Data), &childData) child := SectionItem{ Label: c.Summary, Type: childData.Loinc, Meta: childData.SummaryTranslated, } 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).UTC() 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 } // docEntriesToSectionItems converts document entries to section items with preview links. func docEntriesToSectionItems(entries []*lib.Entry) []SectionItem { var items []SectionItem for _, e := range entries { if e == nil { continue } item := SectionItem{ ID: e.EntryID, Label: e.Value, Type: e.Type, LinkURL: e.EntryID, LinkTitle: "source", } if e.Timestamp > 0 { item.Date = time.Unix(e.Timestamp, 0).UTC().Format("20060102") } items = append(items, item) } return items } // entriesToSectionItems converts Entry slice to SectionItem slice. // Entries with Data fields become expandable with details as children. func entriesToSectionItems(entries []*lib.Entry) []SectionItem { // Internal Data keys that shouldn't be shown to the user skipKeys := map[string]bool{ "source_doc_id": true, "source": true, "source_spans": true, "summary_translated": true, "data_translated": true, } var items []SectionItem for _, e := range entries { if e == nil { continue } // Use Summary as label when Value is empty (common for doc extracts) label := e.Value if label == "" { label = e.Summary } item := SectionItem{ ID: e.EntryID, Label: label, Type: e.Type, } if e.Timestamp > 0 { item.Date = time.Unix(e.Timestamp, 0).UTC().Format("20060102") } // Parse Data to build expandable children if e.Data != "" { var dataMap map[string]interface{} if json.Unmarshal([]byte(e.Data), &dataMap) == nil { // Use translated data values when available translated, _ := dataMap["data_translated"].(map[string]interface{}) // Collect keys in deterministic order: preferred fields first, then alphabetical var keys []string for k := range dataMap { if !skipKeys[k] { keys = append(keys, k) } } sort.Slice(keys, func(i, j int) bool { pi, pj := dataFieldPriority(keys[i]), dataFieldPriority(keys[j]) if pi != pj { return pi < pj } return keys[i] < keys[j] }) for _, k := range keys { val := formatDataValue(k, dataMap[k]) if val == "" { continue } child := SectionItem{Label: k, Value: val} if translated != nil { if tv := formatDataValue(k, translated[k]); tv != "" && tv != val { child.Meta = tv } } item.Children = append(item.Children, child) } if len(item.Children) > 0 { item.Expandable = true } // Link to source document if docID, ok := dataMap["source_doc_id"].(string); ok && docID != "" { item.LinkURL = docID item.LinkTitle = "source" } // Source spans for doc pane highlighting if spans, ok := dataMap["source_spans"]; ok { if b, err := json.Marshal(spans); err == nil { item.SourceSpansJSON = string(b) } } // Show translation as secondary text if tr, ok := dataMap["summary_translated"].(string); ok && tr != "" { item.Meta = tr } } } items = append(items, item) } return items } // dataFieldPriority returns sort priority for data field keys (lower = first). func dataFieldPriority(key string) int { order := map[string]int{ "name": 1, "role": 2, "specialty": 3, "institution": 4, "procedure": 5, "diagnosis": 5, "condition": 5, "therapy": 5, "facility": 6, "surgeon": 6, "provider": 6, "date": 7, "frequency": 8, "duration": 8, "details": 10, "description": 10, "notes": 10, "phone": 11, "address": 12, } if p, ok := order[key]; ok { return p } return 9 // unlisted keys go before details/address } // formatDataValue renders a Data field value as a display string. func formatDataValue(key string, v interface{}) string { switch val := v.(type) { case string: return val case float64: if val == float64(int(val)) { return fmt.Sprintf("%d", int(val)) } return fmt.Sprintf("%g", val) case bool: if val { return "yes" } return "no" case map[string]interface{}: // Flatten nested objects (e.g. settings: {pressure: "5 cmH₂O"}) var parts []string for k, sv := range val { s := formatDataValue(k, sv) if s != "" { parts = append(parts, k+": "+s) } } return strings.Join(parts, ", ") default: return "" } } // 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.RefQuery("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.RefQuery("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 entries, err2 := lib.LabEntryListForIndex() if err2 == 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.EntryQueryOld(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)) } // handleDocumentView returns the markdown content of a document entry. // GET /dossier/{dossierID}/document/{docID} func handleDocumentView(w http.ResponseWriter, r *http.Request) { p := getLoggedInDossier(r) if p == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } parts := strings.Split(r.URL.Path, "/") // /dossier/{id}/document/{docID} → parts[2]=id, parts[4]=docID if len(parts) < 5 { http.NotFound(w, r) return } targetID := parts[2] docID := parts[4] // RBAC check if _, err := lib.DossierGet(p.DossierID, targetID); err != nil { http.Error(w, "Forbidden", http.StatusForbidden) return } entry, err := lib.EntryGet(nil, docID) if err != nil || entry.DossierID != targetID || entry.Category != lib.CategoryDocument { http.NotFound(w, r) return } // Serve original PDF if ?pdf=1 if r.URL.Query().Get("pdf") == "1" { var docData struct { SourceUpload string `json:"source_upload"` } json.Unmarshal([]byte(entry.Data), &docData) if docData.SourceUpload == "" { http.Error(w, "No PDF available", http.StatusNotFound) return } uploadEntry, err := lib.EntryGet(nil, docData.SourceUpload) if err != nil { http.Error(w, "Upload not found", http.StatusNotFound) return } var uploadData struct { Path string `json:"path"` } json.Unmarshal([]byte(uploadEntry.Data), &uploadData) if uploadData.Path == "" { http.Error(w, "No file path", http.StatusNotFound) return } pdfBytes, err := lib.DecryptFile(uploadData.Path) if err != nil { http.Error(w, "Decrypt failed", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/pdf") w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, entry.Value)) w.Write(pdfBytes) return } var data struct { Markdown string `json:"markdown"` MarkdownTranslated string `json:"markdown_translated"` TranslatedTo string `json:"translated_to"` SourceUpload string `json:"source_upload"` } json.Unmarshal([]byte(entry.Data), &data) resp := map[string]interface{}{ "markdown": data.Markdown, "title": entry.Value, "has_pdf": data.SourceUpload != "", } if data.MarkdownTranslated != "" { resp["markdown_translated"] = data.MarkdownTranslated resp["translated_to"] = data.TranslatedTo } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } // 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) // RBAC-checked read target, err := lib.DossierGet(p.DossierID, targetID) if err != nil { http.Error(w, "Forbidden", http.StatusForbidden); return } isSelf := targetID == p.DossierID canEdit := isSelf if !isSelf { if access, found := getAccess(formatHexID(p.DossierID), targetHex); found { canEdit = access.CanEdit } } lang := getLang(r) showDetails := isSelf canManageAccess := isSelf // 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) 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, }) } // handleLabCommentary generates AI commentary for lab trend charts. // POST /dossier/{id}/labs/commentary // Body: {"series":[{"name":"Cholesterol","abbr":"CHOL","unit":"mg/dL","points":[{"date":"2024-01-15","val":210},...],"refLow":0,"refHigh":200,"direction":"lower_better"}]} // Returns: {"commentary":{"CHOL":"Cholesterol dropped 15% from 210 to 180 mg/dL since January — now within normal range.",...}} func handleLabCommentary(w http.ResponseWriter, r *http.Request) { p := getLoggedInDossier(r) if p == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized); return } if r.Method != "POST" { http.Error(w, "Method not allowed", 405); return } if lib.AnthropicKey == "" { http.Error(w, "AI commentary unavailable", 503) return } var body struct { Series []struct { Name string `json:"name"` Abbr string `json:"abbr"` Unit string `json:"unit"` Direction string `json:"direction"` RefLow float64 `json:"refLow"` RefHigh float64 `json:"refHigh"` Points []struct { Date string `json:"date"` Val float64 `json:"val"` } `json:"points"` } `json:"series"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.Series) == 0 { http.Error(w, "Invalid request", 400) return } // Build compact prompt var sb strings.Builder sb.WriteString("You are a medical data analyst. For each lab metric below, write ONE concise sentence (max 20 words) describing the trend for a patient. Focus on: direction (up/down/stable), magnitude (% or absolute change), and whether it's moving toward or away from the normal range. Use plain language, no jargon. Do NOT give medical advice or diagnoses.\n\n") sb.WriteString("Format your response as JSON: {\"ABBR\": \"sentence\", ...}\n\n") sb.WriteString("Metrics:\n") for _, s := range body.Series { if len(s.Points) < 2 { continue } first := s.Points[0] last := s.Points[len(s.Points)-1] pct := 0.0 if first.Val != 0 { pct = (last.Val - first.Val) / first.Val * 100 } var refStr string if s.RefHigh > 0 || s.RefLow > 0 { refStr = fmt.Sprintf(", normal range: %.1f–%.1f %s", s.RefLow, s.RefHigh, s.Unit) } sb.WriteString(fmt.Sprintf("- %s (%s): %.1f→%.1f %s (%+.0f%%) from %s to %s%s\n", s.Name, s.Abbr, first.Val, last.Val, s.Unit, pct, first.Date, last.Date, refStr)) } reqBody, _ := json.Marshal(map[string]interface{}{ "model": "claude-haiku-4-5", "max_tokens": 512, "messages": []map[string]interface{}{ {"role": "user", "content": sb.String()}, }, }) req, err := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", strings.NewReader(string(reqBody))) if err != nil { http.Error(w, "Request error", 500); return } req.Header.Set("Content-Type", "application/json") req.Header.Set("x-api-key", lib.AnthropicKey) req.Header.Set("anthropic-version", "2023-06-01") resp, err := http.DefaultClient.Do(req) if err != nil { http.Error(w, "API error", 502); return } defer resp.Body.Close() var result struct { Content []struct{ Text string `json:"text"` } `json:"content"` Error struct{ Message string `json:"message"` } `json:"error"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || len(result.Content) == 0 { http.Error(w, "Bad API response", 502) return } if resp.StatusCode != 200 { http.Error(w, "API error: "+result.Error.Message, 502) return } // Parse the JSON response from Claude (it may be wrapped in markdown code fence) raw := strings.TrimSpace(result.Content[0].Text) raw = strings.TrimPrefix(raw, "```json") raw = strings.TrimPrefix(raw, "```") raw = strings.TrimSuffix(raw, "```") raw = strings.TrimSpace(raw) var commentary map[string]string if err := json.Unmarshal([]byte(raw), &commentary); err != nil { // Return the raw text as a fallback under "_raw" w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"commentary": map[string]string{"_raw": raw}}) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"commentary": commentary}) } // handleLabSearch serves lab data as JSON. // GET /dossier/{id}/labs?order={entryID} — children for one order (expand) // GET /dossier/{id}/labs?q=sodium — search across all orders func handleLabSearch(w http.ResponseWriter, r *http.Request) { p := getLoggedInDossier(r) if p == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized); return } parts := strings.Split(r.URL.Path, "/") if len(parts) < 3 { http.NotFound(w, r); return } targetID := parts[2] target, err := lib.DossierGet(p.DossierID, targetID) if err != nil { http.Error(w, "Forbidden", http.StatusForbidden); return } type childJSON struct { Label string `json:"label"` Loinc string `json:"loinc"` } type refInfo struct { Direction string `json:"direction"` RefLow float64 `json:"refLow"` RefHigh float64 `json:"refHigh"` } type abbrInfo struct { abbr string siFactor float64 } // Shared: build ref data from loinc→abbr map buildRefs := func(loincAbbrs map[string]abbrInfo) map[string]refInfo { refs := make(map[string]refInfo) ageDays := int64(0) if !target.DOB.IsZero() { ageDays = lib.AgeDays(target.DOB.Unix(), time.Now().Unix()) } sexStr := "" switch target.Sex { case 1: sexStr = "M" case 2: sexStr = "F" } for loinc, info := range loincAbbrs { test, err := lib.LabTestGet(loinc) if err != nil || test == nil { continue } ref, err := lib.LabRefLookup(loinc, sexStr, ageDays) if err != nil || ref == nil { continue } low := lib.FromLabScale(ref.RefLow) / info.siFactor high := lib.FromLabScale(ref.RefHigh) / info.siFactor refs[info.abbr] = refInfo{Direction: test.Direction, RefLow: low, RefHigh: high} } return refs } // Shared: extract child JSON + track loinc abbreviations childToJSON := func(entries []*lib.Entry, loincAbbrs map[string]abbrInfo) []childJSON { var out []childJSON for _, c := range entries { var data struct { Loinc string `json:"loinc"` Abbr string `json:"abbreviation"` SIF float64 `json:"si_factor"` } json.Unmarshal([]byte(c.Data), &data) out = append(out, childJSON{Label: c.Summary, Loinc: data.Loinc}) if data.Loinc != "" && data.Abbr != "" { if _, exists := loincAbbrs[data.Loinc]; !exists { f := data.SIF if f == 0 { f = 1.0 } loincAbbrs[data.Loinc] = abbrInfo{abbr: data.Abbr, siFactor: f} } } } return out } w.Header().Set("Content-Type", "application/json") // Mode 1: expand a single order if orderID := r.URL.Query().Get("order"); orderID != "" { children, _ := lib.EntryChildren(targetID, orderID) loincAbbrs := make(map[string]abbrInfo) childrenOut := childToJSON(children, loincAbbrs) if childrenOut == nil { childrenOut = []childJSON{} } json.NewEncoder(w).Encode(struct { Children []childJSON `json:"children"` Refs map[string]refInfo `json:"refs"` }{Children: childrenOut, Refs: buildRefs(loincAbbrs)}) return } // Mode 2: search q := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q"))) if len(q) < 2 { w.Write([]byte(`{"orders":[],"refs":{}}`)) return } // Build search index: term → []loinc var loincEntries []lib.LoincInfo lib.RefQuery("SELECT loinc_num, long_name, short_name, component, system, property FROM loinc_lab", nil, &loincEntries) searchIndex := make(map[string][]string) for _, l := range loincEntries { // Index by long_name words and component words for _, src := range []string{l.LongName, l.Component} { for _, word := range strings.Fields(strings.ToLower(src)) { word = strings.Trim(word, "()[].,/") if len(word) >= 3 && !contains(searchIndex[word], l.Code) { searchIndex[word] = append(searchIndex[word], l.Code) } } } } matchLoincs := make(map[string]bool) for term, loincs := range searchIndex { if strings.Contains(term, q) { for _, l := range loincs { matchLoincs[l] = true } } } type orderJSON struct { ID string `json:"id"` Name string `json:"name"` Date string `json:"date"` Time string `json:"time"` Count int `json:"count"` Children []childJSON `json:"children"` } orders, _ := lib.EntryQueryOld(targetID, lib.CategoryLab, "lab_order") var matchedOrders []orderJSON loincAbbrs := make(map[string]abbrInfo) for _, order := range orders { children, _ := lib.EntryChildren(targetID, order.EntryID) orderNameMatch := strings.Contains(strings.ToLower(order.Value), q) var matched []childJSON for _, c := range children { var data struct { Loinc string `json:"loinc"` Abbr string `json:"abbreviation"` SIF float64 `json:"si_factor"` } json.Unmarshal([]byte(c.Data), &data) textMatch := strings.Contains(strings.ToLower(c.Summary), q) loincMatch := data.Loinc != "" && matchLoincs[data.Loinc] if orderNameMatch || textMatch || loincMatch { matched = append(matched, childJSON{Label: c.Summary, Loinc: data.Loinc}) if data.Loinc != "" && data.Abbr != "" { if _, exists := loincAbbrs[data.Loinc]; !exists { f := data.SIF if f == 0 { f = 1.0 } loincAbbrs[data.Loinc] = abbrInfo{abbr: data.Abbr, siFactor: f} } } } } if len(matched) == 0 { continue } oj := orderJSON{ID: order.EntryID, Name: order.Value, Count: len(matched), Children: matched} var odata struct{ LocalTime string `json:"local_time"` } if json.Unmarshal([]byte(order.Data), &odata) == nil && odata.LocalTime != "" { if t, err := time.Parse(time.RFC3339, odata.LocalTime); err == nil { oj.Date = t.Format("20060102") if t.Hour() != 0 || t.Minute() != 0 { _, offset := t.Zone() oj.Time = fmt.Sprintf("%02d:%02d %s", t.Hour(), t.Minute(), offsetToTZName(offset)) } } } if oj.Date == "" && order.Timestamp > 0 { oj.Date = time.Unix(order.Timestamp, 0).UTC().Format("20060102") } matchedOrders = append(matchedOrders, oj) } // LOINC name map — use official long_name from loinc_lab loincNameMap := make(map[string]string) for _, l := range loincEntries { if matchLoincs[l.Code] { loincNameMap[l.Code] = l.LongName } } if matchedOrders == nil { matchedOrders = []orderJSON{} } json.NewEncoder(w).Encode(struct { Orders []orderJSON `json:"orders"` Refs map[string]refInfo `json:"refs"` LoincNames map[string]string `json:"loincNames"` }{Orders: matchedOrders, Refs: buildRefs(loincAbbrs), LoincNames: loincNameMap}) }