304 lines
7.9 KiB
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
|
|
}
|
|
}
|