Compare commits

..

10 Commits

Author SHA1 Message Date
joakim 78881e1b07 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>
2026-02-14 23:49:20 +01:00
joakim 0352c22b4f feat(web): add theme system with Obsidian, Paper, and Midnight themes
Three holistic design directions with CSS custom properties, a theme
store persisted to localStorage, and a live switcher in both the header
(cycle button) and settings page (card selector). Also fixes checkbox
checkmark alignment and adds back navigation from settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 22:36:07 +01:00
joakim 6c2fc6960a feat(web): add fade-out animation for checkbox task completion
When a task is completed via checkbox tap, it fades out and collapses
before being removed from the list, matching the swipe-to-complete
animation behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:41:28 +01:00
joakim 5e829320cf feat(web): rewrite home page as single-screen CLI-passthrough orchestrator
Replace multi-page task management with single-screen layout: Header
with report picker at top, scrollable TaskList in the middle, and
InputBar with property pills fixed at the bottom. Owns state for
active report, task loading, input parsing, and task completion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:35:07 +01:00
joakim a6cd0ea41d feat(web): update TaskItem and TaskList for single-screen design
TaskItem: remove onClick navigation, wrap in SwipeAction for
swipe-to-complete, update priority colors (H=red, M=amber, L=gray,
default=hidden), add due-today amber color.

TaskList: accept activeReport prop for context-aware empty states,
replace onToggle/onTaskClick with onComplete, make scrollable with
flex:1 and overflow-y:auto.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:30:02 +01:00
joakim ac0fd6c72f feat(web): add SwipeAction touch gesture component
Implements right-swipe-to-complete with angle-based lock-in (horizontal
must exceed 2x vertical), 100px threshold, green checkmark background
reveal, and CSS transition for snap-back and completion animation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:29:14 +01:00
joakim 2f83e8fe2f feat(web): add InputBar and PropertyPills components
InputBar provides fixed-to-bottom text input with Enter to submit,
blur-delay (150ms) for pill interaction, and cursor-aware text
insertion. PropertyPills shows 8 modifier pills (Due, Pri, Project,
Tag, Recur, Scheduled, Wait, Until) on input focus.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:28:48 +01:00
joakim 5ff75453bc feat(web): add Header and ReportPicker components
Header shows active report name (left) with dropdown chevron and gear
icon linking to /settings (right). ReportPicker uses native Popover API
with 11 reports grouped into Common, Time-based, Recurring, and Archive.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:28:06 +01:00
joakim 40b1f51f64 refactor(web): remove old routes and BottomNav for single-screen redesign
Delete /tasks/new, /tasks/[uuid], /projects, /tags routes and BottomNav
component. Simplify layout to slot-only with 100dvh flexbox. Remove
nav-height CSS variable and .page padding rules.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:27:23 +01:00
joakim 83a9689e47 feat(web): add parse and report API endpoints with store methods
Add tasks.parse() and tasks.listByReport() to the API layer, and
loadReport() and parseAndCreate() to the tasks store with mock mode
support for the CLI-passthrough redesign.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:25:17 +01:00
34 changed files with 3610 additions and 741 deletions
+16 -1
View File
@@ -26,5 +26,20 @@ profile.cov
go.work go.work
go.work.sum go.work.sum
# env file # env files
.env .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" "fmt"
"os" "os"
"strings" "strings"
"time"
"git.jnss.me/joakim/opal/internal/engine" "git.jnss.me/joakim/opal/internal/engine"
"github.com/google/uuid"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -101,99 +99,13 @@ func parseAddArgs(args []string) (string, []string, error) {
} }
func addRecurringTask(description string, mod *engine.Modifier) error { func addRecurringTask(description string, mod *engine.Modifier) error {
// Extract recurrence pattern instance, err := engine.CreateRecurringTask(description, mod)
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)
if err != nil { if err != nil {
return fmt.Errorf("invalid recurrence pattern: %w", err) return err
} }
// Create template task (without saving yet) fmt.Printf("Created recurring task %s\n", *instance.ParentUUID)
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("First instance: %s\n", instance.UUID) fmt.Printf("First instance: %s\n", instance.UUID)
fmt.Printf("Recurrence: %s\n", engine.FormatRecurrenceDuration(duration))
return nil return nil
} }
+95 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"time" "time"
"git.jnss.me/joakim/opal/internal/engine" "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) { func ListTasks(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query() 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 // Build filter from query params
filter := engine.NewFilter() filter := engine.NewFilter()
@@ -409,6 +432,77 @@ func AddTaskTag(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, task) 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 // RemoveTaskTag removes a tag from a task
func RemoveTaskTag(w http.ResponseWriter, r *http.Request) { func RemoveTaskTag(w http.ResponseWriter, r *http.Request) {
uuidStr := chi.URLParam(r, "uuid") 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.Route("/tasks", func(r chi.Router) {
r.Get("/", handlers.ListTasks) r.Get("/", handlers.ListTasks)
r.Post("/", handlers.CreateTask) r.Post("/", handlers.CreateTask)
r.Post("/parse", handlers.ParseTask)
r.Get("/{uuid}", handlers.GetTask) r.Get("/{uuid}", handlers.GetTask)
r.Put("/{uuid}", handlers.UpdateTask) r.Put("/{uuid}", handlers.UpdateTask)
r.Delete("/{uuid}", handlers.DeleteTask) r.Delete("/{uuid}", handlers.DeleteTask)
+2 -2
View File
@@ -173,13 +173,13 @@ func runMigrations() error {
); );
-- Change log (key:value format like edit command) -- Change log (key:value format like edit command)
-- No FK on task_uuid: change logs must survive task deletion
CREATE TABLE change_log ( CREATE TABLE change_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
task_uuid TEXT NOT NULL, task_uuid TEXT NOT NULL,
change_type TEXT NOT NULL, change_type TEXT NOT NULL,
changed_at INTEGER NOT NULL, changed_at INTEGER NOT NULL,
data TEXT NOT NULL, data TEXT NOT NULL
FOREIGN KEY (task_uuid) REFERENCES tasks(uuid) ON DELETE CASCADE
); );
CREATE INDEX idx_change_log_timestamp ON change_log(changed_at); 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 // Test tag filter
// Note: without a status filter, ToSQL adds an implicit template exclusion
// condition, so args will contain [StatusRecurring, "urgent"]
filter = &Filter{ filter = &Filter{
IncludeTags: []string{"urgent"}, IncludeTags: []string{"urgent"},
Attributes: map[string]string{},
} }
where, args = filter.ToSQL() where, args = filter.ToSQL()
if len(args) != 1 || args[0] != "urgent" { if len(args) != 2 || args[1] != "urgent" {
t.Errorf("Expected tag 'urgent' in args, got %v", args) 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) 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 // SpawnNextInstance creates a new task instance from completed recurring task
func SpawnNextInstance(completedInstance *Task) error { func SpawnNextInstance(completedInstance *Task) error {
if completedInstance.ParentUUID == nil { 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. |
+155 -44
View File
@@ -1,28 +1,7 @@
/* Global Styles - Mobile-First */ /* Global Styles - Mobile-First */
/* ── Base tokens (shared across themes) ── */
:root { :root {
/* Colors */
--color-primary: #4f46e5;
--color-primary-dark: #4338ca;
--color-secondary: #6b7280;
--color-success: #10b981;
--color-danger: #ef4444;
--color-warning: #f59e0b;
/* Backgrounds */
--bg-primary: #ffffff;
--bg-secondary: #f9fafb;
--bg-tertiary: #f3f4f6;
/* Text */
--text-primary: #111827;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
/* Borders */
--border-color: #e5e7eb;
--border-radius: 0.5rem;
/* Spacing */ /* Spacing */
--spacing-xs: 0.25rem; --spacing-xs: 0.25rem;
--spacing-sm: 0.5rem; --spacing-sm: 0.5rem;
@@ -30,8 +9,7 @@
--spacing-lg: 1.5rem; --spacing-lg: 1.5rem;
--spacing-xl: 2rem; --spacing-xl: 2rem;
/* Typography */ /* Typography sizes */
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-size-xs: 0.75rem; --font-size-xs: 0.75rem;
--font-size-sm: 0.875rem; --font-size-sm: 0.875rem;
--font-size-base: 1rem; --font-size-base: 1rem;
@@ -40,16 +18,161 @@
--font-size-2xl: 1.5rem; --font-size-2xl: 1.5rem;
/* Layout */ /* Layout */
--nav-height: 60px;
--content-max-width: 768px; --content-max-width: 768px;
--border-radius: 0.5rem;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
} }
/* Reset & Base */ /* ── Theme: Obsidian (dark productivity) ── */
[data-theme="obsidian"] {
--font-sans: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
--color-primary: #39d0ba;
--color-primary-dark: #2db8a4;
--color-secondary: #8b949e;
--color-success: #3fb950;
--color-danger: #f85149;
--color-warning: #d29922;
--color-accent: #39d0ba;
--bg-primary: #161b22;
--bg-secondary: #0d1117;
--bg-tertiary: #21262d;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-tertiary: #484f58;
--border-color: #30363d;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
--focus-ring: rgba(57, 208, 186, 0.3);
/* Semantic badge colors */
--color-project-bg: rgba(57, 208, 186, 0.12);
--color-project-text: #39d0ba;
--color-priority-high-bg: rgba(248, 81, 73, 0.15);
--color-priority-high-text: #f85149;
--color-priority-medium-bg: rgba(210, 153, 34, 0.15);
--color-priority-medium-text: #d29922;
--color-priority-low-bg: rgba(139, 148, 158, 0.1);
--color-priority-low-text: #8b949e;
--color-due-bg: rgba(57, 208, 186, 0.1);
--color-due-text: #39d0ba;
--color-due-today-bg: rgba(210, 153, 34, 0.15);
--color-due-today-text: #d29922;
--color-overdue-bg: rgba(248, 81, 73, 0.15);
--color-overdue-text: #f85149;
--color-tag-bg: rgba(139, 148, 158, 0.1);
--color-tag-text: #8b949e;
color-scheme: dark;
}
/* ── Theme: Paper (warm minimal) ── */
[data-theme="paper"] {
--font-sans: "Inter", "SF Pro Text", system-ui, sans-serif;
--font-mono: ui-monospace, "SF Mono", monospace;
--color-primary: #6366f1;
--color-primary-dark: #4f46e5;
--color-secondary: #78716c;
--color-success: #10b981;
--color-danger: #e11d48;
--color-warning: #d97706;
--color-accent: #6366f1;
--bg-primary: #ffffff;
--bg-secondary: #faf8f5;
--bg-tertiary: #f5f5f4;
--text-primary: #1c1917;
--text-secondary: #78716c;
--text-tertiary: #a8a29e;
--border-color: #e7e5e4;
--shadow-sm: 0 1px 2px 0 rgba(28, 25, 23, 0.04);
--shadow-md: 0 4px 6px -1px rgba(28, 25, 23, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(28, 25, 23, 0.08);
--focus-ring: rgba(99, 102, 241, 0.2);
/* Semantic badge colors */
--color-project-bg: #e0e7ff;
--color-project-text: #4338ca;
--color-priority-high-bg: #ffe4e6;
--color-priority-high-text: #be123c;
--color-priority-medium-bg: #fef3c7;
--color-priority-medium-text: #92400e;
--color-priority-low-bg: #f5f5f4;
--color-priority-low-text: #a8a29e;
--color-due-bg: #dbeafe;
--color-due-text: #1e40af;
--color-due-today-bg: #fef3c7;
--color-due-today-text: #92400e;
--color-overdue-bg: #ffe4e6;
--color-overdue-text: #be123c;
--color-tag-bg: #f5f5f4;
--color-tag-text: #78716c;
color-scheme: light;
}
/* ── Theme: Midnight (vibrant dark) ── */
[data-theme="midnight"] {
--font-sans: "Inter", system-ui, sans-serif;
--font-mono: ui-monospace, monospace;
--color-primary: #8b5cf6;
--color-primary-dark: #7c3aed;
--color-secondary: #94a3b8;
--color-success: #34d399;
--color-danger: #ef4444;
--color-warning: #f97316;
--color-accent: #8b5cf6;
--bg-primary: #1e293b;
--bg-secondary: #0f172a;
--bg-tertiary: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-tertiary: #64748b;
--border-color: #334155;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
--focus-ring: rgba(139, 92, 246, 0.3);
/* Semantic badge colors */
--color-project-bg: rgba(139, 92, 246, 0.15);
--color-project-text: #a78bfa;
--color-priority-high-bg: rgba(239, 68, 68, 0.15);
--color-priority-high-text: #ef4444;
--color-priority-medium-bg: rgba(249, 115, 22, 0.15);
--color-priority-medium-text: #f97316;
--color-priority-low-bg: rgba(148, 163, 184, 0.1);
--color-priority-low-text: #94a3b8;
--color-due-bg: rgba(139, 92, 246, 0.1);
--color-due-text: #a78bfa;
--color-due-today-bg: rgba(249, 115, 22, 0.15);
--color-due-today-text: #f97316;
--color-overdue-bg: rgba(239, 68, 68, 0.15);
--color-overdue-text: #ef4444;
--color-tag-bg: rgba(148, 163, 184, 0.1);
--color-tag-text: #94a3b8;
color-scheme: dark;
}
/* ── Reset & Base ── */
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -67,7 +190,7 @@ html {
body { body {
background-color: var(--bg-secondary); background-color: var(--bg-secondary);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100dvh;
overflow-x: hidden; overflow-x: hidden;
} }
@@ -113,11 +236,6 @@ a:hover {
padding: 0 var(--spacing-md); padding: 0 var(--spacing-md);
} }
.page {
min-height: calc(100vh - var(--nav-height));
padding-bottom: calc(var(--nav-height) + var(--spacing-md));
}
/* Utility Classes */ /* Utility Classes */
.text-center { .text-center {
text-align: center; text-align: center;
@@ -177,10 +295,3 @@ textarea {
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
} }
/* Safe Area Padding (for mobile notches) */
@supports (padding: env(safe-area-inset-bottom)) {
.page {
padding-bottom: calc(var(--nav-height) + var(--spacing-md) + env(safe-area-inset-bottom));
}
}
+22
View File
@@ -98,6 +98,28 @@ export const tasks = {
*/ */
async stop(uuid) { async stop(uuid) {
return apiRequest(`/tasks/${uuid}/stop`, { method: 'POST' }); return apiRequest(`/tasks/${uuid}/stop`, { method: 'POST' });
},
/**
* Parse CLI input and create task
* @param {string} input - Raw opal CLI syntax
* @returns {Promise<Task>}
*/
async parse(input) {
return apiRequest('/tasks/parse', {
method: 'POST',
body: JSON.stringify({ input })
});
},
/**
* List tasks by report name
* @param {string} reportName
* @returns {Promise<Task[]>}
*/
async listByReport(reportName) {
const result = await apiRequest(`/tasks?report=${encodeURIComponent(reportName)}`);
return result.tasks ?? result;
} }
}; };
@@ -1,91 +0,0 @@
<script>
import { page } from '$app/stores';
/**
* @param {string} path
* @returns {boolean}
*/
function isActive(path) {
return $page.url.pathname === path || $page.url.pathname.startsWith(path + '/');
}
</script>
<nav class="bottom-nav">
<a href="/" class="nav-item" class:active={$page.url.pathname === '/'}>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span class="label">Tasks</span>
</a>
<a href="/projects" class="nav-item" class:active={isActive('/projects')}>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span class="label">Projects</span>
</a>
<a href="/tags" class="nav-item" class:active={isActive('/tags')}>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<span class="label">Tags</span>
</a>
<a href="/settings" class="nav-item" class:active={isActive('/settings')}>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span class="label">Settings</span>
</a>
</nav>
<style>
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: var(--nav-height);
background-color: var(--bg-primary);
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-around;
align-items: center;
z-index: 100;
padding-bottom: env(safe-area-inset-bottom, 0);
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
flex: 1;
height: 100%;
color: var(--text-secondary);
text-decoration: none;
transition: color 0.2s;
-webkit-tap-highlight-color: transparent;
}
.nav-item:hover {
text-decoration: none;
}
.nav-item.active {
color: var(--color-primary);
}
.icon {
width: 1.5rem;
height: 1.5rem;
}
.label {
font-size: var(--font-size-xs);
font-weight: 500;
}
</style>
+130
View File
@@ -0,0 +1,130 @@
<script>
import ReportPicker from './ReportPicker.svelte';
import ThemeSwitcher from './ThemeSwitcher.svelte';
/**
* @type {string}
*/
export let activeReport;
/**
* @type {(reportName: string) => void}
*/
export let onReportChange;
/** @type {ReportPicker} */
let picker;
/** Map backend report names to display labels */
const reportLabels = /** @type {Record<string, string>} */ ({
list: 'Pending',
next: 'Next',
active: 'Active',
ready: 'Ready',
overdue: 'Overdue',
waiting: 'Waiting',
newest: 'Newest',
oldest: 'Oldest',
recurring: 'Recurring',
template: 'Template',
completed: 'Completed'
});
$: displayLabel = reportLabels[activeReport] || activeReport;
</script>
<header class="header">
<button
class="report-btn"
on:click={() => picker.toggle()}
>
<span class="report-label">{displayLabel}</span>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div class="header-actions">
<ThemeSwitcher mode="cycle" />
<a href="/settings" class="settings-btn" aria-label="Settings">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</a>
</div>
</header>
<ReportPicker
bind:this={picker}
{activeReport}
onSelect={onReportChange}
/>
<style>
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-sm) var(--spacing-md);
background-color: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.report-btn {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
background: none;
border: none;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--border-radius);
cursor: pointer;
font-family: inherit;
min-width: unset;
}
.report-btn:hover {
background-color: var(--bg-secondary);
}
.report-label {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
}
.chevron {
width: 1rem;
height: 1rem;
color: var(--text-secondary);
}
.header-actions {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.settings-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--border-radius);
color: var(--text-secondary);
transition: background-color 0.2s;
}
.settings-btn:hover {
background-color: var(--bg-secondary);
text-decoration: none;
}
.icon {
width: 1.25rem;
height: 1.25rem;
}
</style>
+192
View File
@@ -0,0 +1,192 @@
<script>
import PropertyPills from './PropertyPills.svelte';
/**
* @type {(input: string) => Promise<void>}
*/
export let onSubmit;
export let error = '';
export let loading = false;
let value = '';
let focused = false;
/** @type {HTMLInputElement|null} */
let inputEl = null;
/** @type {ReturnType<typeof setTimeout>|null} */
let blurTimer = null;
async function handleSubmit() {
const trimmed = value.trim();
if (!trimmed || loading) return;
try {
await onSubmit(trimmed);
value = '';
} catch {
// Value preserved for retry
}
}
/**
* @param {KeyboardEvent} e
*/
function handleKeydown(e) {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
}
function handleFocus() {
if (blurTimer) {
clearTimeout(blurTimer);
blurTimer = null;
}
focused = true;
}
function handleBlur() {
blurTimer = setTimeout(() => {
focused = false;
blurTimer = null;
}, 150);
}
/**
* Insert text at cursor position
* @param {string} text
*/
function insertAtCursor(text) {
if (!inputEl) return;
const start = inputEl.selectionStart ?? value.length;
const end = inputEl.selectionEnd ?? value.length;
// Add leading space if cursor isn't at start and prev char isn't a space
const needsSpace = start > 0 && value[start - 1] !== ' ';
const insert = (needsSpace ? ' ' : '') + text;
value = value.slice(0, start) + insert + value.slice(end);
// Restore focus and cursor position after the inserted text
const newPos = start + insert.length;
requestAnimationFrame(() => {
if (inputEl) {
inputEl.focus();
inputEl.setSelectionRange(newPos, newPos);
}
});
}
</script>
<div class="input-bar">
<PropertyPills visible={focused} onInsert={insertAtCursor} />
{#if error}
<div class="error">{error}</div>
{/if}
<div class="input-row">
<input
bind:this={inputEl}
bind:value
on:keydown={handleKeydown}
on:focus={handleFocus}
on:blur={handleBlur}
type="text"
placeholder="Add task... (e.g. Buy milk due:tomorrow priority:H)"
disabled={loading}
class="input"
/>
<button
class="submit-btn"
on:click={handleSubmit}
disabled={!value.trim() || loading}
type="button"
aria-label="Add task"
>
{#if loading}
<span class="loading"></span>
{:else}
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{/if}
</button>
</div>
</div>
<style>
.input-bar {
flex-shrink: 0;
padding: var(--spacing-sm) var(--spacing-md);
padding-bottom: calc(var(--spacing-sm) + env(safe-area-inset-bottom, 0px));
background-color: var(--bg-primary);
border-top: 1px solid var(--border-color);
}
.input-row {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.input {
flex: 1;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
font-size: var(--font-size-base);
font-family: inherit;
background-color: var(--bg-secondary);
color: var(--text-primary);
min-width: 0;
}
.input::placeholder {
color: var(--text-tertiary);
}
.input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--focus-ring);
}
.submit-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background-color: var(--color-primary);
color: white;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
flex-shrink: 0;
min-width: 44px;
transition: background-color 0.15s;
}
.submit-btn:hover:not(:disabled) {
background-color: var(--color-primary-dark);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.icon {
width: 1.25rem;
height: 1.25rem;
}
.error {
padding: var(--spacing-xs) 0;
font-size: var(--font-size-sm);
color: var(--color-danger);
}
</style>
@@ -0,0 +1,66 @@
<script>
/**
* @type {(text: string) => void}
*/
export let onInsert;
export let visible = false;
const pills = [
{ label: 'Due', text: 'due:' },
{ label: 'Pri', text: 'priority:' },
{ label: 'Project', text: 'project:' },
{ label: 'Tag', text: '+' },
{ label: 'Recur', text: 'recur:' },
{ label: 'Scheduled', text: 'scheduled:' },
{ label: 'Wait', text: 'wait:' },
{ label: 'Until', text: 'until:' }
];
</script>
{#if visible}
<div class="pills">
{#each pills as pill}
<button
class="pill"
type="button"
on:mousedown|preventDefault={() => onInsert(pill.text)}
>
{pill.label}
</button>
{/each}
</div>
{/if}
<style>
.pills {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
padding: var(--spacing-xs) 0;
}
.pill {
padding: 0.25rem 0.625rem;
background-color: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 1rem;
font-size: var(--font-size-xs);
font-family: inherit;
color: var(--text-secondary);
cursor: pointer;
min-height: 28px;
min-width: unset;
transition: background-color 0.15s;
}
.pill:hover {
background-color: var(--border-color);
}
.pill:active {
background-color: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
</style>
@@ -0,0 +1,140 @@
<script>
/**
* @type {string}
*/
export let activeReport;
/**
* @type {(reportName: string) => void}
*/
export let onSelect;
/** @type {HTMLElement|null} */
let popoverEl = null;
const groups = [
{
label: 'Common',
reports: [
{ label: 'Pending', value: 'list' },
{ label: 'Next', value: 'next' },
{ label: 'Active', value: 'active' },
{ label: 'Ready', value: 'ready' }
]
},
{
label: 'Time-based',
reports: [
{ label: 'Overdue', value: 'overdue' },
{ label: 'Waiting', value: 'waiting' },
{ label: 'Newest', value: 'newest' },
{ label: 'Oldest', value: 'oldest' }
]
},
{
label: 'Recurring',
reports: [
{ label: 'Recurring', value: 'recurring' },
{ label: 'Template', value: 'template' }
]
},
{
label: 'Archive',
reports: [
{ label: 'Completed', value: 'completed' }
]
}
];
export function toggle() {
if (popoverEl) {
popoverEl.togglePopover();
}
}
/**
* @param {string} value
*/
function select(value) {
onSelect(value);
if (popoverEl) {
popoverEl.hidePopover();
}
}
</script>
<div class="report-picker" popover bind:this={popoverEl}>
{#each groups as group}
<div class="group">
<div class="group-label">{group.label}</div>
{#each group.reports as report}
<button
class="report-option"
class:active={activeReport === report.value}
on:click={() => select(report.value)}
>
{report.label}
</button>
{/each}
</div>
{/each}
</div>
<style>
.report-picker {
position: fixed;
top: 48px;
left: var(--spacing-md);
right: auto;
margin: 0;
padding: var(--spacing-sm);
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
box-shadow: var(--shadow-lg);
min-width: 180px;
max-width: 240px;
}
.group {
padding: var(--spacing-xs) 0;
}
.group + .group {
border-top: 1px solid var(--border-color);
}
.group-label {
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: var(--spacing-xs) var(--spacing-sm);
}
.report-option {
display: block;
width: 100%;
padding: var(--spacing-sm) var(--spacing-sm);
background: none;
border: none;
border-radius: 0.25rem;
font-size: var(--font-size-sm);
font-family: inherit;
color: var(--text-primary);
text-align: left;
cursor: pointer;
min-height: 36px;
min-width: unset;
}
.report-option:hover {
background-color: var(--bg-secondary);
}
.report-option.active {
background-color: var(--color-primary);
color: white;
}
</style>
@@ -0,0 +1,148 @@
<script>
/**
* @type {() => void}
*/
export let onSwipe;
let offsetX = 0;
let swiping = false;
let locked = false;
let completed = false;
/** @type {number|null} */
let startX = null;
/** @type {number|null} */
let startY = null;
const THRESHOLD = 100;
/**
* @param {TouchEvent} e
*/
function handleTouchStart(e) {
if (completed) return;
const touch = e.touches[0];
startX = touch.clientX;
startY = touch.clientY;
locked = false;
swiping = false;
}
/**
* @param {TouchEvent} e
*/
function handleTouchMove(e) {
if (completed || startX === null || startY === null) return;
const touch = e.touches[0];
const deltaX = touch.clientX - startX;
const deltaY = touch.clientY - startY;
if (!locked && !swiping) {
// Angle-based lock-in: horizontal must dominate
if (Math.abs(deltaX) > 10 && Math.abs(deltaX) > Math.abs(deltaY) * 2) {
swiping = true;
locked = true;
} else if (Math.abs(deltaY) > 10) {
// Vertical scroll — abort
startX = null;
startY = null;
return;
}
}
if (swiping) {
e.preventDefault();
// Only allow right swipe
offsetX = Math.max(0, deltaX);
}
}
function handleTouchEnd() {
if (completed || !swiping) {
resetState();
return;
}
if (offsetX >= THRESHOLD) {
completed = true;
// Animate to full width before firing callback
offsetX = window.innerWidth;
setTimeout(() => {
onSwipe();
}, 200);
} else {
offsetX = 0;
}
swiping = false;
startX = null;
startY = null;
}
function resetState() {
offsetX = 0;
swiping = false;
locked = false;
startX = null;
startY = null;
}
$: progress = Math.min(offsetX / THRESHOLD, 1);
$: transitioning = !swiping && offsetX !== 0;
</script>
<div
class="swipe-container"
on:touchstart={handleTouchStart}
on:touchmove={handleTouchMove}
on:touchend={handleTouchEnd}
on:touchcancel={resetState}
>
<div
class="swipe-background"
style:opacity={progress}
>
<svg class="check-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</div>
<div
class="swipe-content"
class:transitioning
style:transform="translateX({offsetX}px)"
>
<slot />
</div>
</div>
<style>
.swipe-container {
position: relative;
overflow: hidden;
touch-action: pan-y;
}
.swipe-background {
position: absolute;
inset: 0;
background-color: var(--color-success);
display: flex;
align-items: center;
padding-left: var(--spacing-lg);
}
.check-icon {
width: 1.5rem;
height: 1.5rem;
color: white;
}
.swipe-content {
position: relative;
background-color: var(--bg-primary);
}
.swipe-content.transitioning {
transition: transform 0.2s ease-out;
}
</style>
+97 -61
View File
@@ -1,5 +1,7 @@
<script> <script>
import { isToday as isTodayFn } from 'date-fns';
import { formatRelative, isOverdue } from '$lib/utils/dates.js'; import { formatRelative, isOverdue } from '$lib/utils/dates.js';
import SwipeAction from './SwipeAction.svelte';
import Checkbox from './ui/Checkbox.svelte'; import Checkbox from './ui/Checkbox.svelte';
/** /**
@@ -10,56 +12,71 @@
/** /**
* @type {(uuid: string) => void} * @type {(uuid: string) => void}
*/ */
export let onToggle; export let onComplete;
let completing = false;
$: overdue = task.due && isOverdue(task.due);
$: dueToday = task.due && isTodayFn(new Date(task.due * 1000));
function handleCheckbox() {
if (completing) return;
completing = true;
}
/** /**
* @type {(uuid: string) => void} * @param {TransitionEvent} e
*/ */
export let onClick; function handleTransitionEnd(e) {
if (e.propertyName === 'opacity' && completing) {
$: isDue = task.due && isOverdue(task.due); onComplete(task.uuid);
$: priorityLabel = ['Low', 'Default', 'Medium', 'High'][task.priority]; }
}
</script> </script>
<div class="task-item" on:click={() => onClick(task.uuid)} role="button" tabindex="0"> <SwipeAction onSwipe={() => onComplete(task.uuid)}>
<div class="task-checkbox" on:click|stopPropagation={() => onToggle(task.uuid)}> <div class="task-item" class:completing on:transitionend={handleTransitionEnd}>
<Checkbox checked={task.status === 'C'} /> <button class="task-checkbox" on:click|stopPropagation={handleCheckbox} type="button" aria-label="Complete task">
</div> <Checkbox checked={task.status === 'C'} />
</button>
<div class="task-content"> <div class="task-content">
<div class="task-header"> <div class="task-header">
<span class="task-description" class:completed={task.status === 'C'}> <span class="task-description" class:completed={task.status === 'C'}>
{task.description} {task.description}
</span>
</div>
<div class="task-meta">
{#if task.project}
<span class="meta-item project">{task.project}</span>
{/if}
{#if task.priority > 1}
<span class="meta-item priority priority-{task.priority}">
{priorityLabel}
</span> </span>
{/if} </div>
{#if task.due} <div class="task-meta">
<span class="meta-item due" class:overdue={isDue}> {#if task.project}
{formatRelative(task.due)} <span class="meta-item project">{task.project}</span>
</span> {/if}
{/if}
{#if task.tags && task.tags.length > 0} {#if task.priority === 3}
<div class="tags"> <span class="meta-item priority-high">High</span>
{#each task.tags as tag} {:else if task.priority === 2}
<span class="tag">{tag}</span> <span class="meta-item priority-medium">Med</span>
{/each} {:else if task.priority === 0}
</div> <span class="meta-item priority-low">Low</span>
{/if} {/if}
{#if task.due}
<span class="meta-item due" class:overdue class:due-today={dueToday}>
{formatRelative(task.due)}
</span>
{/if}
{#if task.tags && task.tags.length > 0}
<div class="tags">
{#each task.tags as tag}
<span class="tag">{tag}</span>
{/each}
</div>
{/if}
</div>
</div> </div>
</div> </div>
</div> </SwipeAction>
<style> <style>
.task-item { .task-item {
@@ -68,17 +85,18 @@
padding: var(--spacing-md); padding: var(--spacing-md);
background-color: var(--bg-primary); background-color: var(--bg-primary);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: background-color 0.2s;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
transition: opacity 0.25s ease, max-height 0.25s ease 0.05s;
max-height: 200px;
opacity: 1;
} }
.task-item:hover { .task-item.completing {
background-color: var(--bg-secondary); opacity: 0;
} max-height: 0;
padding-top: 0;
.task-item:active { padding-bottom: 0;
background-color: var(--bg-tertiary); overflow: hidden;
} }
.task-checkbox { .task-checkbox {
@@ -86,6 +104,14 @@
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
padding-top: 0.125rem; padding-top: 0.125rem;
cursor: pointer;
background: none;
border: none;
padding-left: 0;
padding-right: 0;
padding-bottom: 0;
min-width: unset;
min-height: unset;
} }
.task-content { .task-content {
@@ -123,28 +149,38 @@
} }
.project { .project {
background-color: #e0e7ff; background-color: var(--color-project-bg);
color: #4338ca; color: var(--color-project-text);
} }
.priority { .priority-high {
background-color: #fef3c7; background-color: var(--color-priority-high-bg);
color: #92400e; color: var(--color-priority-high-text);
} }
.priority-3 { .priority-medium {
background-color: #fee2e2; background-color: var(--color-priority-medium-bg);
color: #991b1b; color: var(--color-priority-medium-text);
}
.priority-low {
background-color: var(--color-priority-low-bg);
color: var(--color-priority-low-text);
} }
.due { .due {
background-color: #dbeafe; background-color: var(--color-due-bg);
color: #1e40af; color: var(--color-due-text);
}
.due.due-today {
background-color: var(--color-due-today-bg);
color: var(--color-due-today-text);
} }
.due.overdue { .due.overdue {
background-color: #fee2e2; background-color: var(--color-overdue-bg);
color: #991b1b; color: var(--color-overdue-text);
} }
.tags { .tags {
@@ -156,8 +192,8 @@
.tag { .tag {
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
padding: 0.125rem 0.5rem; padding: 0.125rem 0.5rem;
background-color: var(--bg-tertiary); background-color: var(--color-tag-bg);
color: var(--text-secondary); color: var(--color-tag-text);
border-radius: 0.25rem; border-radius: 0.25rem;
} }
</style> </style>
+24 -12
View File
@@ -9,15 +9,27 @@
/** /**
* @type {(uuid: string) => void} * @type {(uuid: string) => void}
*/ */
export let onToggle; export let onComplete;
/**
* @type {(uuid: string) => void}
*/
export let onTaskClick;
export let loading = false; export let loading = false;
export let emptyMessage = 'No tasks found'; export let activeReport = 'list';
/** @type {Record<string, string>} */
const emptyMessages = {
list: 'No pending tasks. Add one below!',
completed: 'No completed tasks yet.',
overdue: 'Nothing overdue. Nice work!',
waiting: 'No waiting tasks.',
active: 'No active tasks.',
next: 'No next tasks.',
ready: 'No ready tasks.',
recurring: 'No recurring tasks.',
template: 'No template tasks.',
newest: 'No tasks found.',
oldest: 'No tasks found.'
};
$: emptyMessage = emptyMessages[activeReport] || 'No tasks found';
</script> </script>
<div class="task-list"> <div class="task-list">
@@ -37,8 +49,7 @@
{#each tasks as task (task.uuid)} {#each tasks as task (task.uuid)}
<TaskItem <TaskItem
{task} {task}
onToggle={onToggle} {onComplete}
onClick={onTaskClick}
/> />
{/each} {/each}
{/if} {/if}
@@ -46,10 +57,10 @@
<style> <style>
.task-list { .task-list {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
background-color: var(--bg-primary); background-color: var(--bg-primary);
border-radius: var(--border-radius);
overflow: hidden;
box-shadow: var(--shadow-sm);
} }
.loading-container { .loading-container {
@@ -68,6 +79,7 @@
justify-content: center; justify-content: center;
padding: var(--spacing-xl); padding: var(--spacing-xl);
text-align: center; text-align: center;
height: 100%;
} }
.empty-icon { .empty-icon {
@@ -0,0 +1,135 @@
<script>
import { themeStore, THEMES } from '$lib/stores/theme.js';
/** @type {'cycle' | 'full'} */
export let mode = 'cycle';
const themeMeta = {
obsidian: {
label: 'Obsidian',
description: 'Dark productivity',
colors: ['#0d1117', '#161b22', '#39d0ba']
},
paper: {
label: 'Paper',
description: 'Warm minimal',
colors: ['#faf8f5', '#ffffff', '#6366f1']
},
midnight: {
label: 'Midnight',
description: 'Vibrant dark',
colors: ['#0f172a', '#1e293b', '#8b5cf6']
}
};
</script>
{#if mode === 'cycle'}
<button
class="theme-cycle-btn"
on:click={() => themeStore.cycle()}
aria-label="Switch theme ({themeMeta[$themeStore].label})"
title={themeMeta[$themeStore].label}
>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
</button>
{:else}
<div class="theme-cards">
{#each THEMES as name}
{@const meta = themeMeta[name]}
<button
class="theme-card"
class:active={$themeStore === name}
on:click={() => themeStore.set(name)}
>
<div class="swatches">
{#each meta.colors as color}
<span class="swatch" style="background-color: {color}"></span>
{/each}
</div>
<span class="theme-name">{meta.label}</span>
<span class="theme-desc">{meta.description}</span>
</button>
{/each}
</div>
{/if}
<style>
.theme-cycle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--border-radius);
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
transition: background-color 0.2s;
min-width: 44px;
}
.theme-cycle-btn:hover {
background-color: var(--bg-secondary);
}
.icon {
width: 1.25rem;
height: 1.25rem;
}
.theme-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-sm);
}
.theme-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-md) var(--spacing-sm);
background-color: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: var(--border-radius);
cursor: pointer;
font-family: inherit;
min-width: unset;
transition: border-color 0.15s;
}
.theme-card:hover {
border-color: var(--text-tertiary);
}
.theme-card.active {
border-color: var(--color-primary);
}
.swatches {
display: flex;
gap: 0.25rem;
}
.swatch {
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
border: 1px solid var(--border-color);
}
.theme-name {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-primary);
}
.theme-desc {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
</style>
@@ -66,13 +66,13 @@
content: ""; content: "";
position: absolute; position: absolute;
display: none; display: none;
left: 0.375rem; left: 50%;
top: 0.125rem; top: 45%;
width: 0.375rem; width: 0.3rem;
height: 0.625rem; height: 0.6rem;
border: solid white; border: solid white;
border-width: 0 2px 2px 0; border-width: 0 2px 2px 0;
transform: rotate(45deg); transform: translate(-50%, -50%) rotate(45deg);
} }
.checkbox:checked ~ .checkmark:after { .checkbox:checked ~ .checkmark:after {
+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']
}
];
+12
View File
@@ -17,12 +17,24 @@ import { getItem, setItem, removeItem } from '$lib/utils/storage.js';
*/ */
const STORAGE_KEY = 'opal_auth'; const STORAGE_KEY = 'opal_auth';
const MOCK_MODE = import.meta.env.VITE_MOCK_MODE === 'true';
/** /**
* Load auth state from localStorage * Load auth state from localStorage
* @returns {AuthState} * @returns {AuthState}
*/ */
function loadAuth() { 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) { if (!browser) {
return { return {
accessToken: null, accessToken: null,
+153 -9
View File
@@ -1,26 +1,142 @@
import { writable, derived } from 'svelte/store'; import { writable, derived } from 'svelte/store';
import { tasks as tasksAPI } from '$lib/api/endpoints.js'; import { tasks as tasksAPI } from '$lib/api/endpoints.js';
import { queueChange } from '$lib/utils/sync-queue.js'; import { queueChange } from '$lib/utils/sync-queue.js';
import { generateUUID } from '$lib/utils/uuid.js';
/** /**
* @typedef {import('$lib/api/types.js').Task} Task * @typedef {import('$lib/api/types.js').Task} Task
* @typedef {import('$lib/api/types.js').TaskFilters} TaskFilters * @typedef {import('$lib/api/types.js').TaskFilters} TaskFilters
*/ */
const MOCK_MODE = import.meta.env.VITE_MOCK_MODE === 'true';
/** Report names that map to pending tasks in mock mode */
const PENDING_REPORTS = new Set(['list', 'next', 'active', 'ready', 'overdue', 'waiting', 'newest', 'oldest']);
/** /**
* Create tasks store * Create tasks store
*/ */
function createTasksStore() { function createTasksStore() {
const { subscribe, set, update } = writable(/** @type {Task[]} */ ([])); const { subscribe, set, update } = writable(/** @type {Task[]} */ ([]));
/** @type {Task[]} */
let mockData = [];
/** Ensure mock data is loaded */
async function ensureMockData() {
if (mockData.length === 0) {
const { mockTasks } = await import('$lib/mock/tasks.js');
mockData = [...mockTasks];
}
}
return { return {
subscribe, subscribe,
/** /**
* Load all tasks from API * Load tasks by report name
* @param {string} reportName - Backend report name (e.g. 'list', 'next', 'completed')
*/
async loadReport(reportName) {
if (MOCK_MODE) {
await ensureMockData();
if (reportName === 'completed') {
set(mockData.filter(t => t.status === 'C'));
} else if (PENDING_REPORTS.has(reportName)) {
set(mockData.filter(t => t.status === 'P'));
} else {
set(mockData.filter(t => t.status === 'P'));
}
return;
}
try {
const tasks = await tasksAPI.listByReport(reportName);
set(tasks);
} catch (error) {
console.error('Failed to load report:', error);
throw error;
}
},
/**
* Parse CLI input and create a task
* @param {string} input - Raw opal CLI syntax
* @returns {Promise<Task>}
*/
async parseAndCreate(input) {
if (MOCK_MODE) {
await ensureMockData();
// Naive parse: non-modifier words become description
const words = input.split(/\s+/);
const descWords = [];
const task = /** @type {Task} */ ({
uuid: generateUUID(),
id: mockData.length + 1,
status: 'P',
description: '',
project: null,
priority: 1,
created: Date.now() / 1000,
modified: Date.now() / 1000,
start: null,
end: null,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: []
});
for (let i = 0; i < words.length; i++) {
const w = words[i];
if (w.startsWith('project:')) {
task.project = w.slice(8);
} else if (w.startsWith('priority:') || w.startsWith('pri:')) {
const val = w.includes(':') ? w.split(':')[1] : '1';
/** @type {Record<string, number>} */
const map = { H: 3, M: 2, L: 0, h: 3, m: 2, l: 0 };
task.priority = /** @type {import('$lib/api/types.js').TaskPriority} */ ((map[val] ?? parseInt(val, 10)) || 1);
} else if (w.startsWith('+')) {
task.tags = [...(task.tags || []), w.slice(1)];
} else {
descWords.push(w);
}
}
task.description = descWords.join(' ') || 'New task';
mockData = [task, ...mockData];
update(tasks => [task, ...tasks]);
return task;
}
try {
const created = await tasksAPI.parse(input);
update(tasks => [created, ...tasks]);
return created;
} catch (error) {
console.error('Failed to parse and create task:', error);
throw error;
}
},
/**
* Load all tasks from API (or mock data in dev)
* @param {TaskFilters} [filters] * @param {TaskFilters} [filters]
*/ */
async load(filters = {}) { async load(filters = {}) {
if (MOCK_MODE) {
await ensureMockData();
let filtered = mockData;
if (filters.status) {
filtered = filtered.filter(t => t.status === filters.status);
}
set(filtered);
return;
}
try { try {
const tasks = await tasksAPI.list(filters); const tasks = await tasksAPI.list(filters);
set(tasks); set(tasks);
@@ -35,6 +151,13 @@ function createTasksStore() {
* @param {Partial<Task>} task * @param {Partial<Task>} task
*/ */
async add(task) { async add(task) {
if (MOCK_MODE) {
const fullTask = /** @type {Task} */ ({ ...task, id: mockData.length + 1 });
mockData = [...mockData, fullTask];
update(tasks => [...tasks, fullTask]);
return fullTask;
}
try { try {
const created = await tasksAPI.create(task); const created = await tasksAPI.create(task);
update(tasks => [...tasks, created]); update(tasks => [...tasks, created]);
@@ -68,6 +191,14 @@ function createTasksStore() {
return tasks; return tasks;
}); });
if (MOCK_MODE) {
const index = mockData.findIndex(t => t.uuid === uuid);
if (index >= 0) {
mockData[index] = { ...mockData[index], ...updates, modified: Date.now() / 1000 };
}
return;
}
try { try {
const updated = await tasksAPI.update(uuid, updates); const updated = await tasksAPI.update(uuid, updates);
// Sync with server response // Sync with server response
@@ -97,6 +228,11 @@ function createTasksStore() {
// Optimistic removal // Optimistic removal
update(tasks => tasks.filter(t => t.uuid !== uuid)); update(tasks => tasks.filter(t => t.uuid !== uuid));
if (MOCK_MODE) {
mockData = mockData.filter(t => t.uuid !== uuid);
return;
}
try { try {
await tasksAPI.delete(uuid); await tasksAPI.delete(uuid);
} catch (error) { } catch (error) {
@@ -114,15 +250,23 @@ function createTasksStore() {
* @param {string} uuid * @param {string} uuid
*/ */
async complete(uuid) { async complete(uuid) {
if (MOCK_MODE) {
const mi = mockData.findIndex(t => t.uuid === uuid);
if (mi >= 0) {
mockData[mi] = {
...mockData[mi],
status: /** @type {'P'|'C'} */ ('C'),
end: Date.now() / 1000,
modified: Date.now() / 1000
};
}
update(tasks => tasks.filter(t => t.uuid !== uuid));
return;
}
try { try {
const completed = await tasksAPI.complete(uuid); await tasksAPI.complete(uuid);
update(tasks => { update(tasks => tasks.filter(t => t.uuid !== uuid));
const index = tasks.findIndex(t => t.uuid === uuid);
if (index >= 0) {
tasks[index] = completed;
}
return tasks;
});
} catch (error) { } catch (error) {
queueChange({ queueChange({
type: 'update', type: 'update',
+57
View File
@@ -0,0 +1,57 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
/** @typedef {'obsidian' | 'paper' | 'midnight'} ThemeName */
const STORAGE_KEY = 'opal-theme';
/** @type {ThemeName} */
const DEFAULT_THEME = 'obsidian';
/** @type {ThemeName[]} */
export const THEMES = ['obsidian', 'paper', 'midnight'];
/**
* Read stored theme, falling back to default
* @returns {ThemeName}
*/
function getInitial() {
if (!browser) return DEFAULT_THEME;
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && THEMES.includes(/** @type {ThemeName} */ (stored))) {
return /** @type {ThemeName} */ (stored);
}
return DEFAULT_THEME;
}
function createThemeStore() {
const { subscribe, set, update } = writable(getInitial());
/** Apply theme to the document */
function apply(/** @type {ThemeName} */ theme) {
if (browser) {
document.documentElement.dataset.theme = theme;
localStorage.setItem(STORAGE_KEY, theme);
}
}
// Apply on every change
subscribe(apply);
return {
subscribe,
/** @param {ThemeName} theme */
set(theme) {
set(theme);
},
/** Cycle to the next theme */
cycle() {
update(current => {
const idx = THEMES.indexOf(current);
return THEMES[(idx + 1) % THEMES.length];
});
}
};
}
export const themeStore = createThemeStore();
+20 -18
View File
@@ -1,32 +1,34 @@
<script> <script>
import '../app.css'; import '../app.css';
import BottomNav from '$lib/components/BottomNav.svelte'; import { themeStore } from '$lib/stores/theme.js';
import { authStore } from '$lib/stores/auth.js';
import { page } from '$app/stores';
// Check if on auth pages (don't show nav) // Subscribe to trigger initialization on mount (sets data-theme attribute)
$: isAuthPage = $page.url.pathname.startsWith('/auth'); $: void $themeStore;
</script> </script>
<div class="app"> <svelte:head>
<main class="main"> <!-- Prevent FOUC: apply theme before first paint -->
<slot /> {@html `<script>
</main> (function() {
var t = localStorage.getItem('opal-theme');
if (t && ['obsidian','paper','midnight'].includes(t)) {
document.documentElement.dataset.theme = t;
} else {
document.documentElement.dataset.theme = 'obsidian';
}
})();
</` + 'script>'}
</svelte:head>
{#if $authStore.isAuthenticated && !isAuthPage} <div class="app">
<BottomNav /> <slot />
{/if}
</div> </div>
<style> <style>
.app { .app {
min-height: 100vh; height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} overflow: hidden;
.main {
flex: 1;
width: 100%;
} }
</style> </style>
+54 -139
View File
@@ -2,175 +2,90 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.js'; import { authStore } from '$lib/stores/auth.js';
import { tasksStore, pendingTasks, completedTasks } from '$lib/stores/tasks.js'; import { tasksStore } from '$lib/stores/tasks.js';
import Header from '$lib/components/Header.svelte';
import TaskList from '$lib/components/TaskList.svelte'; import TaskList from '$lib/components/TaskList.svelte';
import Button from '$lib/components/ui/Button.svelte'; import InputBar from '$lib/components/InputBar.svelte';
let activeReport = 'list';
/** @type {import('$lib/api/types.js').Task[]} */
let tasks = [];
let loading = true; let loading = true;
let showCompleted = false; let inputError = '';
$: displayTasks = showCompleted ? $completedTasks : $pendingTasks; // Subscribe to store
const unsubscribe = tasksStore.subscribe(value => {
tasks = value;
});
onMount(async () => { onMount(() => {
// Redirect to login if not authenticated
if (!$authStore.isAuthenticated) { if (!$authStore.isAuthenticated) {
goto('/auth/login'); goto('/auth/login');
return; return;
} }
// Load tasks loadReport(activeReport);
try {
await tasksStore.load({ status: showCompleted ? 'C' : 'P' }); return unsubscribe;
} catch (error) {
console.error('Failed to load tasks:', error);
} finally {
loading = false;
}
}); });
/** /**
* Toggle task completion * @param {string} reportName
* @param {string} uuid
*/ */
async function handleToggle(uuid) { async function loadReport(reportName) {
loading = true;
inputError = '';
try { try {
await tasksStore.complete(uuid); await tasksStore.loadReport(reportName);
// Reload tasks
await tasksStore.load({ status: showCompleted ? 'C' : 'P' });
} catch (error) { } catch (error) {
console.error('Failed to toggle task:', error); console.error('Failed to load report:', error);
} finally {
loading = false;
} }
} }
/** /**
* Navigate to task detail * @param {string} reportName
* @param {string} uuid
*/ */
function handleTaskClick(uuid) { function handleReportChange(reportName) {
goto(`/tasks/${uuid}`); activeReport = reportName;
loadReport(reportName);
} }
/** /**
* Toggle between pending and completed view * @param {string} input
*/ */
async function toggleView() { async function handleSubmit(input) {
showCompleted = !showCompleted; inputError = '';
loading = true;
try { try {
await tasksStore.load({ status: showCompleted ? 'C' : 'P' }); await tasksStore.parseAndCreate(input);
} catch (error) { } catch (error) {
console.error('Failed to load tasks:', error); inputError = error instanceof Error ? error.message : 'Failed to create task';
} finally { }
loading = false; }
/**
* @param {string} uuid
*/
async function handleComplete(uuid) {
try {
await tasksStore.complete(uuid);
} catch (error) {
console.error('Failed to complete task:', error);
} }
} }
</script> </script>
<div class="page"> <Header {activeReport} onReportChange={handleReportChange} />
<div class="container">
<div class="header">
<h1>Tasks</h1>
<a href="/tasks/new" class="new-btn">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
New
</a>
</div>
<div class="filter-bar"> <TaskList
<button {tasks}
class="filter-btn" {loading}
class:active={!showCompleted} {activeReport}
on:click={() => !showCompleted || toggleView()} onComplete={handleComplete}
> />
Pending ({$pendingTasks.length})
</button>
<button
class="filter-btn"
class:active={showCompleted}
on:click={() => showCompleted || toggleView()}
>
Completed ({$completedTasks.length})
</button>
</div>
<TaskList <InputBar
tasks={displayTasks} onSubmit={handleSubmit}
{loading} error={inputError}
onToggle={handleToggle} />
onTaskClick={handleTaskClick}
emptyMessage={showCompleted ? 'No completed tasks' : 'No pending tasks. Add one to get started!'}
/>
</div>
</div>
<style>
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
}
.header h1 {
margin-bottom: 0;
}
.icon {
width: 1rem;
height: 1rem;
}
.new-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
background-color: var(--color-primary);
color: white;
border-radius: var(--border-radius);
font-size: var(--font-size-sm);
font-weight: 500;
text-decoration: none;
transition: background-color 0.2s;
}
.new-btn:hover {
background-color: var(--color-primary-dark);
text-decoration: none;
}
.filter-bar {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
background-color: var(--bg-primary);
padding: var(--spacing-sm);
border-radius: var(--border-radius);
box-shadow: var(--shadow-sm);
}
.filter-btn {
flex: 1;
padding: 0.5rem 1rem;
background-color: transparent;
border: none;
border-radius: var(--border-radius);
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.filter-btn.active {
background-color: var(--color-primary);
color: white;
}
.filter-btn:hover:not(.active) {
background-color: var(--bg-tertiary);
}
</style>
@@ -1,6 +0,0 @@
<div class="page">
<div class="container">
<h1>Projects</h1>
<p class="text-secondary">Projects view coming soon...</p>
</div>
</div>
+47 -1
View File
@@ -1,5 +1,6 @@
<script> <script>
import { authStore } from '$lib/stores/auth.js'; import { authStore } from '$lib/stores/auth.js';
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
import { syncStore } from '$lib/stores/sync.js'; import { syncStore } from '$lib/stores/sync.js';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import Input from '$lib/components/ui/Input.svelte'; import Input from '$lib/components/ui/Input.svelte';
@@ -75,7 +76,19 @@
<div class="page"> <div class="page">
<div class="container"> <div class="container">
<h1>Settings</h1> <div class="page-header">
<a href="/" class="back-link" aria-label="Back to tasks">
<svg class="back-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</a>
<h1>Settings</h1>
</div>
<section class="section">
<h2>Theme</h2>
<ThemeSwitcher mode="full" />
</section>
{#if $authStore.isAuthenticated} {#if $authStore.isAuthenticated}
<section class="section"> <section class="section">
@@ -153,6 +166,39 @@
</div> </div>
<style> <style>
.page-header {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding-top: var(--spacing-lg);
margin-bottom: var(--spacing-md);
}
.page-header h1 {
margin-bottom: 0;
}
.back-link {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--border-radius);
color: var(--text-secondary);
transition: background-color 0.2s;
}
.back-link:hover {
background-color: var(--bg-tertiary);
text-decoration: none;
}
.back-icon {
width: 1.25rem;
height: 1.25rem;
}
.section { .section {
background-color: var(--bg-primary); background-color: var(--bg-primary);
border-radius: var(--border-radius); border-radius: var(--border-radius);
-6
View File
@@ -1,6 +0,0 @@
<div class="page">
<div class="container">
<h1>Tags</h1>
<p class="text-secondary">Tags view coming soon...</p>
</div>
</div>
@@ -1,26 +0,0 @@
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
$: uuid = $page.params.uuid;
</script>
<div class="page">
<div class="container">
<h1>Task Detail</h1>
<p class="text-secondary">UUID: {uuid}</p>
<p class="text-secondary mb-lg">Task detail view coming in next iteration...</p>
<a href="/" class="btn-link">← Back to tasks</a>
</div>
</div>
<style>
.btn-link {
display: inline-block;
padding: 0.75rem 1.5rem;
background-color: var(--color-primary);
color: white;
border-radius: var(--border-radius);
text-decoration: none;
}
</style>
-172
View File
@@ -1,172 +0,0 @@
<script>
import { goto } from '$app/navigation';
import { tasksStore } from '$lib/stores/tasks.js';
import { generateUUID } from '$lib/utils/uuid.js';
import { toUnix } from '$lib/utils/dates.js';
import Button from '$lib/components/ui/Button.svelte';
import Input from '$lib/components/ui/Input.svelte';
import Select from '$lib/components/ui/Select.svelte';
let description = '';
let project = '';
let priority = '1';
let dueDate = '';
let tags = '';
let saving = false;
let error = '';
const priorityOptions = [
{ value: '0', label: 'Low' },
{ value: '1', label: 'Default' },
{ value: '2', label: 'Medium' },
{ value: '3', label: 'High' }
];
async function handleSubmit() {
if (!description.trim()) {
error = 'Description is required';
return;
}
saving = true;
error = '';
try {
/** @type {Partial<import('$lib/api/types.js').Task>} */
const task = {
uuid: generateUUID(),
description: description.trim(),
status: /** @type {'P'} */ ('P'),
priority: /** @type {0|1|2|3} */ (parseInt(priority)),
created: toUnix(new Date()),
modified: toUnix(new Date()),
tags: tags.trim() ? tags.split(',').map(t => t.trim()).filter(t => t) : [],
project: project.trim() || null,
due: dueDate ? toUnix(new Date(dueDate)) : null
};
await tasksStore.add(task);
goto('/');
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create task';
} finally {
saving = false;
}
}
</script>
<div class="page">
<div class="container">
<div class="header">
<a href="/" class="back-btn">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back
</a>
<h1>New Task</h1>
</div>
<form on:submit|preventDefault={handleSubmit} class="task-form">
{#if error}
<div class="error-banner">{error}</div>
{/if}
<Input
label="Description"
placeholder="What needs to be done?"
bind:value={description}
required
id="description"
/>
<Input
label="Project"
placeholder="Optional"
bind:value={project}
id="project"
/>
<Select
label="Priority"
options={priorityOptions}
bind:value={priority}
id="priority"
/>
<Input
label="Due Date"
type="date"
bind:value={dueDate}
id="due"
/>
<Input
label="Tags"
placeholder="Comma separated (e.g., work, urgent)"
bind:value={tags}
id="tags"
/>
<div class="actions">
<Button type="button" variant="secondary" on:click={() => goto('/')}>
Cancel
</Button>
<Button type="submit" loading={saving}>
Create Task
</Button>
</div>
</form>
</div>
</div>
<style>
.header {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.back-btn {
display: flex;
align-items: center;
gap: 0.25rem;
color: var(--color-primary);
text-decoration: none;
font-size: var(--font-size-sm);
}
.icon {
width: 1.25rem;
height: 1.25rem;
}
.task-form {
background-color: var(--bg-primary);
border-radius: var(--border-radius);
padding: var(--spacing-lg);
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.error-banner {
background-color: #fee2e2;
color: var(--color-danger);
padding: var(--spacing-md);
border-radius: var(--border-radius);
font-size: var(--font-size-sm);
}
.actions {
display: flex;
gap: var(--spacing-md);
margin-top: var(--spacing-md);
}
.actions > :global(button) {
flex: 1;
}
</style>