inou/portal/trackers.go

630 lines
18 KiB
Go

package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"inou/lib"
)
type TrackerField struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"`
Datatype string `json:"datatype"`
Min float64 `json:"min,omitempty"`
Max float64 `json:"max,omitempty"`
Step float64 `json:"step,omitempty"`
Unit string `json:"unit,omitempty"`
Required bool `json:"required,omitempty"`
MaxLength int `json:"maxlength,omitempty"`
Options []string `json:"options,omitempty"`
ScaleLabels []string `json:"scale_labels,omitempty"`
Value string // filled from last_response
}
type TrackerGroup struct {
Title string `json:"title"`
Fields []TrackerField `json:"fields"`
}
type ScheduleSlot struct {
Days []string `json:"days"`
Time string `json:"time"`
}
type TrackerView struct {
ID string
Category string
Type string
Question string
Fields []TrackerField
Groups []TrackerGroup
Layout string // "two-column" or empty
Schedule []ScheduleSlot
ScheduleFormatted string
NextAsk int64
NextAskFormatted string
IsOverdue bool
IsDue bool
IsFreeform bool
SourceInput string
LastResponseAt int64
LastResponseFormatted string
LastResponseRaw string
HasResponse bool
}
type EntryView struct {
ID string
Category string
Type string
Value string
Question string
SourceInput string
Fields []TrackerField
Groups []TrackerGroup
Layout string // "two-column" or empty
Timestamp int64
TimeFormatted string
TrackerID string // linked tracker for delete
}
func handleTrackers(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 {
http.NotFound(w, r)
return
}
targetHex := parts[2]
targetID := targetHex
// Check access
isSelf := targetID == p.DossierID
if !isSelf {
if _, found := getAccess(formatHexID(p.DossierID), targetHex); !found {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
}
target, _ := lib.DossierGet(&lib.AccessContext{AccessorID: p.DossierID}, targetID)
if target == nil {
http.NotFound(w, r)
return
}
lang := getLang(r)
showAll := r.URL.Query().Get("all") == "1"
// Fetch trackers from API
url := fmt.Sprintf("http://localhost:8082/api/prompts?dossier=%s&all=1", targetHex)
if showAll {
url += "&all=1"
}
resp, err := http.Get(url)
if err != nil {
render(w, r, PageData{Page: "trackers", Lang: lang, Dossier: p, Error: "Failed to load prompts"})
return
}
defer resp.Body.Close()
var apiPrompts []struct {
ID string `json:"id"`
Category string `json:"category"`
Type string `json:"type"`
Question string `json:"question"`
NextAsk int64 `json:"next_ask"`
InputType string `json:"input_type"`
InputConfig json.RawMessage `json:"input_config"`
Schedule json.RawMessage `json:"schedule"`
SourceInput string `json:"source_input"`
LastResponse json.RawMessage `json:"last_response"`
LastResponseRaw string `json:"last_response_raw"`
LastResponseAt int64 `json:"last_response_at"`
IsDue bool `json:"is_due"`
}
json.NewDecoder(resp.Body).Decode(&apiPrompts)
// Helper to translate category name
translateCategory := func(cat string) string {
switch cat {
case "supplement": return "Supplement"
case "medication": return "Medication"
case "vital": return "Vital"
case "exercise": return "Exercise"
case "symptom": return "Symptom"
case "nutrition": return "Nutrition"
case "note": return "Note"
default: return cat
}
}
// Convert to view models
var trackers []TrackerView
for _, ap := range apiPrompts {
pv := TrackerView{
ID: ap.ID,
Category: translateCategory(ap.Category),
Type: ap.Type,
Question: ap.Question,
NextAsk: ap.NextAsk,
IsDue: ap.IsDue,
IsFreeform: ap.InputType == "freeform",
SourceInput: ap.SourceInput,
LastResponseAt: ap.LastResponseAt,
LastResponseRaw: ap.LastResponseRaw,
HasResponse: ap.LastResponseRaw != "" || ap.LastResponseAt > 0,
}
if ap.NextAsk > 0 {
pv.NextAskFormatted = formatDueDate(ap.NextAsk)
// Freeform trackers (like "anything else") are never overdue
pv.IsOverdue = ap.InputType != "freeform" && ap.NextAsk < time.Now().Unix()
}
if ap.LastResponseAt > 0 {
pv.LastResponseFormatted = time.Unix(ap.LastResponseAt, 0).Format("Jan 2, 3:04 PM")
}
// Parse input_config
if len(ap.InputConfig) > 0 {
var config struct {
Fields []TrackerField `json:"fields"`
Groups []TrackerGroup `json:"groups"`
Layout string `json:"layout"`
}
if json.Unmarshal(ap.InputConfig, &config) == nil {
pv.Fields = config.Fields
pv.Groups = config.Groups
pv.Layout = config.Layout
}
}
// Parse schedule
if len(ap.Schedule) > 0 {
var schedule []ScheduleSlot
if json.Unmarshal(ap.Schedule, &schedule) == nil {
pv.Schedule = schedule
pv.ScheduleFormatted = formatSchedule(schedule)
}
}
// Fill values from last_response (for both flat fields and groups)
log.Printf("DEBUG: q=%s lastResp=%d fields=%d groups=%d", ap.Question, len(ap.LastResponse), len(pv.Fields), len(pv.Groups))
if len(ap.LastResponse) > 0 {
var lastResp map[string]interface{}
if err := json.Unmarshal(ap.LastResponse, &lastResp); err == nil {
log.Printf("DEBUG: parsed lastResp=%v", lastResp)
// Fill flat fields
for i := range pv.Fields {
if v, ok := lastResp[pv.Fields[i].Key]; ok {
pv.Fields[i].Value = fmt.Sprintf("%v", v)
log.Printf("DEBUG: set field %s = %v", pv.Fields[i].Key, v)
}
}
// Fill grouped fields
for gi := range pv.Groups {
for fi := range pv.Groups[gi].Fields {
if v, ok := lastResp[pv.Groups[gi].Fields[fi].Key]; ok {
pv.Groups[gi].Fields[fi].Value = fmt.Sprintf("%v", v)
log.Printf("DEBUG: set group field %s = %v", pv.Groups[gi].Fields[fi].Key, v)
}
}
}
} else {
log.Printf("DEBUG: unmarshal error: %v raw=%s", err, string(ap.LastResponse))
}
}
trackers = append(trackers, pv)
}
data := PageData{
Page: "trackers",
Lang: lang,
Dossier: p,
TargetDossier: target,
}
data.T = translations[lang]
// Build tracker lookup map by tracker ID
promptMap := make(map[string]TrackerView)
for _, p := range trackers {
promptMap[p.ID] = p
log.Printf("DEBUG: Added to promptMap: id=%s, question=%s", p.ID, p.Question)
}
// Fetch today's entries
now := time.Now()
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Unix()
endOfDay := startOfDay + 86400
log.Printf("DEBUG: Fetching entries for today: start=%d end=%d now=%d", startOfDay, endOfDay, now.Unix())
rawEntries, _ := lib.EntryQueryByDate(targetID, startOfDay, endOfDay)
log.Printf("DEBUG: Found %d entries for today", len(rawEntries))
var entries []EntryView
for _, e := range rawEntries {
catName := lib.CategoryName(e.Category)
log.Printf("DEBUG: Processing entry: cat=%d (%s), type=%s, value=%s, search_key=%s", e.Category, catName, e.Type, e.Value, e.SearchKey)
ev := EntryView{
ID: formatHexID(e.EntryID),
Category: lib.CategoryTranslate(e.Category, lang),
Type: e.Type,
Value: e.Value,
Timestamp: e.Timestamp,
TimeFormatted: time.Unix(e.Timestamp, 0).Format("Jan 2, 3:04 PM"),
}
// Link entry to tracker via SearchKey (foreign key relationship)
if e.SearchKey != "" {
log.Printf("DEBUG: Looking for tracker with id=%s", e.SearchKey)
if prompt, ok := promptMap[e.SearchKey]; ok {
log.Printf("DEBUG: Found matching prompt: question=%s, fields=%d, groups=%d", prompt.Question, len(prompt.Fields), len(prompt.Groups))
ev.Question = prompt.Question
ev.TrackerID = prompt.ID
// Copy fields, groups, and layout from prompt
ev.Fields = make([]TrackerField, len(prompt.Fields))
copy(ev.Fields, prompt.Fields)
ev.Groups = make([]TrackerGroup, len(prompt.Groups))
copy(ev.Groups, prompt.Groups)
ev.Layout = prompt.Layout
} else {
log.Printf("DEBUG: No tracker found with id=%s", e.SearchKey)
}
} else {
log.Printf("DEBUG: Entry has no search_key (may be freeform/legacy entry)")
}
// Extract source_input and values from Data JSON
if e.Data != "" {
var dataMap struct {
SourceInput string `json:"source_input"`
Values json.RawMessage `json:"values"`
}
if json.Unmarshal([]byte(e.Data), &dataMap) == nil {
ev.SourceInput = dataMap.SourceInput
// Fill field values
var values map[string]interface{}
if json.Unmarshal(dataMap.Values, &values) == nil {
// Fill flat fields
for i := range ev.Fields {
if v, ok := values[ev.Fields[i].Key]; ok {
ev.Fields[i].Value = fmt.Sprintf("%v", v)
}
}
// Fill grouped fields
for gi := range ev.Groups {
for fi := range ev.Groups[gi].Fields {
if v, ok := values[ev.Groups[gi].Fields[fi].Key]; ok {
ev.Groups[gi].Fields[fi].Value = fmt.Sprintf("%v", v)
}
}
}
}
}
}
entries = append(entries, ev)
}
log.Printf("DEBUG: Built %d entry views for display", len(entries))
// Split trackers into regular and freeform
var allPrompts, freeformPrompts []TrackerView
dueCount := 0
for _, p := range trackers {
if p.IsFreeform {
freeformPrompts = append(freeformPrompts, p)
} else {
allPrompts = append(allPrompts, p)
if p.IsDue {
dueCount++
}
}
}
// Add trackers and entries to template
w.Header().Set("Content-Type", "text/html; charset=utf-8")
templates.ExecuteTemplate(w, "base.tmpl", struct {
PageData
AllPrompts []TrackerView
FreeformPrompts []TrackerView
Entries []EntryView
TargetHex string
DueCount int
}{data, allPrompts, freeformPrompts, entries, targetHex, dueCount})
}
func handleTrackerRespond(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
p := getLoggedInDossier(r)
if p == nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 4 {
http.NotFound(w, r)
return
}
targetHex := parts[2]
r.ParseForm()
trackerID := r.FormValue("tracker_id")
action := r.FormValue("action")
// Build response JSON from form fields
response := make(map[string]interface{})
var responseRaw string
for key, values := range r.Form {
if strings.HasPrefix(key, "field_") && len(values) > 0 {
fieldKey := strings.TrimPrefix(key, "field_")
response[fieldKey] = values[0]
if responseRaw != "" {
responseRaw += ", "
}
responseRaw += values[0]
}
}
if raw := r.FormValue("response_raw"); raw != "" {
responseRaw = raw
}
responseJSON, _ := json.Marshal(response)
// Call API
reqBody := map[string]string{
"tracker_id": trackerID,
"response": string(responseJSON),
"response_raw": responseRaw,
"action": action,
}
reqJSON, _ := json.Marshal(reqBody)
resp, err := http.Post("http://localhost:8082/api/prompts/respond", "application/json", strings.NewReader(string(reqJSON)))
if err != nil {
http.Redirect(w, r, fmt.Sprintf("/dossier/%s/prompts?error=1", targetHex), http.StatusSeeOther)
return
}
defer resp.Body.Close()
// For AJAX requests, return the API response directly
if r.Header.Get("Accept") == "application/json" {
w.Header().Set("Content-Type", "application/json")
io.Copy(w, resp.Body)
return
}
http.Redirect(w, r, fmt.Sprintf("/dossier/%s/prompts", targetHex), http.StatusSeeOther)
}
// formatSchedule returns a human-readable schedule
func formatSchedule(slots []ScheduleSlot) string {
if len(slots) == 0 {
return ""
}
var parts []string
for _, slot := range slots {
// Check if all days
if len(slot.Days) == 7 {
parts = append(parts, fmt.Sprintf("Daily at %s", slot.Time))
} else {
dayStr := strings.Join(slot.Days, ", ")
parts = append(parts, fmt.Sprintf("%s at %s", dayStr, slot.Time))
}
}
return strings.Join(parts, " and ")
}
// formatDueDate returns a human-readable due date
func formatDueDate(ts int64) string {
now := time.Now()
due := time.Unix(ts, 0)
// If in the past, show overdue duration
if due.Before(now) {
diff := now.Sub(due)
if diff < time.Hour {
return "now"
} else if diff < 2*time.Hour {
return "1 hour overdue"
} else if diff < 24*time.Hour {
return fmt.Sprintf("%d hours overdue", int(diff.Hours()))
} else if diff < 48*time.Hour {
return "overdue since yesterday"
} else {
return fmt.Sprintf("overdue since %s", due.Format("Jan 2"))
}
}
// If today
if due.Year() == now.Year() && due.YearDay() == now.YearDay() {
return "today " + due.Format("3:04 PM")
}
// If tomorrow
tomorrow := now.AddDate(0, 0, 1)
if due.Year() == tomorrow.Year() && due.YearDay() == tomorrow.YearDay() {
return "tomorrow " + due.Format("3:04 PM")
}
// Otherwise show date
return due.Format("Jan 2, 3:04 PM")
}
// handleRenderTrackerCard renders just the tracker card HTML for a given prompt
func handleRenderTrackerCard(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r)
if p == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
parts := strings.Split(r.URL.Path, "/")
// Path: /dossier/{id}/prompts/card/{trackerID}
if len(parts) < 6 {
http.NotFound(w, r)
return
}
targetHex := parts[2]
trackerID := parts[5]
// Get tracker from API
resp, err := http.Get(fmt.Sprintf("http://localhost:8082/api/prompts?dossier=%s", targetHex))
if err != nil {
http.Error(w, "Failed to fetch prompts", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
var trackers []map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&trackers); err != nil {
http.Error(w, "Failed to parse prompts", http.StatusInternalServerError)
return
}
// Find the specific prompt
var targetPrompt map[string]interface{}
for _, tracker := range trackers {
if tracker["id"] == trackerID {
targetPrompt = tracker
break
}
}
if targetPrompt == nil {
http.NotFound(w, r)
return
}
// Convert to TrackerView
promptView := convertToTrackerView(targetPrompt)
// Render just the card template
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := templates.ExecuteTemplate(w, "prompt_card.tmpl", promptView); err != nil {
log.Printf("Template execution error: %v", err)
http.Error(w, "Rendering error", http.StatusInternalServerError)
}
}
// convertToTrackerView converts API tracker JSON to TrackerView
func convertToTrackerView(prompt map[string]interface{}) TrackerView {
view := TrackerView{
ID: prompt["id"].(string),
Question: getString(prompt, "question"),
Category: getString(prompt, "category"),
}
// Schedule
if scheduleRaw, ok := prompt["schedule"].([]interface{}); ok {
for _, s := range scheduleRaw {
slot := s.(map[string]interface{})
var days []string
if daysRaw, ok := slot["days"].([]interface{}); ok {
for _, d := range daysRaw {
days = append(days, d.(string))
}
}
view.Schedule = append(view.Schedule, ScheduleSlot{
Days: days,
Time: getString(slot, "time"),
})
}
view.ScheduleFormatted = formatSchedule(view.Schedule)
}
// Next ask
if nextAsk, ok := prompt["next_ask"].(float64); ok {
view.NextAskFormatted = formatDueDate(int64(nextAsk))
}
// Layout
view.Layout = getString(prompt, "layout")
// Input config
if config, ok := prompt["input_config"].(map[string]interface{}); ok {
// Groups
if groupsRaw, ok := config["groups"].([]interface{}); ok {
for _, g := range groupsRaw {
group := g.(map[string]interface{})
fg := TrackerGroup{
Title: getString(group, "title"),
}
if fieldsRaw, ok := group["fields"].([]interface{}); ok {
for _, f := range fieldsRaw {
field := f.(map[string]interface{})
fg.Fields = append(fg.Fields, TrackerField{
Key: getString(field, "key"),
Label: getString(field, "label"),
Type: getString(field, "type"),
Unit: getString(field, "unit"),
})
}
}
view.Groups = append(view.Groups, fg)
}
}
// Flat fields
if fieldsRaw, ok := config["fields"].([]interface{}); ok {
for _, f := range fieldsRaw {
field := f.(map[string]interface{})
view.Fields = append(view.Fields, TrackerField{
Key: getString(field, "key"),
Label: getString(field, "label"),
Type: getString(field, "type"),
Unit: getString(field, "unit"),
})
}
}
}
// Fill values from last_response (for both flat fields and groups)
if lastRespRaw, ok := prompt["last_response"].(string); ok && lastRespRaw != "" {
var lastResp map[string]interface{}
if err := json.Unmarshal([]byte(lastRespRaw), &lastResp); err == nil {
// Fill flat fields
for i := range view.Fields {
if v, ok := lastResp[view.Fields[i].Key]; ok {
view.Fields[i].Value = fmt.Sprintf("%v", v)
}
}
// Fill grouped fields
for gi := range view.Groups {
for fi := range view.Groups[gi].Fields {
if v, ok := lastResp[view.Groups[gi].Fields[fi].Key]; ok {
view.Groups[gi].Fields[fi].Value = fmt.Sprintf("%v", v)
}
}
}
}
}
return view
}
func getString(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok && v != nil {
return fmt.Sprintf("%v", v)
}
return ""
}