inou/portal/dossier_sections.go

1380 lines
44 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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})
}