From 78881e1b07a6b320c7faa642f436ed8b2e381797 Mon Sep 17 00:00:00 2001 From: Joakim Date: Sat, 14 Feb 2026 22:39:11 +0100 Subject: [PATCH] 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 --- .gitignore | 17 +- docs/infrastructure-integration.md | 398 ++++++++++ opal-task/cmd/add.go | 94 +-- opal-task/internal/api/handlers/tasks.go | 96 ++- opal-task/internal/api/handlers/tasks_test.go | 220 ++++++ opal-task/internal/api/server.go | 1 + opal-task/internal/engine/database.go | 4 +- opal-task/internal/engine/filter_test.go | 7 +- opal-task/internal/engine/recurrence.go | 88 +++ opal-web/REQUIREMENTS.md | 290 ++++++++ opal-web/docs/design/architecture.md | 681 ++++++++++++++++++ .../src/lib/components/ReportPicker.svelte | 5 - opal-web/src/lib/mock/tasks.js | 300 ++++++++ opal-web/src/lib/stores/auth.js | 16 +- opal-web/src/lib/stores/tasks.js | 29 +- 15 files changed, 2118 insertions(+), 128 deletions(-) create mode 100644 docs/infrastructure-integration.md create mode 100644 opal-task/internal/api/handlers/tasks_test.go create mode 100644 opal-web/REQUIREMENTS.md create mode 100644 opal-web/docs/design/architecture.md create mode 100644 opal-web/src/lib/mock/tasks.js diff --git a/.gitignore b/.gitignore index 137e01e..d0375ca 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/docs/infrastructure-integration.md b/docs/infrastructure-integration.md new file mode 100644 index 0000000..2141271 --- /dev/null +++ b/docs/infrastructure-integration.md @@ -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//` +- 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: "" +vault_opal_oauth_client_secret: "" +vault_opal_jwt_secret: "" +``` + +### 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 diff --git a/opal-task/cmd/add.go b/opal-task/cmd/add.go index 4e7a9b6..607e16a 100644 --- a/opal-task/cmd/add.go +++ b/opal-task/cmd/add.go @@ -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 } diff --git a/opal-task/internal/api/handlers/tasks.go b/opal-task/internal/api/handlers/tasks.go index 4336f3c..5d3e500 100644 --- a/opal-task/internal/api/handlers/tasks.go +++ b/opal-task/internal/api/handlers/tasks.go @@ -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") diff --git a/opal-task/internal/api/handlers/tasks_test.go b/opal-task/internal/api/handlers/tasks_test.go new file mode 100644 index 0000000..3968126 --- /dev/null +++ b/opal-task/internal/api/handlers/tasks_test.go @@ -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") + } +} diff --git a/opal-task/internal/api/server.go b/opal-task/internal/api/server.go index a177166..c8c4b2e 100644 --- a/opal-task/internal/api/server.go +++ b/opal-task/internal/api/server.go @@ -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) diff --git a/opal-task/internal/engine/database.go b/opal-task/internal/engine/database.go index 299e9a0..a750881 100644 --- a/opal-task/internal/engine/database.go +++ b/opal-task/internal/engine/database.go @@ -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); diff --git a/opal-task/internal/engine/filter_test.go b/opal-task/internal/engine/filter_test.go index b31958a..6b20ae6 100644 --- a/opal-task/internal/engine/filter_test.go +++ b/opal-task/internal/engine/filter_test.go @@ -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) } } diff --git a/opal-task/internal/engine/recurrence.go b/opal-task/internal/engine/recurrence.go index 4fb7bd2..8665b95 100644 --- a/opal-task/internal/engine/recurrence.go +++ b/opal-task/internal/engine/recurrence.go @@ -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 { diff --git a/opal-web/REQUIREMENTS.md b/opal-web/REQUIREMENTS.md new file mode 100644 index 0000000..b8d59d3 --- /dev/null +++ b/opal-web/REQUIREMENTS.md @@ -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 375px–428px 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 diff --git a/opal-web/docs/design/architecture.md b/opal-web/docs/design/architecture.md new file mode 100644 index 0000000..6cefbe0 --- /dev/null +++ b/opal-web/docs/design/architecture.md @@ -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 + +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 + +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. | diff --git a/opal-web/src/lib/components/ReportPicker.svelte b/opal-web/src/lib/components/ReportPicker.svelte index 314855c..79fea9c 100644 --- a/opal-web/src/lib/components/ReportPicker.svelte +++ b/opal-web/src/lib/components/ReportPicker.svelte @@ -9,11 +9,6 @@ */ export let onSelect; - /** - * @type {HTMLElement} - */ - export let anchorEl; - /** @type {HTMLElement|null} */ let popoverEl = null; diff --git a/opal-web/src/lib/mock/tasks.js b/opal-web/src/lib/mock/tasks.js new file mode 100644 index 0000000..dc95c1f --- /dev/null +++ b/opal-web/src/lib/mock/tasks.js @@ -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'] + } +]; diff --git a/opal-web/src/lib/stores/auth.js b/opal-web/src/lib/stores/auth.js index 3c85eb0..8b5205b 100644 --- a/opal-web/src/lib/stores/auth.js +++ b/opal-web/src/lib/stores/auth.js @@ -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, diff --git a/opal-web/src/lib/stores/tasks.js b/opal-web/src/lib/stores/tasks.js index 322a6d7..0d550d1 100644 --- a/opal-web/src/lib/stores/tasks.js +++ b/opal-web/src/lib/stores/tasks.js @@ -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',