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>
9.1 KiB
API Serialization Fix & Urgency Field
Problem
Two issues block showing urgency scores in the web frontend:
-
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 likedescription/Descriptionare 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. -
Urgency is computed but never exposed. The report engine calculates urgency internally for sorting (
sortByUrgencyinreport.go) but discards the score before serialization. TheTaskstruct 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
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.
// 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.
// 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
/**
* @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
UnixTimetype 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