Compare commits

...

4 Commits

Author SHA1 Message Date
joakim b3c30738bd fix(web): minor UI refinements across header, pills, swipe, and settings
- Remove ThemeSwitcher from header (already accessible via settings)
- Increase pill padding and font size for better tap targets
- Guard non-cancelable touchmove preventDefault in SwipeAction
- Restyle settings page with grid-area layout and inline sign-out button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 14:59:58 +01:00
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
joakim 924b66bc64 docs: add opal-task REST API reference
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 14:58:01 +01:00
joakim d86501e4e6 feat(web): use CSS grid-areas layout with anchor-positioned report picker
Replace flexbox layout with CSS grid using named grid-areas for responsive
content containment. Gutters collapse naturally on small screens via
min(--content-max-width, 100%). Anchor ReportPicker to its trigger button
using CSS anchor positioning instead of fixed viewport offsets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 14:08:41 +01:00
18 changed files with 1663 additions and 176 deletions
+921
View File
@@ -0,0 +1,921 @@
# Opal-Task API Reference
REST API for the opal task manager. Built with Go and [chi](https://github.com/go-chi/chi) router, backed by SQLite.
**Base URL:** `http://localhost:8080` (default) or behind a reverse proxy at `/api`
## Table of Contents
- [Authentication](#authentication)
- [Response Format](#response-format)
- [Endpoints](#endpoints)
- [Health](#health)
- [OAuth](#oauth)
- [Tasks](#tasks)
- [Tags](#tags)
- [Projects](#projects)
- [Sync](#sync)
- [API Keys](#api-keys)
- [Data Models](#data-models)
- [Reports](#reports)
---
## Authentication
The API supports two authentication methods:
### API Key
Generate a key with the CLI, then pass it as a Bearer token:
```bash
opal server keygen --name "My Phone"
# Output: oak_aBcDeFgH... (shown once, save it)
```
```
Authorization: Bearer oak_aBcDeFgH...
```
Keys are bcrypt-hashed at rest. The `oak_` prefix identifies opal API keys.
### OAuth / JWT
When OAuth is enabled, authenticate through the [login flow](#get-authlogin) to receive a JWT:
```
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
```
JWTs are HS256-signed, issued by `opal-task`, and expire after 1 hour by default (configurable via `JWT_EXPIRY`).
### Public Endpoints
These endpoints require no authentication:
| Endpoint | Description |
|---|---|
| `GET /health` | Health check |
| `GET /auth/login` | Get OAuth login URL |
| `POST /auth/callback` | OAuth code exchange |
| `POST /auth/refresh` | Refresh access token |
| `POST /auth/logout` | Revoke refresh token |
All other endpoints require a valid `Authorization: Bearer <token>` header.
---
## Response Format
Every response follows this envelope:
### Success
```json
{
"success": true,
"data": { ... }
}
```
### Error
```json
{
"success": false,
"error": "description of what went wrong"
}
```
### Conventions
- **JSON keys** are `snake_case` throughout.
- **Timestamps** are Unix seconds (integers), not ISO 8601 strings. Nullable timestamps are `null`.
- **Durations** (e.g., `recurrence_duration`) are in seconds. A 1-week recurrence is `604800`.
- **Status** is a single-character string: `"P"`, `"C"`, `"D"`, or `"R"`.
### Status Codes
| Code | Meaning |
|---|---|
| `200` | Success |
| `201` | Resource created |
| `400` | Invalid input (bad JSON, missing required fields, invalid UUID) |
| `401` | Missing or invalid authentication |
| `404` | Resource not found |
| `500` | Server error |
| `501` | Feature disabled (e.g., OAuth not configured) |
---
## Endpoints
### Health
#### `GET /health`
Returns server status. No authentication required.
```bash
curl http://localhost:8080/health
```
```json
{
"success": true,
"data": {
"status": "ok"
}
}
```
---
### OAuth
#### `GET /auth/login`
Returns the OAuth authorization URL for redirecting the user to the identity provider.
```bash
curl http://localhost:8080/auth/login
```
```json
{
"success": true,
"data": {
"url": "https://auth.example.com/application/o/authorize/?client_id=...&state=abc123",
"state": "abc123"
}
}
```
Returns `501` if OAuth is not enabled.
---
#### `POST /auth/callback`
Exchanges an OAuth authorization code for access and refresh tokens. The `code` parameter comes from the OAuth provider's redirect.
**Query parameters:**
| Parameter | Type | Required | Description |
|---|---|---|---|
| `code` | string | yes | Authorization code from OAuth provider |
```bash
curl -X POST "http://localhost:8080/auth/callback?code=AUTH_CODE_HERE"
```
```json
{
"success": true,
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "dGhpcyBpcyBhIHJlZnJl...",
"expires_at": 1739700000,
"token_type": "Bearer",
"user": {
"id": 1,
"username": "alice",
"email": "alice@example.com"
}
}
}
```
---
#### `POST /auth/refresh`
Exchanges a valid refresh token for a new access token.
**Request body:**
```json
{
"refresh_token": "dGhpcyBpcyBhIHJlZnJl..."
}
```
```json
{
"success": true,
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"expires_at": 1739703600,
"token_type": "Bearer"
}
}
```
Returns `401` if the refresh token is invalid or expired.
---
#### `POST /auth/logout`
Revokes a refresh token, preventing further use.
**Request body:**
```json
{
"refresh_token": "dGhpcyBpcyBhIHJlZnJl..."
}
```
```json
{
"success": true,
"data": {
"message": "logged out"
}
}
```
---
### Tasks
#### `GET /tasks`
Lists tasks, either by named report or by filter parameters.
**Query parameters:**
| Parameter | Type | Required | Description |
|---|---|---|---|
| `report` | string | no | Named report (see [Reports](#reports)). Overrides filter params. |
| `status` | string | no | Filter by status: `pending`, `completed`, `deleted`, `recurring` |
| `project` | string | no | Filter by project name |
| `priority` | string | no | Filter by priority: `L`, `D`, `M`, `H` |
| `tag` | string[] | no | Filter by tags (repeat for multiple: `?tag=home&tag=urgent`) |
**With report:**
```bash
curl -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/tasks?report=overdue"
```
```json
{
"success": true,
"data": {
"report": "overdue",
"tasks": [ ... ],
"count": 3
}
}
```
**With filters:**
```bash
curl -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/tasks?status=pending&tag=home&priority=H"
```
```json
{
"success": true,
"data": [
{
"uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"id": 42,
"status": "P",
"description": "Fix leaking faucet",
"project": "house",
"priority": 3,
"created": 1739174400,
"modified": 1739545800,
"start": null,
"end": null,
"due": 1740009600,
"scheduled": null,
"wait": null,
"until": null,
"recurrence_duration": null,
"parent_uuid": null,
"tags": ["home", "urgent"],
"urgency": 12.4
}
]
}
```
---
#### `POST /tasks`
Creates a new task using structured JSON fields.
**Request body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `description` | string | yes | Task description |
| `tags` | string[] | no | Tags to attach |
| `project` | string | no | Project name |
| `priority` | string | no | `L` (low), `D` (default), `M` (medium), `H` (high) |
| `due` | int64 | no | Due date as Unix timestamp (seconds) |
| `scheduled` | int64 | no | Scheduled date as Unix timestamp |
| `wait` | int64 | no | Wait-until date as Unix timestamp |
| `until` | int64 | no | Expiration date as Unix timestamp |
| `recurrence` | string | no | Recurrence interval (e.g., `1d`, `2w`, `3m`, `1y`) |
```bash
curl -X POST http://localhost:8080/tasks \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"description": "Buy groceries",
"tags": ["personal", "errands"],
"project": "household",
"priority": "M",
"due": 1739836800
}'
```
**Response** (`201 Created`):
```json
{
"success": true,
"data": {
"uuid": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"id": 43,
"status": "P",
"description": "Buy groceries",
"project": "household",
"priority": 2,
"created": 1739750400,
"modified": 1739750400,
"due": 1739836800,
"tags": ["personal", "errands"],
"urgency": 6.1
}
}
```
---
#### `POST /tasks/parse`
Creates a task from a CLI-style input string. Supports the same syntax as the `opal add` command: words without special prefixes become the description, `+tag` adds tags, `-tag` removes tags, and `key:value` pairs set attributes.
**Request body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `input` | string | yes | CLI-style task input |
**Modifier syntax:**
| Syntax | Meaning | Example |
|---|---|---|
| `+tag` | Add tag | `+home` |
| `-tag` | Remove tag | `-garden` |
| `project:name` | Set project | `project:household` |
| `priority:X` | Set priority | `priority:H` |
| `due:value` | Set due date | `due:tomorrow`, `due:monday`, `due:2026-03-01` |
| `scheduled:value` | Set scheduled date | `scheduled:nextweek` |
| `wait:value` | Set wait date | `wait:friday` |
| `until:value` | Set expiration | `until:eom` |
| `recur:interval` | Set recurrence | `recur:1w`, `recur:2d` |
```bash
curl -X POST http://localhost:8080/tasks/parse \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"input": "Change bed sheets +home project:household due:sunday recur:1w"}'
```
**Response** (`201 Created`):
```json
{
"success": true,
"data": {
"task": {
"uuid": "c9bf9e57-1685-4c89-bafb-ff5af830be8a",
"id": 44,
"status": "P",
"description": "Change bed sheets",
"project": "household",
"priority": 1,
"created": 1739750400,
"modified": 1739750400,
"due": 1739836800,
"recurrence_duration": 604800,
"parent_uuid": "d4e5f6a7-b8c9-0123-4567-890abcdef012",
"tags": ["home"],
"urgency": 5.3
}
}
}
```
For recurring tasks (those with `recur:`), the API creates a template task (status `"R"`) and returns the first instance.
---
#### `GET /tasks/{uuid}`
Returns a single task by its UUID.
```bash
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/tasks/a1b2c3d4-e5f6-7890-abcd-ef1234567890
```
```json
{
"success": true,
"data": {
"uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"id": 42,
"status": "P",
"description": "Fix leaking faucet",
"project": "house",
"priority": 3,
"created": 1739174400,
"modified": 1739545800,
"due": 1740009600,
"tags": ["home", "urgent"],
"urgency": 12.4
}
}
```
Returns `404` if the UUID does not match any task.
---
#### `PUT /tasks/{uuid}`
Updates one or more fields on an existing task. Only include the fields you want to change.
**Request body:**
| Field | Type | Description |
|---|---|---|
| `description` | string | New description |
| `status` | string | New status: `pending`, `completed`, `deleted` |
| `priority` | string | `L`, `D`, `M`, `H` |
| `project` | string | Project name |
| `due` | int64 | Unix timestamp (seconds) |
| `scheduled` | int64 | Unix timestamp |
| `wait` | int64 | Unix timestamp |
| `until` | int64 | Unix timestamp |
| `start` | int64 | Unix timestamp |
| `recurrence` | string | Recurrence interval |
All fields are optional.
```bash
curl -X PUT http://localhost:8080/tasks/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"priority": "H", "due": 1739923200}'
```
**Response:**
```json
{
"success": true,
"data": {
"uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"id": 42,
"status": "P",
"description": "Fix leaking faucet",
"project": "house",
"priority": 3,
"created": 1739174400,
"modified": 1739750400,
"due": 1739923200,
"tags": ["home", "urgent"],
"urgency": 14.7
}
}
```
---
#### `DELETE /tasks/{uuid}`
Deletes a task. By default, sets the task status to `"D"` (soft delete). Pass `permanent=true` to remove it from the database entirely.
**Query parameters:**
| Parameter | Type | Default | Description |
|---|---|---|---|
| `permanent` | string | `false` | Set to `true` for permanent deletion |
```bash
# Soft delete
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/tasks/a1b2c3d4-e5f6-7890-abcd-ef1234567890
# Permanent delete
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/tasks/a1b2c3d4-e5f6-7890-abcd-ef1234567890?permanent=true"
```
```json
{
"success": true,
"data": {
"message": "task deleted"
}
}
```
---
#### `POST /tasks/{uuid}/complete`
Marks a task as completed. Sets the status to `"C"` and records the completion time. For recurring task instances, this may trigger creation of the next instance.
```bash
curl -X POST -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/tasks/a1b2c3d4-e5f6-7890-abcd-ef1234567890/complete
```
**Response:** The updated task object with `"status": "C"`.
---
#### `POST /tasks/{uuid}/start`
Marks a task as actively being worked on by setting its `start` timestamp to now.
```bash
curl -X POST -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/tasks/a1b2c3d4-e5f6-7890-abcd-ef1234567890/start
```
**Response:** The updated task object with `start` set to the current unix timestamp.
---
#### `POST /tasks/{uuid}/stop`
Clears the `start` timestamp, marking the task as no longer actively being worked on.
```bash
curl -X POST -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/tasks/a1b2c3d4-e5f6-7890-abcd-ef1234567890/stop
```
**Response:** The updated task object with `start` cleared to `null`.
---
### Task Tags
#### `GET /tasks/{uuid}/tags`
Returns the tag list for a specific task.
```bash
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/tasks/a1b2c3d4-e5f6-7890-abcd-ef1234567890/tags
```
```json
{
"success": true,
"data": ["home", "urgent"]
}
```
---
#### `POST /tasks/{uuid}/tags`
Adds a tag to a task.
**Request body:**
```json
{
"tag": "important"
}
```
**Response:** The updated task object.
---
#### `DELETE /tasks/{uuid}/tags/{tag}`
Removes a tag from a task.
```bash
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/tasks/a1b2c3d4-e5f6-7890-abcd-ef1234567890/tags/urgent
```
**Response:** The updated task object.
---
### Tags
#### `GET /tags`
Returns all tags used across all tasks.
```bash
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/tags
```
```json
{
"success": true,
"data": ["errands", "home", "important", "personal", "urgent", "work"]
}
```
---
### Projects
#### `GET /projects`
Returns all project names used across all tasks.
```bash
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/projects
```
```json
{
"success": true,
"data": ["household", "work", "garden"]
}
```
---
### Sync
These endpoints power the multi-device sync protocol. The CLI client uses them via `opal sync` commands.
#### `POST /sync/changes`
Returns all changes recorded since a given timestamp. Used by clients to pull updates from the server.
**Request body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `since` | int64 | yes | Unix timestamp (seconds). Return changes after this time. Use `0` for initial sync. |
| `client_id` | string | yes | Unique identifier for the syncing device |
```bash
curl -X POST http://localhost:8080/sync/changes \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"since": 1739600000, "client_id": "phone-abc123"}'
```
```json
{
"success": true,
"data": [
{
"id": 101,
"task_uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"change_type": "create",
"changed_at": 1739650000,
"data": "description:Buy groceries\nstatus:80\npriority:2"
},
{
"id": 102,
"task_uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"change_type": "update",
"changed_at": 1739660000,
"data": "status:67"
}
]
}
```
The `change_type` is one of `create`, `update`, or `delete`. The `data` field uses a `key:value` format with newline separators, recorded by database triggers on every task mutation. Note that change log data uses raw database values (integer status codes), not the API's serialized format.
---
#### `POST /sync/push`
Pushes local task changes to the server. Conflicts are resolved with **last-write-wins** based on the `modified` timestamp.
**Request body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `tasks` | Task[] | yes | Array of full task objects to push |
| `client_id` | string | yes | Unique identifier for the syncing device |
```bash
curl -X POST http://localhost:8080/sync/push \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"client_id": "phone-abc123",
"tasks": [
{
"uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "P",
"description": "Buy groceries",
"priority": 2,
"created": 1739600000,
"modified": 1739650000,
"tags": ["personal"]
}
]
}'
```
```json
{
"success": true,
"data": {
"processed": 1,
"conflicts": 0
}
}
```
- **processed** — number of tasks successfully applied
- **conflicts** — number of tasks where the server had a newer version (still applied via last-write-wins)
---
### API Keys
#### `GET /auth/keys`
Lists all API keys for the current user. The key value itself is not returned (it is only shown once at creation time).
```bash
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/auth/keys
```
```json
{
"success": true,
"data": [
{
"id": 1,
"name": "My Phone",
"user_id": 1,
"created_at": 1736935200,
"last_used": 1739558400,
"revoked": false
}
]
}
```
---
#### `DELETE /auth/keys/{id}`
Revokes an API key. The key becomes immediately unusable.
**URL parameters:**
| Parameter | Type | Description |
|---|---|---|
| `id` | int | API key ID |
```bash
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/auth/keys/1
```
```json
{
"success": true,
"data": {
"message": "API key revoked"
}
}
```
---
## Data Models
### Task
| Field | Type | Description |
|---|---|---|
| `uuid` | string | Unique identifier (UUID v4) |
| `id` | int | Auto-increment database ID |
| `status` | string | `"P"` (pending), `"C"` (completed), `"D"` (deleted), `"R"` (recurring) |
| `description` | string | Task description |
| `project` | string \| null | Project name |
| `priority` | int | `0` = Low, `1` = Default, `2` = Medium, `3` = High |
| `created` | int | Unix timestamp (seconds) |
| `modified` | int | Unix timestamp (seconds) |
| `start` | int \| null | When the task was started (actively being worked on) |
| `end` | int \| null | When the task was completed or deleted |
| `due` | int \| null | Deadline |
| `scheduled` | int \| null | Earliest date the task is actionable |
| `wait` | int \| null | Task is hidden until this date |
| `until` | int \| null | Task auto-deletes after this date |
| `recurrence_duration` | int \| null | Recurrence interval in seconds (e.g., `604800` = 1 week) |
| `parent_uuid` | string \| null | UUID of the recurring template task |
| `tags` | string[] | Attached tags |
| `urgency` | float | Computed urgency score (higher = more urgent) |
#### Status Values
| Value | Meaning |
|---|---|
| `"P"` | Pending — active, not yet completed |
| `"C"` | Completed |
| `"D"` | Deleted (soft delete) |
| `"R"` | Recurring template |
#### Priority Values
| API Input | Numeric Value | Meaning |
|---|---|---|
| `L` | `0` | Low |
| `D` | `1` | Default |
| `M` | `2` | Medium |
| `H` | `3` | High |
Use the letter codes (`L`, `D`, `M`, `H`) when creating or updating tasks. Responses return the numeric value.
---
## Reports
Named reports return pre-filtered, pre-sorted task lists. Pass the report name as `?report=<name>` on `GET /tasks`.
| Report | Description |
|---|---|
| `active` | Tasks that have been started (pending with a `start` time) |
| `all` | All tasks including recurring templates |
| `completed` | Completed tasks |
| `list` | Pending tasks (default view) |
| `minimal` | Minimal output view |
| `newest` | Pending tasks sorted newest first |
| `next` | Next task due |
| `oldest` | Pending tasks sorted oldest first |
| `overdue` | Tasks past their due date |
| `ready` | Tasks ready to work on (past scheduled date, not waiting) |
| `recurring` | Recurring template tasks |
| `template` | Alias for `recurring` |
| `waiting` | Tasks with a future `wait` date |
---
## CORS
The API allows cross-origin requests:
- **Origins:** `*`
- **Methods:** `GET`, `POST`, `PUT`, `DELETE`, `OPTIONS`
- **Headers:** `Content-Type`, `Authorization`
---
## Running the Server
```bash
# Build
go build -o opal main.go
# Generate an API key
./opal server keygen --name "My Device" --db /path/to/opal.db
# Start the server
./opal server start --addr :8080 --db /path/to/opal.db
```
### Environment Variables
| Variable | Default | Description |
|---|---|---|
| `OAUTH_ENABLED` | `false` | Enable OAuth authentication |
| `OAUTH_CLIENT_ID` | — | OAuth client ID |
| `OAUTH_CLIENT_SECRET` | — | OAuth client secret |
| `OAUTH_ISSUER` | — | OAuth issuer URL |
| `OAUTH_REDIRECT_URI` | — | OAuth redirect URI |
| `JWT_SECRET` | — | Secret for signing JWTs |
| `JWT_EXPIRY` | `3600` | JWT lifetime in seconds |
| `REFRESH_TOKEN_EXPIRY` | `604800` | Refresh token lifetime in seconds (default 7 days) |
| `OPAL_DB_PATH` | XDG data dir | Override database file path |
| `OPAL_CONFIG_DIR` | `~/.config/opal` | Config directory |
| `OPAL_DATA_DIR` | `~/.local/share/opal` | Data directory |
+14
View File
@@ -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)
} }
+15 -9
View File
@@ -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")
} }
} }
+64 -6
View File
@@ -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
+8 -4
View File
@@ -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]
} }
} }
+167 -17
View File
@@ -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
```
+1
View File
@@ -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
*/ */
/** /**
+2 -3
View File
@@ -1,6 +1,5 @@
<script> <script>
import ReportPicker from './ReportPicker.svelte'; import ReportPicker from './ReportPicker.svelte';
import ThemeSwitcher from './ThemeSwitcher.svelte';
/** /**
* @type {string} * @type {string}
@@ -45,7 +44,6 @@
</button> </button>
<div class="header-actions"> <div class="header-actions">
<ThemeSwitcher mode="cycle" />
<a href="/settings" class="settings-btn" aria-label="Settings"> <a href="/settings" class="settings-btn" aria-label="Settings">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
@@ -63,16 +61,17 @@
<style> <style>
.header { .header {
grid-area: header;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-sm) var(--spacing-md);
background-color: var(--bg-primary); background-color: var(--bg-primary);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
} }
.report-btn { .report-btn {
anchor-name: --report-btn;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--spacing-xs); gap: var(--spacing-xs);
+1 -1
View File
@@ -119,7 +119,7 @@
<style> <style>
.input-bar { .input-bar {
flex-shrink: 0; grid-area: input;
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-sm) var(--spacing-md);
padding-bottom: calc(var(--spacing-sm) + env(safe-area-inset-bottom, 0px)); padding-bottom: calc(var(--spacing-sm) + env(safe-area-inset-bottom, 0px));
background-color: var(--bg-primary); background-color: var(--bg-primary);
@@ -7,14 +7,14 @@
export let visible = false; export let visible = false;
const pills = [ const pills = [
{ label: 'Due', text: 'due:' }, { label: "Due", text: "due:" },
{ label: 'Pri', text: 'priority:' }, { label: "Pri", text: "priority:" },
{ label: 'Project', text: 'project:' }, { label: "Project", text: "project:" },
{ label: 'Tag', text: '+' }, { label: "Tag", text: "+" },
{ label: 'Recur', text: 'recur:' }, { label: "Recur", text: "recur:" },
{ label: 'Scheduled', text: 'scheduled:' }, { label: "Scheduled", text: "scheduled:" },
{ label: 'Wait', text: 'wait:' }, { label: "Wait", text: "wait:" },
{ label: 'Until', text: 'until:' } { label: "Until", text: "until:" },
]; ];
</script> </script>
@@ -41,11 +41,11 @@
} }
.pill { .pill {
padding: 0.25rem 0.625rem; padding: 0.375rem 0.75rem;
background-color: var(--bg-tertiary); background-color: var(--bg-tertiary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 1rem; border-radius: 1rem;
font-size: var(--font-size-xs); font-size: var(--font-size-s);
font-family: inherit; font-family: inherit;
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
@@ -83,9 +83,8 @@
<style> <style>
.report-picker { .report-picker {
position: fixed; position: fixed;
top: 48px; position-anchor: --report-btn;
left: var(--spacing-md); position-area: bottom span-right;
right: auto;
margin: 0; margin: 0;
padding: var(--spacing-sm); padding: var(--spacing-sm);
background-color: var(--bg-primary); background-color: var(--bg-primary);
@@ -51,7 +51,7 @@
} }
if (swiping) { if (swiping) {
e.preventDefault(); if (e.cancelable) e.preventDefault();
// Only allow right swipe // Only allow right swipe
offsetX = Math.max(0, deltaX); offsetX = Math.max(0, deltaX);
} }
@@ -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>
+2 -1
View File
@@ -57,10 +57,11 @@
<style> <style>
.task-list { .task-list {
flex: 1; grid-area: content;
overflow-y: auto; overflow-y: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
background-color: var(--bg-primary); background-color: var(--bg-primary);
min-height: 0;
} }
.loading-container { .loading-container {
+30 -15
View File
@@ -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
} }
]; ];
+7 -2
View File
@@ -27,8 +27,13 @@
<style> <style>
.app { .app {
height: 100dvh; height: 100dvh;
display: flex; display: grid;
flex-direction: column; grid-template-columns: 1fr min(var(--content-max-width), 100%) 1fr;
grid-template-rows: auto 1fr auto;
grid-template-areas:
". header ."
". content ."
". input .";
overflow: hidden; overflow: hidden;
} }
</style> </style>
+119 -87
View File
@@ -74,107 +74,112 @@
} }
</script> </script>
<div class="page"> <header class="settings-header">
<div class="container"> <a href="/" class="back-link" aria-label="Back to tasks">
<div class="page-header"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<a href="/" class="back-link" aria-label="Back to tasks"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
<svg class="back-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"> </svg>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /> </a>
</svg> <h1>Settings</h1>
</a> {#if $authStore.isAuthenticated}
<h1>Settings</h1> <button class="signout-btn" on:click={logout} aria-label="Sign out">
</div> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
{/if}
</header>
<div class="settings-content">
<section class="section">
<h2>Theme</h2>
<ThemeSwitcher mode="full" />
</section>
{#if $authStore.isAuthenticated}
<section class="section"> <section class="section">
<h2>Theme</h2> <h2>Account</h2>
<ThemeSwitcher mode="full" /> <div class="info-row">
<span class="label">Username:</span>
<span class="value">{$authStore.user?.username || 'Unknown'}</span>
</div>
{#if $authStore.user?.email}
<div class="info-row">
<span class="label">Email:</span>
<span class="value">{$authStore.user.email}</span>
</div>
{/if}
</section> </section>
{#if $authStore.isAuthenticated} <section class="section">
<section class="section"> <h2>Sync</h2>
<h2>Account</h2> <div class="info-row">
<span class="label">Status:</span>
<span class="value">{$syncStore.status}</span>
</div>
<div class="info-row">
<span class="label">Queue:</span>
<span class="value">{$syncStore.queueSize} changes</span>
</div>
{#if $syncStore.lastSync}
<div class="info-row"> <div class="info-row">
<span class="label">Username:</span> <span class="label">Last Sync:</span>
<span class="value">{$authStore.user?.username || 'Unknown'}</span> <span class="value">{new Date($syncStore.lastSync * 1000).toLocaleString()}</span>
</div> </div>
{#if $authStore.user?.email} {/if}
<div class="info-row">
<span class="label">Email:</span>
<span class="value">{$authStore.user.email}</span>
</div>
{/if}
</section>
<section class="section"> <Button on:click={triggerSync} disabled={$syncStore.status === 'syncing'}>
<h2>Sync</h2> {$syncStore.status === 'syncing' ? 'Syncing...' : 'Sync Now'}
<div class="info-row"> </Button>
<span class="label">Status:</span> </section>
<span class="value">{$syncStore.status}</span> {:else}
</div> <section class="section">
<div class="info-row"> <h2>API Key Authentication</h2>
<span class="label">Queue:</span> <p class="text-secondary mb-md">
<span class="value">{$syncStore.queueSize} changes</span> For testing, you can authenticate with an API key. Generate a key using:
</div> <code>opal server keygen --name "Web"</code>
{#if $syncStore.lastSync} </p>
<div class="info-row">
<span class="label">Last Sync:</span>
<span class="value">{new Date($syncStore.lastSync * 1000).toLocaleString()}</span>
</div>
{/if}
<Button on:click={triggerSync} disabled={$syncStore.status === 'syncing'}> <Input
{$syncStore.status === 'syncing' ? 'Syncing...' : 'Sync Now'} label="API Key"
</Button> type="password"
</section> placeholder="oak_..."
bind:value={apiKey}
{error}
/>
<section class="section"> <Button
<h2>Actions</h2> on:click={saveApiKey}
<Button variant="danger" on:click={logout}>Logout</Button> loading={saving}
</section> fullWidth
{:else} >
<section class="section"> Save API Key
<h2>API Key Authentication</h2> </Button>
<p class="text-secondary mb-md">
For testing, you can authenticate with an API key. Generate a key using: <div class="mt-lg text-center">
<code>opal server keygen --name "Web"</code> <p class="text-sm text-secondary">
Or <a href="/auth/login">login with OAuth</a>
</p> </p>
</div>
<Input </section>
label="API Key" {/if}
type="password"
placeholder="oak_..."
bind:value={apiKey}
{error}
/>
<Button
on:click={saveApiKey}
loading={saving}
fullWidth
>
Save API Key
</Button>
<div class="mt-lg text-center">
<p class="text-sm text-secondary">
Or <a href="/auth/login">login with OAuth</a>
</p>
</div>
</section>
{/if}
</div>
</div> </div>
<style> <style>
.page-header { .settings-header {
grid-area: header;
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-sm); gap: var(--spacing-sm);
padding-top: var(--spacing-lg); padding: var(--spacing-sm) var(--spacing-md);
margin-bottom: var(--spacing-md); background-color: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
} }
.page-header h1 { .settings-header h1 {
flex: 1;
font-size: var(--font-size-lg);
font-weight: 600;
margin-bottom: 0; margin-bottom: 0;
} }
@@ -190,21 +195,48 @@
} }
.back-link:hover { .back-link:hover {
background-color: var(--bg-tertiary); background-color: var(--bg-secondary);
text-decoration: none; text-decoration: none;
} }
.back-icon { .signout-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: none;
border: none;
border-radius: var(--border-radius);
color: var(--text-secondary);
cursor: pointer;
transition: background-color 0.2s;
}
.signout-btn:hover {
background-color: var(--bg-secondary);
color: var(--color-danger);
}
.icon {
width: 1.25rem; width: 1.25rem;
height: 1.25rem; height: 1.25rem;
} }
.section { .settings-content {
grid-area: content;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: var(--spacing-md);
min-height: 0;
background-color: var(--bg-primary); background-color: var(--bg-primary);
}
.section {
background-color: var(--bg-secondary);
border-radius: var(--border-radius); border-radius: var(--border-radius);
padding: var(--spacing-lg); padding: var(--spacing-lg);
margin-bottom: var(--spacing-lg); margin-bottom: var(--spacing-lg);
box-shadow: var(--shadow-sm);
} }
.info-row { .info-row {