1153 lines
36 KiB
Go
1153 lines
36 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"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
|
|
// 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", 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/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 = true
|
|
if len(orders) == 0 {
|
|
section.Summary = T("no_lab_data")
|
|
} else {
|
|
section.Summary = fmt.Sprintf("%d orders", len(orders))
|
|
for _, order := range orders {
|
|
item := SectionItem{
|
|
ID: order.EntryID,
|
|
Label: order.Value,
|
|
Expandable: true,
|
|
}
|
|
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 {
|
|
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 item.Date == "" && order.Timestamp > 0 {
|
|
item.Date = time.Unix(order.Timestamp, 0).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.Summary = fmt.Sprintf("%d", 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", 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", len(entries))
|
|
|
|
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":
|
|
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)
|
|
section.Summary = fmt.Sprintf("%d", len(entries))
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
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"`
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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).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,
|
|
}
|
|
|
|
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).Format("20060102")
|
|
}
|
|
|
|
// Parse Data to build expandable children
|
|
if e.Data != "" {
|
|
var dataMap map[string]interface{}
|
|
if json.Unmarshal([]byte(e.Data), &dataMap) == nil {
|
|
// 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
|
|
}
|
|
item.Children = append(item.Children, SectionItem{
|
|
Label: k,
|
|
Value: val,
|
|
})
|
|
}
|
|
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,
|
|
})
|
|
}
|
|
|
|
// 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 tests []lib.LabTest
|
|
lib.RefQuery("SELECT loinc_id, name FROM lab_test", nil, &tests)
|
|
searchIndex := make(map[string][]string)
|
|
for _, test := range tests {
|
|
name := strings.ToLower(test.Name)
|
|
if !contains(searchIndex[name], test.LoincID) {
|
|
searchIndex[name] = append(searchIndex[name], test.LoincID)
|
|
}
|
|
for _, word := range strings.Fields(name) {
|
|
word = strings.Trim(word, "()")
|
|
if len(word) >= 3 && !contains(searchIndex[word], test.LoincID) {
|
|
searchIndex[word] = append(searchIndex[word], test.LoincID)
|
|
}
|
|
}
|
|
}
|
|
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).Format("20060102")
|
|
}
|
|
matchedOrders = append(matchedOrders, oj)
|
|
}
|
|
|
|
// LOINC name map
|
|
loincNameMap := make(map[string]string)
|
|
for _, t := range tests {
|
|
if matchLoincs[t.LoincID] { loincNameMap[t.LoincID] = t.Name }
|
|
}
|
|
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})
|
|
}
|