3bb2ef2759
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>
256 lines
9.1 KiB
Markdown
256 lines
9.1 KiB
Markdown
# 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
|
|
```
|