feat: add JSON serialization, urgency field, and snake_case API contract
Fix latent API bug where multi-word fields (RecurrenceDuration, ParentUUID, CreatedAt) serialized as PascalCase, breaking the frontend. Add explicit snake_case json tags and custom MarshalJSON/UnmarshalJSON on Task, Status, and APIKey to emit unix timestamps and string status codes. Add Urgency float64 as a derived field on Task, populated via PopulateUrgency helper in all handlers before serialization. The report engine's sortByUrgency now also retains the computed score. Frontend updated with urgency type, color-coded badge in TaskItem, and mock data values. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,9 @@ func ListTasks(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Report sorts may already populate urgency, but ensure it for all paths
|
||||
engine.PopulateUrgency(tasks...)
|
||||
|
||||
jsonResponse(w, http.StatusOK, map[string]interface{}{
|
||||
"report": reportName,
|
||||
"tasks": tasks,
|
||||
@@ -87,6 +90,7 @@ func ListTasks(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
engine.PopulateUrgency(tasks...)
|
||||
jsonResponse(w, http.StatusOK, tasks)
|
||||
}
|
||||
|
||||
@@ -159,6 +163,7 @@ func CreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
engine.PopulateUrgency(task)
|
||||
jsonResponse(w, http.StatusCreated, task)
|
||||
}
|
||||
|
||||
@@ -178,6 +183,7 @@ func GetTask(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
engine.PopulateUrgency(task)
|
||||
jsonResponse(w, http.StatusOK, task)
|
||||
}
|
||||
|
||||
@@ -271,6 +277,7 @@ func UpdateTask(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
engine.PopulateUrgency(task)
|
||||
jsonResponse(w, http.StatusOK, task)
|
||||
}
|
||||
|
||||
@@ -322,6 +329,7 @@ func CompleteTask(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
engine.PopulateUrgency(task)
|
||||
jsonResponse(w, http.StatusOK, task)
|
||||
}
|
||||
|
||||
@@ -346,6 +354,7 @@ func StartTask(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
engine.PopulateUrgency(task)
|
||||
jsonResponse(w, http.StatusOK, task)
|
||||
}
|
||||
|
||||
@@ -370,6 +379,7 @@ func StopTask(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
engine.PopulateUrgency(task)
|
||||
jsonResponse(w, http.StatusOK, task)
|
||||
}
|
||||
|
||||
@@ -429,6 +439,7 @@ func AddTaskTag(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
engine.PopulateUrgency(task)
|
||||
jsonResponse(w, http.StatusOK, task)
|
||||
}
|
||||
|
||||
@@ -489,6 +500,7 @@ func ParseTask(w http.ResponseWriter, r *http.Request) {
|
||||
errorResponse(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
engine.PopulateUrgency(instance)
|
||||
jsonResponse(w, http.StatusCreated, map[string]interface{}{"task": instance})
|
||||
return
|
||||
}
|
||||
@@ -500,6 +512,7 @@ func ParseTask(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
engine.PopulateUrgency(task)
|
||||
jsonResponse(w, http.StatusCreated, map[string]interface{}{"task": task})
|
||||
}
|
||||
|
||||
@@ -525,5 +538,6 @@ func RemoveTaskTag(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
engine.PopulateUrgency(task)
|
||||
jsonResponse(w, http.StatusOK, task)
|
||||
}
|
||||
|
||||
@@ -57,8 +57,14 @@ func TestParseTask_DescriptionOnly(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("expected task in data")
|
||||
}
|
||||
if task["Description"] != "buy groceries" {
|
||||
t.Errorf("expected description 'buy groceries', got %v", task["Description"])
|
||||
if task["description"] != "buy groceries" {
|
||||
t.Errorf("expected description 'buy groceries', got %v", task["description"])
|
||||
}
|
||||
if _, ok := task["urgency"]; !ok {
|
||||
t.Error("expected urgency field in response")
|
||||
}
|
||||
if _, ok := task["urgency"].(float64); !ok {
|
||||
t.Error("expected urgency to be a number")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,11 +87,11 @@ func TestParseTask_WithModifiers(t *testing.T) {
|
||||
|
||||
data := resp["data"].(map[string]interface{})
|
||||
task := data["task"].(map[string]interface{})
|
||||
if task["Description"] != "review PR" {
|
||||
t.Errorf("expected description 'review PR', got %v", task["Description"])
|
||||
if task["description"] != "review PR" {
|
||||
t.Errorf("expected description 'review PR', got %v", task["description"])
|
||||
}
|
||||
if task["Project"] != "backend" {
|
||||
t.Errorf("expected project 'backend', got %v", task["Project"])
|
||||
if task["project"] != "backend" {
|
||||
t.Errorf("expected project 'backend', got %v", task["project"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,9 +115,9 @@ func TestParseTask_WithRecurrence(t *testing.T) {
|
||||
data := resp["data"].(map[string]interface{})
|
||||
task := data["task"].(map[string]interface{})
|
||||
|
||||
// The returned task should be the first instance (pending, with ParentUUID)
|
||||
if task["ParentUUID"] == nil {
|
||||
t.Error("expected ParentUUID to be set for recurring instance")
|
||||
// The returned task should be the first instance (pending, with parent_uuid)
|
||||
if task["parent_uuid"] == nil {
|
||||
t.Error("expected parent_uuid to be set for recurring instance")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package engine
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -11,12 +12,69 @@ import (
|
||||
|
||||
// APIKey represents an API key in the database
|
||||
type APIKey struct {
|
||||
ID int
|
||||
Name string
|
||||
UserID int
|
||||
CreatedAt time.Time
|
||||
LastUsed *time.Time
|
||||
Revoked bool
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
UserID int `json:"user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsed *time.Time `json:"last_used,omitempty"`
|
||||
Revoked bool `json:"revoked"`
|
||||
}
|
||||
|
||||
// MarshalJSON emits APIKey with unix timestamps.
|
||||
func (k APIKey) MarshalJSON() ([]byte, error) {
|
||||
type keyJSON struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
UserID int `json:"user_id"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
LastUsed *int64 `json:"last_used,omitempty"`
|
||||
Revoked bool `json:"revoked"`
|
||||
}
|
||||
|
||||
var lastUsed *int64
|
||||
if k.LastUsed != nil {
|
||||
v := k.LastUsed.Unix()
|
||||
lastUsed = &v
|
||||
}
|
||||
|
||||
return json.Marshal(keyJSON{
|
||||
ID: k.ID,
|
||||
Name: k.Name,
|
||||
UserID: k.UserID,
|
||||
CreatedAt: k.CreatedAt.Unix(),
|
||||
LastUsed: lastUsed,
|
||||
Revoked: k.Revoked,
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalJSON parses APIKey from JSON with unix timestamps.
|
||||
func (k *APIKey) UnmarshalJSON(data []byte) error {
|
||||
type keyJSON struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
UserID int `json:"user_id"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
LastUsed *int64 `json:"last_used,omitempty"`
|
||||
Revoked bool `json:"revoked"`
|
||||
}
|
||||
|
||||
var raw keyJSON
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
k.ID = raw.ID
|
||||
k.Name = raw.Name
|
||||
k.UserID = raw.UserID
|
||||
k.CreatedAt = time.Unix(raw.CreatedAt, 0)
|
||||
k.Revoked = raw.Revoked
|
||||
|
||||
if raw.LastUsed != nil {
|
||||
t := time.Unix(*raw.LastUsed, 0)
|
||||
k.LastUsed = &t
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateAPIKey creates a new API key for the given name
|
||||
|
||||
@@ -420,7 +420,8 @@ func mergeFilters(base, user *Filter) *Filter {
|
||||
return merged
|
||||
}
|
||||
|
||||
// sortByUrgency is a helper function to sort tasks by urgency (descending)
|
||||
// sortByUrgency is a helper function to sort tasks by urgency (descending).
|
||||
// It also populates the Urgency field on each task so the score is available in responses.
|
||||
func sortByUrgency(tasks []*Task) []*Task {
|
||||
cfg, _ := GetConfig()
|
||||
coeffs := BuildUrgencyCoefficients(cfg)
|
||||
@@ -428,11 +429,14 @@ func sortByUrgency(tasks []*Task) []*Task {
|
||||
sorted := make([]*Task, len(tasks))
|
||||
copy(sorted, tasks)
|
||||
|
||||
// Calculate and store urgency on each task
|
||||
for _, t := range sorted {
|
||||
t.Urgency = t.CalculateUrgency(coeffs)
|
||||
}
|
||||
|
||||
for i := 0; i < len(sorted)-1; i++ {
|
||||
for j := i + 1; j < len(sorted); j++ {
|
||||
urgI := sorted[i].CalculateUrgency(coeffs)
|
||||
urgJ := sorted[j].CalculateUrgency(coeffs)
|
||||
if urgI < urgJ {
|
||||
if sorted[i].Urgency < sorted[j].Urgency {
|
||||
sorted[i], sorted[j] = sorted[j], sorted[i]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -16,6 +17,24 @@ const (
|
||||
StatusRecurring Status = 'R'
|
||||
)
|
||||
|
||||
// MarshalJSON encodes Status as a single-character string (e.g. "P", "C").
|
||||
func (s Status) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(string(s))
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes a single-character string into a Status.
|
||||
func (s *Status) UnmarshalJSON(data []byte) error {
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(str) != 1 {
|
||||
return fmt.Errorf("invalid status: %q", str)
|
||||
}
|
||||
*s = Status(str[0])
|
||||
return nil
|
||||
}
|
||||
|
||||
type Priority int
|
||||
|
||||
const (
|
||||
@@ -27,31 +46,153 @@ const (
|
||||
|
||||
type Task struct {
|
||||
// Identity
|
||||
UUID uuid.UUID
|
||||
ID int
|
||||
UUID uuid.UUID `json:"uuid"`
|
||||
ID int `json:"id"`
|
||||
|
||||
// Core fields
|
||||
Status Status
|
||||
Description string
|
||||
Project *string
|
||||
Priority Priority
|
||||
Status Status `json:"status"`
|
||||
Description string `json:"description"`
|
||||
Project *string `json:"project"`
|
||||
Priority Priority `json:"priority"`
|
||||
|
||||
// Timestamps
|
||||
Created time.Time
|
||||
Modified time.Time
|
||||
Start *time.Time
|
||||
End *time.Time
|
||||
Due *time.Time
|
||||
Scheduled *time.Time
|
||||
Wait *time.Time
|
||||
Until *time.Time
|
||||
Created time.Time `json:"created"`
|
||||
Modified time.Time `json:"modified"`
|
||||
Start *time.Time `json:"start,omitempty"`
|
||||
End *time.Time `json:"end,omitempty"`
|
||||
Due *time.Time `json:"due,omitempty"`
|
||||
Scheduled *time.Time `json:"scheduled,omitempty"`
|
||||
Wait *time.Time `json:"wait,omitempty"`
|
||||
Until *time.Time `json:"until,omitempty"`
|
||||
|
||||
// Recurrence (parent-child approach)
|
||||
RecurrenceDuration *time.Duration
|
||||
ParentUUID *uuid.UUID
|
||||
RecurrenceDuration *time.Duration `json:"recurrence_duration,omitempty"`
|
||||
ParentUUID *uuid.UUID `json:"parent_uuid,omitempty"`
|
||||
|
||||
// Derived fields (not stored in DB)
|
||||
Tags []string
|
||||
Tags []string `json:"tags"`
|
||||
Urgency float64 `json:"urgency"`
|
||||
}
|
||||
|
||||
// MarshalJSON emits Task with unix timestamps (int64) instead of RFC3339 strings.
|
||||
func (t Task) MarshalJSON() ([]byte, error) {
|
||||
type taskJSON struct {
|
||||
UUID uuid.UUID `json:"uuid"`
|
||||
ID int `json:"id"`
|
||||
Status Status `json:"status"`
|
||||
Description string `json:"description"`
|
||||
Project *string `json:"project"`
|
||||
Priority Priority `json:"priority"`
|
||||
Created int64 `json:"created"`
|
||||
Modified int64 `json:"modified"`
|
||||
Start *int64 `json:"start,omitempty"`
|
||||
End *int64 `json:"end,omitempty"`
|
||||
Due *int64 `json:"due,omitempty"`
|
||||
Scheduled *int64 `json:"scheduled,omitempty"`
|
||||
Wait *int64 `json:"wait,omitempty"`
|
||||
Until *int64 `json:"until,omitempty"`
|
||||
RecurrenceDuration *int64 `json:"recurrence_duration,omitempty"`
|
||||
ParentUUID *uuid.UUID `json:"parent_uuid,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
Urgency float64 `json:"urgency"`
|
||||
}
|
||||
|
||||
toUnix := func(tp *time.Time) *int64 {
|
||||
if tp == nil {
|
||||
return nil
|
||||
}
|
||||
v := tp.Unix()
|
||||
return &v
|
||||
}
|
||||
|
||||
var recurDur *int64
|
||||
if t.RecurrenceDuration != nil {
|
||||
v := int64(*t.RecurrenceDuration / time.Second)
|
||||
recurDur = &v
|
||||
}
|
||||
|
||||
return json.Marshal(taskJSON{
|
||||
UUID: t.UUID,
|
||||
ID: t.ID,
|
||||
Status: t.Status,
|
||||
Description: t.Description,
|
||||
Project: t.Project,
|
||||
Priority: t.Priority,
|
||||
Created: t.Created.Unix(),
|
||||
Modified: t.Modified.Unix(),
|
||||
Start: toUnix(t.Start),
|
||||
End: toUnix(t.End),
|
||||
Due: toUnix(t.Due),
|
||||
Scheduled: toUnix(t.Scheduled),
|
||||
Wait: toUnix(t.Wait),
|
||||
Until: toUnix(t.Until),
|
||||
RecurrenceDuration: recurDur,
|
||||
ParentUUID: t.ParentUUID,
|
||||
Tags: t.Tags,
|
||||
Urgency: t.Urgency,
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalJSON parses Task from JSON with unix timestamps (int64) and duration in seconds.
|
||||
func (t *Task) UnmarshalJSON(data []byte) error {
|
||||
type taskJSON struct {
|
||||
UUID uuid.UUID `json:"uuid"`
|
||||
ID int `json:"id"`
|
||||
Status Status `json:"status"`
|
||||
Description string `json:"description"`
|
||||
Project *string `json:"project"`
|
||||
Priority Priority `json:"priority"`
|
||||
Created int64 `json:"created"`
|
||||
Modified int64 `json:"modified"`
|
||||
Start *int64 `json:"start,omitempty"`
|
||||
End *int64 `json:"end,omitempty"`
|
||||
Due *int64 `json:"due,omitempty"`
|
||||
Scheduled *int64 `json:"scheduled,omitempty"`
|
||||
Wait *int64 `json:"wait,omitempty"`
|
||||
Until *int64 `json:"until,omitempty"`
|
||||
RecurrenceDuration *int64 `json:"recurrence_duration,omitempty"`
|
||||
ParentUUID *uuid.UUID `json:"parent_uuid,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
Urgency float64 `json:"urgency"`
|
||||
}
|
||||
|
||||
var raw taskJSON
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fromUnix := func(v *int64) *time.Time {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
t := time.Unix(*v, 0)
|
||||
return &t
|
||||
}
|
||||
|
||||
t.UUID = raw.UUID
|
||||
t.ID = raw.ID
|
||||
t.Status = raw.Status
|
||||
t.Description = raw.Description
|
||||
t.Project = raw.Project
|
||||
t.Priority = raw.Priority
|
||||
t.Created = time.Unix(raw.Created, 0)
|
||||
t.Modified = time.Unix(raw.Modified, 0)
|
||||
t.Start = fromUnix(raw.Start)
|
||||
t.End = fromUnix(raw.End)
|
||||
t.Due = fromUnix(raw.Due)
|
||||
t.Scheduled = fromUnix(raw.Scheduled)
|
||||
t.Wait = fromUnix(raw.Wait)
|
||||
t.Until = fromUnix(raw.Until)
|
||||
t.ParentUUID = raw.ParentUUID
|
||||
t.Tags = raw.Tags
|
||||
t.Urgency = raw.Urgency
|
||||
|
||||
if raw.RecurrenceDuration != nil {
|
||||
d := time.Duration(*raw.RecurrenceDuration) * time.Second
|
||||
t.RecurrenceDuration = &d
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// timeNow returns current time (allows mocking in tests)
|
||||
@@ -622,3 +763,12 @@ func (t *Task) IsRecurringTemplate() bool {
|
||||
func (t *Task) IsRecurringInstance() bool {
|
||||
return t.ParentUUID != nil
|
||||
}
|
||||
|
||||
// PopulateUrgency computes and sets the Urgency field on the given tasks.
|
||||
func PopulateUrgency(tasks ...*Task) {
|
||||
cfg, _ := GetConfig()
|
||||
coeffs := BuildUrgencyCoefficients(cfg)
|
||||
for _, t := range tasks {
|
||||
t.Urgency = t.CalculateUrgency(coeffs)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user