Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78881e1b07 | |||
| 0352c22b4f | |||
| 6c2fc6960a | |||
| 5e829320cf | |||
| a6cd0ea41d | |||
| ac0fd6c72f | |||
| 2f83e8fe2f | |||
| 5ff75453bc | |||
| 40b1f51f64 | |||
| 83a9689e47 |
+16
-1
@@ -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/
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
# Opal Web — PWA Requirements
|
||||||
|
|
||||||
|
## 1. Problem Statement
|
||||||
|
|
||||||
|
The current opal-web PWA uses a conventional form-based UI for task management.
|
||||||
|
This is misaligned with how opal actually works — opal is a CLI-first tool with
|
||||||
|
a rich, expressive text syntax for creating, filtering, and modifying tasks.
|
||||||
|
|
||||||
|
The new design replaces the form-based approach with a **CLI-passthrough
|
||||||
|
interface**: a single text input that accepts the same syntax as the opal CLI,
|
||||||
|
making the web UI a thin, mobile-friendly shell over the existing command
|
||||||
|
language.
|
||||||
|
|
||||||
|
**Target users:** Existing opal CLI users who want quick mobile/web access
|
||||||
|
without learning a separate UI paradigm.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. User Stories
|
||||||
|
|
||||||
|
### US-1: Quick-add a task from mobile
|
||||||
|
**As a** user, **I want** to type `Buy groceries due:tomorrow +errand` into the
|
||||||
|
input and have it create a task, **so that** I can capture tasks as fast as I do
|
||||||
|
from the terminal.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Given the input is focused and I type a description with modifiers
|
||||||
|
- When I submit (Enter / tap send)
|
||||||
|
- Then the raw input string is sent to the API, which parses it server-side
|
||||||
|
- And a task is created with the parsed description, due date, and tags
|
||||||
|
- And the input clears
|
||||||
|
- And the new task appears in the list
|
||||||
|
|
||||||
|
### US-2: Use property pills to build a command
|
||||||
|
**As a** user, **I want** to tap a `Due` pill above the input to insert `due:`
|
||||||
|
at my cursor position, **so that** I don't have to remember every modifier name
|
||||||
|
on mobile.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Given the input is focused
|
||||||
|
- When I tap the `Due` pill
|
||||||
|
- Then `due:` is inserted at the cursor position in the input
|
||||||
|
- And the keyboard stays open / input retains focus
|
||||||
|
- And the cursor is placed immediately after the inserted text
|
||||||
|
|
||||||
|
### US-3: View my pending tasks
|
||||||
|
**As a** user, **I want** to see my pending tasks in a list above the input,
|
||||||
|
**so that** I have context for what I'm working on.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Given I open the app
|
||||||
|
- When the task list loads
|
||||||
|
- Then I see pending tasks displayed in a compact list, sorted by urgency
|
||||||
|
- And each task shows its description with inline metadata (project, priority,
|
||||||
|
due date, tags) using subtle color coding
|
||||||
|
|
||||||
|
### US-4: Complete a task via swipe
|
||||||
|
**As a** user, **I want** to swipe a task to the right to mark it done, **so
|
||||||
|
that** I can quickly clear completed work.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Given I see a task in the list
|
||||||
|
- When I swipe the task to the right
|
||||||
|
- Then a green background with a checkmark is revealed during the swipe
|
||||||
|
- And on release past the threshold, the task is marked as completed
|
||||||
|
- And the task animates out of the list
|
||||||
|
|
||||||
|
### US-5: Complete a task via checkbox
|
||||||
|
**As a** user, **I want** to tap a checkbox on a task to mark it done, **so
|
||||||
|
that** I have a familiar fallback when swiping is inconvenient.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Given I see a task in the list
|
||||||
|
- When I tap the checkbox/circle on the left side of the task
|
||||||
|
- Then the task is marked as completed and animates out of the list
|
||||||
|
|
||||||
|
### US-6: Filter tasks by report
|
||||||
|
**As a** user, **I want** to switch between different task views (pending, next,
|
||||||
|
active, overdue, completed, etc.), **so that** I can focus on what's relevant.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Given I am on the main task view
|
||||||
|
- When I tap the filter/report button
|
||||||
|
- Then a dropdown opens showing available reports
|
||||||
|
- And selecting a report filters the task list accordingly
|
||||||
|
|
||||||
|
### US-7: Access settings
|
||||||
|
**As a** user, **I want** to access a settings page, **so that** I can
|
||||||
|
configure API connection, view sync status, and log out.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Given I am on the main task view
|
||||||
|
- When I tap the gear icon in the top-right corner
|
||||||
|
- Then I navigate to a settings page
|
||||||
|
- And I can navigate back to the task list
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Functional Requirements
|
||||||
|
|
||||||
|
### App Structure
|
||||||
|
|
||||||
|
| ID | Priority | Requirement |
|
||||||
|
|------|----------|-------------|
|
||||||
|
| F-00 | MUST | The app is a single-screen design: one main view with task list + input bar |
|
||||||
|
| F-01 | MUST | A separate settings page is accessible via a gear icon in the top-right corner |
|
||||||
|
| F-02 | MUST | No bottom navigation bar — the input bar occupies the bottom of the screen |
|
||||||
|
|
||||||
|
### Header
|
||||||
|
|
||||||
|
| ID | Priority | Requirement |
|
||||||
|
|------|----------|-------------|
|
||||||
|
| F-05 | MUST | The header contains the current report name (e.g., "Pending", "Next", "Overdue") |
|
||||||
|
| F-06 | MUST | The header contains a filter/report button that opens a dropdown of available reports |
|
||||||
|
| F-07 | MUST | The header contains a gear icon (top-right) linking to the settings page |
|
||||||
|
|
||||||
|
### Report Picker
|
||||||
|
|
||||||
|
| ID | Priority | Requirement |
|
||||||
|
|------|----------|-------------|
|
||||||
|
| F-08 | MUST | A dropdown/popover triggered by the filter button in the header |
|
||||||
|
| F-09 | MUST | Lists all available reports (see table below) |
|
||||||
|
| F-0A | MUST | Selecting a report reloads the task list with the corresponding filter |
|
||||||
|
| F-0B | MUST | The currently active report is visually indicated in the dropdown |
|
||||||
|
| F-0C | SHOULD | Default report on app open is "Pending" |
|
||||||
|
|
||||||
|
**Reports (all CLI reports):**
|
||||||
|
|
||||||
|
| Report | Description |
|
||||||
|
|------------|------------------------------------------|
|
||||||
|
| Pending | All pending tasks (default) |
|
||||||
|
| Next | Most urgent ready tasks (top N) |
|
||||||
|
| Active | Currently started tasks |
|
||||||
|
| Ready | Tasks with no future wait/scheduled date |
|
||||||
|
| Overdue | Tasks past their due date |
|
||||||
|
| Completed | Completed tasks |
|
||||||
|
| Waiting | Tasks hidden until their wait date |
|
||||||
|
| Recurring | Pending recurring task instances |
|
||||||
|
| Template | Recurring template tasks |
|
||||||
|
| Newest | Most recently created pending tasks |
|
||||||
|
| Oldest | Oldest pending tasks |
|
||||||
|
|
||||||
|
### Input Bar
|
||||||
|
|
||||||
|
| ID | Priority | Requirement |
|
||||||
|
|------|----------|-------------|
|
||||||
|
| F-10 | MUST | A single-line text input is fixed to the bottom of the visible viewport, always visible |
|
||||||
|
| F-11 | MUST | The input accepts opal CLI syntax for the `add` command: description words, `+tag`, `project:`, `due:`, `priority:`, `scheduled:`, `wait:`, `until:`, `recur:` |
|
||||||
|
| F-12 | MUST | Submitting the input sends the raw string to the API for server-side parsing and task creation |
|
||||||
|
| F-13 | MUST | The input clears after successful submission |
|
||||||
|
| F-14 | SHOULD | The input bar remains above the mobile keyboard when the keyboard is open |
|
||||||
|
| F-15 | MUST | The input is add-only — it does not parse other CLI commands (done, modify, delete, etc.) |
|
||||||
|
|
||||||
|
### Property Pills
|
||||||
|
|
||||||
|
| ID | Priority | Requirement |
|
||||||
|
|------|----------|-------------|
|
||||||
|
| F-20 | MUST | When the input is focused, a row of tappable pills appears directly above the input |
|
||||||
|
| F-21 | MUST | Tapping a pill inserts its text at the current cursor position in the input |
|
||||||
|
| F-22 | MUST | After inserting, the input retains focus and the cursor is placed after the inserted text |
|
||||||
|
| F-23 | MUST | Pills disappear when the input loses focus |
|
||||||
|
| F-24 | SHOULD | The pill row is horizontally scrollable if it overflows |
|
||||||
|
|
||||||
|
**Pills (display order):**
|
||||||
|
|
||||||
|
| Pill label | Inserts | Notes |
|
||||||
|
|--------------|---------------|-------|
|
||||||
|
| `Due` | `due:` | Date: tomorrow, mon, eod, 2025-03-15, 3d, etc. |
|
||||||
|
| `Pri` | `priority:` | H, M, L |
|
||||||
|
| `Project` | `project:` | Project name |
|
||||||
|
| `Tag` | `+` | Tag prefix — user types tag name after |
|
||||||
|
| `Recur` | `recur:` | Duration: 1d, 1w, 2w, 1m, daily, weekly, etc. |
|
||||||
|
| `Scheduled` | `scheduled:` | Same date format as due: |
|
||||||
|
| `Wait` | `wait:` | Same date format as due: |
|
||||||
|
| `Until` | `until:` | Same date format as due: |
|
||||||
|
|
||||||
|
### Task List
|
||||||
|
|
||||||
|
| ID | Priority | Requirement |
|
||||||
|
|------|----------|-------------|
|
||||||
|
| F-30 | MUST | The main view displays a scrollable list of tasks above the input bar, filtered by the active report |
|
||||||
|
| F-31 | MUST | Each task row shows: description, and inline metadata where set (project, priority, due date, tags) |
|
||||||
|
| F-32 | MUST | Task rows are compact — hybrid density with subtle color coding for priority and due dates, no full cards |
|
||||||
|
| F-33 | MUST | Swiping a task to the right marks it as completed |
|
||||||
|
| F-34 | MUST | Each task has a small checkbox/circle on the left that also completes the task on tap |
|
||||||
|
| F-35 | MUST | Swipe-to-complete reveals a green background with a checkmark icon during the swipe |
|
||||||
|
| F-36 | SHOULD | Tasks are sorted by urgency (matching the CLI's urgency calculation) |
|
||||||
|
| F-37 | SHOULD | Overdue due dates are visually distinct (red/warm color) |
|
||||||
|
| F-38 | SHOULD | Completed tasks animate out of the list (brief fade/slide) |
|
||||||
|
| F-39 | COULD | Pull-to-refresh to reload task list |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Functional Requirements
|
||||||
|
|
||||||
|
| ID | Category | Requirement |
|
||||||
|
|-------|---------------|-------------|
|
||||||
|
| NF-01 | Mobile-first | The UI must work well on 375px–428px viewport widths (iPhone SE through Pro Max) |
|
||||||
|
| NF-02 | Performance | Task list should render within 100ms of data availability |
|
||||||
|
| NF-03 | PWA | The app must remain installable as a PWA with offline shell caching |
|
||||||
|
| NF-04 | Keyboard | The input bar must not be obscured by the mobile keyboard |
|
||||||
|
| NF-05 | Touch | Swipe gestures must have clear thresholds (no accidental triggers from scrolling) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Constraints & Assumptions
|
||||||
|
|
||||||
|
### Constraints
|
||||||
|
- Must use the existing SvelteKit + Vite + static adapter stack
|
||||||
|
- Must work with the existing opal REST API (or mock mode for dev)
|
||||||
|
- The opal API can be extended or reworked during the design phase to better
|
||||||
|
support the new functionality
|
||||||
|
|
||||||
|
### Assumptions
|
||||||
|
- Users are familiar with opal CLI syntax or will learn it through the pills
|
||||||
|
- The `add` command is the only command handled by the input bar
|
||||||
|
- **Parsing happens server-side** — the frontend sends the raw input string to
|
||||||
|
a new or updated API endpoint; no client-side replication of the opal parser
|
||||||
|
- Completion is handled via swipe gesture or checkbox, not via the input bar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Open Questions
|
||||||
|
|
||||||
|
All previously open questions have been resolved:
|
||||||
|
|
||||||
|
| # | Question | Resolution |
|
||||||
|
|----|----------|------------|
|
||||||
|
| Q1 | How should the task list handle filtering/views? | Reports are available via a filter button/dropdown in the header |
|
||||||
|
| Q2 | Where should the settings link live? | Top-right gear icon |
|
||||||
|
| Q3 | Should swipe-to-complete show a visual indicator? | Yes — green background with checkmark revealed during swipe |
|
||||||
|
| Q4 | Should completed tasks be viewable? | Yes — included as a report option in the filter dropdown |
|
||||||
|
| Q5 | Which reports should the API support? | All CLI reports (see Report Picker section) |
|
||||||
|
|
||||||
|
### Open for Design Phase
|
||||||
|
|
||||||
|
| # | Question | Impact |
|
||||||
|
|----|----------|--------|
|
||||||
|
| Q6 | **What new API endpoint is needed for raw-string task creation?** e.g., `POST /tasks/parse` accepting `{ "input": "Buy groceries due:tomorrow +errand" }`. | Affects backend and frontend contract |
|
||||||
|
| Q7 | **How should the API expose report-based queries?** e.g., `GET /tasks?report=next` vs. dedicated endpoints per report. Some reports (next, ready, overdue) require server-side urgency/date logic. | Affects API design |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Design Notes
|
||||||
|
|
||||||
|
Items for the design phase to pay attention to:
|
||||||
|
|
||||||
|
### Swipe vs. Scroll Conflict
|
||||||
|
Horizontal swipe-to-complete on a vertically scrolling list is a known UX
|
||||||
|
challenge on mobile. The designer should define a clear gesture model:
|
||||||
|
- Minimum horizontal displacement before the swipe "locks in" (vs. being
|
||||||
|
interpreted as a scroll)
|
||||||
|
- Whether the swipe requires a threshold to trigger, or if releasing mid-swipe
|
||||||
|
cancels it
|
||||||
|
- Consider a slight delay or angle detection to disambiguate
|
||||||
|
|
||||||
|
### Input Bar and Mobile Keyboard
|
||||||
|
When the mobile keyboard opens, the input bar must stay visible above it. This
|
||||||
|
is notoriously inconsistent across browsers/OS versions. Design should account
|
||||||
|
for:
|
||||||
|
- `visualViewport` API for keyboard-aware positioning
|
||||||
|
- iOS Safari's quirks with `position: fixed` and the virtual keyboard
|
||||||
|
- Whether the task list should shrink or scroll when the keyboard opens
|
||||||
|
|
||||||
|
### Error Feedback from Parse Endpoint
|
||||||
|
If the server rejects the raw input string (e.g., invalid date `due:neverday`),
|
||||||
|
how is the error communicated? Options:
|
||||||
|
- Inline error message below the input bar (toast-style or persistent)
|
||||||
|
- The input should NOT clear on failure (so the user can fix and retry)
|
||||||
|
|
||||||
|
### Empty States
|
||||||
|
Each report may return zero results. Design should define empty states for:
|
||||||
|
- First-time use (no tasks at all — should guide user toward the input)
|
||||||
|
- Report with no matches (e.g., "No overdue tasks" — brief, non-alarming)
|
||||||
|
|
||||||
|
### Report Picker Density
|
||||||
|
With 11 reports in the dropdown, the designer should consider whether all fit
|
||||||
|
comfortably on a mobile screen without scrolling, or if grouping/sectioning
|
||||||
|
is needed (e.g., "Active views" vs. "Archive views").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Out of Scope
|
||||||
|
|
||||||
|
- OAuth/authentication flow redesign (handled separately; mock mode for dev)
|
||||||
|
- Sync infrastructure changes
|
||||||
|
- Offline task creation queue (existing implementation stays as-is)
|
||||||
|
- Full CLI command passthrough (modify, delete, start, stop — add-only for now)
|
||||||
|
- Task detail/edit view
|
||||||
|
- Projects and Tags dedicated pages
|
||||||
@@ -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
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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,21 +12,33 @@
|
|||||||
/**
|
/**
|
||||||
* @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}>
|
||||||
|
<button class="task-checkbox" on:click|stopPropagation={handleCheckbox} type="button" aria-label="Complete task">
|
||||||
<Checkbox checked={task.status === 'C'} />
|
<Checkbox checked={task.status === 'C'} />
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div class="task-content">
|
<div class="task-content">
|
||||||
<div class="task-header">
|
<div class="task-header">
|
||||||
@@ -38,14 +52,16 @@
|
|||||||
<span class="meta-item project">{task.project}</span>
|
<span class="meta-item project">{task.project}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if task.priority > 1}
|
{#if task.priority === 3}
|
||||||
<span class="meta-item priority priority-{task.priority}">
|
<span class="meta-item priority-high">High</span>
|
||||||
{priorityLabel}
|
{:else if task.priority === 2}
|
||||||
</span>
|
<span class="meta-item priority-medium">Med</span>
|
||||||
|
{:else if task.priority === 0}
|
||||||
|
<span class="meta-item priority-low">Low</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if task.due}
|
{#if task.due}
|
||||||
<span class="meta-item due" class:overdue={isDue}>
|
<span class="meta-item due" class:overdue class:due-today={dueToday}>
|
||||||
{formatRelative(task.due)}
|
{formatRelative(task.due)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -60,6 +76,7 @@
|
|||||||
</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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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']
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
try {
|
if (MOCK_MODE) {
|
||||||
const completed = await tasksAPI.complete(uuid);
|
const mi = mockData.findIndex(t => t.uuid === uuid);
|
||||||
update(tasks => {
|
if (mi >= 0) {
|
||||||
const index = tasks.findIndex(t => t.uuid === uuid);
|
mockData[mi] = {
|
||||||
if (index >= 0) {
|
...mockData[mi],
|
||||||
tasks[index] = completed;
|
status: /** @type {'P'|'C'} */ ('C'),
|
||||||
|
end: Date.now() / 1000,
|
||||||
|
modified: Date.now() / 1000
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return tasks;
|
update(tasks => tasks.filter(t => t.uuid !== uuid));
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tasksAPI.complete(uuid);
|
||||||
|
update(tasks => tasks.filter(t => t.uuid !== uuid));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
queueChange({
|
queueChange({
|
||||||
type: 'update',
|
type: 'update',
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
try {
|
|
||||||
await tasksStore.complete(uuid);
|
|
||||||
// Reload tasks
|
|
||||||
await tasksStore.load({ status: showCompleted ? 'C' : 'P' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to toggle task:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate to task detail
|
|
||||||
* @param {string} uuid
|
|
||||||
*/
|
|
||||||
function handleTaskClick(uuid) {
|
|
||||||
goto(`/tasks/${uuid}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle between pending and completed view
|
|
||||||
*/
|
|
||||||
async function toggleView() {
|
|
||||||
showCompleted = !showCompleted;
|
|
||||||
loading = true;
|
loading = true;
|
||||||
|
inputError = '';
|
||||||
try {
|
try {
|
||||||
await tasksStore.load({ status: showCompleted ? 'C' : 'P' });
|
await tasksStore.loadReport(reportName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load tasks:', error);
|
console.error('Failed to load report:', error);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} reportName
|
||||||
|
*/
|
||||||
|
function handleReportChange(reportName) {
|
||||||
|
activeReport = reportName;
|
||||||
|
loadReport(reportName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} input
|
||||||
|
*/
|
||||||
|
async function handleSubmit(input) {
|
||||||
|
inputError = '';
|
||||||
|
try {
|
||||||
|
await tasksStore.parseAndCreate(input);
|
||||||
|
} catch (error) {
|
||||||
|
inputError = error instanceof Error ? error.message : 'Failed to create task';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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">
|
|
||||||
<button
|
|
||||||
class="filter-btn"
|
|
||||||
class:active={!showCompleted}
|
|
||||||
on:click={() => !showCompleted || toggleView()}
|
|
||||||
>
|
|
||||||
Pending ({$pendingTasks.length})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="filter-btn"
|
|
||||||
class:active={showCompleted}
|
|
||||||
on:click={() => showCompleted || toggleView()}
|
|
||||||
>
|
|
||||||
Completed ({$completedTasks.length})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TaskList
|
<TaskList
|
||||||
tasks={displayTasks}
|
{tasks}
|
||||||
{loading}
|
{loading}
|
||||||
onToggle={handleToggle}
|
{activeReport}
|
||||||
onTaskClick={handleTaskClick}
|
onComplete={handleComplete}
|
||||||
emptyMessage={showCompleted ? 'No completed tasks' : 'No pending tasks. Add one to get started!'}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
<InputBar
|
||||||
.header {
|
onSubmit={handleSubmit}
|
||||||
display: flex;
|
error={inputError}
|
||||||
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>
|
|
||||||
@@ -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">
|
||||||
|
<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>
|
<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);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
Reference in New Issue
Block a user