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
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
import Input from '$lib/components/ui/Input.svelte';
|
import Input from '$lib/components/ui/Input.svelte';
|
||||||
import { auth } from '$lib/api/endpoints.js';
|
import { auth } from '$lib/api/endpoints.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
let apiKey = '';
|
let apiKey = '';
|
||||||
let saving = false;
|
let saving = false;
|
||||||
let error = '';
|
let error = '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save API key as manual auth
|
* Save API key as manual auth
|
||||||
*/
|
*/
|
||||||
@@ -19,10 +19,10 @@
|
|||||||
error = 'API key is required';
|
error = 'API key is required';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
saving = true;
|
saving = true;
|
||||||
error = '';
|
error = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Store API key as access token (for manual auth mode)
|
// Store API key as access token (for manual auth mode)
|
||||||
authStore.setAuth({
|
authStore.setAuth({
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
email: null
|
email: null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
goto('/');
|
goto('/');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : 'Failed to save API key';
|
error = err instanceof Error ? err.message : 'Failed to save API key';
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout
|
* Logout
|
||||||
*/
|
*/
|
||||||
@@ -56,11 +56,11 @@
|
|||||||
console.error('Logout error:', err instanceof Error ? err.message : err);
|
console.error('Logout error:', err instanceof Error ? err.message : err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
authStore.clear();
|
authStore.clear();
|
||||||
goto('/auth/login');
|
goto('/auth/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger manual sync
|
* Trigger manual sync
|
||||||
*/
|
*/
|
||||||
@@ -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>
|
<Button on:click={triggerSync} disabled={$syncStore.status === 'syncing'}>
|
||||||
<span class="value">{$authStore.user.email}</span>
|
{$syncStore.status === 'syncing' ? 'Syncing...' : 'Sync Now'}
|
||||||
</div>
|
</Button>
|
||||||
{/if}
|
</section>
|
||||||
</section>
|
{:else}
|
||||||
|
<section class="section">
|
||||||
<section class="section">
|
<h2>API Key Authentication</h2>
|
||||||
<h2>Sync</h2>
|
<p class="text-secondary mb-md">
|
||||||
<div class="info-row">
|
For testing, you can authenticate with an API key. Generate a key using:
|
||||||
<span class="label">Status:</span>
|
<code>opal server keygen --name "Web"</code>
|
||||||
<span class="value">{$syncStore.status}</span>
|
</p>
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
<Input
|
||||||
<span class="label">Queue:</span>
|
label="API Key"
|
||||||
<span class="value">{$syncStore.queueSize} changes</span>
|
type="password"
|
||||||
</div>
|
placeholder="oak_..."
|
||||||
{#if $syncStore.lastSync}
|
bind:value={apiKey}
|
||||||
<div class="info-row">
|
{error}
|
||||||
<span class="label">Last Sync:</span>
|
/>
|
||||||
<span class="value">{new Date($syncStore.lastSync * 1000).toLocaleString()}</span>
|
|
||||||
</div>
|
<Button
|
||||||
{/if}
|
on:click={saveApiKey}
|
||||||
|
loading={saving}
|
||||||
<Button on:click={triggerSync} disabled={$syncStore.status === 'syncing'}>
|
fullWidth
|
||||||
{$syncStore.status === 'syncing' ? 'Syncing...' : 'Sync Now'}
|
>
|
||||||
</Button>
|
Save API Key
|
||||||
</section>
|
</Button>
|
||||||
|
|
||||||
<section class="section">
|
<div class="mt-lg text-center">
|
||||||
<h2>Actions</h2>
|
<p class="text-sm text-secondary">
|
||||||
<Button variant="danger" on:click={logout}>Logout</Button>
|
Or <a href="/auth/login">login with OAuth</a>
|
||||||
</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>
|
</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,23 +195,50 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -214,20 +246,20 @@
|
|||||||
padding: var(--spacing-sm) 0;
|
padding: var(--spacing-sm) 0;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-row:last-of-type {
|
.info-row:last-of-type {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
background-color: var(--bg-tertiary);
|
background-color: var(--bg-tertiary);
|
||||||
padding: 0.125rem 0.375rem;
|
padding: 0.125rem 0.375rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user