inou/lib/tracker.go

304 lines
7.9 KiB
Go

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