398 lines
15 KiB
Go
398 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"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)
|
|
// Checkin-specific: show "build your profile" prompt
|
|
ShowBuildPrompt bool // true if trackable categories are empty
|
|
TrackableStats map[string]int // counts for trackable categories
|
|
PromptButtons []PromptButton // buttons for empty trackable categories
|
|
}
|
|
|
|
// PromptButton for the "build your profile" section
|
|
type PromptButton 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
|
|
Type string
|
|
Value string
|
|
LinkURL string
|
|
LinkTitle string
|
|
Expandable bool
|
|
Expanded bool
|
|
Children []SectionItem
|
|
}
|
|
|
|
// SectionConfig defines how to build a section for a category
|
|
type SectionConfig struct {
|
|
ID string
|
|
Category int
|
|
Color string
|
|
HeadingKey string
|
|
HideEmpty bool
|
|
ComingSoon bool
|
|
Dynamic bool
|
|
DynamicType string
|
|
}
|
|
|
|
// Standard section configurations
|
|
var sectionConfigs = []SectionConfig{
|
|
{ID: "checkin", HeadingKey: "section_checkin", Color: "B45309"},
|
|
{ID: "imaging", Category: lib.CategoryImaging, Color: "B45309", HeadingKey: "section_imaging", HideEmpty: false},
|
|
{ID: "labs", Category: lib.CategoryLab, Color: "059669", HeadingKey: "section_labs", HideEmpty: false},
|
|
{ID: "documents", Category: lib.CategoryDocument, Color: "06b6d4", HeadingKey: "section_records", HideEmpty: true},
|
|
{ID: "procedures", Category: lib.CategorySurgery, Color: "DC2626", HeadingKey: "section_procedures", HideEmpty: true},
|
|
{ID: "assessments", Category: lib.CategoryAssessment, Color: "7C3AED", HeadingKey: "section_assessments", HideEmpty: true},
|
|
{ID: "genetics", Category: lib.CategoryGenome, Color: "8B5CF6", HeadingKey: "section_genetics", HideEmpty: true, Dynamic: true, DynamicType: "genetics"},
|
|
{ID: "uploads", Color: "6366f1", HeadingKey: "section_uploads", HideEmpty: false},
|
|
{ID: "medications", Category: lib.CategoryMedication, Color: "8b5cf6", HeadingKey: "section_medications", HideEmpty: true},
|
|
{ID: "supplements", Category: lib.CategorySupplement, Color: "8b5cf6", HeadingKey: "section_supplements", HideEmpty: true},
|
|
{ID: "symptoms", Category: lib.CategorySymptom, Color: "F59E0B", HeadingKey: "section_symptoms", HideEmpty: true},
|
|
{ID: "hospitalizations", Category: lib.CategoryHospital, Color: "EF4444", HeadingKey: "section_hospitalizations", HideEmpty: true},
|
|
{ID: "therapies", Category: lib.CategoryTherapy, Color: "10B981", HeadingKey: "section_therapies", HideEmpty: true},
|
|
{ID: "consultations", Category: lib.CategoryConsultation, Color: "3B82F6", HeadingKey: "section_consultations", HideEmpty: true},
|
|
{ID: "diagnoses", Category: lib.CategoryDiagnosis, Color: "EF4444", HeadingKey: "section_diagnoses", HideEmpty: true},
|
|
{ID: "exercise", Category: lib.CategoryExercise, Color: "22C55E", HeadingKey: "section_exercise", HideEmpty: true},
|
|
{ID: "nutrition", Category: lib.CategoryNutrition, Color: "F97316", HeadingKey: "section_nutrition", HideEmpty: true},
|
|
{ID: "fertility", Category: lib.CategoryFertility, Color: "EC4899", HeadingKey: "section_fertility", HideEmpty: true},
|
|
{ID: "notes", Category: lib.CategoryNote, Color: "6B7280", HeadingKey: "section_notes", HideEmpty: true},
|
|
{ID: "history", Category: lib.CategoryHistory, Color: "6B7280", HeadingKey: "section_history", HideEmpty: true},
|
|
{ID: "family_history", Category: lib.CategoryFamilyHistory, Color: "6B7280", HeadingKey: "section_family_history", HideEmpty: true},
|
|
{ID: "birth", Category: lib.CategoryBirth, Color: "EC4899", HeadingKey: "section_birth", HideEmpty: true},
|
|
{ID: "devices", Category: lib.CategoryDevice, Color: "6366F1", HeadingKey: "section_devices", HideEmpty: true},
|
|
{ID: "providers", Category: lib.CategoryProvider, Color: "0EA5E9", HeadingKey: "section_providers", HideEmpty: true},
|
|
{ID: "questions", Category: lib.CategoryQuestion, Color: "8B5CF6", HeadingKey: "section_questions", HideEmpty: true},
|
|
{ID: "vitals", Category: lib.CategoryVital, Color: "ec4899", HeadingKey: "section_vitals", ComingSoon: true},
|
|
{ID: "privacy", HeadingKey: "section_privacy", Color: "64748b"},
|
|
}
|
|
|
|
// BuildDossierSections builds all sections for a dossier
|
|
func BuildDossierSections(targetID, targetHex string, target *lib.Dossier, p *lib.Dossier, lang string, canEdit bool) []DossierSection {
|
|
T := func(key string) string { return translations[lang][key] }
|
|
|
|
var sections []DossierSection
|
|
|
|
for _, cfg := range sectionConfigs {
|
|
section := DossierSection{
|
|
ID: cfg.ID,
|
|
Color: cfg.Color,
|
|
HeadingKey: cfg.HeadingKey,
|
|
Heading: T(cfg.HeadingKey),
|
|
HideEmpty: cfg.HideEmpty,
|
|
ComingSoon: cfg.ComingSoon,
|
|
Dynamic: cfg.Dynamic,
|
|
DynamicType: cfg.DynamicType,
|
|
}
|
|
|
|
switch cfg.ID {
|
|
case "checkin":
|
|
section.ActionURL = fmt.Sprintf("/dossier/%s/prompts", targetHex)
|
|
section.ActionLabel = T("open")
|
|
|
|
// Count trackable categories
|
|
stats := make(map[string]int)
|
|
vitals, _ := lib.EntryList(nil, "", lib.CategoryVital, &lib.EntryFilter{DossierID: targetID, Limit: 1})
|
|
meds, _ := lib.EntryList(nil, "", lib.CategoryMedication, &lib.EntryFilter{DossierID: targetID, Limit: 1})
|
|
supps, _ := lib.EntryList(nil, "", lib.CategorySupplement, &lib.EntryFilter{DossierID: targetID, Limit: 1})
|
|
exercise, _ := lib.EntryList(nil, "", lib.CategoryExercise, &lib.EntryFilter{DossierID: targetID, Limit: 1})
|
|
symptoms, _ := lib.EntryList(nil, "", lib.CategorySymptom, &lib.EntryFilter{DossierID: targetID, Limit: 1})
|
|
|
|
stats["vitals"] = len(vitals)
|
|
stats["medications"] = len(meds)
|
|
stats["supplements"] = len(supps)
|
|
stats["exercise"] = len(exercise)
|
|
stats["symptoms"] = len(symptoms)
|
|
section.TrackableStats = stats
|
|
|
|
// Check if any trackable data exists
|
|
hasData := len(vitals) > 0 || len(meds) > 0 || len(supps) > 0 || len(exercise) > 0 || len(symptoms) > 0
|
|
|
|
if hasData {
|
|
// Compact summary showing what's tracked
|
|
section.Summary = T("checkin_summary")
|
|
} else {
|
|
// Show build profile prompt
|
|
section.ShowBuildPrompt = true
|
|
section.Summary = T("checkin_build_profile")
|
|
promptsURL := fmt.Sprintf("/dossier/%s/prompts", targetHex)
|
|
section.PromptButtons = []PromptButton{
|
|
{Label: T("btn_vitals"), Icon: "❤️", URL: promptsURL + "?add=vital"},
|
|
{Label: T("btn_medications"), Icon: "💊", URL: promptsURL + "?add=medication"},
|
|
{Label: T("btn_supplements"), Icon: "🍊", URL: promptsURL + "?add=supplement"},
|
|
{Label: T("btn_exercise"), Icon: "🏃", URL: promptsURL + "?add=exercise"},
|
|
}
|
|
}
|
|
|
|
case "imaging":
|
|
studies, _ := fetchStudiesWithSeries(targetHex)
|
|
section.Items, section.Summary = buildImagingItems(studies, targetHex, target.DossierID, T)
|
|
if len(studies) > 0 {
|
|
section.ActionURL = fmt.Sprintf("/viewer/?token=%s", target.DossierID)
|
|
section.ActionLabel = T("open_viewer")
|
|
}
|
|
|
|
case "labs":
|
|
entries, _ := lib.EntryList(nil, "", lib.CategoryLab, &lib.EntryFilter{DossierID: targetID, Limit: 50})
|
|
section.Items = entriesToSectionItems(entries)
|
|
if len(entries) > 0 {
|
|
section.Summary = fmt.Sprintf("%d results", len(entries))
|
|
} else {
|
|
section.Summary = T("no_lab_data")
|
|
}
|
|
|
|
case "documents":
|
|
entries, _ := lib.EntryList(nil, "", 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(nil, "", 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(nil, "", 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(nil, "", 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(nil, "", 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
|
|
}
|
|
|
|
// buildImagingItems converts studies to section items
|
|
func buildImagingItems(studies []Study, targetHex, dossierID 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: fmt.Sprintf("%d slices", series.SliceCount),
|
|
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 - no expand needed
|
|
item.Children = nil
|
|
} else {
|
|
item.Value = fmt.Sprintf("%d series", s.SeriesCount)
|
|
}
|
|
|
|
items = append(items, item)
|
|
}
|
|
|
|
var summary string
|
|
if len(studies) > 0 {
|
|
summary = fmt.Sprintf("%d studies, %d slices", len(studies), totalSlices)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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(nil, "", 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,
|
|
})
|
|
}
|