refactor: rename prompt to tracker everywhere

- Rename prompts table to trackers
- Rename all Prompt types/functions to Tracker
- Rename prompt_id to tracker_id throughout
- Rename API endpoints /api/prompts -> /api/trackers
- Rename URL paths /dossier/{id}/prompts -> /dossier/{id}/trackers
- Rename template files and references
- Add migration script for schema changes
- Next: implement self-contained entries with metadata
This commit is contained in:
James 2026-02-09 02:05:17 -05:00
parent 9781b31c7d
commit 96fec23e22
36 changed files with 506 additions and 506 deletions

View File

@ -51,18 +51,18 @@ func loadLLMConfig() {
log.Println("Warning: Gemini API key not found.") log.Println("Warning: Gemini API key not found.")
} }
// Initialize prompts directory // Initialize trackers directory
exe, _ := os.Executable() exe, _ := os.Executable()
promptsDir := filepath.Join(filepath.Dir(exe), "..", "api", "prompts") promptsDir := filepath.Join(filepath.Dir(exe), "..", "api", "trackers")
if _, err := os.Stat(promptsDir); os.IsNotExist(err) { if _, err := os.Stat(promptsDir); os.IsNotExist(err) {
promptsDir = "prompts" // Dev fallback promptsDir = "trackers" // Dev fallback
} }
lib.InitPrompts(promptsDir) lib.InitPrompts(promptsDir)
log.Printf("Prompts directory set to: %s", lib.PromptsDir()) log.Printf("Prompts directory set to: %s", lib.TrackerPromptsDir())
} }
// callLLMForPrompt is the main entry point for turning user text into a structured prompt. // callLLMForTracker is the main entry point for turning user text into a structured prompt.
func callLLMForPrompt(userInput string, dossierID string) (*ExtractionResult, error) { func callLLMForTracker(userInput string, dossierID string) (*ExtractionResult, error) {
triage, err := runTriage(userInput, dossierID) triage, err := runTriage(userInput, dossierID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -71,7 +71,7 @@ func callLLMForPrompt(userInput string, dossierID string) (*ExtractionResult, er
return &ExtractionResult{Error: triage.Error}, nil return &ExtractionResult{Error: triage.Error}, nil
} }
existingTypes := getExistingPromptTypes(dossierID) // Assuming db is accessible in api/main existingTypes := getExistingTrackerTypes(dossierID) // Assuming db is accessible in api/main
return runExtraction(userInput, triage.Category, triage.Language, dossierID, existingTypes) return runExtraction(userInput, triage.Category, triage.Language, dossierID, existingTypes)
} }
@ -79,7 +79,7 @@ func callLLMForPrompt(userInput string, dossierID string) (*ExtractionResult, er
// --- Local Prompt Handling & DB Functions --- // --- Local Prompt Handling & DB Functions ---
func loadPrompt(name string) (string, error) { func loadPrompt(name string) (string, error) {
path := filepath.Join(lib.PromptsDir(), name+".md") path := filepath.Join(lib.TrackerPromptsDir(), name+".md")
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return "", err return "", err
@ -233,10 +233,10 @@ func runExtraction(userInput, category, language, dossierID string, existingType
} }
func getExistingPromptTypes(dossierID string) map[string][]string { func getExistingTrackerTypes(dossierID string) map[string][]string {
result, err := lib.PromptDistinctTypes(dossierID) result, err := lib.TrackerDistinctTypes(dossierID)
if err != nil { if err != nil {
log.Printf("Failed to get existing prompt types: %v", err) log.Printf("Failed to get existing tracker types: %v", err)
return make(map[string][]string) return make(map[string][]string)
} }
return result return result
@ -252,6 +252,6 @@ func callSonnet(prompt string) (string, error) {
} }
func callSonnetWithRetry(prompt string, maxRetries int, baseDelay time.Duration) (string, error) { func callSonnetWithRetry(prompt string, maxRetries int, baseDelay time.Duration) (string, error) {
// ... implementation remains the same, but is not called by the main prompt generation logic. // ... implementation remains the same, but is not called by the main tracker generation logic.
return "", fmt.Errorf("callSonnet is deprecated") return "", fmt.Errorf("callSonnet is deprecated")
} }

View File

@ -11,7 +11,7 @@ import (
) )
// PromptResponse is the API representation of a prompt, including dynamic data. // PromptResponse is the API representation of a prompt, including dynamic data.
type PromptResponse struct { type TrackerResponse struct {
ID string `json:"id"` ID string `json:"id"`
Category string `json:"category"` Category string `json:"category"`
Type string `json:"type"` Type string `json:"type"`
@ -24,21 +24,21 @@ type PromptResponse struct {
Active bool `json:"active"` Active bool `json:"active"`
IsDue bool `json:"is_due"` IsDue bool `json:"is_due"`
// Last response (for pre-filling) - restored from lib.Prompt // Last response (for pre-filling) - restored from lib.Tracker
LastResponse json.RawMessage `json:"last_response,omitempty"` LastResponse json.RawMessage `json:"last_response,omitempty"`
LastResponseRaw string `json:"last_response_raw,omitempty"` LastResponseRaw string `json:"last_response_raw,omitempty"`
LastResponseAt int64 `json:"last_response_at,omitempty"` LastResponseAt int64 `json:"last_response_at,omitempty"`
} }
type PromptRespondRequest struct { type TrackerRespondRequest struct {
PromptID string `json:"prompt_id"` TrackerID string `json:"tracker_id"`
Response string `json:"response"` // JSON string Response string `json:"response"` // JSON string
ResponseRaw string `json:"response_raw"` // what they typed ResponseRaw string `json:"response_raw"` // what they typed
Action string `json:"action"` // "respond", "skip", "dismiss" Action string `json:"action"` // "respond", "skip", "dismiss"
} }
// GET /api/prompts?dossier=X // GET /api/prompts?dossier=X
func handlePrompts(w http.ResponseWriter, r *http.Request) { func handleTrackers(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
dossierHex := r.URL.Query().Get("dossier") dossierHex := r.URL.Query().Get("dossier")
@ -48,13 +48,13 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
} }
dossierID := dossierHex dossierID := dossierHex
var prompts []*lib.Prompt var trackers []*lib.Tracker
var err error var err error
if r.URL.Query().Get("all") == "1" { if r.URL.Query().Get("all") == "1" {
prompts, err = lib.PromptQueryAll(dossierID) trackers, err = lib.TrackerQueryAll(dossierID)
} else { } else {
prompts, err = lib.PromptQueryActive(dossierID) trackers, err = lib.TrackerQueryActive(dossierID)
} }
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
@ -63,14 +63,14 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
// Ensure there's always a freeform prompt // Ensure there's always a freeform prompt
hasFreeform := false hasFreeform := false
for _, p := range prompts { for _, p := range trackers {
if p.InputType == "freeform" && p.Active { if p.InputType == "freeform" && p.Active {
hasFreeform = true hasFreeform = true
break break
} }
} }
if !hasFreeform { if !hasFreeform {
freeform := &lib.Prompt{ freeform := &lib.Tracker{
DossierID: dossierID, DossierID: dossierID,
Category: "note", Category: "note",
Type: "freeform", Type: "freeform",
@ -78,18 +78,18 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
InputType: "freeform", InputType: "freeform",
Active: true, Active: true,
} }
if err := lib.PromptAdd(freeform); err == nil { if err := lib.TrackerAdd(freeform); err == nil {
prompts = append(prompts, freeform) trackers = append(trackers, freeform)
} }
} }
result := make([]PromptResponse, 0, len(prompts)) result := make([]TrackerResponse, 0, len(trackers))
now := time.Now().Unix() now := time.Now().Unix()
for _, p := range prompts { for _, p := range trackers {
isDue := p.NextAsk <= now || p.NextAsk == 0 || p.InputType == "freeform" isDue := p.NextAsk <= now || p.NextAsk == 0 || p.InputType == "freeform"
pr := PromptResponse{ pr := TrackerResponse{
ID: p.PromptID, ID: p.TrackerID,
Category: p.Category, Category: p.Category,
Type: p.Type, Type: p.Type,
Question: p.Question, Question: p.Question,
@ -117,7 +117,7 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
// POST /api/prompts/respond // POST /api/prompts/respond
func handlePromptRespond(w http.ResponseWriter, r *http.Request) { func handleTrackerRespond(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if r.Method != "POST" { if r.Method != "POST" {
@ -125,32 +125,32 @@ func handlePromptRespond(w http.ResponseWriter, r *http.Request) {
return return
} }
var req PromptRespondRequest var req TrackerRespondRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
promptID := req.PromptID trackerID := req.TrackerID
if promptID == "" { if trackerID == "" {
http.Error(w, "prompt_id required", http.StatusBadRequest) http.Error(w, "tracker_id required", http.StatusBadRequest)
return return
} }
var err error var err error
var newPrompt *lib.Prompt var newTracker *lib.Tracker
switch req.Action { switch req.Action {
case "respond", "": case "respond", "":
err = lib.PromptRespond(promptID, req.Response, req.ResponseRaw) err = lib.TrackerRespond(trackerID, req.Response, req.ResponseRaw)
// Check if this is a freeform prompt - if so, generate new prompt from input // Check if this is a freeform tracker - if so, generate new tracker from input
if err == nil && req.ResponseRaw != "" { if err == nil && req.ResponseRaw != "" {
newPrompt, _ = tryGeneratePromptFromFreeform(promptID, req.ResponseRaw) newTracker, _ = tryGenerateTrackerFromFreeform(trackerID, req.ResponseRaw)
} }
case "skip": case "skip":
err = lib.PromptSkip(promptID) err = lib.TrackerSkip(trackerID)
case "dismiss": case "dismiss":
err = lib.PromptDismiss(promptID) err = lib.TrackerDismiss(trackerID)
default: default:
http.Error(w, "invalid action", http.StatusBadRequest) http.Error(w, "invalid action", http.StatusBadRequest)
return return
@ -163,35 +163,35 @@ func handlePromptRespond(w http.ResponseWriter, r *http.Request) {
result := map[string]interface{}{ result := map[string]interface{}{
"ok": true, "ok": true,
"prompt_id": req.PromptID, "tracker_id": req.TrackerID,
"action": req.Action, "action": req.Action,
} }
if newPrompt != nil { if newTracker != nil {
np := promptToAPI(newPrompt) np := promptToAPI(newTracker)
result["new_prompt"] = np result["new_prompt"] = np
} }
json.NewEncoder(w).Encode(result) json.NewEncoder(w).Encode(result)
} }
// Router for /api/prompts and /api/prompts/* // Router for /api/prompts and /api/prompts/*
func handlePromptsRouter(w http.ResponseWriter, r *http.Request) { func handleTrackersRouter(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path path := r.URL.Path
switch { switch {
case path == "/api/prompts" && r.Method == "GET": case path == "/api/prompts" && r.Method == "GET":
handlePrompts(w, r) handleTrackers(w, r)
case path == "/api/prompts" && r.Method == "POST": case path == "/api/prompts" && r.Method == "POST":
handlePromptCreate(w, r) handleTrackerCreate(w, r)
case path == "/api/prompts/respond": case path == "/api/prompts/respond":
handlePromptRespond(w, r) handleTrackerRespond(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
} }
// tryGeneratePromptFromFreeform checks if the prompt is freeform and generates a new prompt from user input // tryGenerateTrackerFromFreeform checks if the tracker is freeform and generates a new tracker from user input
func tryGeneratePromptFromFreeform(promptID string, userInput string) (*lib.Prompt, string) { func tryGenerateTrackerFromFreeform(trackerID string, userInput string) (*lib.Tracker, string) {
p, err := lib.PromptGet(promptID) p, err := lib.TrackerGet(trackerID)
if err != nil || p == nil { if err != nil || p == nil {
return nil, "" return nil, ""
} }
@ -200,9 +200,9 @@ func tryGeneratePromptFromFreeform(promptID string, userInput string) (*lib.Prom
return nil, "" return nil, ""
} }
generated, err := callLLMForPrompt(userInput, p.DossierID) generated, err := callLLMForTracker(userInput, p.DossierID)
if err != nil { if err != nil {
log.Printf("Failed to generate prompt from freeform: %v", err) log.Printf("Failed to generate tracker from freeform: %v", err)
return nil, "" return nil, ""
} }
@ -254,7 +254,7 @@ func tryGeneratePromptFromFreeform(promptID string, userInput string) (*lib.Prom
scheduleJSON, _ := json.Marshal(generated.Schedule) scheduleJSON, _ := json.Marshal(generated.Schedule)
inputConfigJSON, _ := json.Marshal(generated.InputConfig) inputConfigJSON, _ := json.Marshal(generated.InputConfig)
newPrompt := &lib.Prompt{ newTracker := &lib.Tracker{
DossierID: p.DossierID, DossierID: p.DossierID,
Category: generated.Category, Category: generated.Category,
Type: generated.Type, Type: generated.Type,
@ -270,25 +270,25 @@ func tryGeneratePromptFromFreeform(promptID string, userInput string) (*lib.Prom
// NOTE: Don't set LastResponse here - backfilled entries are historical. // NOTE: Don't set LastResponse here - backfilled entries are historical.
// LastResponse will be set when user actually submits a response for "today". // LastResponse will be set when user actually submits a response for "today".
if err := lib.PromptAdd(newPrompt); err != nil { if err := lib.TrackerAdd(newTracker); err != nil {
log.Printf("Failed to create prompt: %v", err) log.Printf("Failed to create prompt: %v", err)
return nil, "" return nil, ""
} }
// Update the entries we just created to link them to this prompt via SearchKey // Update the entries we just created to link them to this tracker via SearchKey
for _, entry := range createdEntries { for _, entry := range createdEntries {
entry.SearchKey = newPrompt.PromptID entry.SearchKey = newTracker.TrackerID
if err := lib.EntryAdd(entry); err != nil { if err := lib.EntryAdd(entry); err != nil {
log.Printf("Failed to update entry search_key: %v", err) log.Printf("Failed to update entry search_key: %v", err)
} }
} }
log.Printf("Created prompt from freeform: %s (%s/%s) and linked %d entries", newPrompt.Question, newPrompt.Category, newPrompt.Type, len(createdEntries)) log.Printf("Created tracker from freeform: %s (%s/%s) and linked %d entries", newTracker.Question, newTracker.Category, newTracker.Type, len(createdEntries))
return newPrompt, primaryEntryValue return newTracker, primaryEntryValue
} }
// promptToAPI converts a Prompt to API response format, now much simpler // promptToAPI converts a Prompt to API response format, now much simpler
func promptToAPI(p *lib.Prompt) map[string]interface{} { func promptToAPI(p *lib.Tracker) map[string]interface{} {
now := time.Now().Unix() now := time.Now().Unix()
isDue := p.NextAsk <= now || p.NextAsk == 0 || p.InputType == "freeform" isDue := p.NextAsk <= now || p.NextAsk == 0 || p.InputType == "freeform"
@ -296,7 +296,7 @@ func promptToAPI(p *lib.Prompt) map[string]interface{} {
json.Unmarshal([]byte(p.InputConfig), &inputConfig) json.Unmarshal([]byte(p.InputConfig), &inputConfig)
result := map[string]interface{}{ result := map[string]interface{}{
"id": p.PromptID, "id": p.TrackerID,
"category": p.Category, "category": p.Category,
"type": p.Type, "type": p.Type,
"question": p.Question, "question": p.Question,
@ -311,7 +311,7 @@ func promptToAPI(p *lib.Prompt) map[string]interface{} {
} }
func handlePromptCreate(w http.ResponseWriter, r *http.Request) { func handleTrackerCreate(w http.ResponseWriter, r *http.Request) {
// This function needs to be updated to use the new Prompt struct // This function needs to be updated to use the new Prompt struct
http.Error(w, "Not implemented", http.StatusNotImplemented) http.Error(w, "Not implemented", http.StatusNotImplemented)
} }

View File

@ -414,7 +414,7 @@ type ParseEntryResponse struct {
Timestamp int64 `json:"timestamp"` Timestamp int64 `json:"timestamp"`
} }
type ParsePromptResponse struct { type ParseTrackerResponse struct {
ID string `json:"id"` ID string `json:"id"`
Category string `json:"category"` Category string `json:"category"`
Type string `json:"type"` Type string `json:"type"`
@ -428,7 +428,7 @@ type ParseResponse struct {
Category string `json:"category"` Category string `json:"category"`
Type string `json:"type"` Type string `json:"type"`
Entries []ParseEntryResponse `json:"entries"` Entries []ParseEntryResponse `json:"entries"`
Prompt *ParsePromptResponse `json:"prompt,omitempty"` Tracker *ParseTrackerResponse `json:"prompt,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
@ -460,7 +460,7 @@ func v1Parse(w http.ResponseWriter, r *http.Request, dossierID string) {
} }
// Run triage + extraction // Run triage + extraction
generated, err := callLLMForPrompt(req.Input, dossierID) generated, err := callLLMForTracker(req.Input, dossierID)
if err != nil { if err != nil {
v1Error(w, err.Error(), http.StatusInternalServerError) v1Error(w, err.Error(), http.StatusInternalServerError)
return return
@ -505,13 +505,13 @@ func v1Parse(w http.ResponseWriter, r *http.Request, dossierID string) {
}) })
} }
// Create prompt if there's a follow-up question with schedule // Create tracker if there's a follow-up question with schedule
if generated.Question != "" && len(generated.Schedule) > 0 { if generated.Question != "" && len(generated.Schedule) > 0 {
nextAsk := calculateNextAskFromSchedule(generated.Schedule, now) nextAsk := calculateNextAskFromSchedule(generated.Schedule, now)
scheduleJSON, _ := json.Marshal(generated.Schedule) scheduleJSON, _ := json.Marshal(generated.Schedule)
inputConfigJSON, _ := json.Marshal(generated.InputConfig) inputConfigJSON, _ := json.Marshal(generated.InputConfig)
prompt := &lib.Prompt{ tracker := &lib.Tracker{
DossierID: dossierID, DossierID: dossierID,
Category: generated.Category, Category: generated.Category,
Type: generated.Type, Type: generated.Type,
@ -527,23 +527,23 @@ func v1Parse(w http.ResponseWriter, r *http.Request, dossierID string) {
// Pre-fill last response from initial extracted data // Pre-fill last response from initial extracted data
if len(generated.Entries) > 0 && generated.Entries[0].Data != nil { if len(generated.Entries) > 0 && generated.Entries[0].Data != nil {
initialData, _ := json.Marshal(generated.Entries[0].Data) initialData, _ := json.Marshal(generated.Entries[0].Data)
prompt.LastResponse = string(initialData) tracker.LastResponse = string(initialData)
prompt.LastResponseRaw = generated.Entries[0].Value tracker.LastResponseRaw = generated.Entries[0].Value
prompt.LastResponseAt = now.Unix() tracker.LastResponseAt = now.Unix()
} }
if err := lib.PromptAdd(prompt); err == nil { if err := lib.TrackerAdd(tracker); err == nil {
var inputConfig any var inputConfig any
json.Unmarshal([]byte(prompt.InputConfig), &inputConfig) json.Unmarshal([]byte(tracker.InputConfig), &inputConfig)
resp.Prompt = &ParsePromptResponse{ resp.Tracker = &ParseTrackerResponse{
ID: prompt.PromptID, ID: tracker.TrackerID,
Category: prompt.Category, Category: tracker.Category,
Type: prompt.Type, Type: tracker.Type,
Question: prompt.Question, Question: tracker.Question,
InputType: prompt.InputType, InputType: tracker.InputType,
InputConfig: inputConfig, InputConfig: inputConfig,
NextAsk: prompt.NextAsk, NextAsk: tracker.NextAsk,
} }
} }
} }
@ -564,7 +564,7 @@ func v1Prompts(w http.ResponseWriter, r *http.Request, dossierID string) {
} }
q := r.URL.Query() q := r.URL.Query()
filter := &lib.PromptFilter{DossierID: dossierID} filter := &lib.TrackerFilter{DossierID: dossierID}
if cat := q.Get("category"); cat != "" { if cat := q.Get("category"); cat != "" {
filter.Category = cat filter.Category = cat
} }
@ -575,16 +575,16 @@ func v1Prompts(w http.ResponseWriter, r *http.Request, dossierID string) {
filter.ActiveOnly = true filter.ActiveOnly = true
} }
prompts, err := lib.PromptList(filter) trackers, err := lib.TrackerList(filter)
if err != nil { if err != nil {
v1Error(w, err.Error(), http.StatusInternalServerError) v1Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
var result []map[string]any var result []map[string]any
for _, p := range prompts { for _, p := range trackers {
result = append(result, map[string]any{ result = append(result, map[string]any{
"id": p.PromptID, "id": p.TrackerID,
"category": p.Category, "category": p.Category,
"type": p.Type, "type": p.Type,
"question": p.Question, "question": p.Question,
@ -766,7 +766,7 @@ func v1Router(w http.ResponseWriter, r *http.Request) {
v1Audit(w, r, parts[1]) v1Audit(w, r, parts[1])
// GET /dossiers/{id}/prompts // GET /dossiers/{id}/prompts
case len(parts) == 3 && parts[0] == "dossiers" && parts[2] == "prompts" && r.Method == "GET": case len(parts) == 3 && parts[0] == "dossiers" && parts[2] == "trackers" && r.Method == "GET":
v1Prompts(w, r, parts[1]) v1Prompts(w, r, parts[1])
// POST /dossiers/{id}/parse // POST /dossiers/{id}/parse

View File

@ -46,7 +46,7 @@ type FormField struct {
Options []string `json:"options,omitempty"` Options []string `json:"options,omitempty"`
} }
// ScheduleSlot defines when a prompt should be shown. // ScheduleSlot defines when a tracker should be shown.
// Supports both old format (Time string) and new format (Times []string). // Supports both old format (Time string) and new format (Times []string).
type ScheduleSlot struct { type ScheduleSlot struct {
Days []string `json:"days"` Days []string `json:"days"`

View File

@ -52,8 +52,8 @@ func main() {
http.HandleFunc("/api/access", handleAccess) http.HandleFunc("/api/access", handleAccess)
http.HandleFunc("/api/audit", handleAudit) http.HandleFunc("/api/audit", handleAudit)
http.HandleFunc("/api/entries", handleEntries) http.HandleFunc("/api/entries", handleEntries)
http.HandleFunc("/api/prompts", handlePromptsRouter) http.HandleFunc("/api/prompts", handleTrackersRouter)
http.HandleFunc("/api/prompts/", handlePromptsRouter) http.HandleFunc("/api/prompts/", handleTrackersRouter)
// http.HandleFunc("/api/prompt/generate", handlePromptGenerate) // REMOVED: Deprecated // http.HandleFunc("/api/prompt/generate", handlePromptGenerate) // REMOVED: Deprecated
// Add the missing freeform handler // Add the missing freeform handler

View File

@ -11,15 +11,15 @@ import (
var promptsDir string var promptsDir string
// InitPrompts sets the directory where prompt files are located. // InitPrompts sets the directory where tracker files are located.
// This must be called by the main application at startup. // This must be called by the main application at startup.
func InitPrompts(path string) { func InitPrompts(path string) {
promptsDir = path promptsDir = path
} }
// PromptsDir returns the configured prompts directory. // TrackerPromptsDir returns the configured trackers directory.
// This is used by local prompt loading functions in consumer packages. // This is used by local tracker loading functions in consumer packages.
func PromptsDir() string { func TrackerPromptsDir() string {
return promptsDir return promptsDir
} }

View File

@ -7,10 +7,10 @@ import (
"time" "time"
) )
// PromptAdd inserts a new prompt. Generates PromptID if empty. // TrackerAdd inserts a new prompt. Generates TrackerID if empty.
func PromptAdd(p *Prompt) error { func TrackerAdd(p *Tracker) error {
if p.PromptID == "" { if p.TrackerID == "" {
p.PromptID = NewID() p.TrackerID = NewID()
} }
now := time.Now().Unix() now := time.Now().Unix()
if p.CreatedAt == 0 { if p.CreatedAt == 0 {
@ -20,31 +20,31 @@ func PromptAdd(p *Prompt) error {
if p.Active == false && p.Dismissed == false { if p.Active == false && p.Dismissed == false {
p.Active = true // default to active p.Active = true // default to active
} }
return Save("prompts", p) return Save("trackers", p)
} }
// PromptModify updates an existing prompt // TrackerModify updates an existing prompt
func PromptModify(p *Prompt) error { func TrackerModify(p *Tracker) error {
p.UpdatedAt = time.Now().Unix() p.UpdatedAt = time.Now().Unix()
return Save("prompts", p) return Save("trackers", p)
} }
// PromptDelete removes a prompt // TrackerDelete removes a prompt
func PromptDelete(promptID string) error { func TrackerDelete(trackerID string) error {
return Delete("prompts", "prompt_id", promptID) return Delete("trackers", "tracker_id", trackerID)
} }
// PromptGet retrieves a single prompt by ID // TrackerGet retrieves a single tracker by ID
func PromptGet(promptID string) (*Prompt, error) { func TrackerGet(trackerID string) (*Tracker, error) {
p := &Prompt{} p := &Tracker{}
return p, Load("prompts", promptID, p) return p, Load("trackers", trackerID, p)
} }
// PromptQueryActive retrieves active prompts due for a dossier // TrackerQueryActive retrieves active trackers due for a dossier
func PromptQueryActive(dossierID string) ([]*Prompt, error) { func TrackerQueryActive(dossierID string) ([]*Tracker, error) {
now := time.Now().Unix() now := time.Now().Unix()
var result []*Prompt var result []*Tracker
err := Query(`SELECT * FROM prompts err := Query(`SELECT * FROM trackers
WHERE dossier_id = ? AND active = 1 AND dismissed = 0 WHERE dossier_id = ? AND active = 1 AND dismissed = 0
AND (expires_at = 0 OR expires_at > ?) AND (expires_at = 0 OR expires_at > ?)
ORDER BY ORDER BY
@ -53,20 +53,20 @@ func PromptQueryActive(dossierID string) ([]*Prompt, error) {
return result, err return result, err
} }
// PromptQueryAll retrieves all prompts for a dossier (including inactive) // TrackerQueryAll retrieves all trackers for a dossier (including inactive)
func PromptQueryAll(dossierID string) ([]*Prompt, error) { func TrackerQueryAll(dossierID string) ([]*Tracker, error) {
var result []*Prompt var result []*Tracker
err := Query(`SELECT * FROM prompts WHERE dossier_id = ? ORDER BY active DESC, time_of_day, created_at`, err := Query(`SELECT * FROM trackers WHERE dossier_id = ? ORDER BY active DESC, time_of_day, created_at`,
[]any{dossierID}, &result) []any{dossierID}, &result)
return result, err return result, err
} }
// PromptRespond records a response and advances next_ask // TrackerRespond records a response and advances next_ask
func PromptRespond(promptID string, response, responseRaw string) error { func TrackerRespond(trackerID string, response, responseRaw string) error {
now := time.Now().Unix() now := time.Now().Unix()
// Get current prompt to calculate next_ask // Get current tracker to calculate next_ask
p, err := PromptGet(promptID) p, err := TrackerGet(trackerID)
if err != nil { if err != nil {
return err return err
} }
@ -77,22 +77,22 @@ func PromptRespond(promptID string, response, responseRaw string) error {
p.NextAsk = calculateNextAsk(p.Frequency, p.TimeOfDay, now) p.NextAsk = calculateNextAsk(p.Frequency, p.TimeOfDay, now)
p.UpdatedAt = now p.UpdatedAt = now
if err := Save("prompts", p); err != nil { if err := Save("trackers", p); err != nil {
return err return err
} }
// Create entry for certain prompt types // Create entry for certain tracker types
if err := promptCreateEntry(p, response, now); err != nil { if err := trackerCreateEntry(p, response, now); err != nil {
// Log but don't fail the response // Log but don't fail the response
log.Printf("Failed to create entry for prompt %s: %v", promptID, err) log.Printf("Failed to create entry for tracker %s: %v", trackerID, err)
} }
return nil return nil
} }
// promptCreateEntry creates an entry from a prompt response // trackerCreateEntry creates an entry from a tracker response
// Uses the prompt's category/type directly - no hardcoded mappings // Uses the prompt's category/type directly - no hardcoded mappings
func promptCreateEntry(p *Prompt, response string, timestamp int64) error { func trackerCreateEntry(p *Tracker, response string, timestamp int64) error {
// Skip freeform/note types for now // Skip freeform/note types for now
if p.InputType == "freeform" { if p.InputType == "freeform" {
return nil return nil
@ -105,8 +105,8 @@ func promptCreateEntry(p *Prompt, response string, timestamp int64) error {
Type: p.Type, Type: p.Type,
Value: responseToValue(response), Value: responseToValue(response),
Timestamp: timestamp, Timestamp: timestamp,
Data: fmt.Sprintf(`{"response":%s,"source":"prompt","prompt_id":"%s"}`, response, p.PromptID), Data: fmt.Sprintf(`{"response":%s,"source":"prompt","tracker_id":"%s"}`, response, p.TrackerID),
SearchKey: p.PromptID, // Foreign key to link entry back to its prompt SearchKey: p.TrackerID, // Foreign key to link entry back to its prompt
} }
return EntryAdd(e) return EntryAdd(e)
} }
@ -141,27 +141,27 @@ func responseToValue(response string) string {
return response return response
} }
// PromptDismiss marks a prompt as dismissed // TrackerDismiss marks a tracker as dismissed
func PromptDismiss(promptID string) error { func TrackerDismiss(trackerID string) error {
p, err := PromptGet(promptID) p, err := TrackerGet(trackerID)
if err != nil { if err != nil {
return err return err
} }
p.Dismissed = true p.Dismissed = true
p.UpdatedAt = time.Now().Unix() p.UpdatedAt = time.Now().Unix()
return Save("prompts", p) return Save("trackers", p)
} }
// PromptSkip advances next_ask to tomorrow without recording a response // TrackerSkip advances next_ask to tomorrow without recording a response
func PromptSkip(promptID string) error { func TrackerSkip(trackerID string) error {
p, err := PromptGet(promptID) p, err := TrackerGet(trackerID)
if err != nil { if err != nil {
return err return err
} }
now := time.Now().Unix() now := time.Now().Unix()
p.NextAsk = now + 24*60*60 p.NextAsk = now + 24*60*60
p.UpdatedAt = now p.UpdatedAt = now
return Save("prompts", p) return Save("trackers", p)
} }
// calculateNextAsk determines when to ask again based on frequency // calculateNextAsk determines when to ask again based on frequency

View File

@ -344,8 +344,8 @@ type AuditEntry struct {
} }
// Prompt represents a scheduled question or tracker (decrypted) // Prompt represents a scheduled question or tracker (decrypted)
type Prompt struct { type Tracker struct {
PromptID string `db:"prompt_id,pk"` TrackerID string `db:"tracker_id,pk"`
DossierID string `db:"dossier_id"` DossierID string `db:"dossier_id"`
Category string `db:"category"` Category string `db:"category"`
Type string `db:"type"` Type string `db:"type"`

View File

@ -481,7 +481,7 @@ func AuditList(f *AuditFilter) ([]*AuditEntry, error) {
// --- PROMPT --- // --- PROMPT ---
type PromptFilter struct { type TrackerFilter struct {
DossierID string DossierID string
Category string Category string
Type string Type string
@ -489,27 +489,27 @@ type PromptFilter struct {
Limit int Limit int
} }
func PromptWrite(prompts ...*Prompt) error { func TrackerWrite(trackers ...*Tracker) error {
if len(prompts) == 0 { if len(trackers) == 0 {
return nil return nil
} }
for _, p := range prompts { for _, p := range trackers {
if p.PromptID == "" { if p.TrackerID == "" {
p.PromptID = NewID() p.TrackerID = NewID()
} }
} }
if len(prompts) == 1 { if len(trackers) == 1 {
return Save("prompts", prompts[0]) return Save("trackers", trackers[0])
} }
return Save("prompts", prompts) return Save("trackers", trackers)
} }
func PromptRemove(ids ...string) error { func TrackerRemove(ids ...string) error {
return deleteByIDs("prompts", "prompt_id", ids) return deleteByIDs("trackers", "tracker_id", ids)
} }
func PromptList(f *PromptFilter) ([]*Prompt, error) { func TrackerList(f *TrackerFilter) ([]*Tracker, error) {
q := "SELECT * FROM prompts WHERE 1=1" q := "SELECT * FROM trackers WHERE 1=1"
args := []any{} args := []any{}
if f != nil { if f != nil {
@ -536,7 +536,7 @@ func PromptList(f *PromptFilter) ([]*Prompt, error) {
q += fmt.Sprintf(" LIMIT %d", f.Limit) q += fmt.Sprintf(" LIMIT %d", f.Limit)
} }
var result []*Prompt var result []*Tracker
err := Query(q, args, &result) err := Query(q, args, &result)
return result, err return result, err
} }
@ -747,17 +747,17 @@ func AccessListByTargetWithNames(targetID string) ([]map[string]interface{}, err
return result, nil return result, nil
} }
// PromptDistinctTypes returns distinct category/type pairs for a dossier's active prompts // TrackerDistinctTypes returns distinct category/type pairs for a dossier's active trackers
func PromptDistinctTypes(dossierID string) (map[string][]string, error) { func TrackerDistinctTypes(dossierID string) (map[string][]string, error) {
var prompts []*Prompt var trackers []*Tracker
if err := Query("SELECT * FROM prompts WHERE dossier_id = ? AND active = 1", []any{dossierID}, &prompts); err != nil { if err := Query("SELECT * FROM trackers WHERE dossier_id = ? AND active = 1", []any{dossierID}, &trackers); err != nil {
return nil, err return nil, err
} }
// Extract distinct category/type pairs // Extract distinct category/type pairs
seen := make(map[string]bool) seen := make(map[string]bool)
result := make(map[string][]string) result := make(map[string][]string)
for _, p := range prompts { for _, p := range trackers {
key := p.Category + "|" + p.Type key := p.Category + "|" + p.Type
if !seen[key] && p.Category != "" && p.Type != "" { if !seen[key] && p.Category != "" && p.Type != "" {
seen[key] = true seen[key] = true

View File

@ -33,7 +33,7 @@ func initMobileAPI(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/auth/verify", handleAPIVerify) mux.HandleFunc("/api/v1/auth/verify", handleAPIVerify)
mux.HandleFunc("/api/v1/dashboard", handleAPIDashboard) mux.HandleFunc("/api/v1/dashboard", handleAPIDashboard)
mux.HandleFunc("/api/v1/prompts", handleAPIPrompts) mux.HandleFunc("/api/v1/prompts", handleAPIPrompts)
mux.HandleFunc("/api/v1/prompts/respond", handleAPIPromptRespond) mux.HandleFunc("/api/v1/prompts/respond", handleAPITrackerRespond)
} }
// --- Auth --- // --- Auth ---
@ -221,10 +221,10 @@ func handleAPIPrompts(w http.ResponseWriter, r *http.Request) {
return return
} }
// TODO: Implement per spec // TODO: Implement per spec
jsonOK(w, map[string]interface{}{"prompts": []interface{}{}}) jsonOK(w, map[string]interface{}{"trackers": []interface{}{}})
} }
func handleAPIPromptRespond(w http.ResponseWriter, r *http.Request) { func handleAPITrackerRespond(w http.ResponseWriter, r *http.Request) {
if cors(w, r) { return } if cors(w, r) { return }
d := getAPIAuth(r) d := getAPIAuth(r)
if d == nil { if d == nil {

View File

@ -32,11 +32,11 @@ type DossierSection struct {
// Checkin-specific: show "build your profile" prompt // Checkin-specific: show "build your profile" prompt
ShowBuildPrompt bool // true if trackable categories are empty ShowBuildPrompt bool // true if trackable categories are empty
TrackableStats map[string]int // counts for trackable categories TrackableStats map[string]int // counts for trackable categories
PromptButtons []PromptButton // buttons for empty trackable categories TrackerButtons []TrackerButton // buttons for empty trackable categories
} }
// PromptButton for the "build your profile" section // TrackerButton for the "build your profile" section
type PromptButton struct { type TrackerButton struct {
Label string Label string
Icon string Icon string
URL string URL string
@ -150,7 +150,7 @@ func BuildDossierSections(targetID, targetHex string, target *lib.Dossier, p *li
section.ShowBuildPrompt = true section.ShowBuildPrompt = true
section.Summary = T("checkin_build_profile") section.Summary = T("checkin_build_profile")
promptsURL := fmt.Sprintf("/dossier/%s/prompts", targetHex) promptsURL := fmt.Sprintf("/dossier/%s/prompts", targetHex)
section.PromptButtons = []PromptButton{ section.TrackerButtons = []TrackerButton{
{Label: T("btn_vitals"), URL: promptsURL + "?add=vital"}, {Label: T("btn_vitals"), URL: promptsURL + "?add=vital"},
{Label: T("btn_medications"), URL: promptsURL + "?add=medication"}, {Label: T("btn_medications"), URL: promptsURL + "?add=medication"},
{Label: T("btn_supplements"), URL: promptsURL + "?add=supplement"}, {Label: T("btn_supplements"), URL: promptsURL + "?add=supplement"},

View File

@ -2005,9 +2005,9 @@ func setupMux() http.Handler {
} else if strings.HasSuffix(path, "/permissions") { handlePermissions(w, r) } else if strings.HasSuffix(path, "/permissions") { handlePermissions(w, r)
} else if strings.Contains(path, "/rbac/") { handleEditRBAC(w, r) } else if strings.Contains(path, "/rbac/") { handleEditRBAC(w, r)
} else if strings.Contains(path, "/access/") { handleEditAccess(w, r) } else if strings.Contains(path, "/access/") { handleEditAccess(w, r)
} else if strings.HasSuffix(path, "/prompts") { handlePrompts(w, r) } else if strings.HasSuffix(path, "/prompts") { handleTrackers(w, r)
} else if strings.Contains(path, "/prompts/card/") { handleRenderPromptCard(w, r) } else if strings.Contains(path, "/prompts/card/") { handleRenderTrackerCard(w, r)
} else if strings.HasSuffix(path, "/prompts/respond") { handlePromptRespond(w, r) } else if strings.HasSuffix(path, "/prompts/respond") { handleTrackerRespond(w, r)
} else if strings.HasSuffix(path, "/upload") { if r.Method == "POST" { handleUploadPost(w, r) } else { handleUploadPage(w, r) } } else if strings.HasSuffix(path, "/upload") { if r.Method == "POST" { handleUploadPost(w, r) } else { handleUploadPage(w, r) }
} else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/delete") { handleDeleteFile(w, r) } else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/delete") { handleDeleteFile(w, r)
} else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/update") { handleUpdateFile(w, r) } else if strings.Contains(path, "/files/") && strings.HasSuffix(path, "/update") { handleUpdateFile(w, r)
@ -2028,7 +2028,7 @@ func setupMux() http.Handler {
mux.HandleFunc("/api/v1/auth/verify", handleAPIVerify) mux.HandleFunc("/api/v1/auth/verify", handleAPIVerify)
mux.HandleFunc("/api/v1/dashboard", handleAPIDashboard) mux.HandleFunc("/api/v1/dashboard", handleAPIDashboard)
mux.HandleFunc("/api/v1/prompts", handleAPIPrompts) mux.HandleFunc("/api/v1/prompts", handleAPIPrompts)
mux.HandleFunc("/api/v1/prompts/respond", handleAPIPromptRespond) mux.HandleFunc("/api/v1/prompts/respond", handleAPITrackerRespond)
mux.HandleFunc("/api", handleAPI) mux.HandleFunc("/api", handleAPI)
mux.HandleFunc("/api/token/generate", handleAPITokenGenerate) mux.HandleFunc("/api/token/generate", handleAPITokenGenerate)

View File

@ -279,7 +279,7 @@ func handleMCPInitialize(w http.ResponseWriter, req mcpRequest) {
"protocolVersion": mcpProtocolVersion, "protocolVersion": mcpProtocolVersion,
"capabilities": map[string]interface{}{ "capabilities": map[string]interface{}{
"tools": map[string]interface{}{}, "tools": map[string]interface{}{},
"prompts": map[string]interface{}{}, "trackers": map[string]interface{}{},
}, },
"serverInfo": map[string]interface{}{ "serverInfo": map[string]interface{}{
"name": mcpServerName, "name": mcpServerName,
@ -617,7 +617,7 @@ func handleMCPPromptsList(w http.ResponseWriter, req mcpRequest) {
}, },
} }
sendMCPResult(w, req.ID, map[string]interface{}{"prompts": prompts}) sendMCPResult(w, req.ID, map[string]interface{}{"trackers": prompts})
} }
func handleMCPPromptsGet(w http.ResponseWriter, req mcpRequest, accessToken, dossierID string) { func handleMCPPromptsGet(w http.ResponseWriter, req mcpRequest, accessToken, dossierID string) {

View File

@ -109,7 +109,7 @@
{{else if eq .Page "styleguide"}}{{template "styleguide" .}} {{else if eq .Page "styleguide"}}{{template "styleguide" .}}
{{else if eq .Page "pricing"}}{{template "pricing" .}} {{else if eq .Page "pricing"}}{{template "pricing" .}}
{{else if eq .Page "faq"}}{{template "faq" .}} {{else if eq .Page "faq"}}{{template "faq" .}}
{{else if eq .Page "prompts"}}{{template "prompts" .}} {{else if eq .Page "trackers"}}{{template "trackers" .}}
{{else if eq .Page "permissions"}}{{template "permissions" .}} {{else if eq .Page "permissions"}}{{template "permissions" .}}
{{else if eq .Page "edit_access"}}{{template "edit_access" .}} {{else if eq .Page "edit_access"}}{{template "edit_access" .}}
{{else if eq .Page "edit_rbac"}}{{template "edit_rbac" .}} {{else if eq .Page "edit_rbac"}}{{template "edit_rbac" .}}

View File

@ -86,7 +86,7 @@
<li>In the text area, add:</li> <li>In the text area, add:</li>
</ol> </ol>
<div class="code-wrapper"> <div class="code-wrapper">
<pre id="custom-instructions">At the start of health-related conversations, use the family_health_context prompt from the Inou Health connector to understand what health data is available.</pre> <pre id="custom-instructions">At the start of health-related conversations, use the family_health_context tracker from the Inou Health connector to understand what health data is available.</pre>
<button class="copy-icon" onclick="copyCode('custom-instructions', this)" title="Copy"> <button class="copy-icon" onclick="copyCode('custom-instructions', this)" title="Copy">
<svg viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect> <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
@ -161,7 +161,7 @@ If you get a 401 error with "token expired", ask me to visit https://inou.com/co
</div> </div>
{{end}} {{end}}
{{else}} {{else}}
<div class="login-prompt"> <div class="login-tracker">
<a href="/start">Sign in</a> to generate your API token and get personalized setup instructions. <a href="/start">Sign in</a> to generate your API token and get personalized setup instructions.
</div> </div>
{{end}} {{end}}

View File

@ -10,7 +10,7 @@
</div> </div>
{{if not (and .Dossier .Dossier.DossierID)}} {{if not (and .Dossier .Dossier.DossierID)}}
<div class="login-prompt"> <div class="login-tracker">
<strong>Let op:</strong> <a href="/start">Log in</a> om gepersonaliseerde instructies te zien met je account-token al ingevuld. <strong>Let op:</strong> <a href="/start">Log in</a> om gepersonaliseerde instructies te zien met je account-token al ingevuld.
</div> </div>
{{end}} {{end}}

View File

@ -10,7 +10,7 @@
</div> </div>
{{if not (and .Dossier .Dossier.DossierID)}} {{if not (and .Dossier .Dossier.DossierID)}}
<div class="login-prompt"> <div class="login-tracker">
<strong>Примечание:</strong> <a href="/start">Войдите</a>, чтобы увидеть персонализированные инструкции с вашим токеном учётной записи. <strong>Примечание:</strong> <a href="/start">Войдите</a>, чтобы увидеть персонализированные инструкции с вашим токеном учётной записи.
</div> </div>
{{end}} {{end}}

View File

@ -728,10 +728,10 @@ loadGeneticsCategories();
{{end}} {{end}}
</div> </div>
{{if .ShowBuildPrompt}} {{if .ShowBuildTracker}}
<div class="build-profile-prompt"> <div class="build-profile-tracker">
<div class="build-profile-buttons"> <div class="build-profile-buttons">
{{range .PromptButtons}} {{range .TrackerButtons}}
<a href="{{.URL}}" class="build-profile-btn"> <a href="{{.URL}}" class="build-profile-btn">
<span>{{.Label}}</span> <span>{{.Label}}</span>
</a> </a>

View File

@ -22,8 +22,8 @@
.quick-start h3 { margin: 0 0 1rem 0; font-size: 1.1rem; } .quick-start h3 { margin: 0 0 1rem 0; font-size: 1.1rem; }
.quick-start p { margin: 0.5rem 0; } .quick-start p { margin: 0.5rem 0; }
.login-prompt { background: #fff3cd; border: 1px solid #ffc107; border-radius: 8px; padding: 1rem 1.5rem; margin-bottom: 1.5rem; } .login-tracker { background: #fff3cd; border: 1px solid #ffc107; border-radius: 8px; padding: 1rem 1.5rem; margin-bottom: 1.5rem; }
.login-prompt a { color: var(--accent); font-weight: 500; } .login-tracker a { color: var(--accent); font-weight: 500; }
</style> </style>
<div class="container"> <div class="container">
@ -35,7 +35,7 @@
<a href="/" class="btn btn-secondary btn-small">← Home</a> <a href="/" class="btn btn-secondary btn-small">← Home</a>
</div> </div>
<div class="login-prompt"> <div class="login-tracker">
<strong>Note:</strong> <a href="/start">Sign in</a> to see personalized setup instructions with your account token pre-filled. <strong>Note:</strong> <a href="/start">Sign in</a> to see personalized setup instructions with your account token pre-filled.
</div> </div>

View File

@ -142,7 +142,7 @@
<div class="sg-settings-row"> <div class="sg-settings-row">
<div> <div>
<div class="sg-settings-label">Primary AI Assistant</div> <div class="sg-settings-label">Primary AI Assistant</div>
<div class="sg-settings-desc">Used for "Ask AI" prompts and analysis</div> <div class="sg-settings-desc">Used for "Ask AI" trackers and analysis</div>
</div> </div>
<div class="sg-settings-control"> <div class="sg-settings-control">
<label class="sg-llm-option selected"><input type="radio" name="llm" checked><span class="sg-llm-icon">🤖</span><span>Claude (Anthropic)</span></label> <label class="sg-llm-option selected"><input type="radio" name="llm" checked><span class="sg-llm-icon">🤖</span><span>Claude (Anthropic)</span></label>
@ -481,14 +481,14 @@
<div class="sg-modal-overlay" id="gene-modal-1" onclick="if(event.target===this)this.classList.remove('show')"> <div class="sg-modal-overlay" id="gene-modal-1" onclick="if(event.target===this)this.classList.remove('show')">
<div class="sg-modal"> <div class="sg-modal">
<h3>Ask AI about CYP2C19</h3> <h3>Ask AI about CYP2C19</h3>
<div class="sg-modal-prompt">I have a genetic variant in CYP2C19 (rs4244285) with genotype G;A. <div class="sg-modal-tracker">I have a genetic variant in CYP2C19 (rs4244285) with genotype G;A.
This makes me an intermediate metabolizer. This makes me an intermediate metabolizer.
What medications are affected by this? What should I discuss with my doctor?</div> What medications are affected by this? What should I discuss with my doctor?</div>
<div class="sg-modal-actions"> <div class="sg-modal-actions">
<button class="btn btn-secondary btn-small" onclick="this.closest('.sg-modal-overlay').classList.remove('show')">Close</button> <button class="btn btn-secondary btn-small" onclick="this.closest('.sg-modal-overlay').classList.remove('show')">Close</button>
<button class="btn btn-primary btn-small" onclick="navigator.clipboard.writeText(this.closest('.sg-modal').querySelector('.sg-modal-prompt').innerText); this.innerText='Copied!'; setTimeout(()=>this.innerText='Copy prompt', 1500)">Copy prompt</button> <button class="btn btn-primary btn-small" onclick="navigator.clipboard.writeText(this.closest('.sg-modal').querySelector('.sg-modal-tracker').innerText); this.innerText='Copied!'; setTimeout(()=>this.innerText='Copy tracker', 1500)">Copy tracker</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,49 +1,49 @@
{{/* Reusable prompt card partial - expects . to be a PromptView */}} {{/* Reusable tracker card partial - expects . to be a TrackerView */}}
<div class="prompt-item prompt-pending" data-prompt-id="{{.ID}}"> <div class="tracker-item tracker-pending" data-tracker-id="{{.ID}}">
<a href="#" class="prompt-dismiss" onclick="showDismissConfirm(this, '{{.ID}}'); return false;" title="Don't ask again">✕</a> <a href="#" class="tracker-dismiss" onclick="showDismissConfirm(this, '{{.ID}}'); return false;" title="Don't ask again">✕</a>
<div class="dismiss-confirm"> <div class="dismiss-confirm">
<span>Stop tracking?</span> <span>Stop tracking?</span>
<a href="#" onclick="confirmDismiss('{{.ID}}'); return false;">Yes</a> <a href="#" onclick="confirmDismiss('{{.ID}}'); return false;">Yes</a>
<a href="#" onclick="hideDismissConfirm(this); return false;">No</a> <a href="#" onclick="hideDismissConfirm(this); return false;">No</a>
</div> </div>
<form class="prompt-form" data-prompt-id="{{.ID}}"> <form class="tracker-form" data-tracker-id="{{.ID}}">
<div class="prompt-header"> <div class="tracker-header">
<div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px;"> <div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px;">
<div> <div>
<span class="prompt-question">{{.Question}}</span> <span class="tracker-question">{{.Question}}</span>
{{if .ScheduleFormatted}} {{if .ScheduleFormatted}}
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 4px;">🔁 {{.ScheduleFormatted}}</div> <div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 4px;">🔁 {{.ScheduleFormatted}}</div>
{{end}} {{end}}
</div> </div>
<div style="display: flex; gap: 12px; align-items: center;"> <div style="display: flex; gap: 12px; align-items: center;">
<span class="prompt-category">{{.Category}}</span> <span class="tracker-category">{{.Category}}</span>
<span class="prompt-due">{{.NextAskFormatted}}</span> <span class="tracker-due">{{.NextAskFormatted}}</span>
</div> </div>
</div> </div>
</div> </div>
<div class="prompt-body"> <div class="tracker-body">
{{if .Groups}} {{if .Groups}}
{{if eq .Layout "two-column"}} {{if eq .Layout "two-column"}}
{{/* Two-column layout - first two groups side-by-side */}} {{/* Two-column layout - first two groups side-by-side */}}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px; align-items: start;"> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px; align-items: start;">
{{range $i, $g := .Groups}} {{range $i, $g := .Groups}}
{{if lt $i 2}} {{if lt $i 2}}
<div class="prompt-group"> <div class="tracker-group">
{{if $g.Title}}<div class="prompt-group-title">{{$g.Title}}</div>{{end}} {{if $g.Title}}<div class="tracker-group-title">{{$g.Title}}</div>{{end}}
<div class="prompt-input-row" style="flex-direction: column; align-items: flex-start; gap: 8px;"> <div class="tracker-input-row" style="flex-direction: column; align-items: flex-start; gap: 8px;">
{{range $g.Fields}} {{range $g.Fields}}
{{if eq .Type "number"}} {{if eq .Type "number"}}
<div style="display: flex; align-items: center; gap: 4px; width: 100%;"> <div style="display: flex; align-items: center; gap: 4px; width: 100%;">
{{if .Label}}<span class="prompt-field-label" style="min-width: 100px;">{{.Label}}:</span>{{end}} {{if .Label}}<span class="tracker-field-label" style="min-width: 100px;">{{.Label}}:</span>{{end}}
<input type="number" name="field_{{.Key}}" <input type="number" name="field_{{.Key}}"
{{if .Min}}min="{{.Min}}"{{end}} {{if .Min}}min="{{.Min}}"{{end}}
{{if .Max}}max="{{.Max}}"{{end}} {{if .Max}}max="{{.Max}}"{{end}}
{{if .Step}}step="{{.Step}}"{{end}} {{if .Step}}step="{{.Step}}"{{end}}
{{if .Value}}value="{{.Value}}"{{end}} {{if .Value}}value="{{.Value}}"{{end}}
class="prompt-input-number" class="tracker-input-number"
style="max-width: 80px;" style="max-width: 80px;"
onkeydown="if(event.key==='Enter'){event.preventDefault();saveItem(this.closest('.prompt-item'));}"> onkeydown="if(event.key==='Enter'){event.preventDefault();saveItem(this.closest('.tracker-item'));}">
{{if .Unit}}<span class="prompt-unit">{{.Unit}}</span>{{end}} {{if .Unit}}<span class="tracker-unit">{{.Unit}}</span>{{end}}
</div> </div>
{{end}} {{end}}
{{end}} {{end}}
@ -55,18 +55,18 @@
{{/* Remaining groups below (index 2+) with save button inline */}} {{/* Remaining groups below (index 2+) with save button inline */}}
{{range $i, $g := .Groups}} {{range $i, $g := .Groups}}
{{if ge $i 2}} {{if ge $i 2}}
<div class="prompt-group" style="margin-top: 16px;"> <div class="tracker-group" style="margin-top: 16px;">
{{if $g.Title}}<div class="prompt-group-title">{{$g.Title}}</div>{{end}} {{if $g.Title}}<div class="tracker-group-title">{{$g.Title}}</div>{{end}}
{{range $g.Fields}} {{range $g.Fields}}
{{if eq .Type "text"}} {{if eq .Type "text"}}
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
<input type="text" name="field_{{.Key}}" <input type="text" name="field_{{.Key}}"
{{if .Value}}value="{{.Value}}"{{end}} {{if .Value}}value="{{.Value}}"{{end}}
{{if .MaxLength}}maxlength="{{.MaxLength}}"{{end}} {{if .MaxLength}}maxlength="{{.MaxLength}}"{{end}}
class="prompt-input-text" class="tracker-input-text"
style="flex: 1;" style="flex: 1;"
placeholder="{{.Label}}"> placeholder="{{.Label}}">
<button type="button" class="btn-save" onclick="saveItem(this.closest('.prompt-item'))">Save</button> <button type="button" class="btn-save" onclick="saveItem(this.closest('.tracker-item'))">Save</button>
</div> </div>
{{end}} {{end}}
{{end}} {{end}}
@ -82,35 +82,35 @@
{{end}} {{end}}
{{if not $hasText}} {{if not $hasText}}
<div style="display: flex; justify-content: flex-end; margin-top: 12px;"> <div style="display: flex; justify-content: flex-end; margin-top: 12px;">
<button type="button" class="btn-save" onclick="saveItem(this.closest('.prompt-item'))">Save</button> <button type="button" class="btn-save" onclick="saveItem(this.closest('.tracker-item'))">Save</button>
</div> </div>
{{end}} {{end}}
{{else}} {{else}}
{{/* Regular grouped fields */}} {{/* Regular grouped fields */}}
{{range .Groups}} {{range .Groups}}
<div class="prompt-group"> <div class="tracker-group">
{{if .Title}}<div class="prompt-group-title">{{.Title}}</div>{{end}} {{if .Title}}<div class="tracker-group-title">{{.Title}}</div>{{end}}
<div class="prompt-input-row" style="justify-content: flex-start; align-items: center; flex-wrap: wrap;"> <div class="tracker-input-row" style="justify-content: flex-start; align-items: center; flex-wrap: wrap;">
{{range .Fields}} {{range .Fields}}
{{if eq .Type "number"}} {{if eq .Type "number"}}
<div style="display: flex; align-items: center; gap: 4px; margin-right: 16px;"> <div style="display: flex; align-items: center; gap: 4px; margin-right: 16px;">
{{if .Label}}<span class="prompt-field-label">{{.Label}}:</span>{{end}} {{if .Label}}<span class="tracker-field-label">{{.Label}}:</span>{{end}}
<input type="number" name="field_{{.Key}}" <input type="number" name="field_{{.Key}}"
{{if .Min}}min="{{.Min}}"{{end}} {{if .Min}}min="{{.Min}}"{{end}}
{{if .Max}}max="{{.Max}}"{{end}} {{if .Max}}max="{{.Max}}"{{end}}
{{if .Step}}step="{{.Step}}"{{end}} {{if .Step}}step="{{.Step}}"{{end}}
{{if .Value}}value="{{.Value}}"{{end}} {{if .Value}}value="{{.Value}}"{{end}}
class="prompt-input-number" class="tracker-input-number"
style="max-width: 80px;" style="max-width: 80px;"
onkeydown="if(event.key==='Enter'){event.preventDefault();saveItem(this.closest('.prompt-item'));}"> onkeydown="if(event.key==='Enter'){event.preventDefault();saveItem(this.closest('.tracker-item'));}">
{{if .Unit}}<span class="prompt-unit">{{.Unit}}</span>{{end}} {{if .Unit}}<span class="tracker-unit">{{.Unit}}</span>{{end}}
</div> </div>
{{else if eq .Type "text"}} {{else if eq .Type "text"}}
<div style="display: flex; align-items: center; gap: 4px; width: 100%;"> <div style="display: flex; align-items: center; gap: 4px; width: 100%;">
<input type="text" name="field_{{.Key}}" <input type="text" name="field_{{.Key}}"
{{if .Value}}value="{{.Value}}"{{end}} {{if .Value}}value="{{.Value}}"{{end}}
{{if .MaxLength}}maxlength="{{.MaxLength}}"{{end}} {{if .MaxLength}}maxlength="{{.MaxLength}}"{{end}}
class="prompt-input-text" class="tracker-input-text"
style="flex: 1;" style="flex: 1;"
placeholder="{{.Label}}"> placeholder="{{.Label}}">
</div> </div>
@ -120,39 +120,39 @@
</div> </div>
{{end}} {{end}}
<div style="display: flex; justify-content: flex-end; margin-top: 12px;"> <div style="display: flex; justify-content: flex-end; margin-top: 12px;">
<button type="button" class="btn-save" onclick="saveItem(this.closest('.prompt-item'))">Save</button> <button type="button" class="btn-save" onclick="saveItem(this.closest('.tracker-item'))">Save</button>
</div> </div>
{{end}} {{end}}
{{else if .Fields}} {{else if .Fields}}
{{/* Flat fields */}} {{/* Flat fields */}}
<div class="prompt-input-row" style="justify-content: flex-start; align-items: center;"> <div class="tracker-input-row" style="justify-content: flex-start; align-items: center;">
{{range .Fields}} {{range .Fields}}
{{if eq .Type "number"}} {{if eq .Type "number"}}
{{if .Label}}<span class="prompt-field-label">{{.Label}}</span>{{end}} {{if .Label}}<span class="tracker-field-label">{{.Label}}</span>{{end}}
<input type="number" name="field_{{.Key}}" <input type="number" name="field_{{.Key}}"
{{if .Min}}min="{{.Min}}"{{end}} {{if .Min}}min="{{.Min}}"{{end}}
{{if .Max}}max="{{.Max}}"{{end}} {{if .Max}}max="{{.Max}}"{{end}}
{{if .Step}}step="{{.Step}}"{{end}} {{if .Step}}step="{{.Step}}"{{end}}
{{if .Value}}value="{{.Value}}"{{else}}placeholder="0"{{end}} {{if .Value}}value="{{.Value}}"{{else}}placeholder="0"{{end}}
class="prompt-input-number" class="tracker-input-number"
style="max-width: 120px;" style="max-width: 120px;"
onkeydown="if(event.key==='Enter'){event.preventDefault();saveItem(this.closest('.prompt-item'));}"> onkeydown="if(event.key==='Enter'){event.preventDefault();saveItem(this.closest('.tracker-item'));}">
{{if .Unit}}<span class="prompt-unit">{{.Unit}}</span>{{end}} {{if .Unit}}<span class="tracker-unit">{{.Unit}}</span>{{end}}
{{else if eq .Type "checkbox"}} {{else if eq .Type "checkbox"}}
<button type="button" class="prompt-btn" data-field="{{.Key}}" data-value="yes" onclick="selectYesNo(this)">Yes</button> <button type="button" class="tracker-btn" data-field="{{.Key}}" data-value="yes" onclick="selectYesNo(this)">Yes</button>
<button type="button" class="prompt-btn" data-field="{{.Key}}" data-value="no" onclick="selectYesNo(this)">No</button> <button type="button" class="tracker-btn" data-field="{{.Key}}" data-value="no" onclick="selectYesNo(this)">No</button>
<input type="hidden" name="field_{{.Key}}" value=""> <input type="hidden" name="field_{{.Key}}" value="">
{{else if eq .Type "text"}} {{else if eq .Type "text"}}
{{if .Label}}<span class="prompt-field-label">{{.Label}}</span>{{end}} {{if .Label}}<span class="tracker-field-label">{{.Label}}</span>{{end}}
<input type="text" name="field_{{.Key}}" <input type="text" name="field_{{.Key}}"
{{if .Value}}value="{{.Value}}"{{end}} {{if .Value}}value="{{.Value}}"{{end}}
{{if .MaxLength}}maxlength="{{.MaxLength}}"{{end}} {{if .MaxLength}}maxlength="{{.MaxLength}}"{{end}}
class="prompt-input-text" class="tracker-input-text"
placeholder="{{.Label}}" placeholder="{{.Label}}"
onkeydown="if(event.key==='Enter'){event.preventDefault();saveItem(this.closest('.prompt-item'));}"> onkeydown="if(event.key==='Enter'){event.preventDefault();saveItem(this.closest('.tracker-item'));}">
{{end}} {{end}}
{{end}} {{end}}
<button type="button" class="btn-save" onclick="saveItem(this.closest('.prompt-item'))" style="margin-left: auto;">Save</button> <button type="button" class="btn-save" onclick="saveItem(this.closest('.tracker-item'))" style="margin-left: auto;">Save</button>
</div> </div>
{{end}} {{end}}
</div> </div>

View File

@ -12,7 +12,7 @@ import (
"inou/lib" "inou/lib"
) )
type PromptField struct { type TrackerField struct {
Key string `json:"key"` Key string `json:"key"`
Label string `json:"label"` Label string `json:"label"`
Type string `json:"type"` Type string `json:"type"`
@ -28,9 +28,9 @@ type PromptField struct {
Value string // filled from last_response Value string // filled from last_response
} }
type PromptGroup struct { type TrackerGroup struct {
Title string `json:"title"` Title string `json:"title"`
Fields []PromptField `json:"fields"` Fields []TrackerField `json:"fields"`
} }
type ScheduleSlot struct { type ScheduleSlot struct {
@ -38,13 +38,13 @@ type ScheduleSlot struct {
Time string `json:"time"` Time string `json:"time"`
} }
type PromptView struct { type TrackerView struct {
ID string ID string
Category string Category string
Type string Type string
Question string Question string
Fields []PromptField Fields []TrackerField
Groups []PromptGroup Groups []TrackerGroup
Layout string // "two-column" or empty Layout string // "two-column" or empty
Schedule []ScheduleSlot Schedule []ScheduleSlot
ScheduleFormatted string ScheduleFormatted string
@ -67,15 +67,15 @@ type EntryView struct {
Value string Value string
Question string Question string
SourceInput string SourceInput string
Fields []PromptField Fields []TrackerField
Groups []PromptGroup Groups []TrackerGroup
Layout string // "two-column" or empty Layout string // "two-column" or empty
Timestamp int64 Timestamp int64
TimeFormatted string TimeFormatted string
PromptID string // linked prompt for delete TrackerID string // linked tracker for delete
} }
func handlePrompts(w http.ResponseWriter, r *http.Request) { func handleTrackers(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r) p := getLoggedInDossier(r)
if p == nil { if p == nil {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
@ -108,7 +108,7 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
lang := getLang(r) lang := getLang(r)
showAll := r.URL.Query().Get("all") == "1" showAll := r.URL.Query().Get("all") == "1"
// Fetch prompts from API // Fetch trackers from API
url := fmt.Sprintf("http://localhost:8082/api/prompts?dossier=%s&all=1", targetHex) url := fmt.Sprintf("http://localhost:8082/api/prompts?dossier=%s&all=1", targetHex)
if showAll { if showAll {
url += "&all=1" url += "&all=1"
@ -116,7 +116,7 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
resp, err := http.Get(url) resp, err := http.Get(url)
if err != nil { if err != nil {
render(w, r, PageData{Page: "prompts", Lang: lang, Dossier: p, Error: "Failed to load prompts"}) render(w, r, PageData{Page: "trackers", Lang: lang, Dossier: p, Error: "Failed to load prompts"})
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
@ -153,9 +153,9 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
} }
// Convert to view models // Convert to view models
var prompts []PromptView var trackers []TrackerView
for _, ap := range apiPrompts { for _, ap := range apiPrompts {
pv := PromptView{ pv := TrackerView{
ID: ap.ID, ID: ap.ID,
Category: translateCategory(ap.Category), Category: translateCategory(ap.Category),
Type: ap.Type, Type: ap.Type,
@ -171,7 +171,7 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
if ap.NextAsk > 0 { if ap.NextAsk > 0 {
pv.NextAskFormatted = formatDueDate(ap.NextAsk) pv.NextAskFormatted = formatDueDate(ap.NextAsk)
// Freeform prompts (like "anything else") are never overdue // Freeform trackers (like "anything else") are never overdue
pv.IsOverdue = ap.InputType != "freeform" && ap.NextAsk < time.Now().Unix() pv.IsOverdue = ap.InputType != "freeform" && ap.NextAsk < time.Now().Unix()
} }
if ap.LastResponseAt > 0 { if ap.LastResponseAt > 0 {
@ -181,8 +181,8 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
// Parse input_config // Parse input_config
if len(ap.InputConfig) > 0 { if len(ap.InputConfig) > 0 {
var config struct { var config struct {
Fields []PromptField `json:"fields"` Fields []TrackerField `json:"fields"`
Groups []PromptGroup `json:"groups"` Groups []TrackerGroup `json:"groups"`
Layout string `json:"layout"` Layout string `json:"layout"`
} }
if json.Unmarshal(ap.InputConfig, &config) == nil { if json.Unmarshal(ap.InputConfig, &config) == nil {
@ -228,20 +228,20 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
} }
} }
prompts = append(prompts, pv) trackers = append(trackers, pv)
} }
data := PageData{ data := PageData{
Page: "prompts", Page: "trackers",
Lang: lang, Lang: lang,
Dossier: p, Dossier: p,
TargetDossier: target, TargetDossier: target,
} }
data.T = translations[lang] data.T = translations[lang]
// Build prompt lookup map by prompt ID // Build tracker lookup map by tracker ID
promptMap := make(map[string]PromptView) promptMap := make(map[string]TrackerView)
for _, p := range prompts { for _, p := range trackers {
promptMap[p.ID] = p promptMap[p.ID] = p
log.Printf("DEBUG: Added to promptMap: id=%s, question=%s", p.ID, p.Question) log.Printf("DEBUG: Added to promptMap: id=%s, question=%s", p.ID, p.Question)
} }
@ -267,21 +267,21 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
TimeFormatted: time.Unix(e.Timestamp, 0).Format("Jan 2, 3:04 PM"), TimeFormatted: time.Unix(e.Timestamp, 0).Format("Jan 2, 3:04 PM"),
} }
// Link entry to prompt via SearchKey (foreign key relationship) // Link entry to tracker via SearchKey (foreign key relationship)
if e.SearchKey != "" { if e.SearchKey != "" {
log.Printf("DEBUG: Looking for prompt with id=%s", e.SearchKey) log.Printf("DEBUG: Looking for tracker with id=%s", e.SearchKey)
if prompt, ok := promptMap[e.SearchKey]; ok { 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)) 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.Question = prompt.Question
ev.PromptID = prompt.ID ev.TrackerID = prompt.ID
// Copy fields, groups, and layout from prompt // Copy fields, groups, and layout from prompt
ev.Fields = make([]PromptField, len(prompt.Fields)) ev.Fields = make([]TrackerField, len(prompt.Fields))
copy(ev.Fields, prompt.Fields) copy(ev.Fields, prompt.Fields)
ev.Groups = make([]PromptGroup, len(prompt.Groups)) ev.Groups = make([]TrackerGroup, len(prompt.Groups))
copy(ev.Groups, prompt.Groups) copy(ev.Groups, prompt.Groups)
ev.Layout = prompt.Layout ev.Layout = prompt.Layout
} else { } else {
log.Printf("DEBUG: No prompt found with id=%s", e.SearchKey) log.Printf("DEBUG: No tracker found with id=%s", e.SearchKey)
} }
} else { } else {
log.Printf("DEBUG: Entry has no search_key (may be freeform/legacy entry)") log.Printf("DEBUG: Entry has no search_key (may be freeform/legacy entry)")
@ -319,10 +319,10 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
} }
log.Printf("DEBUG: Built %d entry views for display", len(entries)) log.Printf("DEBUG: Built %d entry views for display", len(entries))
// Split prompts into regular and freeform // Split trackers into regular and freeform
var allPrompts, freeformPrompts []PromptView var allPrompts, freeformPrompts []TrackerView
dueCount := 0 dueCount := 0
for _, p := range prompts { for _, p := range trackers {
if p.IsFreeform { if p.IsFreeform {
freeformPrompts = append(freeformPrompts, p) freeformPrompts = append(freeformPrompts, p)
} else { } else {
@ -333,19 +333,19 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
} }
} }
// Add prompts and entries to template // Add trackers and entries to template
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
templates.ExecuteTemplate(w, "base.tmpl", struct { templates.ExecuteTemplate(w, "base.tmpl", struct {
PageData PageData
AllPrompts []PromptView AllPrompts []TrackerView
FreeformPrompts []PromptView FreeformPrompts []TrackerView
Entries []EntryView Entries []EntryView
TargetHex string TargetHex string
DueCount int DueCount int
}{data, allPrompts, freeformPrompts, entries, targetHex, dueCount}) }{data, allPrompts, freeformPrompts, entries, targetHex, dueCount})
} }
func handlePromptRespond(w http.ResponseWriter, r *http.Request) { func handleTrackerRespond(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return return
@ -365,7 +365,7 @@ func handlePromptRespond(w http.ResponseWriter, r *http.Request) {
targetHex := parts[2] targetHex := parts[2]
r.ParseForm() r.ParseForm()
promptID := r.FormValue("prompt_id") trackerID := r.FormValue("tracker_id")
action := r.FormValue("action") action := r.FormValue("action")
// Build response JSON from form fields // Build response JSON from form fields
@ -389,7 +389,7 @@ func handlePromptRespond(w http.ResponseWriter, r *http.Request) {
// Call API // Call API
reqBody := map[string]string{ reqBody := map[string]string{
"prompt_id": promptID, "tracker_id": trackerID,
"response": string(responseJSON), "response": string(responseJSON),
"response_raw": responseRaw, "response_raw": responseRaw,
"action": action, "action": action,
@ -469,8 +469,8 @@ func formatDueDate(ts int64) string {
return due.Format("Jan 2, 3:04 PM") return due.Format("Jan 2, 3:04 PM")
} }
// handleRenderPromptCard renders just the prompt card HTML for a given prompt // handleRenderTrackerCard renders just the tracker card HTML for a given prompt
func handleRenderPromptCard(w http.ResponseWriter, r *http.Request) { func handleRenderTrackerCard(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r) p := getLoggedInDossier(r)
if p == nil { if p == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
@ -478,15 +478,15 @@ func handleRenderPromptCard(w http.ResponseWriter, r *http.Request) {
} }
parts := strings.Split(r.URL.Path, "/") parts := strings.Split(r.URL.Path, "/")
// Path: /dossier/{id}/prompts/card/{promptID} // Path: /dossier/{id}/prompts/card/{trackerID}
if len(parts) < 6 { if len(parts) < 6 {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
targetHex := parts[2] targetHex := parts[2]
promptID := parts[5] trackerID := parts[5]
// Get prompt from API // Get tracker from API
resp, err := http.Get(fmt.Sprintf("http://localhost:8082/api/prompts?dossier=%s", targetHex)) resp, err := http.Get(fmt.Sprintf("http://localhost:8082/api/prompts?dossier=%s", targetHex))
if err != nil { if err != nil {
http.Error(w, "Failed to fetch prompts", http.StatusInternalServerError) http.Error(w, "Failed to fetch prompts", http.StatusInternalServerError)
@ -494,17 +494,17 @@ func handleRenderPromptCard(w http.ResponseWriter, r *http.Request) {
} }
defer resp.Body.Close() defer resp.Body.Close()
var prompts []map[string]interface{} var trackers []map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&prompts); err != nil { if err := json.NewDecoder(resp.Body).Decode(&trackers); err != nil {
http.Error(w, "Failed to parse prompts", http.StatusInternalServerError) http.Error(w, "Failed to parse prompts", http.StatusInternalServerError)
return return
} }
// Find the specific prompt // Find the specific prompt
var targetPrompt map[string]interface{} var targetPrompt map[string]interface{}
for _, prompt := range prompts { for _, tracker := range trackers {
if prompt["id"] == promptID { if tracker["id"] == trackerID {
targetPrompt = prompt targetPrompt = tracker
break break
} }
} }
@ -514,8 +514,8 @@ func handleRenderPromptCard(w http.ResponseWriter, r *http.Request) {
return return
} }
// Convert to PromptView // Convert to TrackerView
promptView := convertToPromptView(targetPrompt) promptView := convertToTrackerView(targetPrompt)
// Render just the card template // Render just the card template
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
@ -525,9 +525,9 @@ func handleRenderPromptCard(w http.ResponseWriter, r *http.Request) {
} }
} }
// convertToPromptView converts API prompt JSON to PromptView // convertToTrackerView converts API tracker JSON to TrackerView
func convertToPromptView(prompt map[string]interface{}) PromptView { func convertToTrackerView(prompt map[string]interface{}) TrackerView {
view := PromptView{ view := TrackerView{
ID: prompt["id"].(string), ID: prompt["id"].(string),
Question: getString(prompt, "question"), Question: getString(prompt, "question"),
Category: getString(prompt, "category"), Category: getString(prompt, "category"),
@ -565,13 +565,13 @@ func convertToPromptView(prompt map[string]interface{}) PromptView {
if groupsRaw, ok := config["groups"].([]interface{}); ok { if groupsRaw, ok := config["groups"].([]interface{}); ok {
for _, g := range groupsRaw { for _, g := range groupsRaw {
group := g.(map[string]interface{}) group := g.(map[string]interface{})
fg := PromptGroup{ fg := TrackerGroup{
Title: getString(group, "title"), Title: getString(group, "title"),
} }
if fieldsRaw, ok := group["fields"].([]interface{}); ok { if fieldsRaw, ok := group["fields"].([]interface{}); ok {
for _, f := range fieldsRaw { for _, f := range fieldsRaw {
field := f.(map[string]interface{}) field := f.(map[string]interface{})
fg.Fields = append(fg.Fields, PromptField{ fg.Fields = append(fg.Fields, TrackerField{
Key: getString(field, "key"), Key: getString(field, "key"),
Label: getString(field, "label"), Label: getString(field, "label"),
Type: getString(field, "type"), Type: getString(field, "type"),
@ -587,7 +587,7 @@ func convertToPromptView(prompt map[string]interface{}) PromptView {
if fieldsRaw, ok := config["fields"].([]interface{}); ok { if fieldsRaw, ok := config["fields"].([]interface{}); ok {
for _, f := range fieldsRaw { for _, f := range fieldsRaw {
field := f.(map[string]interface{}) field := f.(map[string]interface{})
view.Fields = append(view.Fields, PromptField{ view.Fields = append(view.Fields, TrackerField{
Key: getString(field, "key"), Key: getString(field, "key"),
Label: getString(field, "label"), Label: getString(field, "label"),
Type: getString(field, "type"), Type: getString(field, "type"),