Files
gems/opal-web/docs/design/api-serialization-and-urgency.md
joakim 3bb2ef2759 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>
2026-02-15 14:58:34 +01:00

9.1 KiB

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

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 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