inou/lib/tracker.go

185 lines
4.9 KiB
Go

package lib
import (
"encoding/json"
"fmt"
"log"
"time"
)
// TrackerAdd inserts a new prompt. Generates TrackerID if empty.
func TrackerAdd(p *Tracker) error {
if p.TrackerID == "" {
p.TrackerID = NewID()
}
now := time.Now().Unix()
if p.CreatedAt == 0 {
p.CreatedAt = now
}
p.UpdatedAt = now
if p.Active == false && p.Dismissed == false {
p.Active = true // default to active
}
return dbSave("trackers", p)
}
// TrackerModify updates an existing prompt
func TrackerModify(p *Tracker) error {
p.UpdatedAt = time.Now().Unix()
return dbSave("trackers", p)
}
// TrackerDelete removes a prompt
func TrackerDelete(trackerID string) error {
return dbDelete("trackers", "tracker_id", trackerID)
}
// TrackerGet retrieves a single tracker by ID
func TrackerGet(trackerID string) (*Tracker, error) {
p := &Tracker{}
return p, dbLoad("trackers", trackerID, p)
}
// TrackerQueryActive retrieves active trackers due for a dossier
func TrackerQueryActive(dossierID string) ([]*Tracker, error) {
now := time.Now().Unix()
var result []*Tracker
err := dbQuery(`SELECT * FROM trackers
WHERE dossier_id = ? AND active = 1 AND dismissed = 0
AND (expires_at = 0 OR expires_at > ?)
ORDER BY
CASE WHEN next_ask <= ? OR next_ask IS NULL OR input_type = 'freeform' THEN 0 ELSE 1 END,
next_ask, time_of_day`, []any{dossierID, now, now}, &result)
return result, err
}
// TrackerQueryAll retrieves all trackers for a dossier (including inactive)
func TrackerQueryAll(dossierID string) ([]*Tracker, error) {
var result []*Tracker
err := dbQuery(`SELECT * FROM trackers WHERE dossier_id = ? ORDER BY active DESC, time_of_day, created_at`,
[]any{dossierID}, &result)
return result, err
}
// TrackerRespond records a response and advances next_ask
func TrackerRespond(trackerID string, response, responseRaw string) error {
now := time.Now().Unix()
// Get current tracker to calculate next_ask
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)
p.UpdatedAt = now
if err := dbSave("trackers", p); err != nil {
return err
}
// Create entry for certain tracker types
if err := trackerCreateEntry(p, response, now); err != nil {
// Log but don't fail the response
log.Printf("Failed to create entry for tracker %s: %v", trackerID, err)
}
return nil
}
// trackerCreateEntry creates an entry from a tracker response
// Uses the prompt's category/type directly - no hardcoded mappings
func trackerCreateEntry(p *Tracker, response string, timestamp int64) error {
// Skip freeform/note types for now
if p.InputType == "freeform" {
return nil
}
// Entry inherits category/type from prompt
e := &Entry{
DossierID: p.DossierID,
Category: CategoryFromString[p.Category], // Prompt still uses string, convert here
Type: p.Type,
Value: responseToValue(response),
Timestamp: timestamp,
Data: fmt.Sprintf(`{"response":%s,"source":"prompt","tracker_id":"%s"}`, response, p.TrackerID),
SearchKey: p.TrackerID, // Foreign key to link entry back to its prompt
}
return EntryAdd(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 // fallback to raw
}
// Single value
if v, ok := resp["value"]; ok {
return fmt.Sprintf("%v", v)
}
// Blood pressure style: systolic/diastolic
if sys, ok := resp["systolic"]; ok {
if dia, ok := resp["diastolic"]; ok {
return fmt.Sprintf("%v/%v", sys, dia)
}
}
// Fallback: join all values
var parts []string
for _, v := range resp {
parts = append(parts, fmt.Sprintf("%v", v))
}
if len(parts) > 0 {
return fmt.Sprintf("%v", parts[0]) // just first for now
}
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
p.UpdatedAt = time.Now().Unix()
return dbSave("trackers", 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
}
now := time.Now().Unix()
p.NextAsk = now + 24*60*60
p.UpdatedAt = now
return dbSave("trackers", p)
}
// calculateNextAsk determines when to ask again based on frequency
func calculateNextAsk(frequency, timeOfDay string, now int64) int64 {
switch frequency {
case "once":
return 0 // never ask again (will be filtered by expires_at or dismissed)
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 // ask daily until dismissed
default:
// Handle "weekly:mon,wed,fri" or other patterns later
return now + 24*60*60
}
}