630 lines
18 KiB
Go
630 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"inou/lib"
|
|
)
|
|
|
|
type PromptField 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 PromptGroup struct {
|
|
Title string `json:"title"`
|
|
Fields []PromptField `json:"fields"`
|
|
}
|
|
|
|
type ScheduleSlot struct {
|
|
Days []string `json:"days"`
|
|
Time string `json:"time"`
|
|
}
|
|
|
|
type PromptView struct {
|
|
ID string
|
|
Category string
|
|
Type string
|
|
Question string
|
|
Fields []PromptField
|
|
Groups []PromptGroup
|
|
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 []PromptField
|
|
Groups []PromptGroup
|
|
Layout string // "two-column" or empty
|
|
Timestamp int64
|
|
TimeFormatted string
|
|
PromptID string // linked prompt for delete
|
|
}
|
|
|
|
func handlePrompts(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(nil, targetID) // nil ctx - internal operation
|
|
if target == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
lang := getLang(r)
|
|
showAll := r.URL.Query().Get("all") == "1"
|
|
|
|
// Fetch prompts 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: "prompts", 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 prompts []PromptView
|
|
for _, ap := range apiPrompts {
|
|
pv := PromptView{
|
|
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 prompts (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 []PromptField `json:"fields"`
|
|
Groups []PromptGroup `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))
|
|
}
|
|
}
|
|
|
|
prompts = append(prompts, pv)
|
|
}
|
|
|
|
data := PageData{
|
|
Page: "prompts",
|
|
Lang: lang,
|
|
Dossier: p,
|
|
TargetDossier: target,
|
|
}
|
|
data.T = translations[lang]
|
|
|
|
// Build prompt lookup map by prompt ID
|
|
promptMap := make(map[string]PromptView)
|
|
for _, p := range prompts {
|
|
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 prompt via SearchKey (foreign key relationship)
|
|
if e.SearchKey != "" {
|
|
log.Printf("DEBUG: Looking for prompt 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.PromptID = prompt.ID
|
|
// Copy fields, groups, and layout from prompt
|
|
ev.Fields = make([]PromptField, len(prompt.Fields))
|
|
copy(ev.Fields, prompt.Fields)
|
|
ev.Groups = make([]PromptGroup, len(prompt.Groups))
|
|
copy(ev.Groups, prompt.Groups)
|
|
ev.Layout = prompt.Layout
|
|
} else {
|
|
log.Printf("DEBUG: No prompt 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 prompts into regular and freeform
|
|
var allPrompts, freeformPrompts []PromptView
|
|
dueCount := 0
|
|
for _, p := range prompts {
|
|
if p.IsFreeform {
|
|
freeformPrompts = append(freeformPrompts, p)
|
|
} else {
|
|
allPrompts = append(allPrompts, p)
|
|
if p.IsDue {
|
|
dueCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add prompts and entries to template
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
templates.ExecuteTemplate(w, "base.tmpl", struct {
|
|
PageData
|
|
AllPrompts []PromptView
|
|
FreeformPrompts []PromptView
|
|
Entries []EntryView
|
|
TargetHex string
|
|
DueCount int
|
|
}{data, allPrompts, freeformPrompts, entries, targetHex, dueCount})
|
|
}
|
|
|
|
func handlePromptRespond(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()
|
|
promptID := r.FormValue("prompt_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{
|
|
"prompt_id": promptID,
|
|
"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")
|
|
}
|
|
|
|
// handleRenderPromptCard renders just the prompt card HTML for a given prompt
|
|
func handleRenderPromptCard(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/{promptID}
|
|
if len(parts) < 6 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
targetHex := parts[2]
|
|
promptID := parts[5]
|
|
|
|
// Get prompt 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 prompts []map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&prompts); err != nil {
|
|
http.Error(w, "Failed to parse prompts", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Find the specific prompt
|
|
var targetPrompt map[string]interface{}
|
|
for _, prompt := range prompts {
|
|
if prompt["id"] == promptID {
|
|
targetPrompt = prompt
|
|
break
|
|
}
|
|
}
|
|
|
|
if targetPrompt == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Convert to PromptView
|
|
promptView := convertToPromptView(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)
|
|
}
|
|
}
|
|
|
|
// convertToPromptView converts API prompt JSON to PromptView
|
|
func convertToPromptView(prompt map[string]interface{}) PromptView {
|
|
view := PromptView{
|
|
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 := PromptGroup{
|
|
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, PromptField{
|
|
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, PromptField{
|
|
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 ""
|
|
}
|