Compare commits
4 Commits
78881e1b07
...
b3c30738bd
| Author | SHA1 | Date | |
|---|---|---|---|
| b3c30738bd | |||
| 3bb2ef2759 | |||
| 924b66bc64 | |||
| d86501e4e6 |
@@ -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 |
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -27,6 +27,7 @@
|
||||
* @property {number|null} recurrence_duration
|
||||
* @property {string|null} parent_uuid
|
||||
* @property {string[]} tags
|
||||
* @property {number} urgency
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script>
|
||||
import ReportPicker from './ReportPicker.svelte';
|
||||
import ThemeSwitcher from './ThemeSwitcher.svelte';
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
@@ -45,7 +44,6 @@
|
||||
</button>
|
||||
|
||||
<div class="header-actions">
|
||||
<ThemeSwitcher mode="cycle" />
|
||||
<a href="/settings" class="settings-btn" aria-label="Settings">
|
||||
<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" />
|
||||
@@ -63,16 +61,17 @@
|
||||
|
||||
<style>
|
||||
.header {
|
||||
grid-area: header;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.report-btn {
|
||||
anchor-name: --report-btn;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
|
||||
<style>
|
||||
.input-bar {
|
||||
flex-shrink: 0;
|
||||
grid-area: input;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
padding-bottom: calc(var(--spacing-sm) + env(safe-area-inset-bottom, 0px));
|
||||
background-color: var(--bg-primary);
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
export let visible = false;
|
||||
|
||||
const pills = [
|
||||
{ label: 'Due', text: 'due:' },
|
||||
{ label: 'Pri', text: 'priority:' },
|
||||
{ label: 'Project', text: 'project:' },
|
||||
{ label: 'Tag', text: '+' },
|
||||
{ label: 'Recur', text: 'recur:' },
|
||||
{ label: 'Scheduled', text: 'scheduled:' },
|
||||
{ label: 'Wait', text: 'wait:' },
|
||||
{ label: 'Until', text: 'until:' }
|
||||
{ label: "Due", text: "due:" },
|
||||
{ label: "Pri", text: "priority:" },
|
||||
{ label: "Project", text: "project:" },
|
||||
{ label: "Tag", text: "+" },
|
||||
{ label: "Recur", text: "recur:" },
|
||||
{ label: "Scheduled", text: "scheduled:" },
|
||||
{ label: "Wait", text: "wait:" },
|
||||
{ label: "Until", text: "until:" },
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -41,11 +41,11 @@
|
||||
}
|
||||
|
||||
.pill {
|
||||
padding: 0.25rem 0.625rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 1rem;
|
||||
font-size: var(--font-size-xs);
|
||||
font-size: var(--font-size-s);
|
||||
font-family: inherit;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
|
||||
@@ -83,9 +83,8 @@
|
||||
<style>
|
||||
.report-picker {
|
||||
position: fixed;
|
||||
top: 48px;
|
||||
left: var(--spacing-md);
|
||||
right: auto;
|
||||
position-anchor: --report-btn;
|
||||
position-area: bottom span-right;
|
||||
margin: 0;
|
||||
padding: var(--spacing-sm);
|
||||
background-color: var(--bg-primary);
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
}
|
||||
|
||||
if (swiping) {
|
||||
e.preventDefault();
|
||||
if (e.cancelable) e.preventDefault();
|
||||
// Only allow right swipe
|
||||
offsetX = Math.max(0, deltaX);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,16 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/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>
|
||||
@@ -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);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -57,10 +57,11 @@
|
||||
|
||||
<style>
|
||||
.task-list {
|
||||
flex: 1;
|
||||
grid-area: content;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
background-color: var(--bg-primary);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
|
||||
@@ -27,8 +27,13 @@
|
||||
<style>
|
||||
.app {
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -74,107 +74,112 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<a href="/" class="back-link" aria-label="Back to tasks">
|
||||
<svg class="back-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
<header class="settings-header">
|
||||
<a href="/" class="back-link" aria-label="Back to tasks">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
<h1>Settings</h1>
|
||||
{#if $authStore.isAuthenticated}
|
||||
<button class="signout-btn" on:click={logout} aria-label="Sign out">
|
||||
<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">
|
||||
<h2>Theme</h2>
|
||||
<ThemeSwitcher mode="full" />
|
||||
<h2>Account</h2>
|
||||
<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>
|
||||
|
||||
{#if $authStore.isAuthenticated}
|
||||
<section class="section">
|
||||
<h2>Account</h2>
|
||||
<section class="section">
|
||||
<h2>Sync</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">
|
||||
<span class="label">Username:</span>
|
||||
<span class="value">{$authStore.user?.username || 'Unknown'}</span>
|
||||
<span class="label">Last Sync:</span>
|
||||
<span class="value">{new Date($syncStore.lastSync * 1000).toLocaleString()}</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>
|
||||
{/if}
|
||||
|
||||
<section class="section">
|
||||
<h2>Sync</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">
|
||||
<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'}>
|
||||
{$syncStore.status === 'syncing' ? 'Syncing...' : 'Sync Now'}
|
||||
</Button>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="section">
|
||||
<h2>API Key Authentication</h2>
|
||||
<p class="text-secondary mb-md">
|
||||
For testing, you can authenticate with an API key. Generate a key using:
|
||||
<code>opal server keygen --name "Web"</code>
|
||||
</p>
|
||||
|
||||
<Button on:click={triggerSync} disabled={$syncStore.status === 'syncing'}>
|
||||
{$syncStore.status === 'syncing' ? 'Syncing...' : 'Sync Now'}
|
||||
</Button>
|
||||
</section>
|
||||
<Input
|
||||
label="API Key"
|
||||
type="password"
|
||||
placeholder="oak_..."
|
||||
bind:value={apiKey}
|
||||
{error}
|
||||
/>
|
||||
|
||||
<section class="section">
|
||||
<h2>Actions</h2>
|
||||
<Button variant="danger" on:click={logout}>Logout</Button>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="section">
|
||||
<h2>API Key Authentication</h2>
|
||||
<p class="text-secondary mb-md">
|
||||
For testing, you can authenticate with an API key. Generate a key using:
|
||||
<code>opal server keygen --name "Web"</code>
|
||||
<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>
|
||||
|
||||
<Input
|
||||
label="API Key"
|
||||
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>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page-header {
|
||||
.settings-header {
|
||||
grid-area: header;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding-top: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding: var(--spacing-sm) 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;
|
||||
}
|
||||
|
||||
@@ -190,21 +195,48 @@
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
background-color: var(--bg-secondary);
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
.section {
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.info-row {
|
||||
|
||||
Reference in New Issue
Block a user