feat: add parse endpoint, refactor recurring tasks, and improve web task completion

Extract CreateRecurringTask into engine package for reuse by both CLI
and API. Add POST /tasks/parse endpoint for CLI-style input parsing.
Remove FK constraint on change_log to preserve history after task
deletion. Update web frontend to filter completed tasks from view and
add mock mode support for development.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 22:39:11 +01:00
parent 0352c22b4f
commit 78881e1b07
15 changed files with 2118 additions and 128 deletions
+16 -1
View File
@@ -26,5 +26,20 @@ profile.cov
go.work
go.work.sum
# env file
# env files
.env
.env.*
!.env.example
# OS files
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
*.swp
*.swo
# Claude Code
.claude/
+398
View File
@@ -0,0 +1,398 @@
# Opal Infrastructure Integration Plan
## Context
This document describes how to integrate opal-task (Go API) and opal-web (SvelteKit PWA) into the existing your-infra Ansible infrastructure on the your-server homelab server.
## Current your-infra Architecture
The VPS (your-server / 203.0.113.10) runs Arch Linux with:
- **Caddy** reverse proxy with auto-HTTPS (Let's Encrypt + Cloudflare DNS challenge)
- **PostgreSQL** and **Valkey** as native systemd services, exposed via Unix sockets only
- **Podman** with Quadlet for containerized services (Authentik, etc.)
- **Authentik** SSO at `auth.example.com`
- Services added as Ansible roles in `roles/<service>/`
- Secrets managed via Ansible Vault (`vault.yml`)
- Caddy configs use sites-enabled pattern (`/etc/caddy/sites-enabled/*.caddy`)
## Opal Service Profile
| Property | opal-task | opal-web |
|----------------|----------------------------------|----------------------------------|
| Type | Go binary (REST API) | Static site (SvelteKit PWA) |
| Database | SQLite (embedded) | None (client-side localStorage) |
| External deps | None | None |
| Port | `:8080` (configurable) | N/A (static files) |
| Auth | OAuth2/JWT via Authentik | Passes tokens to API |
| Build output | Single binary | `build/` directory (HTML/JS/CSS) |
Key observation: opal has **zero external service dependencies** — no PostgreSQL, no Valkey, no message queues. SQLite is embedded in the Go binary.
## Recommended Deployment Architecture
```
opal.example.com (Caddy, auto-HTTPS)
├── /* → file_server /var/www/opal/ (static PWA)
├── /api/* → reverse_proxy :8080 (opal-task binary)
└── /auth/* → reverse_proxy :8080 (opal-task OAuth endpoints)
auth.example.com (Authentik, already running)
└── OAuth2/OIDC provider for opal
```
### Decision: Native systemd service, not containerized
opal-task should run as a **native systemd service**, not in a Podman container.
**Rationale:**
- Single static binary with no runtime dependencies
- SQLite is embedded — no database socket mounts needed
- No benefit from container isolation (no network services to fence off, no complex filesystem needs)
- Simpler deployment: copy binary, restart service
- Matches patterns used by other native services in the infrastructure
- Containerizing would add Quadlet files, image builds, and volume mounts for zero gain
### Decision: Static file serving for frontend
opal-web builds to static HTML/JS/CSS via `adapter-static`. Caddy serves these directly with `file_server` and SPA fallback. No Node.js runtime on the server.
**Rationale:**
- SvelteKit adapter-static produces plain files — no SSR, no server process
- Caddy already handles compression, caching headers, and HTTP/2
- Same pattern as other static frontend
- PWA service worker handles offline caching client-side
### Decision: Keep SQLite, skip PostgreSQL
opal-task is a single-user personal task manager with device sync. SQLite is the correct database.
**Rationale:**
- Single writer (one API server process) — no write contention
- Backup is `sqlite3 .backup` or just `cp` the file
- No connection pooling, no socket permissions, no user/role management
- Moving to PostgreSQL would require rewriting the data layer for no benefit
- If multi-user support is ever needed, this can be revisited
## Ansible Role Design
Create `roles/opal/` in your-infra:
```
roles/opal/
├── defaults/main.yml
├── meta/main.yml
├── tasks/
│ ├── main.yml
│ ├── user.yml
│ ├── backend.yml
│ ├── frontend.yml
│ └── caddy.yml
├── templates/
│ ├── opal.service.j2
│ ├── opal.env.j2
│ └── opal.caddy.j2
├── handlers/
│ └── main.yml
└── files/
└── (binary + static build copied here during deploy)
```
### Role Dependencies
```yaml
# meta/main.yml
dependencies:
- role: caddy
```
No dependency on `postgresql` or `valkey` — opal doesn't use them.
### Default Variables
```yaml
# defaults/main.yml
opal_domain: "opal.example.com"
opal_user: "opal"
opal_group: "opal"
# Paths
opal_binary_path: "/usr/local/bin/opal"
opal_data_dir: "/var/lib/opal"
opal_config_dir: "/etc/opal"
opal_web_root: "/var/www/opal"
opal_db_path: "{{ opal_data_dir }}/opal.db"
# Server
opal_server_addr: ":8080"
# OAuth (secrets from vault)
opal_oauth_enabled: true
opal_oauth_issuer: "https://auth.example.com/application/o/opal/"
opal_oauth_redirect_uri: "https://{{ opal_domain }}/auth/callback"
opal_oauth_client_id: "{{ vault_opal_oauth_client_id }}"
opal_oauth_client_secret: "{{ vault_opal_oauth_client_secret }}"
# JWT (secret from vault)
opal_jwt_secret: "{{ vault_opal_jwt_secret }}"
opal_jwt_expiry: 3600
opal_refresh_token_expiry: 604800
```
### Vault Secrets
Add to `host_vars/your-server/vault.yml`:
```yaml
vault_opal_oauth_client_id: "<from Authentik>"
vault_opal_oauth_client_secret: "<from Authentik>"
vault_opal_jwt_secret: "<openssl rand -hex 32>"
```
### Task Breakdown
**user.yml** — Create dedicated system user:
```yaml
- name: Create opal system user
ansible.builtin.user:
name: "{{ opal_user }}"
group: "{{ opal_group }}"
system: true
shell: /bin/false
home: "{{ opal_data_dir }}"
create_home: false
```
**backend.yml** — Deploy binary and systemd unit:
1. Create directories (`/var/lib/opal`, `/etc/opal`)
2. Copy pre-built binary to `/usr/local/bin/opal`
3. Template environment file to `/etc/opal/opal.env`
4. Template systemd unit to `/etc/systemd/system/opal.service`
5. Enable and start the service
**frontend.yml** — Deploy static build:
1. Create `/var/www/opal/`
2. Synchronize build output to web root
3. Set ownership to `root:root` (Caddy reads as root)
**caddy.yml** — Deploy reverse proxy config:
1. Template Caddy site config to `/etc/caddy/sites-enabled/opal.caddy`
2. Notify Caddy reload handler
### systemd Unit Template
```ini
# opal.service.j2
[Unit]
Description=Opal Task API Server
After=network.target
[Service]
Type=simple
User={{ opal_user }}
Group={{ opal_group }}
WorkingDirectory={{ opal_data_dir }}
EnvironmentFile={{ opal_config_dir }}/opal.env
ExecStart={{ opal_binary_path }} server start --addr {{ opal_server_addr }}
Restart=always
RestartSec=5
# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths={{ opal_data_dir }}
ReadOnlyPaths={{ opal_config_dir }}
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
MemoryDenyWriteExecute=true
[Install]
WantedBy=multi-user.target
```
### Caddy Site Template
```caddyfile
# opal.caddy.j2
{{ opal_domain }} {
root * {{ opal_web_root }}
# API and auth endpoints → Go backend
handle /api/* {
uri strip_prefix /api
reverse_proxy localhost{{ opal_server_addr }}
}
handle /auth/* {
reverse_proxy localhost{{ opal_server_addr }}
}
# Static PWA with SPA fallback
handle {
try_files {path} /index.html
file_server
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://auth.example.com;"
-Server
}
log {
output file /var/log/caddy/opal.log
format json
}
encode gzip zstd
}
```
### Environment File Template
```bash
# opal.env.j2
# Server
SERVER_ADDR={{ opal_server_addr }}
OPAL_CONFIG_DIR={{ opal_config_dir }}
OPAL_DATA_DIR={{ opal_data_dir }}
# OAuth
OAUTH_ENABLED={{ opal_oauth_enabled | lower }}
OAUTH_ISSUER={{ opal_oauth_issuer }}
OAUTH_CLIENT_ID={{ opal_oauth_client_id }}
OAUTH_CLIENT_SECRET={{ opal_oauth_client_secret }}
OAUTH_REDIRECT_URI={{ opal_oauth_redirect_uri }}
# JWT
JWT_SECRET={{ opal_jwt_secret }}
JWT_EXPIRY={{ opal_jwt_expiry }}
REFRESH_TOKEN_EXPIRY={{ opal_refresh_token_expiry }}
```
## Build & Deploy Strategy
### Option A: Local build + Ansible deploy (recommended for now)
Build on the dev machine, deploy with Ansible:
```bash
# Build backend (cross-compile for linux/amd64)
cd opal-task
GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -o opal main.go
# Build frontend
cd opal-web
npm run build
# Deploy
ansible-playbook playbooks/deploy-opal.yml --ask-vault-pass
```
The Ansible playbook copies the pre-built binary and static files to the server. This keeps the server clean (no Go toolchain, no Node.js).
**Note on CGO:** opal-task uses `mattn/go-sqlite3` which requires CGO. Cross-compiling with `CGO_ENABLED=1` needs a C cross-compiler (`x86_64-linux-gnu-gcc` or equivalent). If building on the same architecture as the server, this just works. For true cross-compilation, consider using `zig cc` as the C compiler or building in a Docker container matching the target.
### Option B: GitHub Actions CI/CD (future)
Follow the existing CI/CD pattern already in your-infra:
1. Push to `main` triggers workflow
2. Build Go binary in a Linux container (solves CGO cross-compilation)
3. Build SvelteKit static site
4. SSH to your-server, copy artifacts, restart service
```yaml
# .github/workflows/deploy.yml (sketch)
on:
push:
branches: [main]
paths:
- 'opal-task/**'
- 'opal-web/**'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build backend
run: |
cd opal-task
go build -o opal main.go
- name: Build frontend
run: |
cd opal-web
npm ci
npm run build
- name: Deploy
run: |
scp opal-task/opal ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/tmp/opal
rsync -avz --delete opal-web/build/ ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/var/www/opal/
ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \
"sudo cp /tmp/opal /usr/local/bin/ && sudo systemctl restart opal"
```
## Backup Strategy
SQLite backup is trivial — one file, atomic snapshots:
```bash
# Daily backup via cron or systemd timer
sqlite3 /var/lib/opal/opal.db ".backup /var/backups/opal/opal-$(date +%Y%m%d).db"
# Retention: keep 14 days
find /var/backups/opal/ -name "opal-*.db" -mtime +14 -delete
```
This can be added as a systemd timer in the Ansible role, or as a cron entry.
## Monitoring
opal-task exposes `GET /health` with no authentication. Add this to the existing metrics stack:
- Caddy access logs in JSON at `/var/log/caddy/opal.log`
- systemd journal for opal service (`journalctl -u opal`)
- Health check polling from VictoriaMetrics or a simple uptime probe
## Playbook Integration
Add to `your-infra.yml` (homelab playbook):
```yaml
- hosts: your-server
roles:
# ... existing roles ...
- role: opal
tags: [opal]
```
Deploy independently:
```bash
ansible-playbook your-infra.yml --tags opal --ask-vault-pass
```
Or create a dedicated `playbooks/deploy-opal.yml` for quick redeploys.
## Checklist
- [ ] Create Authentik OAuth application for opal (see `docs/authentik-setup.md`)
- [ ] Add vault secrets to `host_vars/your-server/vault.yml`
- [ ] Create `roles/opal/` in your-infra
- [ ] Point DNS `opal.example.com` to your-server IP
- [ ] Build and deploy backend binary
- [ ] Build and deploy frontend static files
- [ ] Verify health check: `curl https://opal.example.com/api/health`
- [ ] Test OAuth login flow end-to-end
- [ ] Set up SQLite backup timer
- [ ] (Optional) Add GitHub Actions CI/CD workflow
+3 -91
View File
@@ -4,10 +4,8 @@ import (
"fmt"
"os"
"strings"
"time"
"git.jnss.me/joakim/opal/internal/engine"
"github.com/google/uuid"
"github.com/spf13/cobra"
)
@@ -101,99 +99,13 @@ func parseAddArgs(args []string) (string, []string, error) {
}
func addRecurringTask(description string, mod *engine.Modifier) error {
// Extract recurrence pattern
recurPattern := mod.SetAttributes["recur"]
if recurPattern == nil {
return fmt.Errorf("no recurrence pattern specified")
}
// Validate: recurring tasks must have due date
if mod.SetAttributes["due"] == nil {
return fmt.Errorf("recurring tasks require a due date (use due:YYYY-MM-DD or due:monday)")
}
duration, err := engine.ParseRecurrencePattern(*recurPattern)
instance, err := engine.CreateRecurringTask(description, mod)
if err != nil {
return fmt.Errorf("invalid recurrence pattern: %w", err)
return err
}
// Create template task (without saving yet)
now := time.Now()
template := &engine.Task{
UUID: uuid.New(),
Status: engine.StatusRecurring,
Description: description,
Priority: engine.PriorityDefault,
Created: now,
Modified: now,
RecurrenceDuration: &duration,
Tags: []string{},
}
// Create modifier without the recur attribute
tempMod := &engine.Modifier{
SetAttributes: make(map[string]*string),
AttributeOrder: []string{},
AddTags: mod.AddTags,
RemoveTags: mod.RemoveTags,
}
// Copy all attributes except recur
for _, key := range mod.AttributeOrder {
if key != "recur" {
val := mod.SetAttributes[key]
tempMod.SetAttributes[key] = val
tempMod.AttributeOrder = append(tempMod.AttributeOrder, key)
}
}
// Apply modifiers to template before first save
if err := tempMod.ApplyToNew(template); err != nil {
return fmt.Errorf("failed to apply modifiers to template: %w", err)
}
// Save template
if err := template.Save(); err != nil {
return fmt.Errorf("failed to save template: %w", err)
}
// Add tags to template (requires task.ID from save)
for _, tag := range mod.AddTags {
if err := template.AddTag(tag); err != nil {
return fmt.Errorf("failed to add tag to template: %w", err)
}
}
// Create first instance
instance := &engine.Task{
UUID: uuid.New(),
Status: engine.StatusPending,
Description: description,
Priority: template.Priority,
Created: now,
Modified: now,
ParentUUID: &template.UUID,
Due: template.Due,
Wait: template.Wait,
Scheduled: template.Scheduled,
Project: template.Project,
Tags: []string{},
}
if err := instance.Save(); err != nil {
return fmt.Errorf("failed to save first instance: %w", err)
}
// Copy tags to instance
for _, tag := range template.Tags {
if err := instance.AddTag(tag); err != nil {
return fmt.Errorf("failed to add tag to instance: %w", err)
}
}
fmt.Printf("Created recurring task %s\n", template.UUID)
fmt.Printf("Created recurring task %s\n", *instance.ParentUUID)
fmt.Printf("First instance: %s\n", instance.UUID)
fmt.Printf("Recurrence: %s\n", engine.FormatRecurrenceDuration(duration))
return nil
}
+95 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"git.jnss.me/joakim/opal/internal/engine"
@@ -30,10 +31,32 @@ func errorResponse(w http.ResponseWriter, status int, message string) {
})
}
// ListTasks returns tasks based on filter query parameters
// ListTasks returns tasks based on filter query parameters or a named report
func ListTasks(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
// Check for report mode
if reportName := query.Get("report"); reportName != "" {
report, err := engine.GetReport(reportName)
if err != nil {
errorResponse(w, http.StatusBadRequest, err.Error())
return
}
tasks, err := report.Execute(nil)
if err != nil {
errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, map[string]interface{}{
"report": reportName,
"tasks": tasks,
"count": len(tasks),
})
return
}
// Build filter from query params
filter := engine.NewFilter()
@@ -409,6 +432,77 @@ func AddTaskTag(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, task)
}
// ParseTaskRequest represents the request body for parsing a CLI-style task input
type ParseTaskRequest struct {
Input string `json:"input"`
}
// ParseTask accepts a raw CLI-style input string, parses it, and creates a task
func ParseTask(w http.ResponseWriter, r *http.Request) {
var req ParseTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorResponse(w, http.StatusBadRequest, "invalid request body")
return
}
input := strings.TrimSpace(req.Input)
if input == "" {
errorResponse(w, http.StatusBadRequest, "input is required")
return
}
// Split input on whitespace
args := strings.Fields(input)
// Classify args: words with +/- prefix or containing : are modifiers
var descParts []string
var modifierArgs []string
for _, arg := range args {
if strings.HasPrefix(arg, "+") || strings.HasPrefix(arg, "-") || strings.Contains(arg, ":") {
modifierArgs = append(modifierArgs, arg)
} else {
descParts = append(descParts, arg)
}
}
if len(descParts) == 0 {
errorResponse(w, http.StatusBadRequest, "description is required")
return
}
description := strings.Join(descParts, " ")
// Parse modifiers
var mod *engine.Modifier
if len(modifierArgs) > 0 {
var err error
mod, err = engine.ParseModifier(modifierArgs)
if err != nil {
errorResponse(w, http.StatusBadRequest, fmt.Sprintf("failed to parse modifiers: %v", err))
return
}
}
// Check for recurring task
if mod != nil && mod.SetAttributes["recur"] != nil {
instance, err := engine.CreateRecurringTask(description, mod)
if err != nil {
errorResponse(w, http.StatusBadRequest, err.Error())
return
}
jsonResponse(w, http.StatusCreated, map[string]interface{}{"task": instance})
return
}
// Create regular task
task, err := engine.CreateTaskWithModifier(description, mod)
if err != nil {
errorResponse(w, http.StatusBadRequest, err.Error())
return
}
jsonResponse(w, http.StatusCreated, map[string]interface{}{"task": task})
}
// RemoveTaskTag removes a tag from a task
func RemoveTaskTag(w http.ResponseWriter, r *http.Request) {
uuidStr := chi.URLParam(r, "uuid")
@@ -0,0 +1,220 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"git.jnss.me/joakim/opal/internal/engine"
)
func TestMain(m *testing.M) {
testDir := "/tmp/opal-handler-test"
engine.SetConfigDirOverride(testDir)
engine.SetDataDirOverride(testDir)
if err := os.MkdirAll(testDir, 0755); err != nil {
panic(err)
}
if err := engine.InitDB(); err != nil {
panic(err)
}
defer engine.CloseDB()
code := m.Run()
os.RemoveAll(testDir)
os.Exit(code)
}
func TestParseTask_DescriptionOnly(t *testing.T) {
body := `{"input": "buy groceries"}`
req := httptest.NewRequest(http.MethodPost, "/tasks/parse", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
ParseTask(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
data, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatal("expected data in response")
}
task, ok := data["task"].(map[string]interface{})
if !ok {
t.Fatal("expected task in data")
}
if task["Description"] != "buy groceries" {
t.Errorf("expected description 'buy groceries', got %v", task["Description"])
}
}
func TestParseTask_WithModifiers(t *testing.T) {
body := `{"input": "review PR priority:H project:backend +code"}`
req := httptest.NewRequest(http.MethodPost, "/tasks/parse", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
ParseTask(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
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["Project"] != "backend" {
t.Errorf("expected project 'backend', got %v", task["Project"])
}
}
func TestParseTask_WithRecurrence(t *testing.T) {
body := `{"input": "team meeting due:2026-03-01 recur:1w +meetings"}`
req := httptest.NewRequest(http.MethodPost, "/tasks/parse", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
ParseTask(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
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")
}
}
func TestParseTask_EmptyInput(t *testing.T) {
body := `{"input": ""}`
req := httptest.NewRequest(http.MethodPost, "/tasks/parse", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
ParseTask(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestParseTask_OnlyModifiers(t *testing.T) {
body := `{"input": "+tag priority:H"}`
req := httptest.NewRequest(http.MethodPost, "/tasks/parse", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
ParseTask(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestParseTask_InvalidModifier(t *testing.T) {
body := `{"input": "some task due:notadate"}`
req := httptest.NewRequest(http.MethodPost, "/tasks/parse", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
ParseTask(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestListTasks_WithReport(t *testing.T) {
// Create a task first so the report has something to return
_, err := engine.CreateTask("report test task")
if err != nil {
t.Fatalf("failed to create test task: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/tasks?report=list", nil)
w := httptest.NewRecorder()
ListTasks(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
data := resp["data"].(map[string]interface{})
if data["report"] != "list" {
t.Errorf("expected report name 'list', got %v", data["report"])
}
if data["count"] == nil {
t.Error("expected count in response")
}
if data["tasks"] == nil {
t.Error("expected tasks in response")
}
}
func TestListTasks_UnknownReport(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/tasks?report=nonexistent", nil)
w := httptest.NewRecorder()
ListTasks(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestListTasks_NoReport(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
w := httptest.NewRecorder()
ListTasks(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
// Without report param, data should be the tasks array directly (not wrapped)
if resp["success"] != true {
t.Error("expected success to be true")
}
}
+1
View File
@@ -53,6 +53,7 @@ func (s *Server) setupRoutes() {
r.Route("/tasks", func(r chi.Router) {
r.Get("/", handlers.ListTasks)
r.Post("/", handlers.CreateTask)
r.Post("/parse", handlers.ParseTask)
r.Get("/{uuid}", handlers.GetTask)
r.Put("/{uuid}", handlers.UpdateTask)
r.Delete("/{uuid}", handlers.DeleteTask)
+2 -2
View File
@@ -173,13 +173,13 @@ func runMigrations() error {
);
-- Change log (key:value format like edit command)
-- No FK on task_uuid: change logs must survive task deletion
CREATE TABLE change_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_uuid TEXT NOT NULL,
change_type TEXT NOT NULL,
changed_at INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY (task_uuid) REFERENCES tasks(uuid) ON DELETE CASCADE
data TEXT NOT NULL
);
CREATE INDEX idx_change_log_timestamp ON change_log(changed_at);
+5 -2
View File
@@ -150,13 +150,16 @@ func TestFilterToSQL(t *testing.T) {
}
// Test tag filter
// Note: without a status filter, ToSQL adds an implicit template exclusion
// condition, so args will contain [StatusRecurring, "urgent"]
filter = &Filter{
IncludeTags: []string{"urgent"},
Attributes: map[string]string{},
}
where, args = filter.ToSQL()
if len(args) != 1 || args[0] != "urgent" {
t.Errorf("Expected tag 'urgent' in args, got %v", args)
if len(args) != 2 || args[1] != "urgent" {
t.Errorf("Expected tag 'urgent' as second arg (after template exclusion), got %v", args)
}
}
+88
View File
@@ -100,6 +100,94 @@ func CalculateNextDue(currentDue time.Time, recurrence time.Duration) time.Time
return currentDue.Add(recurrence)
}
// CreateRecurringTask creates a recurring task template and its first instance.
// It validates the recurrence pattern and due date, creates the template with
// StatusRecurring, then creates the first pending instance linked to it.
// Returns the first instance task.
func CreateRecurringTask(description string, mod *Modifier) (*Task, error) {
recurPattern := mod.SetAttributes["recur"]
if recurPattern == nil {
return nil, fmt.Errorf("no recurrence pattern specified")
}
if mod.SetAttributes["due"] == nil {
return nil, fmt.Errorf("recurring tasks require a due date (use due:YYYY-MM-DD or due:monday)")
}
duration, err := ParseRecurrencePattern(*recurPattern)
if err != nil {
return nil, fmt.Errorf("invalid recurrence pattern: %w", err)
}
now := time.Now()
template := &Task{
UUID: uuid.New(),
Status: StatusRecurring,
Description: description,
Priority: PriorityDefault,
Created: now,
Modified: now,
RecurrenceDuration: &duration,
Tags: []string{},
}
// Build modifier without the recur attribute
tempMod := &Modifier{
SetAttributes: make(map[string]*string),
AttributeOrder: []string{},
AddTags: mod.AddTags,
RemoveTags: mod.RemoveTags,
}
for _, key := range mod.AttributeOrder {
if key != "recur" {
tempMod.SetAttributes[key] = mod.SetAttributes[key]
tempMod.AttributeOrder = append(tempMod.AttributeOrder, key)
}
}
if err := tempMod.ApplyToNew(template); err != nil {
return nil, fmt.Errorf("failed to apply modifiers to template: %w", err)
}
if err := template.Save(); err != nil {
return nil, fmt.Errorf("failed to save template: %w", err)
}
for _, tag := range mod.AddTags {
if err := template.AddTag(tag); err != nil {
return nil, fmt.Errorf("failed to add tag to template: %w", err)
}
}
// Create first instance
instance := &Task{
UUID: uuid.New(),
Status: StatusPending,
Description: description,
Priority: template.Priority,
Created: now,
Modified: now,
ParentUUID: &template.UUID,
Due: template.Due,
Wait: template.Wait,
Scheduled: template.Scheduled,
Project: template.Project,
Tags: []string{},
}
if err := instance.Save(); err != nil {
return nil, fmt.Errorf("failed to save first instance: %w", err)
}
for _, tag := range template.Tags {
if err := instance.AddTag(tag); err != nil {
return nil, fmt.Errorf("failed to add tag to instance: %w", err)
}
}
return instance, nil
}
// SpawnNextInstance creates a new task instance from completed recurring task
func SpawnNextInstance(completedInstance *Task) error {
if completedInstance.ParentUUID == nil {
+290
View File
@@ -0,0 +1,290 @@
# Opal Web — PWA Requirements
## 1. Problem Statement
The current opal-web PWA uses a conventional form-based UI for task management.
This is misaligned with how opal actually works — opal is a CLI-first tool with
a rich, expressive text syntax for creating, filtering, and modifying tasks.
The new design replaces the form-based approach with a **CLI-passthrough
interface**: a single text input that accepts the same syntax as the opal CLI,
making the web UI a thin, mobile-friendly shell over the existing command
language.
**Target users:** Existing opal CLI users who want quick mobile/web access
without learning a separate UI paradigm.
---
## 2. User Stories
### US-1: Quick-add a task from mobile
**As a** user, **I want** to type `Buy groceries due:tomorrow +errand` into the
input and have it create a task, **so that** I can capture tasks as fast as I do
from the terminal.
**Acceptance Criteria:**
- Given the input is focused and I type a description with modifiers
- When I submit (Enter / tap send)
- Then the raw input string is sent to the API, which parses it server-side
- And a task is created with the parsed description, due date, and tags
- And the input clears
- And the new task appears in the list
### US-2: Use property pills to build a command
**As a** user, **I want** to tap a `Due` pill above the input to insert `due:`
at my cursor position, **so that** I don't have to remember every modifier name
on mobile.
**Acceptance Criteria:**
- Given the input is focused
- When I tap the `Due` pill
- Then `due:` is inserted at the cursor position in the input
- And the keyboard stays open / input retains focus
- And the cursor is placed immediately after the inserted text
### US-3: View my pending tasks
**As a** user, **I want** to see my pending tasks in a list above the input,
**so that** I have context for what I'm working on.
**Acceptance Criteria:**
- Given I open the app
- When the task list loads
- Then I see pending tasks displayed in a compact list, sorted by urgency
- And each task shows its description with inline metadata (project, priority,
due date, tags) using subtle color coding
### US-4: Complete a task via swipe
**As a** user, **I want** to swipe a task to the right to mark it done, **so
that** I can quickly clear completed work.
**Acceptance Criteria:**
- Given I see a task in the list
- When I swipe the task to the right
- Then a green background with a checkmark is revealed during the swipe
- And on release past the threshold, the task is marked as completed
- And the task animates out of the list
### US-5: Complete a task via checkbox
**As a** user, **I want** to tap a checkbox on a task to mark it done, **so
that** I have a familiar fallback when swiping is inconvenient.
**Acceptance Criteria:**
- Given I see a task in the list
- When I tap the checkbox/circle on the left side of the task
- Then the task is marked as completed and animates out of the list
### US-6: Filter tasks by report
**As a** user, **I want** to switch between different task views (pending, next,
active, overdue, completed, etc.), **so that** I can focus on what's relevant.
**Acceptance Criteria:**
- Given I am on the main task view
- When I tap the filter/report button
- Then a dropdown opens showing available reports
- And selecting a report filters the task list accordingly
### US-7: Access settings
**As a** user, **I want** to access a settings page, **so that** I can
configure API connection, view sync status, and log out.
**Acceptance Criteria:**
- Given I am on the main task view
- When I tap the gear icon in the top-right corner
- Then I navigate to a settings page
- And I can navigate back to the task list
---
## 3. Functional Requirements
### App Structure
| ID | Priority | Requirement |
|------|----------|-------------|
| F-00 | MUST | The app is a single-screen design: one main view with task list + input bar |
| F-01 | MUST | A separate settings page is accessible via a gear icon in the top-right corner |
| F-02 | MUST | No bottom navigation bar — the input bar occupies the bottom of the screen |
### Header
| ID | Priority | Requirement |
|------|----------|-------------|
| F-05 | MUST | The header contains the current report name (e.g., "Pending", "Next", "Overdue") |
| F-06 | MUST | The header contains a filter/report button that opens a dropdown of available reports |
| F-07 | MUST | The header contains a gear icon (top-right) linking to the settings page |
### Report Picker
| ID | Priority | Requirement |
|------|----------|-------------|
| F-08 | MUST | A dropdown/popover triggered by the filter button in the header |
| F-09 | MUST | Lists all available reports (see table below) |
| F-0A | MUST | Selecting a report reloads the task list with the corresponding filter |
| F-0B | MUST | The currently active report is visually indicated in the dropdown |
| F-0C | SHOULD | Default report on app open is "Pending" |
**Reports (all CLI reports):**
| Report | Description |
|------------|------------------------------------------|
| Pending | All pending tasks (default) |
| Next | Most urgent ready tasks (top N) |
| Active | Currently started tasks |
| Ready | Tasks with no future wait/scheduled date |
| Overdue | Tasks past their due date |
| Completed | Completed tasks |
| Waiting | Tasks hidden until their wait date |
| Recurring | Pending recurring task instances |
| Template | Recurring template tasks |
| Newest | Most recently created pending tasks |
| Oldest | Oldest pending tasks |
### Input Bar
| ID | Priority | Requirement |
|------|----------|-------------|
| F-10 | MUST | A single-line text input is fixed to the bottom of the visible viewport, always visible |
| F-11 | MUST | The input accepts opal CLI syntax for the `add` command: description words, `+tag`, `project:`, `due:`, `priority:`, `scheduled:`, `wait:`, `until:`, `recur:` |
| F-12 | MUST | Submitting the input sends the raw string to the API for server-side parsing and task creation |
| F-13 | MUST | The input clears after successful submission |
| F-14 | SHOULD | The input bar remains above the mobile keyboard when the keyboard is open |
| F-15 | MUST | The input is add-only — it does not parse other CLI commands (done, modify, delete, etc.) |
### Property Pills
| ID | Priority | Requirement |
|------|----------|-------------|
| F-20 | MUST | When the input is focused, a row of tappable pills appears directly above the input |
| F-21 | MUST | Tapping a pill inserts its text at the current cursor position in the input |
| F-22 | MUST | After inserting, the input retains focus and the cursor is placed after the inserted text |
| F-23 | MUST | Pills disappear when the input loses focus |
| F-24 | SHOULD | The pill row is horizontally scrollable if it overflows |
**Pills (display order):**
| Pill label | Inserts | Notes |
|--------------|---------------|-------|
| `Due` | `due:` | Date: tomorrow, mon, eod, 2025-03-15, 3d, etc. |
| `Pri` | `priority:` | H, M, L |
| `Project` | `project:` | Project name |
| `Tag` | `+` | Tag prefix — user types tag name after |
| `Recur` | `recur:` | Duration: 1d, 1w, 2w, 1m, daily, weekly, etc. |
| `Scheduled` | `scheduled:` | Same date format as due: |
| `Wait` | `wait:` | Same date format as due: |
| `Until` | `until:` | Same date format as due: |
### Task List
| ID | Priority | Requirement |
|------|----------|-------------|
| F-30 | MUST | The main view displays a scrollable list of tasks above the input bar, filtered by the active report |
| F-31 | MUST | Each task row shows: description, and inline metadata where set (project, priority, due date, tags) |
| F-32 | MUST | Task rows are compact — hybrid density with subtle color coding for priority and due dates, no full cards |
| F-33 | MUST | Swiping a task to the right marks it as completed |
| F-34 | MUST | Each task has a small checkbox/circle on the left that also completes the task on tap |
| F-35 | MUST | Swipe-to-complete reveals a green background with a checkmark icon during the swipe |
| F-36 | SHOULD | Tasks are sorted by urgency (matching the CLI's urgency calculation) |
| F-37 | SHOULD | Overdue due dates are visually distinct (red/warm color) |
| F-38 | SHOULD | Completed tasks animate out of the list (brief fade/slide) |
| F-39 | COULD | Pull-to-refresh to reload task list |
---
## 4. Non-Functional Requirements
| ID | Category | Requirement |
|-------|---------------|-------------|
| NF-01 | Mobile-first | The UI must work well on 375px428px viewport widths (iPhone SE through Pro Max) |
| NF-02 | Performance | Task list should render within 100ms of data availability |
| NF-03 | PWA | The app must remain installable as a PWA with offline shell caching |
| NF-04 | Keyboard | The input bar must not be obscured by the mobile keyboard |
| NF-05 | Touch | Swipe gestures must have clear thresholds (no accidental triggers from scrolling) |
---
## 5. Constraints & Assumptions
### Constraints
- Must use the existing SvelteKit + Vite + static adapter stack
- Must work with the existing opal REST API (or mock mode for dev)
- The opal API can be extended or reworked during the design phase to better
support the new functionality
### Assumptions
- Users are familiar with opal CLI syntax or will learn it through the pills
- The `add` command is the only command handled by the input bar
- **Parsing happens server-side** — the frontend sends the raw input string to
a new or updated API endpoint; no client-side replication of the opal parser
- Completion is handled via swipe gesture or checkbox, not via the input bar
---
## 6. Open Questions
All previously open questions have been resolved:
| # | Question | Resolution |
|----|----------|------------|
| Q1 | How should the task list handle filtering/views? | Reports are available via a filter button/dropdown in the header |
| Q2 | Where should the settings link live? | Top-right gear icon |
| Q3 | Should swipe-to-complete show a visual indicator? | Yes — green background with checkmark revealed during swipe |
| Q4 | Should completed tasks be viewable? | Yes — included as a report option in the filter dropdown |
| Q5 | Which reports should the API support? | All CLI reports (see Report Picker section) |
### Open for Design Phase
| # | Question | Impact |
|----|----------|--------|
| Q6 | **What new API endpoint is needed for raw-string task creation?** e.g., `POST /tasks/parse` accepting `{ "input": "Buy groceries due:tomorrow +errand" }`. | Affects backend and frontend contract |
| Q7 | **How should the API expose report-based queries?** e.g., `GET /tasks?report=next` vs. dedicated endpoints per report. Some reports (next, ready, overdue) require server-side urgency/date logic. | Affects API design |
---
## 7. Design Notes
Items for the design phase to pay attention to:
### Swipe vs. Scroll Conflict
Horizontal swipe-to-complete on a vertically scrolling list is a known UX
challenge on mobile. The designer should define a clear gesture model:
- Minimum horizontal displacement before the swipe "locks in" (vs. being
interpreted as a scroll)
- Whether the swipe requires a threshold to trigger, or if releasing mid-swipe
cancels it
- Consider a slight delay or angle detection to disambiguate
### Input Bar and Mobile Keyboard
When the mobile keyboard opens, the input bar must stay visible above it. This
is notoriously inconsistent across browsers/OS versions. Design should account
for:
- `visualViewport` API for keyboard-aware positioning
- iOS Safari's quirks with `position: fixed` and the virtual keyboard
- Whether the task list should shrink or scroll when the keyboard opens
### Error Feedback from Parse Endpoint
If the server rejects the raw input string (e.g., invalid date `due:neverday`),
how is the error communicated? Options:
- Inline error message below the input bar (toast-style or persistent)
- The input should NOT clear on failure (so the user can fix and retry)
### Empty States
Each report may return zero results. Design should define empty states for:
- First-time use (no tasks at all — should guide user toward the input)
- Report with no matches (e.g., "No overdue tasks" — brief, non-alarming)
### Report Picker Density
With 11 reports in the dropdown, the designer should consider whether all fit
comfortably on a mobile screen without scrolling, or if grouping/sectioning
is needed (e.g., "Active views" vs. "Archive views").
---
## 8. Out of Scope
- OAuth/authentication flow redesign (handled separately; mock mode for dev)
- Sync infrastructure changes
- Offline task creation queue (existing implementation stays as-is)
- Full CLI command passthrough (modify, delete, start, stop — add-only for now)
- Task detail/edit view
- Projects and Tags dedicated pages
+681
View File
@@ -0,0 +1,681 @@
# Opal Web PWA — Architecture Design
**Target environment:** Internal product. Android Chrome and desktop
Chrome/Firefox. Browser versions are controlled. iOS is not in scope.
**CSS philosophy:** Prefer modern HTML/CSS over JavaScript wherever possible.
Use `dvh` units, `has()`, container queries, `flex-wrap`, `popover`,
`@starting-style`, and other modern features freely — no legacy browser
concerns.
## 1. Architecture Overview
The redesign transforms opal-web from a multi-page form-based app into a
single-screen CLI-passthrough interface. The frontend becomes a thin shell over
the opal command language, with all parsing handled server-side.
```mermaid
graph TD
subgraph "Frontend (SvelteKit Static PWA)"
Layout["+layout.svelte"]
Home["+page.svelte — Single Screen"]
Settings["/settings"]
Home --> Header
Home --> TaskList
Home --> InputBar
Header --> ReportPicker["ReportPicker dropdown"]
Header --> GearIcon["⚙ → /settings"]
InputBar --> PropertyPills
InputBar --> TextInput["CLI text input"]
TaskList --> TaskItem["TaskItem (swipe + checkbox)"]
end
subgraph "Backend (Go + chi)"
API["REST API"]
Parser["Modifier Parser"]
Reports["Report Engine"]
DB["SQLite"]
API --> Parser
API --> Reports
Parser --> DB
Reports --> DB
end
TextInput -- "POST /tasks/parse\n{input: raw string}" --> API
TaskList -- "GET /tasks?report=pending" --> API
TaskItem -- "POST /tasks/:uuid/complete" --> API
```
### Page Structure
```
/ ........................ Single-screen task view (auth-gated)
/settings ................ Settings page (gear icon)
/auth/login .............. OAuth login
/auth/callback ........... OAuth callback
```
All other routes (`/tasks/new`, `/tasks/[uuid]`, `/projects`, `/tags`) are
**removed**. The bottom navigation bar is removed. The input bar replaces it.
---
## 2. Component Design
### 2.1 Root Layout (`+layout.svelte`)
**Responsibility:** App shell — auth gating, global styles, routing.
**Changes from current:**
- Remove `BottomNav` component entirely
- No conditional nav rendering — the layout is just a slot
### 2.2 Home Page (`+page.svelte`)
**Responsibility:** The single-screen orchestrator. Owns the three vertical
zones: header, task list, input bar.
**Interface (reactive state):**
```typescript
// Active report selection
let activeReport: string = 'pending'
// Task data
let tasks: Task[] = []
let loading: boolean = true
// Input bar
let inputValue: string = ''
let inputFocused: boolean = false
let inputError: string | null = null
```
**Behavior:**
- On mount: redirect to `/auth/login` if not authenticated, otherwise load
tasks for default report (`pending`)
- On report change: reload tasks with `GET /tasks?report={name}`
- On input submit: `POST /tasks/parse` with raw string. On success, the
response contains the created task — insert it into the list at the top
(rather than re-fetching the entire list). The next report load will sort it
into the correct position
- On task complete (swipe or checkbox): `POST /tasks/:uuid/complete`, remove
from list with animation
### 2.3 Header
**Responsibility:** Shows current report name, report picker trigger, settings
link.
**Interface:**
```typescript
interface HeaderProps {
activeReport: string
onReportChange: (report: string) => void
}
```
**Layout:** `[Report Name ▾]` left-aligned, `[⚙]` right-aligned. Tapping the
report name or chevron opens the `ReportPicker`.
### 2.4 ReportPicker
**Responsibility:** Dropdown overlay listing all available reports.
**Interface:**
```typescript
interface ReportPickerProps {
activeReport: string
open: boolean
onSelect: (report: string) => void
onClose: () => void
}
```
**Reports list:**
| Group | Reports |
|--------------|----------------------------------------------|
| Common | Pending, Next, Active, Ready |
| Time-based | Overdue, Waiting, Newest, Oldest |
| Recurring | Recurring, Template |
| Archive | Completed |
The dropdown is grouped with subtle dividers to manage the 11-item density on
mobile. The active report has a visual indicator (checkmark or highlight).
**Implementation:** Use the native Popover API (`popover` attribute +
`popovertarget`). This gives us light-dismiss (tap outside to close), top-layer
rendering, and `::backdrop` styling for free — no JS for open/close state or
click-outside detection. Entry/exit animations via `@starting-style` +
`transition-behavior: allow-discrete`.
**Dismiss:** Tapping outside (popover light-dismiss), selecting a report, or
pressing Escape.
### 2.5 TaskList
**Responsibility:** Scrollable list of tasks between header and input bar.
**Interface:**
```typescript
interface TaskListProps {
tasks: Task[]
loading: boolean
activeReport: string
onComplete: (uuid: string) => void
}
```
**Empty states:**
- Loading: skeleton or spinner
- No tasks at all (first use): "Type a task below to get started"
- No tasks for report: "No {report} tasks" (e.g., "No overdue tasks")
**Scroll region:** Uses `flex: 1` with `overflow-y: auto` between the
fixed-position header and input bar. Must account for keyboard open state.
### 2.6 TaskItem
**Responsibility:** Single task row with completion interactions.
**Interface:**
```typescript
interface TaskItemProps {
task: Task
onComplete: (uuid: string) => void
}
```
**Visual layout:**
```
[○] Buy groceries due:Feb 15
+errand project:Home pri:H
```
- Left: completion circle/checkbox
- Center: description on first line, metadata pills on second line (only if
metadata exists)
- Metadata: project badge, priority indicator, due date, tags — all inline with
subtle color coding
**Completion interactions:**
1. **Checkbox tap:** Calls `onComplete` immediately
2. **Swipe right:** See section 2.7
**Priority colors:**
- High (3): red/warm
- Medium (2): amber
- Low (0): muted gray
- Default (1): no indicator
**Due date colors:**
- Overdue: red
- Due today: amber
- Due within 7 days: default
- Future/none: muted
### 2.7 SwipeAction
**Responsibility:** Wraps TaskItem to provide swipe-to-complete gesture.
**Interface:**
```typescript
interface SwipeActionProps {
onSwipe: () => void
threshold?: number // pixels, default 100
children: Snippet // TaskItem content
}
```
**Gesture model (addresses Design Note on swipe/scroll conflict):**
1. Touch starts — record `startX`, `startY`
2. On move, compute `deltaX` and `deltaY`
3. **Lock-in rule:** If `|deltaX| > 10px` AND `|deltaX| > |deltaY| * 2`
(angle < ~27°), lock to horizontal swipe and call `preventDefault()` on the
touch event to suppress scrolling
4. If vertical movement dominates first, do nothing (allow scroll)
5. During swipe: translate the task row, reveal green background with checkmark
icon underneath
6. **Threshold:** If `deltaX > 100px` on release → trigger completion
7. **Cancel:** If released before threshold → CSS transition back to
`translate(0)` (set `transition: transform 0.2s ease` on release, remove it
on touch start)
8. **Completion animation:** On threshold met, add a `completing` class that
triggers a CSS transition (`opacity: 0; height: 0; margin: 0; padding: 0;
overflow: hidden; transition: all 0.3s ease`). The `transitionend` event
fires the API call and removes the DOM element
9. The swipe tracking (touch start/move/end, deltaX/deltaY, translateX) requires
JS. But all animations (snap-back, completion fade-out) are CSS transitions
### 2.8 InputBar
**Responsibility:** Fixed-to-bottom CLI input with property pills.
**Interface:**
```typescript
interface InputBarProps {
value: string
error: string | null
loading: boolean
onSubmit: (input: string) => void
onInput: (value: string) => void
onFocusChange: (focused: boolean) => void
}
```
**Layout (bottom-up):**
```
┌─────────────────────────────────────┐
│ [Due] [Pri] [Project] [Tag] ... │ ← Pills (visible when focused)
├─────────────────────────────────────┤
│ [ Add a task... ] [→] │ ← Input + submit button
├─────────────────────────────────────┤
│ (error message if any) │ ← Inline error
└─────────────────────────────────────┘
```
**Keyboard handling (addresses Design Note):**
- The entire page layout uses `height: 100dvh` (`dvh` = dynamic viewport
height, which accounts for the virtual keyboard on Android Chrome/Firefox)
- The layout is a CSS grid or flexbox column: `header | task-list (flex:1) |
input-bar`. When the keyboard opens, `100dvh` shrinks, the flex container
shrinks, and the input bar stays at the bottom — no JS needed
- This is a controlled-browser internal product (Android Chrome / desktop
Firefox+Chrome); `dvh` is fully supported
**Error display:**
- Errors appear as a small red text line between the input and the pills
- Input does NOT clear on error — user can fix and retry
- Error clears on next input change
### 2.9 PropertyPills
**Responsibility:** Horizontal row of tappable shortcut pills.
**Interface:**
```typescript
interface PropertyPillsProps {
visible: boolean
onInsert: (text: string) => void
}
```
**Pills (ordered per requirements):**
| Label | Inserts |
|------------|--------------|
| Due | `due:` |
| Pri | `priority:` |
| Project | `project:` |
| Tag | `+` |
| Recur | `recur:` |
| Scheduled | `scheduled:` |
| Wait | `wait:` |
| Until | `until:` |
**Behavior:**
- Tapping a pill calls `onInsert(text)` — the parent `InputBar` handles cursor
positioning via `selectionStart`/`selectionEnd` on the input element
- Pills wrap over multiple lines using `display: flex; flex-wrap: wrap; gap`
- Appears with a brief slide-up animation when input focuses
- Disappears when input blurs (with short delay to allow pill tap to register
before blur fires)
---
## 3. Data Model
No changes to the task entity. The existing `Task` type from
`src/lib/api/types.js` is sufficient.
**New derived type for API responses:**
```typescript
// Report metadata returned alongside task list
interface ReportResponse {
report: string // Report name
tasks: Task[] // Filtered/sorted tasks
count: number // Total count
}
// Parse endpoint response
interface ParseResponse {
task: Task // The created task
}
// Parse endpoint error
interface ParseError {
error: string // Human-readable error message
field?: string // Which modifier failed, if applicable
}
```
---
## 4. API Design
### 4.1 New Endpoint: Parse and Create Task
Resolves **Q6** from requirements.
```
POST /tasks/parse
Content-Type: application/json
Authorization: Bearer <token>
Request:
{
"input": "Buy groceries due:tomorrow +errand priority:H"
}
Success Response (201 Created):
{
"success": true,
"data": {
"task": { ...Task object... }
}
}
Error Response (400 Bad Request):
{
"success": false,
"error": "invalid date value for 'due': neverday"
}
```
**Backend implementation:** The handler inlines the trivial arg classification
from `cmd/add.go:parseAddArgs()` (split on whitespace, args with `+`/`-` prefix
or containing `:` are modifiers, the rest is description). The real parsing is
done by `engine.ParseModifier()` and `engine.CreateTaskWithModifier()`, which
are already in the engine package.
Handler steps:
1. Split `input` string on whitespace
2. Classify args: modifier if starts with `+`/`-` or contains `:`, otherwise
description word
3. Call `engine.ParseModifier(modifierArgs)`
4. Call `engine.CreateTaskWithModifier(description, modifier)`
5. Handle recurrence if `recur` is set (same logic as `cmd/add.go:addRecurringTask`)
6. Return the created task or a parse error
### 4.2 Updated Endpoint: Report-Based Task Listing
Resolves **Q7** from requirements.
```
GET /tasks?report=pending
Authorization: Bearer <token>
Response (200 OK):
{
"success": true,
"data": {
"report": "pending",
"tasks": [ ...Task objects... ],
"count": 42
}
}
```
**Query parameters:**
- `report` — Report name (default: `pending`). One of: `pending`, `next`,
`active`, `ready`, `overdue`, `completed`, `waiting`, `recurring`,
`template`, `newest`, `oldest`
- Existing filter params (`project`, `tag`, etc.) are merged with the report's
base filter, allowing further narrowing
**Backend implementation:** The report engine already exists in
`internal/engine/report.go`. The handler:
1. Looks up the report by name (map in `report.go` already has all reports)
2. Applies the report's base filter + any additional query params
3. Executes the query
4. Applies post-filters, sort, and limit functions
5. Returns tasks in the report's sort order
**Mapping of frontend report names to backend:**
| Frontend | Backend report name |
|-------------|-------------------|
| Pending | `list` |
| Next | `next` |
| Active | `active` |
| Ready | `ready` |
| Overdue | `overdue` |
| Completed | `completed` |
| Waiting | `waiting` |
| Recurring | `recurring` |
| Template | `template` |
| Newest | `newest` |
| Oldest | `oldest` |
### 4.3 Existing Endpoints (No Changes)
These are already sufficient:
- `POST /tasks/{uuid}/complete` — Used by swipe and checkbox
- `GET /health` — Health check
- All auth endpoints — Unchanged
### 4.4 Error Response Format
All endpoints already use the standard envelope:
```typescript
{ success: boolean, data?: any, error?: string }
```
No change needed. The parse endpoint follows the same pattern, with `error`
containing a human-readable message suitable for display below the input bar.
---
## 5. Technical Decisions
### ADR-1: Single-screen layout replaces multi-page routing
**Context:** The current app has bottom nav with routes for tasks, projects,
tags, and settings. Requirements specify a single-screen design.
**Decision:** Remove bottom nav and all routes except `/`, `/settings`,
`/auth/*`. The home page owns the full screen layout.
**Alternatives:** Keep routes but hide nav (rejected — adds routing complexity
for no benefit since there's only one functional view).
**Consequences:** Simpler mental model. Projects/tags pages (stubs) are removed.
Task detail route is removed (out of scope per requirements). `tasks/new` is
removed (replaced by input bar).
### ADR-2: Server-side parsing via new `/tasks/parse` endpoint
**Context:** The frontend needs to accept raw CLI syntax. Parsing is complex
(dates, recurrence, relative expressions).
**Decision:** New `POST /tasks/parse` endpoint. Frontend sends the raw string;
backend splits and parses using existing `parseAddArgs()` + `ParseModifier()`.
**Alternatives:**
- Client-side parsing (rejected — would duplicate 500+ lines of Go date/modifier
logic in JS, creating a maintenance burden)
- Sending pre-split args array (rejected — adds no value; splitting on
whitespace is trivial)
**Consequences:** Clean separation. Frontend is truly a thin shell. Parsing
logic stays in one place. Trade-off: requires network round-trip for every add,
but this matches the "CLI-passthrough" philosophy.
### ADR-3: Report-based queries via `?report=` query parameter
**Context:** The frontend needs to show 11 different task views. Reports involve
complex server-side logic (urgency calculation, date filtering, sorting).
**Decision:** Extend the existing `GET /tasks` endpoint with a `report` query
parameter. When present, it uses the report engine instead of raw filter queries.
**Alternatives:**
- Dedicated endpoints per report (`GET /reports/next`) — rejected: adds 11
routes with boilerplate
- Client-side filtering/sorting — rejected: urgency calculation is complex and
should stay server-side
**Consequences:** Single endpoint serves both raw filter queries (backward
compatible) and report-based queries. The report engine already exists and is
well-tested.
### ADR-4: Swipe gesture with angle-based lock-in
**Context:** Horizontal swipe on a vertical scroll list has ambiguity. Must not
accidentally complete tasks during normal scrolling.
**Decision:** Use angle detection. If initial movement angle is < ~27° from
horizontal (|deltaX| > |deltaY| * 2) after 10px of horizontal movement, lock
into swipe mode. Otherwise, allow scroll. Threshold of 100px to trigger
completion.
**Alternatives:**
- Time-based delay (rejected — feels sluggish)
- Long-press then swipe (rejected — adds friction)
- Only allow checkbox completion (rejected — swipe is a MUST requirement)
**Consequences:** Natural gesture feel. Small risk of mis-detection near the
boundary angle, mitigated by the 100px release threshold (mid-swipe cancel is
always safe).
### ADR-5: Pure CSS keyboard-aware layout with `dvh` units
**Context:** Mobile keyboards push fixed elements off-screen. The input bar must
stay visible above the keyboard.
**Decision:** Use `height: 100dvh` on the app shell with a flexbox column
layout (`header | task-list flex:1 | input-bar`). The `dvh` (dynamic viewport
height) unit automatically accounts for the virtual keyboard — when the keyboard
opens, `100dvh` shrinks, the flex container reflows, and the input bar stays
anchored at the bottom. No JavaScript needed.
**Alternatives:**
- `visualViewport` API with JS resize listener (rejected — unnecessary
complexity; `dvh` solves this in CSS)
- `position: fixed; bottom: 0` (rejected — doesn't reflow the scroll area)
- `interactive-widget=resizes-content` meta tag (viable fallback, but `dvh`
alone is sufficient on target browsers)
**Consequences:** Zero JS for keyboard handling. Depends on `dvh` support, which
is available in all target browsers (Chrome 108+, Firefox 108+). Not an issue
since this is an internal product with controlled browser versions.
### ADR-6: Blur-delay for property pill interaction
**Context:** Tapping a pill above the input causes the input to blur (since the
pill is a separate element), which would hide the pills before the tap registers.
**Decision:** Add a ~150ms delay before hiding pills on blur. If a pill tap
occurs during that window, cancel the hide and re-focus the input.
**Alternatives:**
- Use `mousedown`/`touchstart` with `preventDefault` on pills to prevent blur
(simpler, but may have side effects on mobile)
- Keep pills always visible (rejected — requirement F-23 says pills disappear on
blur)
**Consequences:** Slight visual delay before pills disappear, but the
interaction is reliable. The `preventDefault` approach could be used as a
refinement if the delay feels wrong.
---
## 6. File & Module Structure
### New / Modified Files
```
src/
├── lib/
│ ├── api/
│ │ └── endpoints.js # MODIFY: add tasks.parse(), update tasks.list()
│ ├── components/
│ │ ├── Header.svelte # NEW: report name + picker trigger + gear
│ │ ├── ReportPicker.svelte # NEW: dropdown with grouped reports
│ │ ├── InputBar.svelte # NEW: CLI input + submit + error display
│ │ ├── PropertyPills.svelte # NEW: horizontal pill row
│ │ ├── SwipeAction.svelte # NEW: swipe gesture wrapper
│ │ ├── TaskList.svelte # MODIFY: empty states, remove loading card
│ │ ├── TaskItem.svelte # MODIFY: layout tweaks, remove click nav
│ │ └── ui/ # KEEP: existing UI primitives
│ ├── stores/
│ │ └── tasks.js # MODIFY: add loadReport(), parseAndCreate()
│ └── mock/
│ └── tasks.js # KEEP: existing mock data
├── routes/
│ ├── +layout.svelte # MODIFY: remove BottomNav
│ ├── +page.svelte # REWRITE: single-screen orchestrator
│ ├── settings/+page.svelte # KEEP: unchanged
│ ├── auth/
│ │ ├── login/+page.svelte # KEEP: unchanged
│ │ └── callback/+page.svelte # KEEP: unchanged
│ ├── tasks/ # DELETE: entire directory
│ ├── projects/+page.svelte # DELETE
│ └── tags/+page.svelte # DELETE
└── app.css # MODIFY: add swipe/pill/input-bar styles
```
### Deleted Files
- `src/routes/tasks/new/+page.svelte` — replaced by InputBar
- `src/routes/tasks/[uuid]/+page.svelte` — out of scope
- `src/routes/projects/+page.svelte` — out of scope
- `src/routes/tags/+page.svelte` — out of scope
- `src/lib/components/BottomNav.svelte` — replaced by InputBar
### Backend Changes
```
opal-task/
└── internal/
└── api/
├── server.go # MODIFY: add POST /tasks/parse route
├── handlers.go # MODIFY: add HandleParseTask(),
│ # update HandleListTasks() for ?report=
└── (no new files needed)
```
---
## 7. Integration Points
### Frontend → Backend API
| Action | Endpoint | When |
|---------------------|-------------------------------|----------------------------|
| Load tasks | `GET /tasks?report={name}` | On mount, report change |
| Create task | `POST /tasks/parse` | Input bar submit |
| Complete task | `POST /tasks/{uuid}/complete` | Swipe or checkbox |
| Auth (existing) | `GET /auth/login`, etc. | Login flow (unchanged) |
| Sync (existing) | `POST /sync/*` | Background sync (unchanged)|
### Mock Mode
Mock mode (`VITE_MOCK_MODE=true`) continues to work:
- `tasks.loadReport(name)` returns mock data filtered client-side by status
(good enough for development)
- `tasks.parseAndCreate(input)` does a naive client-side split (description =
non-modifier words, ignore modifiers) and adds to mock array
- No backend needed for development
### PWA / Service Worker
No changes to PWA config. The new endpoint (`/tasks/parse`) is covered by the
existing Workbox runtime cache pattern (`/api/*` with NetworkFirst strategy).
---
## 8. Deferred Items
Items explicitly not included in this design, to be addressed in future
iterations:
| Req ID | Description | Priority | Notes |
|--------|-------------|----------|-------|
| F-39 | Pull-to-refresh to reload task list | COULD | Low priority; report switching already reloads. Can be added later with `overscroll-behavior` + a small JS handler. |
@@ -9,11 +9,6 @@
*/
export let onSelect;
/**
* @type {HTMLElement}
*/
export let anchorEl;
/** @type {HTMLElement|null} */
let popoverEl = null;
+300
View File
@@ -0,0 +1,300 @@
/**
* Mock task data for local development / design review.
* Covers a realistic spread of projects, priorities, tags, due dates, and statuses.
*/
const now = Math.floor(Date.now() / 1000);
const HOUR = 3600;
const DAY = 86400;
/** @type {import('$lib/api/types.js').Task[]} */
export const mockTasks = [
// ── Pending tasks ────────────────────────────────────────────
{
uuid: '11111111-1111-4111-a111-111111111101',
id: 1,
status: 'P',
description: 'Set up Caddy reverse proxy for opal-web',
project: 'Infrastructure',
priority: 3,
created: now - 7 * DAY,
modified: now - 1 * DAY,
start: now - 2 * HOUR,
end: null,
due: now + 2 * DAY,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['devops', 'selfhosted']
},
{
uuid: '11111111-1111-4111-a111-111111111102',
id: 2,
status: 'P',
description: 'Write unit tests for task filter parsing',
project: 'Opal',
priority: 2,
created: now - 5 * DAY,
modified: now - 3 * DAY,
start: null,
end: null,
due: now + 5 * DAY,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['testing', 'backend']
},
{
uuid: '11111111-1111-4111-a111-111111111103',
id: 3,
status: 'P',
description: 'Fix tag extraction for nested wiki-links',
project: 'Jade',
priority: 2,
created: now - 3 * DAY,
modified: now - 3 * DAY,
start: null,
end: null,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['bug']
},
{
uuid: '11111111-1111-4111-a111-111111111104',
id: 4,
status: 'P',
description: 'Grocery run - farmers market',
project: null,
priority: 1,
created: now - 1 * DAY,
modified: now - 1 * DAY,
start: null,
end: null,
due: now + 1 * DAY,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['errand']
},
{
uuid: '11111111-1111-4111-a111-111111111105',
id: 5,
status: 'P',
description: 'Design task detail page for opal-web',
project: 'Opal',
priority: 3,
created: now - 2 * DAY,
modified: now - 2 * DAY,
start: null,
end: null,
due: now - 1 * DAY, // overdue
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['frontend', 'design']
},
{
uuid: '11111111-1111-4111-a111-111111111106',
id: 6,
status: 'P',
description: 'Renew domain registration for jnss.me',
project: 'Infrastructure',
priority: 1,
created: now - 14 * DAY,
modified: now - 14 * DAY,
start: null,
end: null,
due: now + 30 * DAY,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['admin']
},
{
uuid: '11111111-1111-4111-a111-111111111107',
id: 7,
status: 'P',
description: 'Add recurrence UI to task creation form',
project: 'Opal',
priority: 1,
created: now - 4 * DAY,
modified: now - 4 * DAY,
start: null,
end: null,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['frontend']
},
{
uuid: '11111111-1111-4111-a111-111111111108',
id: 8,
status: 'P',
description: 'Migrate Nextcloud to latest stable',
project: 'Infrastructure',
priority: 0,
created: now - 10 * DAY,
modified: now - 10 * DAY,
start: null,
end: null,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['selfhosted', 'maintenance']
},
{
uuid: '11111111-1111-4111-a111-111111111109',
id: 9,
status: 'P',
description: 'Read "Designing Data-Intensive Applications" ch. 7',
project: null,
priority: 0,
created: now - 6 * DAY,
modified: now - 6 * DAY,
start: null,
end: null,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['reading', 'learning']
},
{
uuid: '11111111-1111-4111-a111-111111111110',
id: 10,
status: 'P',
description: 'Review PR: sync conflict resolution strategy',
project: 'Opal',
priority: 2,
created: now - 1 * DAY,
modified: now - 1 * DAY,
start: null,
end: null,
due: now, // due today
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['review', 'backend']
},
// ── Completed tasks ──────────────────────────────────────────
{
uuid: '22222222-2222-4222-a222-222222222201',
id: 11,
status: 'C',
description: 'Implement XDG directory support',
project: 'Opal',
priority: 2,
created: now - 14 * DAY,
modified: now - 7 * DAY,
start: null,
end: now - 7 * DAY,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['backend', 'refactor']
},
{
uuid: '22222222-2222-4222-a222-222222222202',
id: 12,
status: 'C',
description: 'Set up Authentik OAuth provider',
project: 'Infrastructure',
priority: 3,
created: now - 21 * DAY,
modified: now - 10 * DAY,
start: null,
end: now - 10 * DAY,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['auth', 'selfhosted']
},
{
uuid: '22222222-2222-4222-a222-222222222203',
id: 13,
status: 'C',
description: 'Build setup wizard for first-run',
project: 'Opal',
priority: 2,
created: now - 10 * DAY,
modified: now - 5 * DAY,
start: null,
end: now - 5 * DAY,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['ux', 'backend']
},
{
uuid: '22222222-2222-4222-a222-222222222204',
id: 14,
status: 'C',
description: 'Fix PersistentPreRun initialization order',
project: 'Opal',
priority: 3,
created: now - 8 * DAY,
modified: now - 6 * DAY,
start: null,
end: now - 6 * DAY,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['bug', 'backend']
},
{
uuid: '22222222-2222-4222-a222-222222222205',
id: 15,
status: 'C',
description: 'Write deployment guide with Caddy config',
project: 'Opal',
priority: 1,
created: now - 9 * DAY,
modified: now - 6 * DAY,
start: null,
end: now - 6 * DAY,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['docs']
}
];
+14 -2
View File
@@ -17,12 +17,24 @@ import { getItem, setItem, removeItem } from '$lib/utils/storage.js';
*/
const STORAGE_KEY = 'opal_auth';
const MOCK_MODE = import.meta.env.VITE_MOCK_MODE === 'true';
/**
* Load auth state from localStorage
* @returns {AuthState}
*/
function loadAuth() {
// In mock mode, always return authenticated
if (MOCK_MODE) {
return {
accessToken: 'mock-token',
refreshToken: '',
expiresAt: 9999999999,
user: { id: 1, username: 'dev', email: 'dev@localhost' },
isAuthenticated: true
};
}
if (!browser) {
return {
accessToken: null,
@@ -32,7 +44,7 @@ function loadAuth() {
isAuthenticated: false
};
}
const stored = getItem(STORAGE_KEY);
if (stored) {
// Check if token expired
@@ -46,7 +58,7 @@ function loadAuth() {
isAuthenticated: Boolean(stored.accessToken)
};
}
return {
accessToken: null,
refreshToken: null,
+5 -24
View File
@@ -251,41 +251,22 @@ function createTasksStore() {
*/
async complete(uuid) {
if (MOCK_MODE) {
update(tasks => {
const index = tasks.findIndex(t => t.uuid === uuid);
if (index >= 0) {
const newStatus = tasks[index].status === 'C' ? 'P' : 'C';
tasks[index] = {
...tasks[index],
status: /** @type {'P'|'C'} */ (newStatus),
end: newStatus === 'C' ? Date.now() / 1000 : null,
modified: Date.now() / 1000
};
}
return [...tasks];
});
const mi = mockData.findIndex(t => t.uuid === uuid);
if (mi >= 0) {
const newStatus = mockData[mi].status === 'C' ? 'P' : 'C';
mockData[mi] = {
...mockData[mi],
status: /** @type {'P'|'C'} */ (newStatus),
end: newStatus === 'C' ? Date.now() / 1000 : null,
status: /** @type {'P'|'C'} */ ('C'),
end: Date.now() / 1000,
modified: Date.now() / 1000
};
}
update(tasks => tasks.filter(t => t.uuid !== uuid));
return;
}
try {
const completed = await tasksAPI.complete(uuid);
update(tasks => {
const index = tasks.findIndex(t => t.uuid === uuid);
if (index >= 0) {
tasks[index] = completed;
}
return tasks;
});
await tasksAPI.complete(uuid);
update(tasks => tasks.filter(t => t.uuid !== uuid));
} catch (error) {
queueChange({
type: 'update',