diff --git a/opal-task/internal/api/handlers/tasks.go b/opal-task/internal/api/handlers/tasks.go index 5d3e500..82aed63 100644 --- a/opal-task/internal/api/handlers/tasks.go +++ b/opal-task/internal/api/handlers/tasks.go @@ -49,6 +49,9 @@ func ListTasks(w http.ResponseWriter, r *http.Request) { return } + // Report sorts may already populate urgency, but ensure it for all paths + engine.PopulateUrgency(tasks...) + jsonResponse(w, http.StatusOK, map[string]interface{}{ "report": reportName, "tasks": tasks, @@ -87,6 +90,7 @@ func ListTasks(w http.ResponseWriter, r *http.Request) { return } + engine.PopulateUrgency(tasks...) jsonResponse(w, http.StatusOK, tasks) } @@ -159,6 +163,7 @@ func CreateTask(w http.ResponseWriter, r *http.Request) { return } + engine.PopulateUrgency(task) jsonResponse(w, http.StatusCreated, task) } @@ -178,6 +183,7 @@ func GetTask(w http.ResponseWriter, r *http.Request) { return } + engine.PopulateUrgency(task) jsonResponse(w, http.StatusOK, task) } @@ -271,6 +277,7 @@ func UpdateTask(w http.ResponseWriter, r *http.Request) { return } + engine.PopulateUrgency(task) jsonResponse(w, http.StatusOK, task) } @@ -322,6 +329,7 @@ func CompleteTask(w http.ResponseWriter, r *http.Request) { return } + engine.PopulateUrgency(task) jsonResponse(w, http.StatusOK, task) } @@ -346,6 +354,7 @@ func StartTask(w http.ResponseWriter, r *http.Request) { return } + engine.PopulateUrgency(task) jsonResponse(w, http.StatusOK, task) } @@ -370,6 +379,7 @@ func StopTask(w http.ResponseWriter, r *http.Request) { return } + engine.PopulateUrgency(task) jsonResponse(w, http.StatusOK, task) } @@ -429,6 +439,7 @@ func AddTaskTag(w http.ResponseWriter, r *http.Request) { return } + engine.PopulateUrgency(task) jsonResponse(w, http.StatusOK, task) } @@ -489,6 +500,7 @@ func ParseTask(w http.ResponseWriter, r *http.Request) { errorResponse(w, http.StatusBadRequest, err.Error()) return } + engine.PopulateUrgency(instance) jsonResponse(w, http.StatusCreated, map[string]interface{}{"task": instance}) return } @@ -500,6 +512,7 @@ func ParseTask(w http.ResponseWriter, r *http.Request) { return } + engine.PopulateUrgency(task) jsonResponse(w, http.StatusCreated, map[string]interface{}{"task": task}) } @@ -525,5 +538,6 @@ func RemoveTaskTag(w http.ResponseWriter, r *http.Request) { return } + engine.PopulateUrgency(task) jsonResponse(w, http.StatusOK, task) } diff --git a/opal-task/internal/api/handlers/tasks_test.go b/opal-task/internal/api/handlers/tasks_test.go index 3968126..e2c0308 100644 --- a/opal-task/internal/api/handlers/tasks_test.go +++ b/opal-task/internal/api/handlers/tasks_test.go @@ -57,8 +57,14 @@ func TestParseTask_DescriptionOnly(t *testing.T) { if !ok { t.Fatal("expected task in data") } - if task["Description"] != "buy groceries" { - t.Errorf("expected description 'buy groceries', got %v", task["Description"]) + if task["description"] != "buy groceries" { + 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{}) task := data["task"].(map[string]interface{}) - if task["Description"] != "review PR" { - t.Errorf("expected description 'review PR', got %v", task["Description"]) + if task["description"] != "review PR" { + t.Errorf("expected description 'review PR', got %v", task["description"]) } - if task["Project"] != "backend" { - t.Errorf("expected project 'backend', got %v", task["Project"]) + if task["project"] != "backend" { + 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{}) task := data["task"].(map[string]interface{}) - // The returned task should be the first instance (pending, with ParentUUID) - if task["ParentUUID"] == nil { - t.Error("expected ParentUUID to be set for recurring instance") + // The returned task should be the first instance (pending, with parent_uuid) + if task["parent_uuid"] == nil { + t.Error("expected parent_uuid to be set for recurring instance") } } diff --git a/opal-task/internal/engine/auth.go b/opal-task/internal/engine/auth.go index 819809b..d0ae659 100644 --- a/opal-task/internal/engine/auth.go +++ b/opal-task/internal/engine/auth.go @@ -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 diff --git a/opal-task/internal/engine/report.go b/opal-task/internal/engine/report.go index d4d20eb..89c9911 100644 --- a/opal-task/internal/engine/report.go +++ b/opal-task/internal/engine/report.go @@ -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] } } diff --git a/opal-task/internal/engine/task.go b/opal-task/internal/engine/task.go index 64e54ca..965f258 100644 --- a/opal-task/internal/engine/task.go +++ b/opal-task/internal/engine/task.go @@ -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) + } +} diff --git a/opal-web/docs/design/api-serialization-and-urgency.md b/opal-web/docs/design/api-serialization-and-urgency.md new file mode 100644 index 0000000..1339428 --- /dev/null +++ b/opal-web/docs/design/api-serialization-and-urgency.md @@ -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 +``` diff --git a/opal-web/src/lib/api/types.js b/opal-web/src/lib/api/types.js index 36bc3e1..7813321 100644 --- a/opal-web/src/lib/api/types.js +++ b/opal-web/src/lib/api/types.js @@ -27,6 +27,7 @@ * @property {number|null} recurrence_duration * @property {string|null} parent_uuid * @property {string[]} tags + * @property {number} urgency */ /** diff --git a/opal-web/src/lib/components/TaskItem.svelte b/opal-web/src/lib/components/TaskItem.svelte index 08c3311..db62384 100644 --- a/opal-web/src/lib/components/TaskItem.svelte +++ b/opal-web/src/lib/components/TaskItem.svelte @@ -73,6 +73,16 @@ {/each} {/if} + + {#if task.urgency > 0} + = 10} + class:urgency-high={task.urgency >= 5 && task.urgency < 10} + class:urgency-normal={task.urgency > 0 && task.urgency < 5} + > + {task.urgency.toFixed(1)} + + {/if} @@ -196,4 +206,21 @@ color: var(--color-tag-text); 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); + } diff --git a/opal-web/src/lib/mock/tasks.js b/opal-web/src/lib/mock/tasks.js index dc95c1f..5b74730 100644 --- a/opal-web/src/lib/mock/tasks.js +++ b/opal-web/src/lib/mock/tasks.js @@ -27,7 +27,8 @@ export const mockTasks = [ until: null, recurrence_duration: null, parent_uuid: null, - tags: ['devops', 'selfhosted'] + tags: ['devops', 'selfhosted'], + urgency: 14.2 }, { uuid: '11111111-1111-4111-a111-111111111102', @@ -46,7 +47,8 @@ export const mockTasks = [ until: null, recurrence_duration: null, parent_uuid: null, - tags: ['testing', 'backend'] + tags: ['testing', 'backend'], + urgency: 7.3 }, { uuid: '11111111-1111-4111-a111-111111111103', @@ -65,7 +67,8 @@ export const mockTasks = [ until: null, recurrence_duration: null, parent_uuid: null, - tags: ['bug'] + tags: ['bug'], + urgency: 4.1 }, { uuid: '11111111-1111-4111-a111-111111111104', @@ -84,7 +87,8 @@ export const mockTasks = [ until: null, recurrence_duration: null, parent_uuid: null, - tags: ['errand'] + tags: ['errand'], + urgency: 3.5 }, { uuid: '11111111-1111-4111-a111-111111111105', @@ -103,7 +107,8 @@ export const mockTasks = [ until: null, recurrence_duration: null, parent_uuid: null, - tags: ['frontend', 'design'] + tags: ['frontend', 'design'], + urgency: 15.8 }, { uuid: '11111111-1111-4111-a111-111111111106', @@ -122,7 +127,8 @@ export const mockTasks = [ until: null, recurrence_duration: null, parent_uuid: null, - tags: ['admin'] + tags: ['admin'], + urgency: 2.4 }, { uuid: '11111111-1111-4111-a111-111111111107', @@ -141,7 +147,8 @@ export const mockTasks = [ until: null, recurrence_duration: null, parent_uuid: null, - tags: ['frontend'] + tags: ['frontend'], + urgency: 2.9 }, { uuid: '11111111-1111-4111-a111-111111111108', @@ -160,7 +167,8 @@ export const mockTasks = [ until: null, recurrence_duration: null, parent_uuid: null, - tags: ['selfhosted', 'maintenance'] + tags: ['selfhosted', 'maintenance'], + urgency: 1.6 }, { uuid: '11111111-1111-4111-a111-111111111109', @@ -179,7 +187,8 @@ export const mockTasks = [ until: null, recurrence_duration: null, parent_uuid: null, - tags: ['reading', 'learning'] + tags: ['reading', 'learning'], + urgency: 1.2 }, { uuid: '11111111-1111-4111-a111-111111111110', @@ -198,7 +207,8 @@ export const mockTasks = [ until: null, recurrence_duration: null, parent_uuid: null, - tags: ['review', 'backend'] + tags: ['review', 'backend'], + urgency: 10.5 }, // ── Completed tasks ────────────────────────────────────────── @@ -219,7 +229,8 @@ export const mockTasks = [ until: null, recurrence_duration: null, parent_uuid: null, - tags: ['backend', 'refactor'] + tags: ['backend', 'refactor'], + urgency: 0 }, { uuid: '22222222-2222-4222-a222-222222222202', @@ -238,7 +249,8 @@ export const mockTasks = [ until: null, recurrence_duration: null, parent_uuid: null, - tags: ['auth', 'selfhosted'] + tags: ['auth', 'selfhosted'], + urgency: 0 }, { uuid: '22222222-2222-4222-a222-222222222203', @@ -257,7 +269,8 @@ export const mockTasks = [ until: null, recurrence_duration: null, parent_uuid: null, - tags: ['ux', 'backend'] + tags: ['ux', 'backend'], + urgency: 0 }, { uuid: '22222222-2222-4222-a222-222222222204', @@ -276,7 +289,8 @@ export const mockTasks = [ until: null, recurrence_duration: null, parent_uuid: null, - tags: ['bug', 'backend'] + tags: ['bug', 'backend'], + urgency: 0 }, { uuid: '22222222-2222-4222-a222-222222222205', @@ -295,6 +309,7 @@ export const mockTasks = [ until: null, recurrence_duration: null, parent_uuid: null, - tags: ['docs'] + tags: ['docs'], + urgency: 0 } ];