package lib import ( "encoding/json" "fmt" "log" "strconv" "time" ) // Tracker data lives in entries with CategoryTracker. // // Entry field mapping: // EntryID = tracker ID // DossierID = owner // Category = CategoryTracker (26) // Type = tracker type ("blood_pressure", "weight", etc.) // Value = question text // Tags = health category ("vital", "lab", "supplement") // SearchKey = next_ask unix timestamp as string (for scheduling queries) // Ordinal = flags: 1=active, 2=dismissed // Timestamp = created_at // Data = JSON with everything else (see trackerData) const ( trackerActive = 1 trackerDismissed = 2 ) // trackerData holds the JSON blob stored in Entry.Data type trackerData struct { Frequency string `json:"frequency,omitempty"` TimeOfDay string `json:"time_of_day,omitempty"` Schedule json.RawMessage `json:"schedule,omitempty"` ExpiresAt int64 `json:"expires_at,omitempty"` InputType string `json:"input_type,omitempty"` InputConfig json.RawMessage `json:"input_config,omitempty"` GroupName string `json:"group_name,omitempty"` SourceInput string `json:"source_input,omitempty"` LastResponse string `json:"last_response,omitempty"` LastResponseRaw string `json:"last_response_raw,omitempty"` LastResponseAt int64 `json:"last_response_at,omitempty"` } // trackerToEntry converts a Tracker to an Entry func trackerToEntry(p *Tracker) *Entry { flags := 0 if p.Active { flags |= trackerActive } if p.Dismissed { flags |= trackerDismissed } d := trackerData{ Frequency: p.Frequency, TimeOfDay: p.TimeOfDay, ExpiresAt: p.ExpiresAt, InputType: p.InputType, GroupName: p.GroupName, SourceInput: p.SourceInput, LastResponse: p.LastResponse, LastResponseRaw: p.LastResponseRaw, LastResponseAt: p.LastResponseAt, } if p.Schedule != "" { d.Schedule = json.RawMessage(p.Schedule) } if p.InputConfig != "" { d.InputConfig = json.RawMessage(p.InputConfig) } data, _ := json.Marshal(d) nextAsk := "" if p.NextAsk > 0 { nextAsk = strconv.FormatInt(p.NextAsk, 10) } return &Entry{ EntryID: p.TrackerID, DossierID: p.DossierID, Category: CategoryTracker, Type: p.Type, Value: p.Question, Tags: p.Category, SearchKey: nextAsk, Ordinal: flags, Timestamp: p.CreatedAt, Data: string(data), } } // entryToTracker converts an Entry back to a Tracker func entryToTracker(e *Entry) *Tracker { var d trackerData json.Unmarshal([]byte(e.Data), &d) nextAsk, _ := strconv.ParseInt(e.SearchKey, 10, 64) return &Tracker{ TrackerID: e.EntryID, DossierID: e.DossierID, Category: e.Tags, Type: e.Type, Question: e.Value, Frequency: d.Frequency, TimeOfDay: d.TimeOfDay, Schedule: string(d.Schedule), NextAsk: nextAsk, ExpiresAt: d.ExpiresAt, InputType: d.InputType, InputConfig: string(d.InputConfig), GroupName: d.GroupName, SourceInput: d.SourceInput, LastResponse: d.LastResponse, LastResponseRaw: d.LastResponseRaw, LastResponseAt: d.LastResponseAt, Active: e.Ordinal&trackerActive != 0, Dismissed: e.Ordinal&trackerDismissed != 0, CreatedAt: e.Timestamp, UpdatedAt: e.Timestamp, } } // TrackerAdd creates a new tracker entry. func TrackerAdd(p *Tracker) error { if p.TrackerID == "" { p.TrackerID = NewID() } now := time.Now().Unix() if p.CreatedAt == 0 { p.CreatedAt = now } if !p.Active && !p.Dismissed { p.Active = true } return EntryWrite("", trackerToEntry(p)) } // TrackerModify updates an existing tracker. func TrackerModify(p *Tracker) error { return EntryWrite("", trackerToEntry(p)) } // TrackerDelete removes a tracker. func TrackerDelete(trackerID string) error { e, err := entryGetByID("", trackerID) if err != nil || e == nil { return fmt.Errorf("tracker not found") } return EntryDelete("", e.DossierID, &Filter{Category: CategoryTracker, Type: trackerID}) } // TrackerGet retrieves a single tracker by ID. func TrackerGet(trackerID string) (*Tracker, error) { e, err := entryGetByID("", trackerID) if err != nil { return nil, err } if e == nil || e.Category != CategoryTracker { return nil, fmt.Errorf("tracker not found") } return entryToTracker(e), nil } // TrackerQueryActive retrieves active trackers due for a dossier. func TrackerQueryActive(dossierID string) ([]*Tracker, error) { entries, err := EntryRead("", dossierID, &Filter{Category: CategoryTracker}) if err != nil { return nil, err } now := time.Now().Unix() var result []*Tracker for _, e := range entries { t := entryToTracker(e) if !t.Active || t.Dismissed { continue } if t.ExpiresAt > 0 && t.ExpiresAt <= now { continue } result = append(result, t) } return result, nil } // TrackerQueryAll retrieves all trackers for a dossier (including inactive). func TrackerQueryAll(dossierID string) ([]*Tracker, error) { entries, err := EntryRead("", dossierID, &Filter{Category: CategoryTracker}) if err != nil { return nil, err } result := make([]*Tracker, 0, len(entries)) for _, e := range entries { result = append(result, entryToTracker(e)) } return result, nil } // TrackerRespond records a response and advances next_ask. func TrackerRespond(trackerID string, response, responseRaw string) error { now := time.Now().Unix() p, err := TrackerGet(trackerID) if err != nil { return err } p.LastResponse = response p.LastResponseRaw = responseRaw p.LastResponseAt = now p.NextAsk = calculateNextAsk(p.Frequency, p.TimeOfDay, now) if err := EntryWrite("", trackerToEntry(p)); err != nil { return err } // Create data entry for the response if err := trackerCreateEntry(p, response, now); err != nil { log.Printf("Failed to create entry for tracker %s: %v", trackerID, err) } return nil } // trackerCreateEntry creates a data entry from a tracker response. func trackerCreateEntry(p *Tracker, response string, timestamp int64) error { if p.InputType == "freeform" { return nil } e := &Entry{ DossierID: p.DossierID, Category: CategoryFromString[p.Category], Type: p.Type, Value: responseToValue(response), Timestamp: timestamp, Data: fmt.Sprintf(`{"response":%s,"source":"prompt","tracker_id":"%s"}`, response, p.TrackerID), SearchKey: p.TrackerID, } return EntryWrite("", e) } // responseToValue converts JSON response to a human-readable value string. func responseToValue(response string) string { var resp map[string]interface{} if err := json.Unmarshal([]byte(response), &resp); err != nil { return response } if v, ok := resp["value"]; ok { return fmt.Sprintf("%v", v) } if sys, ok := resp["systolic"]; ok { if dia, ok := resp["diastolic"]; ok { return fmt.Sprintf("%v/%v", sys, dia) } } var parts []string for _, v := range resp { parts = append(parts, fmt.Sprintf("%v", v)) } if len(parts) > 0 { return parts[0] } return response } // TrackerDismiss marks a tracker as dismissed. func TrackerDismiss(trackerID string) error { p, err := TrackerGet(trackerID) if err != nil { return err } p.Dismissed = true return EntryWrite("", trackerToEntry(p)) } // TrackerSkip advances next_ask to tomorrow without recording a response. func TrackerSkip(trackerID string) error { p, err := TrackerGet(trackerID) if err != nil { return err } p.NextAsk = time.Now().Unix() + 24*60*60 return EntryWrite("", trackerToEntry(p)) } // calculateNextAsk determines when to ask again based on frequency. func calculateNextAsk(frequency, timeOfDay string, now int64) int64 { switch frequency { case "once": return 0 case "daily": return now + 24*60*60 case "twice_daily": return now + 12*60*60 case "weekly": return now + 7*24*60*60 case "until_resolved": return now + 24*60*60 default: return now + 24*60*60 } }