cleanup: remove v1 dossier, rename dossier_v2 to dossier

- Remove handleDossier (legacy v1 handler)
- Remove /v1 route
- Rename dossier_v2.tmpl → dossier.tmpl
- Remove HealthEntryView struct and entriesToView helper
- Add 'build profile' prompt to Daily Check-in for empty trackables
- Update demo handler to use unified dossier page
This commit is contained in:
Johan Jongsma 2026-02-02 07:54:52 +00:00
parent e46abbdddd
commit c604514abb
7 changed files with 309 additions and 1072 deletions

View File

@ -98,7 +98,12 @@ section_questions: "Questions"
# Check-in
checkin_summary: "Track vitals, symptoms & more"
checkin_build_profile: "The more you track, the better AI can help."
open: "Open"
btn_vitals: "Vitals"
btn_medications: "Medications"
btn_supplements: "Supplements"
btn_exercise: "Exercise"
# Section summaries
imaging_summary: "%d studies · %d slices"

View File

@ -25,6 +25,17 @@ type DossierSection struct {
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
@ -105,10 +116,43 @@ func BuildDossierSections(targetID, targetHex string, target *lib.Dossier, p *li
switch cfg.ID {
case "checkin":
section.Summary = T("checkin_summary")
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)

View File

@ -129,46 +129,10 @@ type PageData struct {
HasRead, HasWrite, HasDelete, HasManage bool
Categories []CategoryAccess
EntryGrants []EntryGrant
// Health data categories
Documents []HealthEntryView
Procedures []HealthEntryView
Medications []HealthEntryView
Assessments []HealthEntryView
Symptoms []HealthEntryView
Labs []HealthEntryView
Hospitalizations []HealthEntryView
Therapies []HealthEntryView
// Dossier v2: unified sections
// Dossier: unified sections
Sections []DossierSection
}
type HealthEntryView struct {
ID string
Value string
Summary string
Date string
Type string
Provider string
}
func entriesToView(entries []*lib.Entry, _ error) []HealthEntryView {
var views []HealthEntryView
for _, e := range entries {
date := ""
if e.Timestamp > 0 {
date = time.Unix(e.Timestamp, 0).Format("2006-01-02")
}
views = append(views, HealthEntryView{
ID: e.EntryID,
Value: e.Value,
Summary: e.Summary,
Date: date,
Type: e.Type,
})
}
return views
}
type CategoryAccess struct {
ID int
Name string
@ -949,8 +913,11 @@ func handleDemo(w http.ResponseWriter, r *http.Request) {
genomeEntries, _ := lib.EntryList(nil, "", lib.CategoryGenome, &lib.EntryFilter{DossierID: demoDossierID, Limit: 1}) // nil ctx - demo lookup
hasGenome := len(genomeEntries) > 0
// Build sections for demo dossier
sections := BuildDossierSections(demoDossierID, formatHexID(demoDossierID), target, p, lang, false)
render(w, r, PageData{
Page: "dossier_v1",
Page: "dossier",
Lang: lang,
Embed: isEmbed(r),
Dossier: p,
@ -961,87 +928,10 @@ func handleDemo(w http.ResponseWriter, r *http.Request) {
StudyCount: len(studies),
TotalSlices: totalSlices,
HasGenome: hasGenome,
Sections: sections,
})
}
func handleDossier(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 3 || parts[2] == "" { http.NotFound(w, r); return }
targetID := parts[2]
isSelf := targetID == p.DossierID
hasAccess := isSelf
var relation int
var isCareReceiver bool
canEdit := isSelf
if !isSelf {
access, found := getAccess(formatHexID(p.DossierID), formatHexID(targetID))
hasAccess = found
if found {
relation = access.Relation
isCareReceiver = access.IsCareReceiver
canEdit = access.CanEdit
touchAccess(formatHexID(p.DossierID), formatHexID(targetID))
}
}
if !hasAccess { http.Error(w, "Forbidden", http.StatusForbidden); return }
target, err := lib.DossierGet(nil, targetID) // nil ctx - internal operation
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} // parent, child, spouse, sibling, guardian, caregiver
showDetails := isSelf || familyRelations[relation]
canManageAccess := isSelf || isCareReceiver
accessRecords, _ := listAccessors(formatHexID(targetID))
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,
})
}
uploadDir := filepath.Join(uploadsDir, formatHexID(targetID))
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
})
var sizeStr string
if uploadSize < 1024 { sizeStr = fmt.Sprintf("%d B", uploadSize)
} else if uploadSize < 1024*1024 { sizeStr = fmt.Sprintf("%.1f KB", float64(uploadSize)/1024)
} else { sizeStr = fmt.Sprintf("%.1f MB", float64(uploadSize)/(1024*1024)) }
// Query studies and series via API
targetHex := parts[2]
studies, _ := fetchStudiesWithSeries(targetHex)
hasImaging := len(studies) > 0
var totalSlices int
genomeEntries, _ := lib.EntryList(nil, "", lib.CategoryGenome, &lib.EntryFilter{DossierID: targetID, Limit: 1}) // nil ctx - internal
hasGenome := len(genomeEntries) > 0
for _, s := range studies { totalSlices += s.SliceCount }
// Query health data categories
documents := entriesToView(lib.EntryList(nil, "", lib.CategoryDocument, &lib.EntryFilter{DossierID: targetID, Limit: 50}))
procedures := entriesToView(lib.EntryList(nil, "", lib.CategorySurgery, &lib.EntryFilter{DossierID: targetID, Limit: 50}))
medications := entriesToView(lib.EntryList(nil, "", lib.CategoryMedication, &lib.EntryFilter{DossierID: targetID, Limit: 50}))
assessments := entriesToView(lib.EntryList(nil, "", lib.CategoryAssessment, &lib.EntryFilter{DossierID: targetID, Limit: 50}))
symptoms := entriesToView(lib.EntryList(nil, "", lib.CategorySymptom, &lib.EntryFilter{DossierID: targetID, Limit: 50}))
labs := entriesToView(lib.EntryList(nil, "", lib.CategoryLab, &lib.EntryFilter{DossierID: targetID, Limit: 50}))
hospitalizations := entriesToView(lib.EntryList(nil, "", lib.CategoryHospital, &lib.EntryFilter{DossierID: targetID, Limit: 50}))
therapies := entriesToView(lib.EntryList(nil, "", lib.CategoryTherapy, &lib.EntryFilter{DossierID: targetID, Limit: 50}))
render(w, r, PageData{Page: "dossier_v1", Lang: lang, Embed: isEmbed(r), Dossier: p, TargetDossier: target, ShowDetails: showDetails, CanManageAccess: canManageAccess, CanEdit: canEdit, AccessList: accessList, Uploads: uploadCount > 0, UploadCount: uploadCount, UploadSize: sizeStr, HasImaging: hasImaging, Studies: studies, StudyCount: len(studies), TotalSlices: totalSlices, HasGenome: hasGenome, Documents: documents, Procedures: procedures, Medications: medications, Assessments: assessments, Symptoms: symptoms, Labs: labs, Hospitalizations: hospitalizations, Therapies: therapies})
}
func handleAddDossier(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return }
@ -1924,8 +1814,7 @@ func setupMux() http.Handler {
mux.HandleFunc("/dossier/add", handleAddDossier)
mux.HandleFunc("/dossier/", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if strings.HasSuffix(path, "/v1") { handleDossier(w, r) // legacy, keep for comparison
} else if strings.HasSuffix(path, "/edit") { handleEditDossier(w, r)
if strings.HasSuffix(path, "/edit") { handleEditDossier(w, r)
} else if strings.HasSuffix(path, "/share") { handleShareAccess(w, r)
} else if strings.HasSuffix(path, "/revoke") { handleRevokeAccess(w, r)
} else if strings.HasSuffix(path, "/audit") { handleAuditLog(w, r)

View File

@ -1879,3 +1879,52 @@ a:hover {
.hidden-row {
display: none !important;
}
/* Build profile prompt (Daily Check-in) */
.build-profile-prompt {
padding: 20px 24px;
border-top: 1px solid var(--border);
}
.build-profile-text {
font-size: 1rem;
font-weight: 500;
color: var(--text);
margin-bottom: 4px;
}
.build-profile-hint {
font-size: 0.9rem;
color: var(--text-muted);
margin-bottom: 16px;
}
.build-profile-buttons {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.build-profile-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.9rem;
color: var(--text);
text-decoration: none;
transition: all 0.15s;
}
.build-profile-btn:hover {
border-color: var(--accent);
background: var(--accent-light);
color: var(--accent);
}
.build-profile-icon {
font-size: 1.1rem;
}

View File

@ -91,8 +91,7 @@
{{else if eq .Page "onboard"}}{{template "onboard" .}}
{{else if eq .Page "minor_error"}}{{template "minor_error" .}}
{{else if eq .Page "dashboard"}}{{template "dashboard" .}}
{{else if eq .Page "dossier"}}{{template "dossier_v2" .}}
{{else if eq .Page "dossier_v1"}}{{template "dossier" .}}
{{else if eq .Page "dossier"}}{{template "dossier" .}}
{{else if eq .Page "add_dossier"}}{{template "add_dossier" .}}
{{else if eq .Page "share"}}{{template "share" .}}
{{else if eq .Page "upload"}}{{template "upload" .}}

View File

@ -1,5 +1,4 @@
{{define "dossier"}}
<script>function toggleExpand(el) { el.classList.toggle("expanded"); var icon = el.querySelector(".expand-icon"); if (icon) icon.textContent = el.classList.contains("expanded") ? "" : "+"; var children = el.nextElementSibling; if (children && children.classList.contains("data-row-children")) children.classList.toggle("show"); }</script>
<div class="sg-container">
<div class="dossier-header" style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px;">
<div>
@ -17,361 +16,23 @@
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{if .Success}}<div class="success">{{.Success}}</div>{{end}}
<!-- Daily Check-in Section -->
<div class="data-card">
<div class="data-card-header">
<div class="data-card-indicator checkin"></div>
<div class="data-card-title">
<span class="section-heading">Daily Check-in</span>
<span class="data-card-summary">Track vitals, symptoms & more</span>
</div>
<a href="/dossier/{{.TargetDossier.DossierID}}/prompts" class="btn btn-small btn-primary">Open</a>
</div>
</div>
{{/* Render all sections using unified template */}}
{{range .Sections}}
{{template "section_block" .}}
{{end}}
<!-- Imaging Section -->
<div class="data-card">
<div class="data-card-header">
<div class="data-card-indicator imaging"></div>
<div class="data-card-title">
<span class="section-heading">{{.T.section_imaging}}</span>
{{if .Studies}}
<span class="data-card-summary" data-studies="{{.StudyCount}}" data-slices="{{.TotalSlices}}"></span>
{{else}}
<span class="data-card-summary">{{.T.no_imaging}}</span>
{{end}}
</div>
{{if .HasImaging}}
<a href="/viewer/?token={{.TargetDossier.DossierID}}" target="_blank" class="btn btn-small">{{.T.open_viewer}}</a>
{{end}}
</div>
{{if .Studies}}
<div class="data-table" id="studies-table">
{{range $i, $s := .Studies}}
{{if eq $s.SeriesCount 1}}
<div class="data-row single study-row" data-index="{{$i}}">
<div class="data-row-main">
<span class="data-label">{{$s.Description}}</span>
</div>
<div class="data-values">
<span class="data-date" data-date="{{$s.Date}}"></span>
<a href="/viewer/?token={{$.TargetDossier.DossierID}}&study={{$s.ID}}" target="_blank" class="btn-icon" title="{{$.T.open_viewer}}">→</a>
</div>
</div>
{{else}}
<div class="data-row expandable study-row" data-index="{{$i}}" onclick="toggleExpand(this)">
<div class="data-row-main">
<span class="expand-icon">+</span>
<span class="data-label">{{$s.Description}}</span>
</div>
<div class="data-values">
<span class="data-value mono series-count" data-count="{{$s.SeriesCount}}"></span>
<span class="data-date" data-date="{{$s.Date}}"></span>
<a href="/viewer/?token={{$.TargetDossier.DossierID}}&study={{$s.ID}}" target="_blank" class="btn-icon" onclick="event.stopPropagation()" title="{{$.T.open_viewer}}">→</a>
</div>
</div>
<div class="data-row-children" data-index="{{$i}}">
{{range $s.Series}}{{if gt .SliceCount 0}}
<div class="data-row child">
<span class="data-label">{{if .Description}}{{.Description}}{{else}}{{.Modality}}{{end}}</span>
<span class="data-value mono slice-count" data-count="{{.SliceCount}}"></span>
<a href="/viewer/?token={{$.TargetDossier.DossierID}}&study={{$s.ID}}&series={{.ID}}" target="_blank" class="btn-icon" title="{{$.T.open_viewer}}">→</a>
</div>
{{end}}{{end}}
</div>
{{end}}
{{end}}
{{if gt .StudyCount 5}}
<div class="data-row show-more" id="show-more-studies" data-count="{{.StudyCount}}"></div>
{{end}}
</div>
{{end}}
</div>
<!-- Labs Section -->
<div class="data-card">
<div class="data-card-header">
<div class="data-card-indicator labs"></div>
<div class="data-card-title">
<span class="section-heading">{{.T.section_labs}}</span>
{{if .Labs}}
<span class="data-card-summary">{{len .Labs}} results</span>
{{else}}
<span class="data-card-summary">{{.T.no_lab_data}}</span>
{{end}}
</div>
</div>
{{if .Labs}}
<div class="data-table">
{{range .Labs}}
<div class="data-row">
<div class="data-row-main">
<span class="data-label">{{.Value}}</span>
{{if .Summary}}<span class="data-meta">{{.Summary}}</span>{{end}}
</div>
<div class="data-values">
{{if .Date}}<span class="data-date">{{.Date}}</span>{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
</div>
<!-- Documents Section -->
<div class="data-card" {{if not .Documents}}style="display:none;"{{end}}>
<div class="data-card-header">
<div class="data-card-indicator records"></div>
<div class="data-card-title">
<span class="section-heading">{{.T.section_records}}</span>
<span class="data-card-summary">{{len .Documents}} documents</span>
</div>
</div>
{{if .Documents}}
<div class="data-table">
{{range .Documents}}
<div class="data-row">
<div class="data-row-main">
<span class="data-label">{{.Value}}</span>
{{if .Summary}}<span class="data-meta">{{.Summary}}</span>{{end}}
</div>
<div class="data-values">
{{if .Type}}<span class="data-value">{{.Type}}</span>{{end}}
{{if .Date}}<span class="data-date">{{.Date}}</span>{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
</div>
<!-- Procedures Section -->
<div class="data-card" {{if not .Procedures}}style="display:none;"{{end}}>
<div class="data-card-header">
<div class="data-card-indicator" style="background: #DC2626;"></div>
<div class="data-card-title">
<span class="section-heading">Procedures &amp; Surgery</span>
<span class="data-card-summary">{{len .Procedures}} procedures</span>
</div>
</div>
{{if .Procedures}}
<div class="data-table">
{{range .Procedures}}
<div class="data-row">
<div class="data-row-main">
<span class="data-label">{{.Value}}</span>
{{if .Summary}}<span class="data-meta">{{.Summary}}</span>{{end}}
</div>
<div class="data-values">
{{if .Date}}<span class="data-date">{{.Date}}</span>{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
</div>
<!-- Assessments Section -->
<div class="data-card" {{if not .Assessments}}style="display:none;"{{end}}>
<div class="data-card-header">
<div class="data-card-indicator" style="background: #7C3AED;"></div>
<div class="data-card-title">
<span class="section-heading">Clinical Assessments</span>
<span class="data-card-summary">{{len .Assessments}} assessments</span>
</div>
</div>
{{if .Assessments}}
<div class="data-table">
{{range .Assessments}}
<div class="data-row">
<div class="data-row-main">
<span class="data-label">{{.Value}}</span>
{{if .Summary}}<span class="data-meta">{{.Summary}}</span>{{end}}
</div>
<div class="data-values">
{{if .Date}}<span class="data-date">{{.Date}}</span>{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
</div>
<!-- Genetics Section -->
<div class="data-card" id="genetics-card" {{if not .HasGenome}}style="display:none;"{{end}}>
<div class="data-card-header">
<div class="data-card-indicator" style="background: #8B5CF6;"></div>
<div class="data-card-title">
<span class="section-heading">{{.T.section_genetics}}</span>
<span class="data-card-summary" id="genetics-summary">Loading...</span>
</div>
<a href="#" class="btn btn-small" id="show-all-genetics" style="display:none;" onclick="showAllGeneticsWarning(); return false;">Show all</a>
</div>
<div class="data-table" id="genetics-table"></div>
</div>
<!-- Genetics Warning Modal -->
<div id="genetics-warning-modal" class="modal" style="display:none;">
<div class="modal-content" style="max-width: 520px;">
<h3 style="margin-bottom: 16px;">⚠️ Before you continue</h3>
<p style="margin-bottom: 16px;">Here you can browse all your raw genetic variants. However, the real value comes from using <a href="/connect">Claude and other LLMs with your health dossier</a> — they can interpret these variants and correlate them with your labs, imaging, and medical history.</p>
<p style="margin-bottom: 12px;"><strong>Keep in mind:</strong></p>
<ul style="margin-bottom: 16px; padding-left: 20px; line-height: 1.6;">
<li>Many associations are based on early or limited research</li>
<li>A "risk variant" means slightly higher odds — not a diagnosis</li>
<li>Consumer tests (23andMe, AncestryDNA) can have false positives</li>
</ul>
<p style="margin-bottom: 20px;">These findings can be a starting point for conversations with your doctor — especially if certain conditions run in your family.</p>
<div style="display: flex; gap: 12px; justify-content: flex-end;">
<button class="btn btn-secondary" onclick="closeGeneticsWarning()">Close</button>
<button class="btn btn-primary" onclick="confirmShowAllGenetics()">I understand, show all</button>
</div>
</div>
</div>
<!-- Uploads Section -->
<div class="data-card">
<div class="data-card-header">
<div class="data-card-indicator uploads"></div>
<div class="data-card-title">
<span class="section-heading">{{.T.section_uploads}}</span>
<span class="data-card-summary">
{{if .Uploads}}<span data-files="{{.UploadCount}}" data-size="{{.UploadSize}}"></span>{{else}}{{.T.no_files}}{{end}}
</span>
</div>
{{if .CanEdit}}<a href="/dossier/{{.TargetDossier.DossierID}}/upload" class="btn btn-small">{{.T.manage}}</a>{{else}}<span class="btn btn-small btn-disabled" title="{{.T.no_upload_access}}">{{.T.manage}}</span>{{end}}
</div>
</div>
<!-- Medications Section -->
<div class="data-card" {{if not .Medications}}style="display:none;"{{end}}>
<div class="data-card-header">
<div class="data-card-indicator medications"></div>
<div class="data-card-title">
<span class="section-heading">{{.T.section_medications}}</span>
<span class="data-card-summary">{{len .Medications}} medications</span>
</div>
</div>
{{if .Medications}}
<div class="data-table">
{{range .Medications}}
<div class="data-row">
<div class="data-row-main">
<span class="data-label">{{.Value}}</span>
{{if .Summary}}<span class="data-meta">{{.Summary}}</span>{{end}}
</div>
<div class="data-values">
{{if .Date}}<span class="data-date">{{.Date}}</span>{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
</div>
<!-- Symptoms Section -->
<div class="data-card" {{if not .Symptoms}}style="display:none;"{{end}}>
<div class="data-card-header">
<div class="data-card-indicator" style="background: #F59E0B;"></div>
<div class="data-card-title">
<span class="section-heading">Symptoms</span>
<span class="data-card-summary">{{len .Symptoms}} symptoms</span>
</div>
</div>
{{if .Symptoms}}
<div class="data-table">
{{range .Symptoms}}
<div class="data-row">
<div class="data-row-main">
<span class="data-label">{{.Value}}</span>
{{if .Summary}}<span class="data-meta">{{.Summary}}</span>{{end}}
</div>
<div class="data-values">
{{if .Date}}<span class="data-date">{{.Date}}</span>{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
</div>
<!-- Hospitalizations Section -->
<div class="data-card" {{if not .Hospitalizations}}style="display:none;"{{end}}>
<div class="data-card-header">
<div class="data-card-indicator" style="background: #EF4444;"></div>
<div class="data-card-title">
<span class="section-heading">Hospitalizations</span>
<span class="data-card-summary">{{len .Hospitalizations}} hospitalizations</span>
</div>
</div>
{{if .Hospitalizations}}
<div class="data-table">
{{range .Hospitalizations}}
<div class="data-row">
<div class="data-row-main">
<span class="data-label">{{.Value}}</span>
{{if .Summary}}<span class="data-meta">{{.Summary}}</span>{{end}}
</div>
<div class="data-values">
{{if .Date}}<span class="data-date">{{.Date}}</span>{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
</div>
<!-- Therapies Section -->
<div class="data-card" {{if not .Therapies}}style="display:none;"{{end}}>
<div class="data-card-header">
<div class="data-card-indicator" style="background: #10B981;"></div>
<div class="data-card-title">
<span class="section-heading">Therapies</span>
<span class="data-card-summary">{{len .Therapies}} therapies</span>
</div>
</div>
{{if .Therapies}}
<div class="data-table">
{{range .Therapies}}
<div class="data-row">
<div class="data-row-main">
<span class="data-label">{{.Value}}</span>
{{if .Summary}}<span class="data-meta">{{.Summary}}</span>{{end}}
</div>
<div class="data-values">
{{if .Date}}<span class="data-date">{{.Date}}</span>{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
</div>
<!-- Vitals Section - Coming Soon -->
<div class="data-card coming-soon">
<div class="data-card-header">
<div class="data-card-indicator vitals"></div>
<div class="data-card-title">
<span class="section-heading">{{.T.section_vitals}}</span>
<span class="data-card-summary">{{.T.vitals_desc}}</span>
</div>
<span class="badge-soon">{{.T.coming_soon}}</span>
</div>
</div>
<!-- Privacy Section -->
{{/* Privacy section - special structure */}}
<div class="data-card">
<div class="data-card-header">
<div class="data-card-indicator privacy"></div>
<div class="data-card-title">
<span class="section-heading">{{.T.section_privacy}}</span>
<span class="data-card-summary">{{len .AccessList}} {{.T.people_with_access_count}}</span>
<span class="section-heading">{{$.T.section_privacy}}</span>
<span class="data-card-summary">{{len $.AccessList}} {{$.T.people_with_access_count}}</span>
</div>
</div>
<div class="data-table">
{{range .AccessList}}
{{range $.AccessList}}
<div class="data-row">
<div class="data-row-main">
<span class="data-label">{{.Name}}{{if .IsSelf}} ({{$.T.you}}){{else if .IsPending}} ({{$.T.pending}}){{end}}</span>
@ -388,18 +49,17 @@
{{end}}
</div>
{{end}}
{{if not .AccessList}}
{{if not $.AccessList}}
<div class="data-row">
<span class="text-muted">{{.T.no_access_yet}}</span>
<span class="text-muted">{{$.T.no_access_yet}}</span>
</div>
{{end}}
<!-- Privacy actions -->
<div class="data-row privacy-actions">
<a href="/dossier/{{.TargetDossier.DossierID}}/share" class="privacy-action">{{.T.share_access}}</a>
{{if .CanManageAccess}}<a href="/dossier/{{.TargetDossier.DossierID}}/permissions" class="privacy-action">{{.T.manage_permissions}}</a>{{end}}
<a href="/dossier/{{.TargetDossier.DossierID}}/audit" class="privacy-action">{{.T.view_audit_log}}</a>
{{if or (eq .Dossier.DossierID .TargetDossier.DossierID) .CanManageAccess}}<a href="/dossier/{{.TargetDossier.DossierID}}/export" class="privacy-action">{{.T.export_data}}</a>{{end}}
<a href="/dossier/{{$.TargetDossier.DossierID}}/share" class="privacy-action">{{$.T.share_access}}</a>
{{if $.CanManageAccess}}<a href="/dossier/{{$.TargetDossier.DossierID}}/permissions" class="privacy-action">{{$.T.manage_permissions}}</a>{{end}}
<a href="/dossier/{{$.TargetDossier.DossierID}}/audit" class="privacy-action">{{$.T.view_audit_log}}</a>
{{if or (eq $.Dossier.DossierID $.TargetDossier.DossierID) $.CanManageAccess}}<a href="/dossier/{{$.TargetDossier.DossierID}}/export" class="privacy-action">{{$.T.export_data}}</a>{{end}}
</div>
</div>
</div>
@ -407,76 +67,95 @@
{{template "footer"}}
</div>
{{/* Genetics Warning Modal */}}
<div id="genetics-warning-modal" class="modal" style="display:none;">
<div class="modal-content" style="max-width: 520px;">
<h3 style="margin-bottom: 16px;">⚠️ Before you continue</h3>
<p style="margin-bottom: 16px;">Here you can browse all your raw genetic variants. However, the real value comes from using <a href="/connect">Claude and other LLMs with your health dossier</a> — they can interpret these variants and correlate them with your labs, imaging, and medical history.</p>
<p style="margin-bottom: 12px;"><strong>Keep in mind:</strong></p>
<ul style="margin-bottom: 16px; padding-left: 20px; line-height: 1.6;">
<li>Many associations are based on early or limited research</li>
<li>A "risk variant" means slightly higher odds — not a diagnosis</li>
<li>Consumer tests (23andMe, AncestryDNA) can have false positives</li>
</ul>
<p style="margin-bottom: 20px;">These findings can be a starting point for conversations with your doctor — especially if certain conditions run in your family.</p>
<div style="display: flex; gap: 12px; justify-content: flex-end;">
<button class="btn btn-secondary" onclick="closeGeneticsWarning()">Close</button>
<button class="btn btn-primary" onclick="confirmShowAllGenetics()">I understand, show all</button>
</div>
</div>
</div>
<script>
const dossierGUID = "{{.TargetDossier.DossierID}}";
const hasGenome = {{if .HasGenome}}true{{else}}false{{end}};
const userLang = "{{.Lang}}";
// Show all categories, but hide negative variants by default
let showAllGenetics = false; // show negative variants
let showAllCategories = false; // show all 13 categories (vs top 5)
let allCategories = {};
// Section expand/collapse
function toggleSection(el) {
el.classList.toggle('expanded');
const icon = el.querySelector('.expand-icon');
if (icon) icon.textContent = el.classList.contains('expanded') ? '' : '+';
const children = el.nextElementSibling;
if (children && children.classList.contains('section-children')) {
children.classList.toggle('show');
}
}
// i18n strings from server
// Format dates from YYYYMMDD
document.querySelectorAll('[data-date]').forEach(el => {
const d = el.dataset.date;
if (d && d.length === 8) {
const date = new Date(d.slice(0,4) + '-' + d.slice(4,6) + '-' + d.slice(6,8));
el.textContent = date.toLocaleDateString();
}
});
// Genetics dynamic loading (if genetics section exists)
{{if .HasGenome}}
const i18n = {
studies: "{{.T.imaging_summary}}",
series: "{{.T.series_count}}",
files: "{{.T.files_summary}}",
showAll: "{{.T.show_all_studies}}",
genomeEnglishOnly: "{{.T.genome_english_only}}",
genomeVariants: "{{.T.genome_variants}}",
genomeHidden: "{{.T.genome_hidden}}",
genomeShowAllCategories: "{{.T.genome_show_all_categories}}"
};
function toggleExpand(el) {
el.classList.toggle('expanded');
const icon = el.querySelector('.expand-icon');
if (icon) icon.textContent = el.classList.contains('expanded') ? '' : '+';
const children = el.nextElementSibling;
if (children && children.classList.contains('data-row-children')) {
children.classList.toggle('show');
}
}
let showAllGenetics = false;
let showAllCategories = false;
let allCategories = {};
// Format allele: ensure single semicolon between letters
function formatAllele(genotype) {
const letters = genotype.replace(/;/g, '');
return letters.split('').join(';');
}
// Load genetics categories from API
async function loadGeneticsCategories() {
if (!hasGenome) return;
const container = document.getElementById('genetics-content');
if (!container) return;
try {
// Fetch category counts (includes shown/hidden per category)
const resp = await fetch(`/api/categories?dossier=${dossierGUID}&type=genome`);
allCategories = await resp.json();
// Show "Show all" link if there are any hidden variants
const totalHidden = Object.values(allCategories).reduce((sum, c) => sum + (c.hidden || 0), 0);
if (totalHidden > 0) {
document.getElementById('show-all-genetics').style.display = '';
const showAllBtn = document.getElementById('show-all-genetics');
if (showAllBtn && totalHidden > 0) {
showAllBtn.style.display = '';
}
renderGeneticsCategories();
} catch (e) {
document.getElementById('genetics-summary').textContent = 'Error loading';
container.innerHTML = '<div class="data-row"><span class="text-muted">Error loading genetics</span></div>';
}
}
function renderGeneticsCategories() {
let totalShown = 0;
let totalHidden = 0;
const container = document.getElementById('genetics-content');
const summaryEl = document.getElementById('genetics-summary');
if (!container) return;
let totalShown = 0, totalHidden = 0;
for (const counts of Object.values(allCategories)) {
totalShown += counts.shown || 0;
totalHidden += counts.hidden || 0;
}
if (totalShown === 0 && totalHidden === 0) {
document.getElementById('genetics-card').style.display = 'none';
document.getElementById('section-genetics')?.remove();
return;
}
@ -484,59 +163,45 @@ function renderGeneticsCategories() {
if (totalHidden > 0 && !showAllGenetics) {
summary += ` <span class="text-muted">(${totalHidden} ${i18n.genomeHidden})</span>`;
}
document.getElementById('genetics-summary').innerHTML = summary;
if (summaryEl) summaryEl.innerHTML = summary;
const btn = document.getElementById('show-all-genetics');
if (showAllGenetics) {
btn.textContent = 'Hide negative';
btn.onclick = () => { showAllGenetics = false; reloadAllExpandedCategories(); renderGeneticsCategories(); return false; };
} else {
btn.textContent = 'Show all';
btn.onclick = () => { showAllGeneticsWarning(); return false; };
if (btn) {
if (showAllGenetics) {
btn.textContent = 'Hide negative';
btn.onclick = () => { showAllGenetics = false; renderGeneticsCategories(); return false; };
} else {
btn.textContent = 'Show all';
btn.onclick = () => { showGeneticsWarning(); return false; };
}
}
const table = document.getElementById('genetics-table');
let html = '';
const categoryPriority = {
'traits': 1, 'metabolism': 2, 'longevity': 3, 'blood': 4, 'cardiovascular': 5,
'neurological': 6, 'mental_health': 7, 'autoimmune': 8, 'medication': 9,
'fertility': 10, 'disease': 11, 'cancer': 12, 'other': 13
};
const sorted = Object.entries(allCategories).sort((a, b) => {
const aPriority = categoryPriority[a[0]] || 99;
const bPriority = categoryPriority[b[0]] || 99;
return aPriority !== bPriority ? aPriority - bPriority :
(showAllGenetics ? (b[1].shown + b[1].hidden) : b[1].shown) -
(showAllGenetics ? (a[1].shown + a[1].hidden) : a[1].shown);
});
// Show language notice for non-English users
const maxCategories = 5;
const displayCategories = showAllCategories ? sorted : sorted.slice(0, maxCategories);
let html = '';
if (userLang !== 'en' && i18n.genomeEnglishOnly) {
html += `<div class="data-row" style="background: var(--bg-muted); color: var(--text-muted); font-size: 0.9rem; padding: 12px 16px;">
<span>🌐 ${i18n.genomeEnglishOnly}</span>
</div>`;
}
// Sort by predefined priority (less alarming first), then by count within same priority
const categoryPriority = {
'traits': 1,
'metabolism': 2,
'longevity': 3,
'blood': 4,
'cardiovascular': 5,
'neurological': 6,
'mental_health': 7,
'autoimmune': 8,
'medication': 9,
'fertility': 10,
'disease': 11,
'cancer': 12,
'other': 13
};
const sorted = Object.entries(allCategories).sort((a, b) => {
const aPriority = categoryPriority[a[0]] || 99;
const bPriority = categoryPriority[b[0]] || 99;
if (aPriority !== bPriority) return aPriority - bPriority;
// Same priority: sort by count
const aCount = showAllGenetics ? (a[1].shown + a[1].hidden) : a[1].shown;
const bCount = showAllGenetics ? (b[1].shown + b[1].hidden) : b[1].shown;
return bCount - aCount;
});
const maxCategories = 5;
const displayCategories = showAllCategories ? sorted : sorted.slice(0, maxCategories);
for (const [cat, counts] of displayCategories) {
const shown = counts.shown || 0;
const hidden = counts.hidden || 0;
const shown = counts.shown || 0, hidden = counts.hidden || 0;
const displayCount = showAllGenetics ? (shown + hidden) : shown;
if (displayCount === 0) continue;
@ -546,7 +211,7 @@ function renderGeneticsCategories() {
countText = `${shown} ${i18n.genomeVariants} <span class="text-muted">(${hidden} ${i18n.genomeHidden})</span>`;
}
html += `
<div class="data-row expandable genetics-category" data-category="${cat}" onclick="toggleGeneticsCategory(this, '${cat}')">
<div class="data-row expandable" data-category="${cat}" onclick="toggleGeneticsCategory(this, '${cat}')">
<div class="data-row-main">
<span class="expand-icon">+</span>
<span class="data-label">${label}</span>
@ -555,25 +220,17 @@ function renderGeneticsCategories() {
<span class="data-meta">${countText}</span>
</div>
</div>
<div class="data-row-children genetics-children" data-category="${cat}"></div>`;
<div class="section-children" data-category="${cat}"></div>`;
}
// Show "Show all X categories" link if there are more
if (!showAllCategories && sorted.length > maxCategories) {
html += `<div class="data-row show-more" style="cursor: pointer;" onclick="showAllCategories = true; renderGeneticsCategories();">${i18n.genomeShowAllCategories.replace('%d', sorted.length)} →</div>`;
}
table.innerHTML = html;
container.innerHTML = html;
}
// Reload data for any expanded categories (when toggling show all)
function reloadAllExpandedCategories() {
document.querySelectorAll('.genetics-children[data-loaded="true"]').forEach(el => {
el.removeAttribute('data-loaded');
});
}
function showAllGeneticsWarning() {
function showGeneticsWarning() {
document.getElementById('genetics-warning-modal').style.display = 'flex';
}
@ -598,7 +255,6 @@ async function toggleGeneticsCategory(el, category) {
if (children.dataset.loaded) return;
children.dataset.loaded = 'true';
children.innerHTML = '<div class="data-row child"><span class="text-muted">Loading...</span></div>';
try {
@ -612,17 +268,15 @@ async function toggleGeneticsCategory(el, category) {
}
let html = '';
const maxShow = 5;
data.matches.slice(0, maxShow).forEach(m => {
data.matches.slice(0, 5).forEach(m => {
const mag = m.magnitude ? m.magnitude.toFixed(1) : '0';
const gene = m.gene || '';
const allele = formatAllele(m.genotype);
const allele = m.genotype.replace(/;/g, '').split('').join(';');
html += `
<div class="data-row child" style="flex-direction: column; align-items: flex-start; gap: 8px; padding: 12px 16px;">
<div class="sg-gene-row" style="width: 100%;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; width: 100%;">
<div class="sg-gene-main">
<span class="sg-gene-name">${gene}</span>
<span class="sg-gene-name">${m.gene || ''}</span>
<span class="sg-gene-rsid">${m.rsid}</span>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
@ -635,43 +289,38 @@ async function toggleGeneticsCategory(el, category) {
</div>`;
});
if (data.total > maxShow) {
const remaining = data.total - maxShow;
const showMoreText = remaining > 50
? `${remaining} more variants — <a href="/connect" style="color: var(--primary);">use Claude</a> to explore intelligently`
: `Show more (${remaining} remaining) →`;
html += `<div class="sg-show-more" data-offset="${maxShow}" data-total="${data.total}" onclick="loadMoreGenetics('${category}', this)">${showMoreText}</div>`;
if (data.total > 5) {
const remaining = data.total - 5;
html += `<div class="sg-show-more" data-offset="5" data-total="${data.total}" data-category="${category}" onclick="loadMoreGenetics(this)">${remaining > 50 ? `${remaining} more — <a href="/connect">use Claude</a>` : `Show more (${remaining}) →`}</div>`;
}
children.innerHTML = html;
} catch (e) {
children.innerHTML = '<div class="data-row child"><span class="text-muted">Error loading variants</span></div>';
children.innerHTML = '<div class="data-row child"><span class="text-muted">Error loading</span></div>';
}
}
async function loadMoreGenetics(category, el) {
const batchSize = 20;
const currentOffset = parseInt(el.dataset.offset || '0', 10);
const total = parseInt(el.dataset.total || '0', 10);
async function loadMoreGenetics(el) {
const offset = parseInt(el.dataset.offset, 10);
const total = parseInt(el.dataset.total, 10);
const category = el.dataset.category;
el.textContent = 'Loading...';
try {
const includeHidden = showAllGenetics ? '&include_hidden=true' : '';
const resp = await fetch(`/api/genome?dossier=${dossierGUID}&category=${encodeURIComponent(category)}&offset=${currentOffset}&limit=${batchSize}${includeHidden}`);
const resp = await fetch(`/api/genome?dossier=${dossierGUID}&category=${encodeURIComponent(category)}&offset=${offset}&limit=20${includeHidden}`);
const data = await resp.json();
let html = '';
data.matches.forEach(m => {
const mag = m.magnitude ? m.magnitude.toFixed(1) : '0';
const gene = m.gene || '';
const allele = formatAllele(m.genotype);
const allele = m.genotype.replace(/;/g, '').split('').join(';');
html += `
<div class="data-row child" style="flex-direction: column; align-items: flex-start; gap: 8px; padding: 12px 16px;">
<div class="sg-gene-row" style="width: 100%;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; width: 100%;">
<div class="sg-gene-main">
<span class="sg-gene-name">${gene}</span>
<span class="sg-gene-name">${m.gene || ''}</span>
<span class="sg-gene-rsid">${m.rsid}</span>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
@ -684,81 +333,105 @@ async function loadMoreGenetics(category, el) {
</div>`;
});
// Insert new items before the "Show more" button
el.insertAdjacentHTML('beforebegin', html);
const newOffset = currentOffset + data.matches.length;
const newOffset = offset + data.matches.length;
const remaining = total - newOffset;
if (remaining > 0) {
el.dataset.offset = newOffset;
if (remaining > 50) {
el.innerHTML = `${remaining} more variants — <a href="/connect" style="color: var(--primary);">use Claude</a> to explore intelligently`;
} else {
el.textContent = `Show more (${remaining} remaining) →`;
}
el.innerHTML = remaining > 50 ? `${remaining} more — <a href="/connect">use Claude</a>` : `Show more (${remaining}) →`;
} else {
el.remove();
}
} catch (e) {
el.textContent = 'Error loading variants';
el.textContent = 'Error';
}
}
loadGeneticsCategories();
document.querySelectorAll('[data-date]').forEach(el => {
const d = el.dataset.date;
if (d && d.length === 8) {
const date = new Date(d.slice(0,4) + '-' + d.slice(4,6) + '-' + d.slice(6,8));
el.textContent = date.toLocaleDateString();
}
});
document.querySelectorAll('[data-studies]').forEach(el => {
const studies = el.dataset.studies;
const slices = el.dataset.slices;
el.textContent = i18n.studies.replace('%d', studies).replace('%d', slices);
});
document.querySelectorAll('.series-count').forEach(el => {
const n = parseInt(el.dataset.count, 10);
el.textContent = i18n.series.replace('%d', n);
});
const plural = new Intl.PluralRules(navigator.language);
document.querySelectorAll('.slice-count').forEach(el => {
const n = parseInt(el.dataset.count, 10);
el.textContent = n + (plural.select(n) === 'one' ? ' slice' : ' slices');
});
document.querySelectorAll('[data-files]').forEach(el => {
const files = el.dataset.files;
const size = el.dataset.size;
el.textContent = i18n.files.replace('%d', files).replace('%s', size);
});
const maxVisible = 5;
document.querySelectorAll('[data-index]').forEach(el => {
if (parseInt(el.dataset.index, 10) >= maxVisible) {
el.style.display = 'none';
}
});
const showMore = document.getElementById('show-more-studies');
if (showMore) {
const count = showMore.dataset.count;
showMore.innerHTML = '<span class="text-muted">' + i18n.showAll.replace('%d', count) + '</span>';
showMore.style.cursor = 'pointer';
showMore.onclick = () => {
document.querySelectorAll('[data-index]').forEach(el => el.style.display = '');
showMore.style.display = 'none';
};
}
if (new URLSearchParams(window.location.search).get("requested") === "1") {
alert("Your request has been sent. The dossier owner will be notified and can approve access.");
history.replaceState(null, "", window.location.pathname);
}
{{end}}
</script>
{{end}}
{{/* Unified section block template */}}
{{define "section_block"}}
<div class="data-card{{if .ComingSoon}} coming-soon{{end}}" id="section-{{.ID}}"{{if and .HideEmpty (eq (len .Items) 0) (not .Dynamic) (not .ComingSoon)}} style="display:none;"{{end}}>
<div class="data-card-header">
<div class="data-card-indicator" style="background: #{{.Color}};"></div>
<div class="data-card-title">
<span class="section-heading">{{.Heading}}</span>
<span class="data-card-summary"{{if .Dynamic}} id="{{.ID}}-summary"{{end}}>{{.Summary}}</span>
</div>
{{if .ComingSoon}}
<span class="badge-soon">Coming soon</span>
{{else if eq .ID "genetics"}}
<a href="#" class="btn btn-small" id="show-all-genetics" onclick="showGeneticsWarning(); return false;">Show all</a>
{{else if .ActionURL}}
<a href="{{.ActionURL}}" {{if eq .ID "imaging"}}target="_blank"{{end}} class="btn btn-small{{if eq .ID "checkin"}} btn-primary{{end}}">{{.ActionLabel}}</a>
{{end}}
</div>
{{if .ShowBuildPrompt}}
<div class="build-profile-prompt">
<p class="build-profile-text">📋 Build your health profile</p>
<p class="build-profile-hint">{{.Summary}}</p>
<div class="build-profile-buttons">
{{range .PromptButtons}}
<a href="{{.URL}}" class="build-profile-btn">
<span class="build-profile-icon">{{.Icon}}</span>
<span>{{.Label}}</span>
</a>
{{end}}
</div>
</div>
{{end}}
{{if .Dynamic}}
<div class="data-table" id="{{.ID}}-content"></div>
{{else if .Items}}
<div class="data-table">
{{range $i, $item := .Items}}
{{if $item.Expandable}}
<div class="data-row expandable{{if gt $i 4}} hidden-row{{end}}" data-index="{{$i}}" onclick="toggleSection(this)">
<div class="data-row-main">
<span class="expand-icon">+</span>
<span class="data-label">{{$item.Label}}</span>
</div>
<div class="data-values">
{{if $item.Value}}<span class="data-value mono">{{$item.Value}}</span>{{end}}
{{if $item.Date}}<span class="data-date" data-date="{{$item.Date}}"></span>{{end}}
{{if $item.LinkURL}}<a href="{{$item.LinkURL}}" target="_blank" class="btn-icon" onclick="event.stopPropagation()" title="{{$item.LinkTitle}}">→</a>{{end}}
</div>
</div>
<div class="section-children{{if gt $i 4}} hidden-row{{end}}" data-index="{{$i}}">
{{range $item.Children}}
<div class="data-row child">
<span class="data-label">{{.Label}}</span>
{{if .Value}}<span class="data-value mono">{{.Value}}</span>{{end}}
{{if .LinkURL}}<a href="{{.LinkURL}}" target="_blank" class="btn-icon" title="{{.LinkTitle}}">→</a>{{end}}
</div>
{{end}}
</div>
{{else}}
<div class="data-row{{if gt $i 4}} hidden-row{{end}}" data-index="{{$i}}">
<div class="data-row-main">
<span class="data-label">{{$item.Label}}</span>
{{if $item.Meta}}<span class="data-meta">{{$item.Meta}}</span>{{end}}
</div>
<div class="data-values">
{{if $item.Type}}<span class="data-value">{{$item.Type}}</span>{{end}}
{{if $item.Date}}<span class="data-date" data-date="{{$item.Date}}"></span>{{end}}
{{if $item.LinkURL}}<a href="{{$item.LinkURL}}" target="_blank" class="btn-icon" title="{{$item.LinkTitle}}">→</a>{{end}}
</div>
</div>
{{end}}
{{end}}
{{if gt (len .Items) 5}}
<div class="data-row show-more" onclick="this.parentElement.querySelectorAll('.hidden-row').forEach(el => el.classList.remove('hidden-row')); this.remove();">
<span class="text-muted">Show all {{len .Items}}...</span>
</div>
{{end}}
</div>
{{end}}
</div>
{{end}}

View File

@ -1,422 +0,0 @@
{{define "dossier_v2"}}
<div class="sg-container">
<div class="dossier-header" style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px;">
<div>
<h1 style="font-size: 2.5rem; font-weight: 700; margin-bottom: 8px;">{{.TargetDossier.Name}}</h1>
{{if .ShowDetails}}
<p style="font-size: 1.15rem; font-weight: 300; color: var(--text-muted);">
{{if .TargetDossier.DateOfBirth}}{{.T.born}}: {{printf "%.10s" .TargetDossier.DateOfBirth}}{{end}}
{{if .TargetDossier.Sex}} · {{sexT .TargetDossier.Sex .Lang}}{{end}}
</p>
{{end}}
</div>
<a href="/dashboard" class="btn btn-secondary btn-small">← {{.T.back_to_dossiers}}</a>
</div>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{if .Success}}<div class="success">{{.Success}}</div>{{end}}
{{/* Render all sections using unified template */}}
{{range .Sections}}
{{template "section_block" .}}
{{end}}
{{/* Privacy section - special structure */}}
<div class="data-card">
<div class="data-card-header">
<div class="data-card-indicator privacy"></div>
<div class="data-card-title">
<span class="section-heading">{{$.T.section_privacy}}</span>
<span class="data-card-summary">{{len $.AccessList}} {{$.T.people_with_access_count}}</span>
</div>
</div>
<div class="data-table">
{{range $.AccessList}}
<div class="data-row">
<div class="data-row-main">
<span class="data-label">{{.Name}}{{if .IsSelf}} ({{$.T.you}}){{else if .IsPending}} ({{$.T.pending}}){{end}}</span>
<span class="data-meta">{{.Relation}}{{if .CanEdit}} · {{$.T.can_edit}}{{end}}</span>
</div>
{{if and $.CanManageAccess (not .IsSelf)}}
<div style="display: flex; gap: 8px;">
<a href="/dossier/{{$.TargetDossier.DossierID}}/access/{{.DossierID}}" class="btn btn-secondary btn-small">Edit</a>
<form action="/dossier/{{$.TargetDossier.DossierID}}/revoke" method="POST" style="display:inline;">
<input type="hidden" name="accessor_id" value="{{.DossierID}}">
<button type="submit" class="btn btn-danger btn-small" onclick="return confirm('Remove access for {{.Name}} to {{$.TargetDossier.Name}}?')">{{$.T.remove}}</button>
</form>
</div>
{{end}}
</div>
{{end}}
{{if not $.AccessList}}
<div class="data-row">
<span class="text-muted">{{$.T.no_access_yet}}</span>
</div>
{{end}}
<div class="data-row privacy-actions">
<a href="/dossier/{{$.TargetDossier.DossierID}}/share" class="privacy-action">{{$.T.share_access}}</a>
{{if $.CanManageAccess}}<a href="/dossier/{{$.TargetDossier.DossierID}}/permissions" class="privacy-action">{{$.T.manage_permissions}}</a>{{end}}
<a href="/dossier/{{$.TargetDossier.DossierID}}/audit" class="privacy-action">{{$.T.view_audit_log}}</a>
{{if or (eq $.Dossier.DossierID $.TargetDossier.DossierID) $.CanManageAccess}}<a href="/dossier/{{$.TargetDossier.DossierID}}/export" class="privacy-action">{{$.T.export_data}}</a>{{end}}
</div>
</div>
</div>
{{template "footer"}}
</div>
{{/* Genetics Warning Modal */}}
<div id="genetics-warning-modal" class="modal" style="display:none;">
<div class="modal-content" style="max-width: 520px;">
<h3 style="margin-bottom: 16px;">⚠️ Before you continue</h3>
<p style="margin-bottom: 16px;">Here you can browse all your raw genetic variants. However, the real value comes from using <a href="/connect">Claude and other LLMs with your health dossier</a> — they can interpret these variants and correlate them with your labs, imaging, and medical history.</p>
<p style="margin-bottom: 12px;"><strong>Keep in mind:</strong></p>
<ul style="margin-bottom: 16px; padding-left: 20px; line-height: 1.6;">
<li>Many associations are based on early or limited research</li>
<li>A "risk variant" means slightly higher odds — not a diagnosis</li>
<li>Consumer tests (23andMe, AncestryDNA) can have false positives</li>
</ul>
<p style="margin-bottom: 20px;">These findings can be a starting point for conversations with your doctor — especially if certain conditions run in your family.</p>
<div style="display: flex; gap: 12px; justify-content: flex-end;">
<button class="btn btn-secondary" onclick="closeGeneticsWarning()">Close</button>
<button class="btn btn-primary" onclick="confirmShowAllGenetics()">I understand, show all</button>
</div>
</div>
</div>
<script>
const dossierGUID = "{{.TargetDossier.DossierID}}";
const userLang = "{{.Lang}}";
// Section expand/collapse
function toggleSection(el) {
el.classList.toggle('expanded');
const icon = el.querySelector('.expand-icon');
if (icon) icon.textContent = el.classList.contains('expanded') ? '' : '+';
const children = el.nextElementSibling;
if (children && children.classList.contains('section-children')) {
children.classList.toggle('show');
}
}
// Format dates from YYYYMMDD
document.querySelectorAll('[data-date]').forEach(el => {
const d = el.dataset.date;
if (d && d.length === 8) {
const date = new Date(d.slice(0,4) + '-' + d.slice(4,6) + '-' + d.slice(6,8));
el.textContent = date.toLocaleDateString();
}
});
// Genetics dynamic loading (if genetics section exists)
{{if .HasGenome}}
const i18n = {
genomeEnglishOnly: "{{.T.genome_english_only}}",
genomeVariants: "{{.T.genome_variants}}",
genomeHidden: "{{.T.genome_hidden}}",
genomeShowAllCategories: "{{.T.genome_show_all_categories}}"
};
let showAllGenetics = false;
let showAllCategories = false;
let allCategories = {};
async function loadGeneticsCategories() {
const container = document.getElementById('genetics-content');
if (!container) return;
try {
const resp = await fetch(`/api/categories?dossier=${dossierGUID}&type=genome`);
allCategories = await resp.json();
const totalHidden = Object.values(allCategories).reduce((sum, c) => sum + (c.hidden || 0), 0);
const showAllBtn = document.getElementById('show-all-genetics');
if (showAllBtn && totalHidden > 0) {
showAllBtn.style.display = '';
}
renderGeneticsCategories();
} catch (e) {
container.innerHTML = '<div class="data-row"><span class="text-muted">Error loading genetics</span></div>';
}
}
function renderGeneticsCategories() {
const container = document.getElementById('genetics-content');
const summaryEl = document.getElementById('genetics-summary');
if (!container) return;
let totalShown = 0, totalHidden = 0;
for (const counts of Object.values(allCategories)) {
totalShown += counts.shown || 0;
totalHidden += counts.hidden || 0;
}
if (totalShown === 0 && totalHidden === 0) {
document.getElementById('section-genetics')?.remove();
return;
}
let summary = showAllGenetics ? `${totalShown + totalHidden} ${i18n.genomeVariants}` : `${totalShown} ${i18n.genomeVariants}`;
if (totalHidden > 0 && !showAllGenetics) {
summary += ` <span class="text-muted">(${totalHidden} ${i18n.genomeHidden})</span>`;
}
if (summaryEl) summaryEl.innerHTML = summary;
const btn = document.getElementById('show-all-genetics');
if (btn) {
if (showAllGenetics) {
btn.textContent = 'Hide negative';
btn.onclick = () => { showAllGenetics = false; renderGeneticsCategories(); return false; };
} else {
btn.textContent = 'Show all';
btn.onclick = () => { showGeneticsWarning(); return false; };
}
}
const categoryPriority = {
'traits': 1, 'metabolism': 2, 'longevity': 3, 'blood': 4, 'cardiovascular': 5,
'neurological': 6, 'mental_health': 7, 'autoimmune': 8, 'medication': 9,
'fertility': 10, 'disease': 11, 'cancer': 12, 'other': 13
};
const sorted = Object.entries(allCategories).sort((a, b) => {
const aPriority = categoryPriority[a[0]] || 99;
const bPriority = categoryPriority[b[0]] || 99;
return aPriority !== bPriority ? aPriority - bPriority :
(showAllGenetics ? (b[1].shown + b[1].hidden) : b[1].shown) -
(showAllGenetics ? (a[1].shown + a[1].hidden) : a[1].shown);
});
const maxCategories = 5;
const displayCategories = showAllCategories ? sorted : sorted.slice(0, maxCategories);
let html = '';
if (userLang !== 'en' && i18n.genomeEnglishOnly) {
html += `<div class="data-row" style="background: var(--bg-muted); color: var(--text-muted); font-size: 0.9rem; padding: 12px 16px;">
<span>🌐 ${i18n.genomeEnglishOnly}</span>
</div>`;
}
for (const [cat, counts] of displayCategories) {
const shown = counts.shown || 0, hidden = counts.hidden || 0;
const displayCount = showAllGenetics ? (shown + hidden) : shown;
if (displayCount === 0) continue;
const label = cat.charAt(0).toUpperCase() + cat.slice(1).replace(/_/g, ' ');
let countText = `${displayCount} ${i18n.genomeVariants}`;
if (hidden > 0 && !showAllGenetics) {
countText = `${shown} ${i18n.genomeVariants} <span class="text-muted">(${hidden} ${i18n.genomeHidden})</span>`;
}
html += `
<div class="data-row expandable" data-category="${cat}" onclick="toggleGeneticsCategory(this, '${cat}')">
<div class="data-row-main">
<span class="expand-icon">+</span>
<span class="data-label">${label}</span>
</div>
<div class="data-values">
<span class="data-meta">${countText}</span>
</div>
</div>
<div class="section-children" data-category="${cat}"></div>`;
}
if (!showAllCategories && sorted.length > maxCategories) {
html += `<div class="data-row show-more" style="cursor: pointer;" onclick="showAllCategories = true; renderGeneticsCategories();">${i18n.genomeShowAllCategories.replace('%d', sorted.length)} →</div>`;
}
container.innerHTML = html;
}
function showGeneticsWarning() {
document.getElementById('genetics-warning-modal').style.display = 'flex';
}
function closeGeneticsWarning() {
document.getElementById('genetics-warning-modal').style.display = 'none';
}
function confirmShowAllGenetics() {
closeGeneticsWarning();
showAllGenetics = true;
renderGeneticsCategories();
}
async function toggleGeneticsCategory(el, category) {
el.classList.toggle('expanded');
const icon = el.querySelector('.expand-icon');
if (icon) icon.textContent = el.classList.contains('expanded') ? '' : '+';
const children = el.nextElementSibling;
if (!children) return;
children.classList.toggle('show');
if (children.dataset.loaded) return;
children.dataset.loaded = 'true';
children.innerHTML = '<div class="data-row child"><span class="text-muted">Loading...</span></div>';
try {
const includeHidden = showAllGenetics ? '&include_hidden=true' : '';
const resp = await fetch(`/api/genome?dossier=${dossierGUID}&category=${encodeURIComponent(category)}${includeHidden}`);
const data = await resp.json();
if (!data.matches || data.matches.length === 0) {
children.innerHTML = '<div class="data-row child"><span class="text-muted">No variants found</span></div>';
return;
}
let html = '';
data.matches.slice(0, 5).forEach(m => {
const mag = m.magnitude ? m.magnitude.toFixed(1) : '0';
const allele = m.genotype.replace(/;/g, '').split('').join(';');
html += `
<div class="data-row child" style="flex-direction: column; align-items: flex-start; gap: 8px; padding: 12px 16px;">
<div class="sg-gene-row" style="width: 100%;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; width: 100%;">
<div class="sg-gene-main">
<span class="sg-gene-name">${m.gene || ''}</span>
<span class="sg-gene-rsid">${m.rsid}</span>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<span class="sg-gene-allele">${allele}</span>
<span class="badge" style="font-size: 0.7rem;">mag ${mag}</span>
</div>
</div>
<div class="sg-gene-summary">${m.summary || ''}</div>
</div>
</div>`;
});
if (data.total > 5) {
const remaining = data.total - 5;
html += `<div class="sg-show-more" data-offset="5" data-total="${data.total}" data-category="${category}" onclick="loadMoreGenetics(this)">${remaining > 50 ? `${remaining} more — <a href="/connect">use Claude</a>` : `Show more (${remaining}) →`}</div>`;
}
children.innerHTML = html;
} catch (e) {
children.innerHTML = '<div class="data-row child"><span class="text-muted">Error loading</span></div>';
}
}
async function loadMoreGenetics(el) {
const offset = parseInt(el.dataset.offset, 10);
const total = parseInt(el.dataset.total, 10);
const category = el.dataset.category;
el.textContent = 'Loading...';
try {
const includeHidden = showAllGenetics ? '&include_hidden=true' : '';
const resp = await fetch(`/api/genome?dossier=${dossierGUID}&category=${encodeURIComponent(category)}&offset=${offset}&limit=20${includeHidden}`);
const data = await resp.json();
let html = '';
data.matches.forEach(m => {
const mag = m.magnitude ? m.magnitude.toFixed(1) : '0';
const allele = m.genotype.replace(/;/g, '').split('').join(';');
html += `
<div class="data-row child" style="flex-direction: column; align-items: flex-start; gap: 8px; padding: 12px 16px;">
<div class="sg-gene-row" style="width: 100%;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; width: 100%;">
<div class="sg-gene-main">
<span class="sg-gene-name">${m.gene || ''}</span>
<span class="sg-gene-rsid">${m.rsid}</span>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<span class="sg-gene-allele">${allele}</span>
<span class="badge" style="font-size: 0.7rem;">mag ${mag}</span>
</div>
</div>
<div class="sg-gene-summary">${m.summary || ''}</div>
</div>
</div>`;
});
el.insertAdjacentHTML('beforebegin', html);
const newOffset = offset + data.matches.length;
const remaining = total - newOffset;
if (remaining > 0) {
el.dataset.offset = newOffset;
el.innerHTML = remaining > 50 ? `${remaining} more — <a href="/connect">use Claude</a>` : `Show more (${remaining}) →`;
} else {
el.remove();
}
} catch (e) {
el.textContent = 'Error';
}
}
loadGeneticsCategories();
{{end}}
</script>
{{end}}
{{/* Unified section block template */}}
{{define "section_block"}}
<div class="data-card{{if .ComingSoon}} coming-soon{{end}}" id="section-{{.ID}}"{{if and .HideEmpty (eq (len .Items) 0) (not .Dynamic) (not .ComingSoon)}} style="display:none;"{{end}}>
<div class="data-card-header">
<div class="data-card-indicator" style="background: #{{.Color}};"></div>
<div class="data-card-title">
<span class="section-heading">{{.Heading}}</span>
<span class="data-card-summary"{{if .Dynamic}} id="{{.ID}}-summary"{{end}}>{{.Summary}}</span>
</div>
{{if .ComingSoon}}
<span class="badge-soon">Coming soon</span>
{{else if eq .ID "genetics"}}
<a href="#" class="btn btn-small" id="show-all-genetics" onclick="showGeneticsWarning(); return false;">Show all</a>
{{else if .ActionURL}}
<a href="{{.ActionURL}}" {{if eq .ID "imaging"}}target="_blank"{{end}} class="btn btn-small{{if eq .ID "checkin"}} btn-primary{{end}}">{{.ActionLabel}}</a>
{{end}}
</div>
{{if .Dynamic}}
<div class="data-table" id="{{.ID}}-content"></div>
{{else if .Items}}
<div class="data-table">
{{range $i, $item := .Items}}
{{if $item.Expandable}}
<div class="data-row expandable{{if gt $i 4}} hidden-row{{end}}" data-index="{{$i}}" onclick="toggleSection(this)">
<div class="data-row-main">
<span class="expand-icon">+</span>
<span class="data-label">{{$item.Label}}</span>
</div>
<div class="data-values">
{{if $item.Value}}<span class="data-value mono">{{$item.Value}}</span>{{end}}
{{if $item.Date}}<span class="data-date" data-date="{{$item.Date}}"></span>{{end}}
{{if $item.LinkURL}}<a href="{{$item.LinkURL}}" target="_blank" class="btn-icon" onclick="event.stopPropagation()" title="{{$item.LinkTitle}}">→</a>{{end}}
</div>
</div>
<div class="section-children{{if gt $i 4}} hidden-row{{end}}" data-index="{{$i}}">
{{range $item.Children}}
<div class="data-row child">
<span class="data-label">{{.Label}}</span>
{{if .Value}}<span class="data-value mono">{{.Value}}</span>{{end}}
{{if .LinkURL}}<a href="{{.LinkURL}}" target="_blank" class="btn-icon" title="{{.LinkTitle}}">→</a>{{end}}
</div>
{{end}}
</div>
{{else}}
<div class="data-row{{if gt $i 4}} hidden-row{{end}}" data-index="{{$i}}">
<div class="data-row-main">
<span class="data-label">{{$item.Label}}</span>
{{if $item.Meta}}<span class="data-meta">{{$item.Meta}}</span>{{end}}
</div>
<div class="data-values">
{{if $item.Type}}<span class="data-value">{{$item.Type}}</span>{{end}}
{{if $item.Date}}<span class="data-date" data-date="{{$item.Date}}"></span>{{end}}
{{if $item.LinkURL}}<a href="{{$item.LinkURL}}" target="_blank" class="btn-icon" title="{{$item.LinkTitle}}">→</a>{{end}}
</div>
</div>
{{end}}
{{end}}
{{if gt (len .Items) 5}}
<div class="data-row show-more" onclick="this.parentElement.querySelectorAll('.hidden-row').forEach(el => el.classList.remove('hidden-row')); this.remove();">
<span class="text-muted">Show all {{len .Items}}...</span>
</div>
{{end}}
</div>
{{end}}
</div>
{{end}}