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.")
}
// Initialize prompts directory
// Initialize trackers directory
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) {
promptsDir = "prompts" // Dev fallback
promptsDir = "trackers" // Dev fallback
}
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.
func callLLMForPrompt(userInput string, dossierID string) (*ExtractionResult, error) {
// callLLMForTracker is the main entry point for turning user text into a structured prompt.
func callLLMForTracker(userInput string, dossierID string) (*ExtractionResult, error) {
triage, err := runTriage(userInput, dossierID)
if err != nil {
return nil, err
@ -71,7 +71,7 @@ func callLLMForPrompt(userInput string, dossierID string) (*ExtractionResult, er
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)
}
@ -79,7 +79,7 @@ func callLLMForPrompt(userInput string, dossierID string) (*ExtractionResult, er
// --- Local Prompt Handling & DB Functions ---
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)
if err != nil {
return "", err
@ -233,10 +233,10 @@ func runExtraction(userInput, category, language, dossierID string, existingType
}
func getExistingPromptTypes(dossierID string) map[string][]string {
result, err := lib.PromptDistinctTypes(dossierID)
func getExistingTrackerTypes(dossierID string) map[string][]string {
result, err := lib.TrackerDistinctTypes(dossierID)
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 result
@ -252,6 +252,6 @@ func callSonnet(prompt string) (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")
}

View File

@ -11,7 +11,7 @@ import (
)
// PromptResponse is the API representation of a prompt, including dynamic data.
type PromptResponse struct {
type TrackerResponse struct {
ID string `json:"id"`
Category string `json:"category"`
Type string `json:"type"`
@ -24,21 +24,21 @@ type PromptResponse struct {
Active bool `json:"active"`
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"`
LastResponseRaw string `json:"last_response_raw,omitempty"`
LastResponseAt int64 `json:"last_response_at,omitempty"`
}
type PromptRespondRequest struct {
PromptID string `json:"prompt_id"`
type TrackerRespondRequest struct {
TrackerID string `json:"tracker_id"`
Response string `json:"response"` // JSON string
ResponseRaw string `json:"response_raw"` // what they typed
Action string `json:"action"` // "respond", "skip", "dismiss"
}
// 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")
dossierHex := r.URL.Query().Get("dossier")
@ -48,13 +48,13 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
}
dossierID := dossierHex
var prompts []*lib.Prompt
var trackers []*lib.Tracker
var err error
if r.URL.Query().Get("all") == "1" {
prompts, err = lib.PromptQueryAll(dossierID)
trackers, err = lib.TrackerQueryAll(dossierID)
} else {
prompts, err = lib.PromptQueryActive(dossierID)
trackers, err = lib.TrackerQueryActive(dossierID)
}
if err != nil {
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
hasFreeform := false
for _, p := range prompts {
for _, p := range trackers {
if p.InputType == "freeform" && p.Active {
hasFreeform = true
break
}
}
if !hasFreeform {
freeform := &lib.Prompt{
freeform := &lib.Tracker{
DossierID: dossierID,
Category: "note",
Type: "freeform",
@ -78,18 +78,18 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
InputType: "freeform",
Active: true,
}
if err := lib.PromptAdd(freeform); err == nil {
prompts = append(prompts, freeform)
if err := lib.TrackerAdd(freeform); err == nil {
trackers = append(trackers, freeform)
}
}
result := make([]PromptResponse, 0, len(prompts))
result := make([]TrackerResponse, 0, len(trackers))
now := time.Now().Unix()
for _, p := range prompts {
for _, p := range trackers {
isDue := p.NextAsk <= now || p.NextAsk == 0 || p.InputType == "freeform"
pr := PromptResponse{
ID: p.PromptID,
pr := TrackerResponse{
ID: p.TrackerID,
Category: p.Category,
Type: p.Type,
Question: p.Question,
@ -117,7 +117,7 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
// 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")
if r.Method != "POST" {
@ -125,32 +125,32 @@ func handlePromptRespond(w http.ResponseWriter, r *http.Request) {
return
}
var req PromptRespondRequest
var req TrackerRespondRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
promptID := req.PromptID
if promptID == "" {
http.Error(w, "prompt_id required", http.StatusBadRequest)
trackerID := req.TrackerID
if trackerID == "" {
http.Error(w, "tracker_id required", http.StatusBadRequest)
return
}
var err error
var newPrompt *lib.Prompt
var newTracker *lib.Tracker
switch req.Action {
case "respond", "":
err = lib.PromptRespond(promptID, req.Response, req.ResponseRaw)
// Check if this is a freeform prompt - if so, generate new prompt from input
err = lib.TrackerRespond(trackerID, req.Response, req.ResponseRaw)
// Check if this is a freeform tracker - if so, generate new tracker from input
if err == nil && req.ResponseRaw != "" {
newPrompt, _ = tryGeneratePromptFromFreeform(promptID, req.ResponseRaw)
newTracker, _ = tryGenerateTrackerFromFreeform(trackerID, req.ResponseRaw)
}
case "skip":
err = lib.PromptSkip(promptID)
err = lib.TrackerSkip(trackerID)
case "dismiss":
err = lib.PromptDismiss(promptID)
err = lib.TrackerDismiss(trackerID)
default:
http.Error(w, "invalid action", http.StatusBadRequest)
return
@ -163,35 +163,35 @@ func handlePromptRespond(w http.ResponseWriter, r *http.Request) {
result := map[string]interface{}{
"ok": true,
"prompt_id": req.PromptID,
"tracker_id": req.TrackerID,
"action": req.Action,
}
if newPrompt != nil {
np := promptToAPI(newPrompt)
if newTracker != nil {
np := promptToAPI(newTracker)
result["new_prompt"] = np
}
json.NewEncoder(w).Encode(result)
}
// 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
switch {
case path == "/api/prompts" && r.Method == "GET":
handlePrompts(w, r)
handleTrackers(w, r)
case path == "/api/prompts" && r.Method == "POST":
handlePromptCreate(w, r)
handleTrackerCreate(w, r)
case path == "/api/prompts/respond":
handlePromptRespond(w, r)
handleTrackerRespond(w, r)
default:
http.NotFound(w, r)
}
}
// tryGeneratePromptFromFreeform checks if the prompt is freeform and generates a new prompt from user input
func tryGeneratePromptFromFreeform(promptID string, userInput string) (*lib.Prompt, string) {
p, err := lib.PromptGet(promptID)
// tryGenerateTrackerFromFreeform checks if the tracker is freeform and generates a new tracker from user input
func tryGenerateTrackerFromFreeform(trackerID string, userInput string) (*lib.Tracker, string) {
p, err := lib.TrackerGet(trackerID)
if err != nil || p == nil {
return nil, ""
}
@ -200,9 +200,9 @@ func tryGeneratePromptFromFreeform(promptID string, userInput string) (*lib.Prom
return nil, ""
}
generated, err := callLLMForPrompt(userInput, p.DossierID)
generated, err := callLLMForTracker(userInput, p.DossierID)
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, ""
}
@ -254,7 +254,7 @@ func tryGeneratePromptFromFreeform(promptID string, userInput string) (*lib.Prom
scheduleJSON, _ := json.Marshal(generated.Schedule)
inputConfigJSON, _ := json.Marshal(generated.InputConfig)
newPrompt := &lib.Prompt{
newTracker := &lib.Tracker{
DossierID: p.DossierID,
Category: generated.Category,
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.
// 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)
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 {
entry.SearchKey = newPrompt.PromptID
entry.SearchKey = newTracker.TrackerID
if err := lib.EntryAdd(entry); err != nil {
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))
return newPrompt, primaryEntryValue
log.Printf("Created tracker from freeform: %s (%s/%s) and linked %d entries", newTracker.Question, newTracker.Category, newTracker.Type, len(createdEntries))
return newTracker, primaryEntryValue
}
// 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()
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)
result := map[string]interface{}{
"id": p.PromptID,
"id": p.TrackerID,
"category": p.Category,
"type": p.Type,
"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
http.Error(w, "Not implemented", http.StatusNotImplemented)
}

View File

@ -414,7 +414,7 @@ type ParseEntryResponse struct {
Timestamp int64 `json:"timestamp"`
}
type ParsePromptResponse struct {
type ParseTrackerResponse struct {
ID string `json:"id"`
Category string `json:"category"`
Type string `json:"type"`
@ -428,7 +428,7 @@ type ParseResponse struct {
Category string `json:"category"`
Type string `json:"type"`
Entries []ParseEntryResponse `json:"entries"`
Prompt *ParsePromptResponse `json:"prompt,omitempty"`
Tracker *ParseTrackerResponse `json:"prompt,omitempty"`
Error string `json:"error,omitempty"`
}
@ -460,7 +460,7 @@ func v1Parse(w http.ResponseWriter, r *http.Request, dossierID string) {
}
// Run triage + extraction
generated, err := callLLMForPrompt(req.Input, dossierID)
generated, err := callLLMForTracker(req.Input, dossierID)
if err != nil {
v1Error(w, err.Error(), http.StatusInternalServerError)
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 {
nextAsk := calculateNextAskFromSchedule(generated.Schedule, now)
scheduleJSON, _ := json.Marshal(generated.Schedule)
inputConfigJSON, _ := json.Marshal(generated.InputConfig)
prompt := &lib.Prompt{
tracker := &lib.Tracker{
DossierID: dossierID,
Category: generated.Category,
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
if len(generated.Entries) > 0 && generated.Entries[0].Data != nil {
initialData, _ := json.Marshal(generated.Entries[0].Data)
prompt.LastResponse = string(initialData)
prompt.LastResponseRaw = generated.Entries[0].Value
prompt.LastResponseAt = now.Unix()
tracker.LastResponse = string(initialData)
tracker.LastResponseRaw = generated.Entries[0].Value
tracker.LastResponseAt = now.Unix()
}
if err := lib.PromptAdd(prompt); err == nil {
if err := lib.TrackerAdd(tracker); err == nil {
var inputConfig any
json.Unmarshal([]byte(prompt.InputConfig), &inputConfig)
json.Unmarshal([]byte(tracker.InputConfig), &inputConfig)
resp.Prompt = &ParsePromptResponse{
ID: prompt.PromptID,
Category: prompt.Category,
Type: prompt.Type,
Question: prompt.Question,
InputType: prompt.InputType,
resp.Tracker = &ParseTrackerResponse{
ID: tracker.TrackerID,
Category: tracker.Category,
Type: tracker.Type,
Question: tracker.Question,
InputType: tracker.InputType,
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()
filter := &lib.PromptFilter{DossierID: dossierID}
filter := &lib.TrackerFilter{DossierID: dossierID}
if cat := q.Get("category"); cat != "" {
filter.Category = cat
}
@ -575,16 +575,16 @@ func v1Prompts(w http.ResponseWriter, r *http.Request, dossierID string) {
filter.ActiveOnly = true
}
prompts, err := lib.PromptList(filter)
trackers, err := lib.TrackerList(filter)
if err != nil {
v1Error(w, err.Error(), http.StatusInternalServerError)
return
}
var result []map[string]any
for _, p := range prompts {
for _, p := range trackers {
result = append(result, map[string]any{
"id": p.PromptID,
"id": p.TrackerID,
"category": p.Category,
"type": p.Type,
"question": p.Question,
@ -766,7 +766,7 @@ func v1Router(w http.ResponseWriter, r *http.Request) {
v1Audit(w, r, parts[1])
// 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])
// POST /dossiers/{id}/parse

View File

@ -46,7 +46,7 @@ type FormField struct {
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).
type ScheduleSlot struct {
Days []string `json:"days"`

View File

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

View File

@ -11,15 +11,15 @@ import (
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.
func InitPrompts(path string) {
promptsDir = path
}
// PromptsDir returns the configured prompts directory.
// This is used by local prompt loading functions in consumer packages.
func PromptsDir() string {
// TrackerPromptsDir returns the configured trackers directory.
// This is used by local tracker loading functions in consumer packages.
func TrackerPromptsDir() string {
return promptsDir
}

View File

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

View File

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

View File

@ -32,11 +32,11 @@ type DossierSection struct {
// Checkin-specific: show "build your profile" prompt
ShowBuildPrompt bool // true if trackable categories are empty
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
type PromptButton struct {
// TrackerButton for the "build your profile" section
type TrackerButton struct {
Label string
Icon string
URL string
@ -150,7 +150,7 @@ func BuildDossierSections(targetID, targetHex string, target *lib.Dossier, p *li
section.ShowBuildPrompt = true
section.Summary = T("checkin_build_profile")
promptsURL := fmt.Sprintf("/dossier/%s/prompts", targetHex)
section.PromptButtons = []PromptButton{
section.TrackerButtons = []TrackerButton{
{Label: T("btn_vitals"), URL: promptsURL + "?add=vital"},
{Label: T("btn_medications"), URL: promptsURL + "?add=medication"},
{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.Contains(path, "/rbac/") { handleEditRBAC(w, r)
} else if strings.Contains(path, "/access/") { handleEditAccess(w, r)
} else if strings.HasSuffix(path, "/prompts") { handlePrompts(w, r)
} else if strings.Contains(path, "/prompts/card/") { handleRenderPromptCard(w, r)
} else if strings.HasSuffix(path, "/prompts/respond") { handlePromptRespond(w, r)
} else if strings.HasSuffix(path, "/prompts") { handleTrackers(w, r)
} else if strings.Contains(path, "/prompts/card/") { handleRenderTrackerCard(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.Contains(path, "/files/") && strings.HasSuffix(path, "/delete") { handleDeleteFile(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/dashboard", handleAPIDashboard)
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/token/generate", handleAPITokenGenerate)

View File

@ -279,7 +279,7 @@ func handleMCPInitialize(w http.ResponseWriter, req mcpRequest) {
"protocolVersion": mcpProtocolVersion,
"capabilities": map[string]interface{}{
"tools": map[string]interface{}{},
"prompts": map[string]interface{}{},
"trackers": map[string]interface{}{},
},
"serverInfo": map[string]interface{}{
"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) {

View File

@ -109,7 +109,7 @@
{{else if eq .Page "styleguide"}}{{template "styleguide" .}}
{{else if eq .Page "pricing"}}{{template "pricing" .}}
{{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 "edit_access"}}{{template "edit_access" .}}
{{else if eq .Page "edit_rbac"}}{{template "edit_rbac" .}}

View File

@ -86,7 +86,7 @@
<li>In the text area, add:</li>
</ol>
<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">
<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>
@ -161,7 +161,7 @@ If you get a 401 error with "token expired", ask me to visit https://inou.com/co
</div>
{{end}}
{{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.
</div>
{{end}}

View File

@ -10,7 +10,7 @@
</div>
{{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.
</div>
{{end}}

View File

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

View File

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

View File

@ -22,8 +22,8 @@
.quick-start h3 { margin: 0 0 1rem 0; font-size: 1.1rem; }
.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-prompt a { color: var(--accent); font-weight: 500; }
.login-tracker { background: #fff3cd; border: 1px solid #ffc107; border-radius: 8px; padding: 1rem 1.5rem; margin-bottom: 1.5rem; }
.login-tracker a { color: var(--accent); font-weight: 500; }
</style>
<div class="container">
@ -35,7 +35,7 @@
<a href="/" class="btn btn-secondary btn-small">← Home</a>
</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.
</div>

View File

@ -142,7 +142,7 @@
<div class="sg-settings-row">
<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 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>
@ -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">
<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.
What medications are affected by this? What should I discuss with my doctor?</div>
<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-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>

View File

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

View File

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