inou/portal/dossier_sections.go

757 lines
24 KiB
Go

package main
import (
"encoding/json"
"fmt"
"html/template"
"net/http"
"strings"
"time"
"inou/lib"
"path/filepath"
"os"
)
// DossierSection represents a unified section block on the dossier page
type DossierSection struct {
ID string // "imaging", "labs", "genetics", etc.
Icon string // emoji or icon identifier
Color string // hex color for indicator (without #)
HeadingKey string // translation key for heading
Heading string // resolved heading text
Summary string // summary line
ActionURL string // optional button URL
ActionLabel string // optional button label (translation key resolved)
Items []SectionItem // the actual data rows
HideEmpty bool // hide entire section when no items
ComingSoon bool // show as coming soon
Dynamic bool // loaded via JS (like genetics)
DynamicType string // "genetics" for special handling
CustomHTML string // for completely custom sections (privacy)
Searchable bool // show search/filter box in header
// Checkin-specific: show "build your profile" prompt
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
}
// SectionConfig defines how to build a section for a category
type SectionConfig struct {
ID string
Category int
Color string
HeadingKey string
HideEmpty bool
ComingSoon bool
Dynamic bool
DynamicType string
}
// Standard section configurations
var sectionConfigs = []SectionConfig{
{ID: "checkin", HeadingKey: "section_checkin", Color: "B45309"},
{ID: "imaging", Category: lib.CategoryImaging, Color: "B45309", HeadingKey: "section_imaging", HideEmpty: false},
{ID: "labs", Category: lib.CategoryLab, Color: "059669", HeadingKey: "section_labs", HideEmpty: false},
{ID: "documents", Category: lib.CategoryDocument, Color: "06b6d4", HeadingKey: "section_records", HideEmpty: true},
{ID: "procedures", Category: lib.CategorySurgery, Color: "DC2626", HeadingKey: "section_procedures", HideEmpty: true},
{ID: "assessments", Category: lib.CategoryAssessment, Color: "7C3AED", HeadingKey: "section_assessments", HideEmpty: true},
{ID: "genetics", Category: lib.CategoryGenome, Color: "8B5CF6", HeadingKey: "section_genetics", HideEmpty: true, Dynamic: true, DynamicType: "genetics"},
{ID: "uploads", Color: "6366f1", HeadingKey: "section_uploads", HideEmpty: false},
{ID: "medications", Category: lib.CategoryMedication, Color: "8b5cf6", HeadingKey: "section_medications", HideEmpty: true},
{ID: "supplements", Category: lib.CategorySupplement, Color: "8b5cf6", HeadingKey: "section_supplements", HideEmpty: true},
{ID: "symptoms", Category: lib.CategorySymptom, Color: "F59E0B", HeadingKey: "section_symptoms", HideEmpty: true},
{ID: "hospitalizations", Category: lib.CategoryHospital, Color: "EF4444", HeadingKey: "section_hospitalizations", HideEmpty: true},
{ID: "therapies", Category: lib.CategoryTherapy, Color: "10B981", HeadingKey: "section_therapies", HideEmpty: true},
{ID: "consultations", Category: lib.CategoryConsultation, Color: "3B82F6", HeadingKey: "section_consultations", HideEmpty: true},
{ID: "diagnoses", Category: lib.CategoryDiagnosis, Color: "EF4444", HeadingKey: "section_diagnoses", HideEmpty: true},
{ID: "exercise", Category: lib.CategoryExercise, Color: "22C55E", HeadingKey: "section_exercise", HideEmpty: true},
{ID: "nutrition", Category: lib.CategoryNutrition, Color: "F97316", HeadingKey: "section_nutrition", HideEmpty: true},
{ID: "fertility", Category: lib.CategoryFertility, Color: "EC4899", HeadingKey: "section_fertility", HideEmpty: true},
{ID: "notes", Category: lib.CategoryNote, Color: "6B7280", HeadingKey: "section_notes", HideEmpty: true},
{ID: "history", Category: lib.CategoryHistory, Color: "6B7280", HeadingKey: "section_history", HideEmpty: true},
{ID: "family_history", Category: lib.CategoryFamilyHistory, Color: "6B7280", HeadingKey: "section_family_history", HideEmpty: true},
{ID: "birth", Category: lib.CategoryBirth, Color: "EC4899", HeadingKey: "section_birth", HideEmpty: true},
{ID: "devices", Category: lib.CategoryDevice, Color: "6366F1", HeadingKey: "section_devices", HideEmpty: true},
{ID: "providers", Category: lib.CategoryProvider, Color: "0EA5E9", HeadingKey: "section_providers", HideEmpty: true},
{ID: "questions", Category: lib.CategoryQuestion, Color: "8B5CF6", HeadingKey: "section_questions", HideEmpty: true},
{ID: "vitals", Category: lib.CategoryVital, Color: "ec4899", HeadingKey: "section_vitals", ComingSoon: true},
{ID: "privacy", HeadingKey: "section_privacy", Color: "64748b"},
}
// BuildDossierSections builds all sections for a dossier
func BuildDossierSections(targetID, targetHex string, target *lib.Dossier, p *lib.Dossier, lang string, canEdit bool) []DossierSection {
T := func(key string) string { return translations[lang][key] }
var sections []DossierSection
for _, cfg := range sectionConfigs {
section := DossierSection{
ID: cfg.ID,
Color: cfg.Color,
HeadingKey: cfg.HeadingKey,
Heading: T(cfg.HeadingKey),
HideEmpty: cfg.HideEmpty,
ComingSoon: cfg.ComingSoon,
Dynamic: cfg.Dynamic,
DynamicType: cfg.DynamicType,
}
switch cfg.ID {
case "checkin":
section.ActionURL = fmt.Sprintf("/dossier/%s/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":
section.Items, section.Summary = buildLabItems(targetID, lang, T)
section.Searchable = len(section.Items) > 5
case "documents":
entries, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryDocument, &lib.EntryFilter{DossierID: targetID, Limit: 50})
section.Items = entriesToSectionItems(entries)
section.Summary = fmt.Sprintf("%d documents", len(entries))
case "procedures":
entries, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategorySurgery, &lib.EntryFilter{DossierID: targetID, Limit: 50})
section.Items = entriesToSectionItems(entries)
section.Summary = fmt.Sprintf("%d procedures", len(entries))
case "assessments":
entries, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryAssessment, &lib.EntryFilter{DossierID: targetID, Limit: 50})
section.Items = entriesToSectionItems(entries)
section.Summary = fmt.Sprintf("%d assessments", len(entries))
case "genetics":
genomeEntries, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryGenome, &lib.EntryFilter{DossierID: targetID, Limit: 1})
if len(genomeEntries) > 0 {
section.Summary = "Loading..."
}
// Items loaded dynamically via JS
case "uploads":
uploadDir := filepath.Join(uploadsDir, targetHex)
var uploadCount int
var uploadSize int64
filepath.Walk(uploadDir, func(path string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() { uploadCount++; uploadSize += info.Size() }
return nil
})
if uploadCount > 0 {
section.Summary = fmt.Sprintf("%d files, %s", uploadCount, formatSize(uploadSize))
} else {
section.Summary = T("no_files")
}
if canEdit {
section.ActionURL = fmt.Sprintf("/dossier/%s/upload", targetHex)
section.ActionLabel = T("manage")
}
case "vitals":
section.Summary = T("vitals_desc")
case "privacy":
// Handled separately - needs access list, not entries
continue
default:
// Generic handler for any category with a Category set
if cfg.Category > 0 {
entries, _ := lib.EntryList(lib.SystemAccessorID, "", cfg.Category, &lib.EntryFilter{DossierID: targetID, Limit: 50})
section.Items = entriesToSectionItems(entries)
// Use section ID for summary (e.g., "2 medications" not "2 items")
section.Summary = fmt.Sprintf("%d %s", len(entries), cfg.ID)
}
}
// Skip empty sections if configured to hide
if section.HideEmpty && len(section.Items) == 0 && !section.Dynamic && !section.ComingSoon && section.ID != "checkin" && section.ID != "uploads" {
continue
}
sections = append(sections, section)
}
return sections
}
// pluralT returns a translated plural string like "3 slices" using translation keys.
// Keys: "{key}_one", "{key}_few" (Russian 2-4), "{key}_other".
// Falls back: few->other, missing key->English.
func pluralT(n int, key, lang string) string {
T := func(k string) string {
if t, ok := translations[lang]; ok {
if s, ok := t[k]; ok { return s }
}
if t, ok := translations["en"]; ok {
if s, ok := t[k]; ok { return s }
}
return k
}
form := pluralForm(n, lang)
s := T(key + "_" + form)
return fmt.Sprintf(s, n)
}
func pluralForm(n int, lang string) string {
switch lang {
case "ru":
mod10, mod100 := n%10, n%100
if mod10 == 1 && mod100 != 11 {
return "one"
}
if mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) {
return "few"
}
return "other"
default:
if n == 1 {
return "one"
}
return "other"
}
}
func offsetToTZName(offset int) string {
switch offset {
case -4 * 3600:
return "EDT"
case -5 * 3600:
return "EST"
case -6 * 3600:
return "CST"
case -7 * 3600:
return "MST"
case -8 * 3600:
return "PST"
case 0:
return "UTC"
case 1 * 3600:
return "CET"
case 2 * 3600:
return "CEST"
case 3 * 3600:
return "MSK"
default:
h := offset / 3600
m := (offset % 3600) / 60
if m < 0 {
m = -m
}
return fmt.Sprintf("%+03d%02d", h, m)
}
}
// buildImagingItems converts studies to section items
func buildImagingItems(studies []Study, targetHex, dossierID, lang string, T func(string) string) ([]SectionItem, string) {
var items []SectionItem
var totalSlices int
for _, s := range studies {
totalSlices += s.SliceCount
item := SectionItem{
ID: s.ID,
Label: s.Description,
Date: s.Date,
LinkURL: fmt.Sprintf("/viewer/?token=%s&study=%s", dossierID, s.ID),
LinkTitle: T("open_viewer"),
Expandable: s.SeriesCount > 1,
}
// Add series as children
for _, series := range s.Series {
if series.SliceCount > 0 {
label := series.Description
if label == "" {
label = series.Modality
}
child := SectionItem{
ID: series.ID,
Label: label,
Value: pluralT(series.SliceCount, "slice", lang),
LinkURL: fmt.Sprintf("/viewer/?token=%s&study=%s&series=%s", dossierID, s.ID, series.ID),
LinkTitle: T("open_viewer"),
}
item.Children = append(item.Children, child)
}
}
if !item.Expandable {
// Single series - show slice count, no expand
item.Children = nil
item.Value = pluralT(s.SliceCount, "slice", lang)
} else {
item.Value = pluralT(s.SeriesCount, "series", lang)
}
items = append(items, item)
}
var summary string
if len(studies) > 0 {
summary = fmt.Sprintf("%d studies, %d slices", len(studies), totalSlices)
}
return items, summary
}
// buildLabItems creates parent/child lab section items
func buildLabItems(dossierID, lang string, T func(string) string) ([]SectionItem, string) {
// Get lab orders (parents)
orders, _ := lib.EntryQuery(dossierID, lib.CategoryLab, "lab_order")
// Also get standalone lab results (no parent)
allLabs, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryLab, &lib.EntryFilter{DossierID: dossierID, Limit: 5000})
var standalones []*lib.Entry
for _, e := range allLabs {
if e.ParentID == "" && e.Type != "lab_order" {
standalones = append(standalones, e)
}
}
if len(orders) == 0 && len(standalones) == 0 {
return nil, T("no_lab_data")
}
var items []SectionItem
var totalTests int
// Process lab orders with children
for _, order := range orders {
// Get children for this order
children, _ := lib.EntryChildren(dossierID, order.EntryID)
totalTests += len(children)
item := SectionItem{
ID: order.EntryID,
Label: order.Value,
Expandable: len(children) > 0,
}
// Use original local_time from Data JSON if available
var data struct {
LocalTime string `json:"local_time"`
}
if json.Unmarshal([]byte(order.Data), &data) == nil && data.LocalTime != "" {
if t, err := time.Parse(time.RFC3339, data.LocalTime); err == nil {
item.Date = t.Format("20060102")
if t.Hour() != 0 || t.Minute() != 0 {
_, offset := t.Zone()
item.Time = fmt.Sprintf("%02d:%02d %s", t.Hour(), t.Minute(), offsetToTZName(offset))
}
} else {
fmt.Printf("[DEBUG] Failed to parse local_time for %s: %s (err: %v)\n", order.EntryID, data.LocalTime, err)
}
}
// Fallback: if date still not set, use timestamp
if item.Date == "" && order.Timestamp > 0 {
t := time.Unix(order.Timestamp, 0)
item.Date = t.Format("20060102")
fmt.Printf("[DEBUG] Set date from timestamp for %s: %s -> %s\n", order.EntryID, order.Value, item.Date)
}
if item.Date == "" {
fmt.Printf("[DEBUG] No date set for order %s: %s (timestamp: %d, local_time: %s)\n", order.EntryID, order.Value, order.Timestamp, data.LocalTime)
}
if len(children) > 0 {
item.Value = pluralT(len(children), "result", lang)
for _, c := range children {
// Extract LOINC for precise matching
var childData struct {
Loinc string `json:"loinc"`
}
json.Unmarshal([]byte(c.Data), &childData)
child := SectionItem{
Label: c.Summary,
Type: childData.Loinc, // Store LOINC in Type field
}
item.Children = append(item.Children, child)
}
}
items = append(items, item)
}
// Add standalone lab results
for _, standalone := range standalones {
var data struct {
Loinc string `json:"loinc"`
}
json.Unmarshal([]byte(standalone.Data), &data)
item := SectionItem{
ID: standalone.EntryID,
Label: standalone.Summary,
Type: data.Loinc, // Store LOINC for search matching
}
// Set date from timestamp
if standalone.Timestamp > 0 {
t := time.Unix(standalone.Timestamp, 0)
item.Date = t.Format("20060102")
}
items = append(items, item)
totalTests++
}
summary := fmt.Sprintf("%s, %s", pluralT(len(orders), "order", lang), pluralT(totalTests, "result", lang))
return items, summary
}
// entriesToSectionItems converts Entry slice to SectionItem slice
func entriesToSectionItems(entries []*lib.Entry) []SectionItem {
var items []SectionItem
for _, e := range entries {
if e == nil {
continue
}
item := SectionItem{
ID: e.EntryID,
Label: e.Value,
Meta: e.Summary,
Type: e.Type,
}
if e.Timestamp > 0 {
// Convert Unix timestamp to YYYYMMDD
// item.Date = time.Unix(e.Timestamp, 0).Format("20060102")
}
items = append(items, item)
}
return items
}
// buildLoincNameMap builds a JSON map of LOINC code → full test name
// for displaying full names in charts.
func buildLoincNameMap() string {
tests, err := lib.LabTestList()
if 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 {
tests, err := lib.LabTestList()
if 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.EntryQuery(dossierID, lib.CategoryLab, "")
if err != nil {
return "{}"
}
// Build loinc → abbreviation + si_factor map from entries
type testInfo struct {
abbr string
siFactor float64
}
loincMap := make(map[string]testInfo) // loinc → {abbr, siFactor}
for _, e := range entries {
if e.ParentID == "" {
continue
}
var data struct {
Loinc string `json:"loinc"`
Abbr string `json:"abbreviation"`
SIFactor float64 `json:"si_factor"`
}
if json.Unmarshal([]byte(e.Data), &data) != nil || data.Loinc == "" || data.Abbr == "" {
continue
}
if _, exists := loincMap[data.Loinc]; !exists {
factor := data.SIFactor
if factor == 0 {
factor = 1.0
}
loincMap[data.Loinc] = testInfo{abbr: data.Abbr, siFactor: factor}
}
}
// Patient's current age in days (for reference range lookup)
ageDays := int64(0)
if !dob.IsZero() {
ageDays = lib.AgeDays(dob.Unix(), time.Now().Unix())
}
// Convert sex int to CALIPER format
sexStr := ""
switch sex {
case 1:
sexStr = "M"
case 2:
sexStr = "F"
}
// For each unique loinc, look up lab_test (direction) and lab_reference (ranges)
for loinc, info := range loincMap {
test, err := lib.LabTestGet(loinc)
if err != nil || test == nil {
continue
}
ref, err := lib.LabRefLookup(loinc, sexStr, ageDays)
if err != nil || ref == nil {
continue
}
// Convert SI reference values back to original unit for chart display
low := lib.FromLabScale(ref.RefLow) / info.siFactor
high := lib.FromLabScale(ref.RefHigh) / info.siFactor
result[info.abbr] = refInfo{
Direction: test.Direction,
RefLow: low,
RefHigh: high,
}
}
b, _ := json.Marshal(result)
return string(b)
}
// formatSize formats bytes to human readable
func formatSize(bytes int64) string {
if bytes < 1024 {
return fmt.Sprintf("%d B", bytes)
} else if bytes < 1024*1024 {
return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
}
return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
}
// handleDossierV2 renders the new unified dossier page
func handleDossierV2(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
// Parse path: /dossier/{id}/v2
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 3 || parts[2] == "" { http.NotFound(w, r); return }
targetID := parts[2]
targetHex := formatHexID(targetID)
// Check access (same pattern as handleDossier)
isSelf := targetID == p.DossierID
hasAccess := isSelf
var relation int
var isCareReceiver bool
canEdit := isSelf
if !isSelf {
access, found := getAccess(formatHexID(p.DossierID), targetHex)
hasAccess = found
if found {
relation = access.Relation
isCareReceiver = access.IsCareReceiver
canEdit = access.CanEdit
touchAccess(formatHexID(p.DossierID), targetHex)
}
}
if !hasAccess { http.Error(w, "Forbidden", http.StatusForbidden); return }
target, err := lib.DossierGet(nil, targetID)
if err != nil { http.NotFound(w, r); return }
lang := getLang(r)
familyRelations := map[int]bool{1: true, 2: true, 3: true, 4: true, 5: true, 6: true}
showDetails := isSelf || familyRelations[relation]
canManageAccess := isSelf || isCareReceiver
// Build access list (for privacy section)
accessRecords, _ := listAccessors(targetHex)
var accessList []AccessEntry
for _, ar := range accessRecords {
accessList = append(accessList, AccessEntry{
DossierID: ar.Accessor,
Name: ar.Name,
Relation: T(lang, "rel_" + fmt.Sprintf("%d", ar.Relation)),
CanEdit: ar.CanEdit,
IsSelf: ar.Accessor == p.DossierID,
})
}
// Check for genome
genomeEntries, _ := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryGenome, &lib.EntryFilter{DossierID: targetID, Limit: 1})
hasGenome := len(genomeEntries) > 0
// Build sections
sections := BuildDossierSections(targetID, targetHex, target, p, lang, canEdit)
// Build lab reference data for charts
labRefJSON := template.JS(buildLabRefData(targetID, target.DOB, target.Sex))
labSearchJSON := template.JS(buildLabSearchIndex())
loincNameJSON := template.JS(buildLoincNameMap())
render(w, r, PageData{
Page: "dossier",
Lang: lang,
Embed: isEmbed(r),
Dossier: p,
TargetDossier: target,
ShowDetails: showDetails,
CanManageAccess: canManageAccess,
CanEdit: canEdit,
AccessList: accessList,
HasGenome: hasGenome,
Sections: sections,
LabRefJSON: labRefJSON,
LabSearchJSON: labSearchJSON,
LoincNameJSON: loincNameJSON,
})
}