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:
2026-02-15 14:58:34 +01:00
parent 924b66bc64
commit 3bb2ef2759
9 changed files with 581 additions and 51 deletions
+64 -6
View File
@@ -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
+8 -4
View File
@@ -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]
}
}
+167 -17
View File
@@ -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)
}
}