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(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 "" }