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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Report sorts may already populate urgency, but ensure it for all paths
|
||||||
|
engine.PopulateUrgency(tasks...)
|
||||||
|
|
||||||
jsonResponse(w, http.StatusOK, map[string]interface{}{
|
jsonResponse(w, http.StatusOK, map[string]interface{}{
|
||||||
"report": reportName,
|
"report": reportName,
|
||||||
"tasks": tasks,
|
"tasks": tasks,
|
||||||
@@ -87,6 +90,7 @@ func ListTasks(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
engine.PopulateUrgency(tasks...)
|
||||||
jsonResponse(w, http.StatusOK, tasks)
|
jsonResponse(w, http.StatusOK, tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +163,7 @@ func CreateTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
engine.PopulateUrgency(task)
|
||||||
jsonResponse(w, http.StatusCreated, task)
|
jsonResponse(w, http.StatusCreated, task)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +183,7 @@ func GetTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
engine.PopulateUrgency(task)
|
||||||
jsonResponse(w, http.StatusOK, task)
|
jsonResponse(w, http.StatusOK, task)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +277,7 @@ func UpdateTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
engine.PopulateUrgency(task)
|
||||||
jsonResponse(w, http.StatusOK, task)
|
jsonResponse(w, http.StatusOK, task)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,6 +329,7 @@ func CompleteTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
engine.PopulateUrgency(task)
|
||||||
jsonResponse(w, http.StatusOK, task)
|
jsonResponse(w, http.StatusOK, task)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +354,7 @@ func StartTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
engine.PopulateUrgency(task)
|
||||||
jsonResponse(w, http.StatusOK, task)
|
jsonResponse(w, http.StatusOK, task)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,6 +379,7 @@ func StopTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
engine.PopulateUrgency(task)
|
||||||
jsonResponse(w, http.StatusOK, task)
|
jsonResponse(w, http.StatusOK, task)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +439,7 @@ func AddTaskTag(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
engine.PopulateUrgency(task)
|
||||||
jsonResponse(w, http.StatusOK, task)
|
jsonResponse(w, http.StatusOK, task)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,6 +500,7 @@ func ParseTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
errorResponse(w, http.StatusBadRequest, err.Error())
|
errorResponse(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
engine.PopulateUrgency(instance)
|
||||||
jsonResponse(w, http.StatusCreated, map[string]interface{}{"task": instance})
|
jsonResponse(w, http.StatusCreated, map[string]interface{}{"task": instance})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -500,6 +512,7 @@ func ParseTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
engine.PopulateUrgency(task)
|
||||||
jsonResponse(w, http.StatusCreated, map[string]interface{}{"task": task})
|
jsonResponse(w, http.StatusCreated, map[string]interface{}{"task": task})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,5 +538,6 @@ func RemoveTaskTag(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
engine.PopulateUrgency(task)
|
||||||
jsonResponse(w, http.StatusOK, task)
|
jsonResponse(w, http.StatusOK, task)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,8 +57,14 @@ func TestParseTask_DescriptionOnly(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected task in data")
|
t.Fatal("expected task in data")
|
||||||
}
|
}
|
||||||
if task["Description"] != "buy groceries" {
|
if task["description"] != "buy groceries" {
|
||||||
t.Errorf("expected description 'buy groceries', got %v", task["Description"])
|
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{})
|
data := resp["data"].(map[string]interface{})
|
||||||
task := data["task"].(map[string]interface{})
|
task := data["task"].(map[string]interface{})
|
||||||
if task["Description"] != "review PR" {
|
if task["description"] != "review PR" {
|
||||||
t.Errorf("expected description 'review PR', got %v", task["Description"])
|
t.Errorf("expected description 'review PR', got %v", task["description"])
|
||||||
}
|
}
|
||||||
if task["Project"] != "backend" {
|
if task["project"] != "backend" {
|
||||||
t.Errorf("expected project 'backend', got %v", task["Project"])
|
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{})
|
data := resp["data"].(map[string]interface{})
|
||||||
task := data["task"].(map[string]interface{})
|
task := data["task"].(map[string]interface{})
|
||||||
|
|
||||||
// The returned task should be the first instance (pending, with ParentUUID)
|
// The returned task should be the first instance (pending, with parent_uuid)
|
||||||
if task["ParentUUID"] == nil {
|
if task["parent_uuid"] == nil {
|
||||||
t.Error("expected ParentUUID to be set for recurring instance")
|
t.Error("expected parent_uuid to be set for recurring instance")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package engine
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -11,12 +12,69 @@ import (
|
|||||||
|
|
||||||
// APIKey represents an API key in the database
|
// APIKey represents an API key in the database
|
||||||
type APIKey struct {
|
type APIKey struct {
|
||||||
ID int
|
ID int `json:"id"`
|
||||||
Name string
|
Name string `json:"name"`
|
||||||
UserID int
|
UserID int `json:"user_id"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time `json:"created_at"`
|
||||||
LastUsed *time.Time
|
LastUsed *time.Time `json:"last_used,omitempty"`
|
||||||
Revoked bool
|
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
|
// GenerateAPIKey creates a new API key for the given name
|
||||||
|
|||||||
@@ -420,7 +420,8 @@ func mergeFilters(base, user *Filter) *Filter {
|
|||||||
return merged
|
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 {
|
func sortByUrgency(tasks []*Task) []*Task {
|
||||||
cfg, _ := GetConfig()
|
cfg, _ := GetConfig()
|
||||||
coeffs := BuildUrgencyCoefficients(cfg)
|
coeffs := BuildUrgencyCoefficients(cfg)
|
||||||
@@ -428,11 +429,14 @@ func sortByUrgency(tasks []*Task) []*Task {
|
|||||||
sorted := make([]*Task, len(tasks))
|
sorted := make([]*Task, len(tasks))
|
||||||
copy(sorted, 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 i := 0; i < len(sorted)-1; i++ {
|
||||||
for j := i + 1; j < len(sorted); j++ {
|
for j := i + 1; j < len(sorted); j++ {
|
||||||
urgI := sorted[i].CalculateUrgency(coeffs)
|
if sorted[i].Urgency < sorted[j].Urgency {
|
||||||
urgJ := sorted[j].CalculateUrgency(coeffs)
|
|
||||||
if urgI < urgJ {
|
|
||||||
sorted[i], sorted[j] = sorted[j], sorted[i]
|
sorted[i], sorted[j] = sorted[j], sorted[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package engine
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,6 +17,24 @@ const (
|
|||||||
StatusRecurring Status = 'R'
|
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
|
type Priority int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -27,31 +46,153 @@ const (
|
|||||||
|
|
||||||
type Task struct {
|
type Task struct {
|
||||||
// Identity
|
// Identity
|
||||||
UUID uuid.UUID
|
UUID uuid.UUID `json:"uuid"`
|
||||||
ID int
|
ID int `json:"id"`
|
||||||
|
|
||||||
// Core fields
|
// Core fields
|
||||||
Status Status
|
Status Status `json:"status"`
|
||||||
Description string
|
Description string `json:"description"`
|
||||||
Project *string
|
Project *string `json:"project"`
|
||||||
Priority Priority
|
Priority Priority `json:"priority"`
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
Created time.Time
|
Created time.Time `json:"created"`
|
||||||
Modified time.Time
|
Modified time.Time `json:"modified"`
|
||||||
Start *time.Time
|
Start *time.Time `json:"start,omitempty"`
|
||||||
End *time.Time
|
End *time.Time `json:"end,omitempty"`
|
||||||
Due *time.Time
|
Due *time.Time `json:"due,omitempty"`
|
||||||
Scheduled *time.Time
|
Scheduled *time.Time `json:"scheduled,omitempty"`
|
||||||
Wait *time.Time
|
Wait *time.Time `json:"wait,omitempty"`
|
||||||
Until *time.Time
|
Until *time.Time `json:"until,omitempty"`
|
||||||
|
|
||||||
// Recurrence (parent-child approach)
|
// Recurrence (parent-child approach)
|
||||||
RecurrenceDuration *time.Duration
|
RecurrenceDuration *time.Duration `json:"recurrence_duration,omitempty"`
|
||||||
ParentUUID *uuid.UUID
|
ParentUUID *uuid.UUID `json:"parent_uuid,omitempty"`
|
||||||
|
|
||||||
// Derived fields (not stored in DB)
|
// 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)
|
// timeNow returns current time (allows mocking in tests)
|
||||||
@@ -622,3 +763,12 @@ func (t *Task) IsRecurringTemplate() bool {
|
|||||||
func (t *Task) IsRecurringInstance() bool {
|
func (t *Task) IsRecurringInstance() bool {
|
||||||
return t.ParentUUID != nil
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
# API Serialization Fix & Urgency Field
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Two issues block showing urgency scores in the web frontend:
|
||||||
|
|
||||||
|
1. **The Task struct has no JSON tags.** Go defaults to PascalCase field names
|
||||||
|
(`Description`, `ParentUUID`, `RecurrenceDuration`), but the frontend expects
|
||||||
|
snake_case (`description`, `parent_uuid`, `recurrence_duration`). There is no
|
||||||
|
transformation layer on either side — the frontend code works today only
|
||||||
|
because single-word fields like `description`/`Description` are
|
||||||
|
case-insensitive in JavaScript property access... actually they're not.
|
||||||
|
**This is a latent bug** — any field with multiple words
|
||||||
|
(`RecurrenceDuration`, `ParentUUID`) is broken in production.
|
||||||
|
|
||||||
|
2. **Urgency is computed but never exposed.** The report engine calculates
|
||||||
|
urgency internally for sorting (`sortByUrgency` in `report.go`) but discards
|
||||||
|
the score before serialization. The `Task` struct has no urgency field.
|
||||||
|
|
||||||
|
### Secondary issue: `time.Time` serialization
|
||||||
|
|
||||||
|
Go's `time.Time` marshals as RFC3339 strings (`"2026-02-15T10:30:00Z"`), but the
|
||||||
|
frontend expects unix timestamps (numbers). The `Status` type (`byte`) marshals
|
||||||
|
as an integer (80 for `'P'`), but the frontend expects a string character
|
||||||
|
(`"P"`). These need explicit handling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Backend (`opal-task`)
|
||||||
|
|
||||||
|
#### 1. Add json tags to `engine.Task`
|
||||||
|
|
||||||
|
File: `internal/engine/task.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Task 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 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"`
|
||||||
|
RecurrenceDuration *time.Duration `json:"recurrence_duration,omitempty"`
|
||||||
|
ParentUUID *uuid.UUID `json:"parent_uuid,omitempty"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Urgency float64 `json:"urgency"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Custom JSON marshaling for `Status` and timestamps
|
||||||
|
|
||||||
|
`Status` is a `byte` — it will serialize as `80` not `"P"`. Add a
|
||||||
|
`MarshalJSON`/`UnmarshalJSON` pair on `Status` to emit a single-character
|
||||||
|
string.
|
||||||
|
|
||||||
|
For `time.Time`, the cleanest approach is a custom `MarshalJSON` on `Task` that
|
||||||
|
emits unix timestamps for all time fields. This matches the existing frontend
|
||||||
|
expectation and avoids date-parsing complexity in the browser.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// On Status type
|
||||||
|
func (s Status) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(string(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For timestamps, implement `MarshalJSON` and `UnmarshalJSON` on `Task` that
|
||||||
|
convert between `time.Time` and unix seconds (int64). Nullable time fields
|
||||||
|
become `null` or the unix value. `RecurrenceDuration` (a `time.Duration`, stored
|
||||||
|
internally as nanoseconds) must also be converted to seconds for consistency —
|
||||||
|
the API uses seconds as its universal time unit. The symmetric pair ensures
|
||||||
|
`json.Unmarshal` into a `Task` works correctly (for sync, tests,
|
||||||
|
client-to-client), not just the handler request structs.
|
||||||
|
|
||||||
|
#### 3. Populate urgency before returning
|
||||||
|
|
||||||
|
**Where:** Centralize in a helper that handlers call before responding.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// In a new file or in task.go
|
||||||
|
func PopulateUrgency(tasks ...*Task) {
|
||||||
|
cfg, _ := GetConfig()
|
||||||
|
coeffs := BuildUrgencyCoefficients(cfg)
|
||||||
|
for _, t := range tasks {
|
||||||
|
t.Urgency = t.CalculateUrgency(coeffs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Call sites** — every handler in `handlers/tasks.go` that returns task(s):
|
||||||
|
|
||||||
|
| Handler | Returns |
|
||||||
|
|----------------|-----------|
|
||||||
|
| `ListTasks` | `[]*Task` |
|
||||||
|
| `CreateTask` | `*Task` |
|
||||||
|
| `GetTask` | `*Task` |
|
||||||
|
| `UpdateTask` | `*Task` |
|
||||||
|
| `CompleteTask` | `*Task` |
|
||||||
|
| `StartTask` | `*Task` |
|
||||||
|
| `StopTask` | `*Task` |
|
||||||
|
| `AddTaskTag` | `*Task` |
|
||||||
|
| `RemoveTaskTag`| `*Task` |
|
||||||
|
| `ParseTask` | `*Task` |
|
||||||
|
|
||||||
|
The report path (`ListTasks` with `?report=`) already sorts by urgency via
|
||||||
|
`sortByUrgency` — it should also populate the field so the score is in the
|
||||||
|
response. Currently urgency is calculated, used for sorting, then thrown away.
|
||||||
|
|
||||||
|
#### 4. Update tests
|
||||||
|
|
||||||
|
`handlers/tasks_test.go` currently asserts PascalCase keys
|
||||||
|
(`task["Description"]`). Update to snake_case (`task["description"]`). Add
|
||||||
|
assertions for `urgency` field presence and that it's a number.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend (`opal-web`)
|
||||||
|
|
||||||
|
#### 5. Add `urgency` to Task type
|
||||||
|
|
||||||
|
File: `src/lib/api/types.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Task
|
||||||
|
* ...existing fields...
|
||||||
|
* @property {number} urgency
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. Display urgency in TaskItem
|
||||||
|
|
||||||
|
File: `src/lib/components/TaskItem.svelte`
|
||||||
|
|
||||||
|
Show the urgency score as a small numeric badge in the task metadata row.
|
||||||
|
Render it as a one-decimal float (e.g. `8.2`) with color coding:
|
||||||
|
|
||||||
|
| Range | Meaning | Color |
|
||||||
|
|----------|-------------|--------------------------------|
|
||||||
|
| >= 10 | Critical | `--color-priority-high-text` |
|
||||||
|
| >= 5 | High | `--color-priority-medium-text` |
|
||||||
|
| > 0 | Normal | `--text-secondary` |
|
||||||
|
| 0 | None | Don't render |
|
||||||
|
|
||||||
|
Position: rightmost item in the meta row, right-aligned. Use a monospace or
|
||||||
|
tabular-nums font variant so scores don't cause layout shift.
|
||||||
|
|
||||||
|
#### 7. Update mock data
|
||||||
|
|
||||||
|
File: `src/lib/mock/tasks.js`
|
||||||
|
|
||||||
|
Add `urgency` field to mock tasks with representative values so mock mode
|
||||||
|
continues to work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Decisions
|
||||||
|
|
||||||
|
### ADR-7: JSON tags with snake_case convention
|
||||||
|
|
||||||
|
**Context:** The Go Task struct has no json tags. PascalCase default breaks
|
||||||
|
multi-word fields in the frontend.
|
||||||
|
|
||||||
|
**Decision:** Add explicit `json:"snake_case"` tags to all Task fields.
|
||||||
|
|
||||||
|
**Alternatives:**
|
||||||
|
- Frontend transformation layer (rejected — masks the real problem, adds runtime
|
||||||
|
overhead, easy to forget when adding new fields)
|
||||||
|
- Middleware that converts all response keys (rejected — fragile, doesn't handle
|
||||||
|
nested types, hides the contract)
|
||||||
|
|
||||||
|
**Consequences:** Breaking change to API response shape for any existing
|
||||||
|
consumers. Since the web frontend is the only consumer and its types already
|
||||||
|
expect snake_case, this is actually a **fix** not a break.
|
||||||
|
|
||||||
|
### ADR-8: Custom MarshalJSON for unix timestamps
|
||||||
|
|
||||||
|
**Context:** `time.Time` marshals as RFC3339 strings, frontend expects unix ints.
|
||||||
|
|
||||||
|
**Decision:** Implement `MarshalJSON` on `Task` that emits unix seconds for all
|
||||||
|
time fields and string for Status.
|
||||||
|
|
||||||
|
**Alternatives:**
|
||||||
|
- Use a custom `UnixTime` type wrapper (rejected — too invasive, changes every
|
||||||
|
function that touches time fields)
|
||||||
|
- Parse RFC3339 in the frontend (rejected — adds complexity to every consumer,
|
||||||
|
breaks existing date math that assumes unix seconds)
|
||||||
|
|
||||||
|
**Consequences:** Time values in API responses are plain integers (seconds).
|
||||||
|
Nullable times are `null`. `recurrence_duration` is also seconds (not
|
||||||
|
nanoseconds) — a 1-week recurrence is `604800`, not `604800000000000`. Simple to
|
||||||
|
consume in any language.
|
||||||
|
|
||||||
|
### ADR-9: Urgency as a derived field on Task struct
|
||||||
|
|
||||||
|
**Context:** Urgency is computed from task attributes + config coefficients. It's
|
||||||
|
used for sorting in reports but never exposed to consumers.
|
||||||
|
|
||||||
|
**Decision:** Add `Urgency float64` to the Task struct alongside `Tags` (both
|
||||||
|
are derived/computed, not stored in DB). Populate via a `PopulateUrgency` helper
|
||||||
|
called in handlers before serialization.
|
||||||
|
|
||||||
|
**Alternatives:**
|
||||||
|
- Separate response wrapper struct (rejected — duplicates the entire type for one
|
||||||
|
field, adds mapping boilerplate in every handler)
|
||||||
|
- Compute client-side (rejected — requires shipping coefficient config to the
|
||||||
|
frontend, duplicates complex calculation logic)
|
||||||
|
|
||||||
|
**Consequences:** Every API response that includes tasks will have urgency
|
||||||
|
scores. The score is a snapshot at response time — it may drift slightly from
|
||||||
|
what the sort order used if computed at different moments, but this is negligible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Change Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
opal-task/
|
||||||
|
internal/engine/
|
||||||
|
task.go .............. ADD json tags, ADD Urgency field, ADD MarshalJSON
|
||||||
|
ADD PopulateUrgency helper
|
||||||
|
urgency.go ........... No changes
|
||||||
|
report.go ............ Populate urgency after sort (in sortByUrgency or Execute)
|
||||||
|
internal/api/handlers/
|
||||||
|
tasks.go ............. Call PopulateUrgency before jsonResponse in all handlers
|
||||||
|
tasks_test.go ........ Update key assertions to snake_case, add urgency checks
|
||||||
|
|
||||||
|
opal-web/
|
||||||
|
src/lib/api/types.js ... ADD urgency field to Task typedef
|
||||||
|
src/lib/components/
|
||||||
|
TaskItem.svelte ...... ADD urgency badge to meta row
|
||||||
|
src/lib/mock/tasks.js .. ADD urgency values to mock data
|
||||||
|
```
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
* @property {number|null} recurrence_duration
|
* @property {number|null} recurrence_duration
|
||||||
* @property {string|null} parent_uuid
|
* @property {string|null} parent_uuid
|
||||||
* @property {string[]} tags
|
* @property {string[]} tags
|
||||||
|
* @property {number} urgency
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -73,6 +73,16 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if task.urgency > 0}
|
||||||
|
<span class="meta-item urgency"
|
||||||
|
class:urgency-critical={task.urgency >= 10}
|
||||||
|
class:urgency-high={task.urgency >= 5 && task.urgency < 10}
|
||||||
|
class:urgency-normal={task.urgency > 0 && task.urgency < 5}
|
||||||
|
>
|
||||||
|
{task.urgency.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,4 +206,21 @@
|
|||||||
color: var(--color-tag-text);
|
color: var(--color-tag-text);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.urgency {
|
||||||
|
margin-left: auto;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgency-critical {
|
||||||
|
color: var(--color-priority-high-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgency-high {
|
||||||
|
color: var(--color-priority-medium-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgency-normal {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ export const mockTasks = [
|
|||||||
until: null,
|
until: null,
|
||||||
recurrence_duration: null,
|
recurrence_duration: null,
|
||||||
parent_uuid: null,
|
parent_uuid: null,
|
||||||
tags: ['devops', 'selfhosted']
|
tags: ['devops', 'selfhosted'],
|
||||||
|
urgency: 14.2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
uuid: '11111111-1111-4111-a111-111111111102',
|
uuid: '11111111-1111-4111-a111-111111111102',
|
||||||
@@ -46,7 +47,8 @@ export const mockTasks = [
|
|||||||
until: null,
|
until: null,
|
||||||
recurrence_duration: null,
|
recurrence_duration: null,
|
||||||
parent_uuid: null,
|
parent_uuid: null,
|
||||||
tags: ['testing', 'backend']
|
tags: ['testing', 'backend'],
|
||||||
|
urgency: 7.3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
uuid: '11111111-1111-4111-a111-111111111103',
|
uuid: '11111111-1111-4111-a111-111111111103',
|
||||||
@@ -65,7 +67,8 @@ export const mockTasks = [
|
|||||||
until: null,
|
until: null,
|
||||||
recurrence_duration: null,
|
recurrence_duration: null,
|
||||||
parent_uuid: null,
|
parent_uuid: null,
|
||||||
tags: ['bug']
|
tags: ['bug'],
|
||||||
|
urgency: 4.1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
uuid: '11111111-1111-4111-a111-111111111104',
|
uuid: '11111111-1111-4111-a111-111111111104',
|
||||||
@@ -84,7 +87,8 @@ export const mockTasks = [
|
|||||||
until: null,
|
until: null,
|
||||||
recurrence_duration: null,
|
recurrence_duration: null,
|
||||||
parent_uuid: null,
|
parent_uuid: null,
|
||||||
tags: ['errand']
|
tags: ['errand'],
|
||||||
|
urgency: 3.5
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
uuid: '11111111-1111-4111-a111-111111111105',
|
uuid: '11111111-1111-4111-a111-111111111105',
|
||||||
@@ -103,7 +107,8 @@ export const mockTasks = [
|
|||||||
until: null,
|
until: null,
|
||||||
recurrence_duration: null,
|
recurrence_duration: null,
|
||||||
parent_uuid: null,
|
parent_uuid: null,
|
||||||
tags: ['frontend', 'design']
|
tags: ['frontend', 'design'],
|
||||||
|
urgency: 15.8
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
uuid: '11111111-1111-4111-a111-111111111106',
|
uuid: '11111111-1111-4111-a111-111111111106',
|
||||||
@@ -122,7 +127,8 @@ export const mockTasks = [
|
|||||||
until: null,
|
until: null,
|
||||||
recurrence_duration: null,
|
recurrence_duration: null,
|
||||||
parent_uuid: null,
|
parent_uuid: null,
|
||||||
tags: ['admin']
|
tags: ['admin'],
|
||||||
|
urgency: 2.4
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
uuid: '11111111-1111-4111-a111-111111111107',
|
uuid: '11111111-1111-4111-a111-111111111107',
|
||||||
@@ -141,7 +147,8 @@ export const mockTasks = [
|
|||||||
until: null,
|
until: null,
|
||||||
recurrence_duration: null,
|
recurrence_duration: null,
|
||||||
parent_uuid: null,
|
parent_uuid: null,
|
||||||
tags: ['frontend']
|
tags: ['frontend'],
|
||||||
|
urgency: 2.9
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
uuid: '11111111-1111-4111-a111-111111111108',
|
uuid: '11111111-1111-4111-a111-111111111108',
|
||||||
@@ -160,7 +167,8 @@ export const mockTasks = [
|
|||||||
until: null,
|
until: null,
|
||||||
recurrence_duration: null,
|
recurrence_duration: null,
|
||||||
parent_uuid: null,
|
parent_uuid: null,
|
||||||
tags: ['selfhosted', 'maintenance']
|
tags: ['selfhosted', 'maintenance'],
|
||||||
|
urgency: 1.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
uuid: '11111111-1111-4111-a111-111111111109',
|
uuid: '11111111-1111-4111-a111-111111111109',
|
||||||
@@ -179,7 +187,8 @@ export const mockTasks = [
|
|||||||
until: null,
|
until: null,
|
||||||
recurrence_duration: null,
|
recurrence_duration: null,
|
||||||
parent_uuid: null,
|
parent_uuid: null,
|
||||||
tags: ['reading', 'learning']
|
tags: ['reading', 'learning'],
|
||||||
|
urgency: 1.2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
uuid: '11111111-1111-4111-a111-111111111110',
|
uuid: '11111111-1111-4111-a111-111111111110',
|
||||||
@@ -198,7 +207,8 @@ export const mockTasks = [
|
|||||||
until: null,
|
until: null,
|
||||||
recurrence_duration: null,
|
recurrence_duration: null,
|
||||||
parent_uuid: null,
|
parent_uuid: null,
|
||||||
tags: ['review', 'backend']
|
tags: ['review', 'backend'],
|
||||||
|
urgency: 10.5
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Completed tasks ──────────────────────────────────────────
|
// ── Completed tasks ──────────────────────────────────────────
|
||||||
@@ -219,7 +229,8 @@ export const mockTasks = [
|
|||||||
until: null,
|
until: null,
|
||||||
recurrence_duration: null,
|
recurrence_duration: null,
|
||||||
parent_uuid: null,
|
parent_uuid: null,
|
||||||
tags: ['backend', 'refactor']
|
tags: ['backend', 'refactor'],
|
||||||
|
urgency: 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
uuid: '22222222-2222-4222-a222-222222222202',
|
uuid: '22222222-2222-4222-a222-222222222202',
|
||||||
@@ -238,7 +249,8 @@ export const mockTasks = [
|
|||||||
until: null,
|
until: null,
|
||||||
recurrence_duration: null,
|
recurrence_duration: null,
|
||||||
parent_uuid: null,
|
parent_uuid: null,
|
||||||
tags: ['auth', 'selfhosted']
|
tags: ['auth', 'selfhosted'],
|
||||||
|
urgency: 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
uuid: '22222222-2222-4222-a222-222222222203',
|
uuid: '22222222-2222-4222-a222-222222222203',
|
||||||
@@ -257,7 +269,8 @@ export const mockTasks = [
|
|||||||
until: null,
|
until: null,
|
||||||
recurrence_duration: null,
|
recurrence_duration: null,
|
||||||
parent_uuid: null,
|
parent_uuid: null,
|
||||||
tags: ['ux', 'backend']
|
tags: ['ux', 'backend'],
|
||||||
|
urgency: 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
uuid: '22222222-2222-4222-a222-222222222204',
|
uuid: '22222222-2222-4222-a222-222222222204',
|
||||||
@@ -276,7 +289,8 @@ export const mockTasks = [
|
|||||||
until: null,
|
until: null,
|
||||||
recurrence_duration: null,
|
recurrence_duration: null,
|
||||||
parent_uuid: null,
|
parent_uuid: null,
|
||||||
tags: ['bug', 'backend']
|
tags: ['bug', 'backend'],
|
||||||
|
urgency: 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
uuid: '22222222-2222-4222-a222-222222222205',
|
uuid: '22222222-2222-4222-a222-222222222205',
|
||||||
@@ -295,6 +309,7 @@ export const mockTasks = [
|
|||||||
until: null,
|
until: null,
|
||||||
recurrence_duration: null,
|
recurrence_duration: null,
|
||||||
parent_uuid: null,
|
parent_uuid: null,
|
||||||
tags: ['docs']
|
tags: ['docs'],
|
||||||
|
urgency: 0
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user