Compare commits
34 Commits
f05d6e154e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d57aed8ce | |||
| 393b7a144a | |||
| 201f32d095 | |||
| e86d063912 | |||
| 10421b0ec6 | |||
| 08123aa3c5 | |||
| 6c28e4d24a | |||
| 9973631df0 | |||
| a11f452d3b | |||
| 0e3750e755 | |||
| 8693681660 | |||
| 41a12fe7a9 | |||
| acab4333a7 | |||
| cd77443a07 | |||
| b7e0d434ba | |||
| 04fa9222d8 | |||
| 5301fbf706 | |||
| 4b35753fc7 | |||
| f5a5323c15 | |||
| 0ff0db642a | |||
| 24e9883f68 | |||
| aa2ca9aec3 | |||
| b53e77a8ec | |||
| 07d1a78dfc | |||
| 2fa1316f0d | |||
| 3c0d4ee471 | |||
| feb5406077 | |||
| 32cc05a546 | |||
| 7aaaa86a0a | |||
| 6fb8a40a43 | |||
| b02c40f716 | |||
| 779da6ddfd | |||
| f57baee6bc | |||
| a551f50cef |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,470 @@
|
||||
# Opal CLI: User Experience Improvements
|
||||
|
||||
**Status:** Draft — awaiting feedback
|
||||
**Date:** 2026-02-18
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Opal's CLI is functional and expressive, but several gaps in feedback, safety,
|
||||
and discoverability make daily use rougher than it needs to be. These
|
||||
improvements target the person who uses `opal` 10+ times a day — reducing
|
||||
friction, preventing mistakes, and surfacing information at the right moment.
|
||||
|
||||
---
|
||||
|
||||
## Proposed Improvements
|
||||
|
||||
### IMP-1: Undo / Uncomplete
|
||||
|
||||
**Priority:** MUST
|
||||
**Noted in:** `opal-web/BUGS.md` (missing uncomplete feat)
|
||||
|
||||
#### Problem
|
||||
|
||||
Accidentally completing or deleting the wrong task has no quick recovery path.
|
||||
The only workaround is `opal edit <uuid>` and manually setting status back to
|
||||
`pending`. This is slow, error-prone, and requires knowing the UUID.
|
||||
|
||||
#### User Stories
|
||||
|
||||
**US-1.1** As a user, I want to uncomplete a task so that I can recover from
|
||||
accidental completions.
|
||||
|
||||
- **Given** task 3 was just completed
|
||||
- **When** I run `opal 3 uncomplete` (or `opal undo`)
|
||||
- **Then** task 3 returns to `pending` status, its `end` timestamp is cleared,
|
||||
and it reappears in my default report
|
||||
|
||||
**US-1.2** As a user, I want a generic `undo` that reverts my last action so
|
||||
that I don't need to know the exact reverse command.
|
||||
|
||||
- **Given** I just ran `opal 5 delete`
|
||||
- **When** I run `opal undo`
|
||||
- **Then** task 5 is restored to its previous status
|
||||
|
||||
#### Functional Requirements
|
||||
|
||||
1. FR-1.1: `opal undo` MUST revert the last mutating CLI action (done,
|
||||
delete, modify, add, start, stop).
|
||||
2. FR-1.2: `opal <id> uncomplete` MUST set a completed task back to pending
|
||||
and clear the `end` timestamp.
|
||||
3. FR-1.3: Undo history SHOULD persist across CLI invocations (stored in a
|
||||
local undo log file or DB table).
|
||||
4. FR-1.4: Undo SHOULD support at least the last 10 operations.
|
||||
5. FR-1.5: `opal undo` MUST display what was reverted
|
||||
(e.g., `Undone: task 3 "Buy milk" restored to pending`).
|
||||
|
||||
#### Design Decisions
|
||||
|
||||
- **Scope:** Local only — undo does NOT propagate across sync boundaries.
|
||||
- **Undo `add`:** Deletes the created task entirely (hard delete, not soft).
|
||||
- **Multi-level:** `opal undo` can be called repeatedly to walk back through
|
||||
the last 10 operations (stack-based, LIFO).
|
||||
|
||||
---
|
||||
|
||||
### IMP-2: Better Feedback After `add`
|
||||
|
||||
**Priority:** MUST
|
||||
|
||||
#### Problem
|
||||
|
||||
`opal add Buy groceries due:tomorrow +errand` prints:
|
||||
|
||||
```
|
||||
Created task 8f3a1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c
|
||||
```
|
||||
|
||||
The UUID is meaningless for subsequent commands. The user can't confirm their
|
||||
modifiers were parsed correctly without running a separate `list` or `info`.
|
||||
|
||||
#### User Stories
|
||||
|
||||
**US-2.1** As a user, I want to see the display ID and parsed attributes after
|
||||
adding a task so that I can confirm it was created correctly and reference it
|
||||
immediately.
|
||||
|
||||
- **Given** I run `opal add Buy groceries due:tomorrow +errand`
|
||||
- **When** the task is created
|
||||
- **Then** I see output like:
|
||||
```
|
||||
Created task 3 — "Buy groceries"
|
||||
Due: tomorrow (2026-02-19)
|
||||
Tags: errand
|
||||
Priority: default
|
||||
```
|
||||
|
||||
#### Functional Requirements
|
||||
|
||||
1. FR-2.1: `add` MUST display the new task's display ID (not just UUID).
|
||||
2. FR-2.2: `add` MUST echo back all parsed modifiers so the user can verify.
|
||||
3. FR-2.3: For recurring tasks, `add` MUST show recurrence interval and the
|
||||
first instance's due date.
|
||||
4. FR-2.4: The display ID shown MUST be valid for immediate use
|
||||
(e.g., `opal 3 done`).
|
||||
|
||||
---
|
||||
|
||||
### IMP-3: Show Matched Tasks in Confirmations
|
||||
|
||||
**Priority:** MUST
|
||||
|
||||
#### Problem
|
||||
|
||||
Destructive commands (`done`, `delete`, `modify`) prompt with only a count:
|
||||
|
||||
```
|
||||
About to complete 3 tasks. Proceed? (y/N):
|
||||
```
|
||||
|
||||
The user can't verify *which* 3 tasks will be affected without cancelling and
|
||||
running a separate `list` command with the same filter.
|
||||
|
||||
#### User Stories
|
||||
|
||||
**US-3.1** As a user, I want to see the list of affected tasks before
|
||||
confirming a bulk action so that I can verify I'm not making a mistake.
|
||||
|
||||
- **Given** I run `opal +errand done`
|
||||
- **When** 3 tasks match
|
||||
- **Then** I see:
|
||||
```
|
||||
About to complete 3 tasks:
|
||||
1 Buy groceries due:tomorrow +errand
|
||||
4 Return library books due:fri +errand
|
||||
7 Pick up dry cleaning +errand
|
||||
Proceed? (y/N):
|
||||
```
|
||||
|
||||
#### Functional Requirements
|
||||
|
||||
1. FR-3.1: `done`, `delete`, and `modify` MUST list matched tasks (ID,
|
||||
description, key attributes) before the confirmation prompt.
|
||||
2. FR-3.2: If more than 10 tasks match, show the first 10 and note
|
||||
"...and N more".
|
||||
3. FR-3.3: Single-task operations (e.g., `opal 3 done`) SHOULD still show the
|
||||
task description for verification but skip the y/N prompt.
|
||||
|
||||
---
|
||||
|
||||
### IMP-4: Fix `delete` Not Resolving Display IDs
|
||||
|
||||
**Priority:** MUST (bug fix)
|
||||
|
||||
#### Problem
|
||||
|
||||
`delete` calls `engine.GetTasks(filter)` directly without loading the working
|
||||
set to resolve display IDs. This means `opal 3 delete` may not resolve ID 3
|
||||
correctly, unlike `done` and `modify` which both load the working set.
|
||||
|
||||
#### Functional Requirements
|
||||
|
||||
1. FR-4.1: `delete` MUST load the working set and resolve display IDs the same
|
||||
way `done` and `modify` do.
|
||||
2. FR-4.2: All action commands (done, delete, modify, start, stop, info, edit)
|
||||
MUST use the same ID resolution path.
|
||||
|
||||
---
|
||||
|
||||
### IMP-5: Handle Colons in Descriptions
|
||||
|
||||
**Priority:** MUST
|
||||
|
||||
#### Problem
|
||||
|
||||
`parseAddArgs` treats any argument containing `:` as a modifier. This silently
|
||||
drops or misparses descriptions containing colons:
|
||||
|
||||
```
|
||||
opal add "Meeting: discuss Q3 goals" # "Meeting:" parsed as modifier
|
||||
opal add Fix bug in http://example.com # "http:" parsed as modifier
|
||||
```
|
||||
|
||||
There's no escaping mechanism or error message — the description is silently
|
||||
truncated.
|
||||
|
||||
#### User Stories
|
||||
|
||||
**US-5.1** As a user, I want to include colons in task descriptions so that I
|
||||
can write natural language without worrying about parser conflicts.
|
||||
|
||||
- **Given** I run `opal add "Meeting: discuss Q3 goals"`
|
||||
- **When** the task is created
|
||||
- **Then** the description is `Meeting: discuss Q3 goals` with no modifiers
|
||||
|
||||
#### Functional Requirements
|
||||
|
||||
1. FR-5.1: Quoted strings MUST be treated as description text, not parsed for
|
||||
modifiers.
|
||||
2. FR-5.2: Only tokens matching a known modifier pattern (`key:value` where
|
||||
`key` is a recognized attribute like `due`, `priority`, `project`, `recur`,
|
||||
`status`, `wait`, `scheduled`, `until`) SHOULD be treated as modifiers.
|
||||
3. FR-5.3: Unknown `key:value` patterns SHOULD be treated as description text,
|
||||
not silently dropped.
|
||||
4. FR-5.4: If a token is ambiguous, prefer treating it as description text.
|
||||
|
||||
#### Design Decisions
|
||||
|
||||
- **Allowlist approach:** Only recognized attribute keys (`due`, `priority`,
|
||||
`project`, `recur`, `status`, `wait`, `scheduled`, `until`) are treated as
|
||||
modifiers. All other `key:value` tokens are treated as description text.
|
||||
|
||||
---
|
||||
|
||||
### IMP-6: Consistent Error on No-Match
|
||||
|
||||
**Priority:** SHOULD
|
||||
|
||||
#### Problem
|
||||
|
||||
Action commands behave inconsistently when no tasks match a filter:
|
||||
|
||||
| Command | No-match behavior | Exit code |
|
||||
|----------|-------------------------------------|-----------|
|
||||
| `done` | Prints "No tasks matched." | 0 |
|
||||
| `delete` | Prints "No tasks matched." | 0 |
|
||||
| `modify` | Returns error "no tasks matched" | 1 |
|
||||
| `start` | (unknown — needs verification) | ? |
|
||||
| `stop` | (unknown — needs verification) | ? |
|
||||
|
||||
#### Functional Requirements
|
||||
|
||||
1. FR-6.1: All action commands MUST return exit code 1 when no tasks match an
|
||||
explicit filter.
|
||||
2. FR-6.2: All action commands MUST print to stderr (not stdout) when no tasks
|
||||
match, to support scripting.
|
||||
3. FR-6.3: The message SHOULD be consistent:
|
||||
`Error: no tasks matched filter "<filter>"`.
|
||||
|
||||
---
|
||||
|
||||
### IMP-7: Recurring Task Feedback
|
||||
|
||||
**Priority:** SHOULD
|
||||
|
||||
#### Problem
|
||||
|
||||
Completing a recurring task instance gives no indication about recurrence:
|
||||
|
||||
```
|
||||
$ opal 3 done
|
||||
Completed 1 task(s).
|
||||
```
|
||||
|
||||
The user doesn't know if a next instance was spawned, when it's due, or whether
|
||||
the recurrence is still active.
|
||||
|
||||
#### User Stories
|
||||
|
||||
**US-7.1** As a user, I want to see recurrence information when completing a
|
||||
recurring task so that I know the schedule is continuing.
|
||||
|
||||
- **Given** task 3 is a recurring weekly task
|
||||
- **When** I run `opal 3 done`
|
||||
- **Then** I see:
|
||||
```
|
||||
Completed task 3 — "Weekly review"
|
||||
Next instance created — due: 2026-02-25 (in 7 days)
|
||||
```
|
||||
|
||||
#### Functional Requirements
|
||||
|
||||
1. FR-7.1: Completing a recurring task instance MUST display whether a new
|
||||
instance was created and its due date.
|
||||
2. FR-7.2: If no new instance was created (e.g., recurrence was cleared), the
|
||||
output MUST say so.
|
||||
3. FR-7.3: `info` on a recurring instance SHOULD show the recurrence pattern
|
||||
and parent template UUID.
|
||||
|
||||
---
|
||||
|
||||
### IMP-8: Shell Completions
|
||||
|
||||
**Priority:** SHOULD
|
||||
|
||||
#### Problem
|
||||
|
||||
No tab completion exists for commands, report names, project names, or tag
|
||||
names. For a CLI with 14 commands, 13 report names, and user-defined projects
|
||||
and tags, discoverability is poor.
|
||||
|
||||
#### Functional Requirements
|
||||
|
||||
1. FR-8.1: `opal completion bash|zsh|fish` MUST generate shell completion
|
||||
scripts (cobra has built-in support for this).
|
||||
2. FR-8.2: Completions SHOULD cover: commands, report names, `+tag` names,
|
||||
`project:` values, `priority:` values, and `status:` values.
|
||||
3. FR-8.3: Dynamic completions for tags and projects SHOULD query the database.
|
||||
4. FR-8.4: Setup instructions SHOULD be printed after `opal completion <shell>`.
|
||||
|
||||
---
|
||||
|
||||
### IMP-9: Relative Dates in CLI Reports
|
||||
|
||||
**Priority:** SHOULD
|
||||
|
||||
#### Problem
|
||||
|
||||
CLI report tables likely show absolute dates (`2026-02-20`). When scanning a
|
||||
task list, relative dates ("in 2d", "yesterday", "3w ago") are faster to parse
|
||||
at a glance. The web UI already uses relative dates.
|
||||
|
||||
#### Functional Requirements
|
||||
|
||||
1. FR-9.1: Due dates in reports MUST be shown as relative when within 14 days
|
||||
(e.g., "tomorrow", "in 3d", "2d ago").
|
||||
2. FR-9.2: Dates beyond 14 days SHOULD fall back to short absolute format
|
||||
(e.g., "Feb 28", "Mar 15").
|
||||
3. FR-9.3: `info` SHOULD show both absolute and relative
|
||||
(e.g., `Due: 2026-02-20 (in 2 days)`).
|
||||
4. FR-9.4: Relative display COULD be togglable via config
|
||||
(`date_display: relative|absolute`).
|
||||
|
||||
---
|
||||
|
||||
### IMP-10: Dry-Run / Preview for Action Commands
|
||||
|
||||
**Priority:** SHOULD
|
||||
|
||||
#### Problem
|
||||
|
||||
Before running `opal +errand done`, the user often runs `opal +errand list`
|
||||
first to preview. This is a two-step workflow that could be one step.
|
||||
|
||||
#### Functional Requirements
|
||||
|
||||
1. FR-10.1: `done`, `delete`, `modify`, `start`, and `stop` SHOULD support a
|
||||
`--dry-run` flag that lists matched tasks without acting.
|
||||
2. FR-10.2: Dry-run output MUST match the same format as the confirmation
|
||||
listing (IMP-3), followed by "Dry run — no changes made."
|
||||
3. FR-10.3: Dry-run MUST exit with code 0 if tasks matched, 1 if none matched.
|
||||
|
||||
---
|
||||
|
||||
### IMP-11: Task Annotations
|
||||
|
||||
**Priority:** SHOULD
|
||||
|
||||
#### Problem
|
||||
|
||||
There's no way to attach notes to a task after creation. For long-running tasks
|
||||
like "Debug auth issue" or "Research hosting options", users want to record
|
||||
progress without cluttering the description.
|
||||
|
||||
#### User Stories
|
||||
|
||||
**US-11.1** As a user, I want to annotate tasks with timestamped notes so that
|
||||
I can track progress and findings over time.
|
||||
|
||||
- **Given** task 3 exists
|
||||
- **When** I run `opal 3 annotate "Traced to token expiry in middleware"`
|
||||
- **Then** the annotation is saved with a timestamp
|
||||
- **And** `opal 3 info` shows the annotation under the task details
|
||||
|
||||
**US-11.2** As a user, I want to link a task to a jade-depo note so that I can
|
||||
associate detailed research or write-ups with a task.
|
||||
|
||||
- **Given** task 3 exists and a jade-depo note "debug-auth-issue.md" exists
|
||||
- **When** I run `opal 3 annotate --note debug-auth-issue` (or similar)
|
||||
- **Then** the task stores a reference to the jade-depo note
|
||||
- **And** `opal 3 info` shows the linked note path
|
||||
|
||||
#### Functional Requirements
|
||||
|
||||
1. FR-11.1: `opal <id> annotate "<text>"` SHOULD add a timestamped note.
|
||||
2. FR-11.2: Annotations MUST be visible in `info` and `edit`.
|
||||
3. FR-11.3: Annotations MUST sync via the existing change log / sync system.
|
||||
4. FR-11.4: `opal <id> denotate` COULD remove the most recent annotation.
|
||||
5. FR-11.5: Annotations SHOULD support linking to jade-depo notes (exact
|
||||
mechanism TBD — flag, URI scheme, or convention like `note:slug`).
|
||||
|
||||
#### Design Decisions
|
||||
|
||||
- **Storage:** JSON text column (`annotations`) on the `tasks` table. Each
|
||||
annotation is a JSON object with `timestamp` and `text` fields. Stored as a
|
||||
JSON array, e.g.:
|
||||
```json
|
||||
[
|
||||
{"timestamp": 1708300000, "text": "Traced to token expiry in middleware"},
|
||||
{"timestamp": 1708310000, "text": "note:debug-auth-issue"}
|
||||
]
|
||||
```
|
||||
This keeps annotations co-located with the task, avoids schema complexity,
|
||||
and syncs naturally via the existing change_log triggers.
|
||||
|
||||
#### Open Questions
|
||||
|
||||
- Should annotations be searchable via filters (e.g., `opal annotation:token list`)?
|
||||
- Jade-depo integration: should `opal 3 annotate --note <title>` verify the
|
||||
note exists in jade-depo, or just store the reference loosely? Loose coupling
|
||||
is simpler but can lead to stale links.
|
||||
|
||||
---
|
||||
|
||||
### IMP-12: Task History
|
||||
|
||||
**Priority:** COULD
|
||||
|
||||
#### Problem
|
||||
|
||||
The `change_log` table records every mutation for sync, but there's no
|
||||
user-facing way to view a task's history. Useful for understanding what changed,
|
||||
when, and debugging unexpected state.
|
||||
|
||||
#### Functional Requirements
|
||||
|
||||
1. FR-12.1: `opal <id> log` COULD display the change history for a task.
|
||||
2. FR-12.2: Output SHOULD show timestamp, change type, and what changed:
|
||||
```
|
||||
2026-02-18 09:15 created "Buy groceries" priority:D
|
||||
2026-02-18 10:30 modified priority: D → H
|
||||
2026-02-18 14:00 completed
|
||||
```
|
||||
3. FR-12.3: History MUST respect the existing change_log retention policy.
|
||||
4. FR-12.4: History SHOULD be surfaced in `info` output (recent changes
|
||||
section) and available in `edit` as read-only comment lines.
|
||||
|
||||
---
|
||||
|
||||
### IMP-13: Version Command
|
||||
|
||||
**Priority:** COULD
|
||||
|
||||
#### Functional Requirements
|
||||
|
||||
1. FR-13.1: `opal version` (or `opal --version`) MUST print the build version.
|
||||
2. FR-13.2: Version SHOULD be set at build time via `ldflags`, reading from a
|
||||
`VERSION` file in the repo root.
|
||||
3. FR-13.3: Output SHOULD include version, commit hash, and build date.
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- **GUI/TUI redesign** — this document covers CLI UX only.
|
||||
- **New task attributes** (e.g., estimated effort, dependencies between tasks).
|
||||
- **Multi-user features** — opal is a personal/household tool.
|
||||
- **Plugin/hook system** — not needed at this stage.
|
||||
- **Web UI changes** — covered separately in `opal-web/REQUIREMENTS.md`.
|
||||
|
||||
---
|
||||
|
||||
## Priority Summary
|
||||
|
||||
| ID | Improvement | Priority | Effort |
|
||||
|--------|--------------------------------------|----------|----------|
|
||||
| IMP-1 | Undo / uncomplete | MUST | Medium |
|
||||
| IMP-2 | Better `add` feedback | MUST | Low |
|
||||
| IMP-3 | Show matched tasks in confirmations | MUST | Low |
|
||||
| IMP-4 | Fix `delete` display ID resolution | MUST | Low |
|
||||
| IMP-5 | Handle colons in descriptions | MUST | Medium |
|
||||
| IMP-6 | Consistent no-match error behavior | SHOULD | Low |
|
||||
| IMP-7 | Recurring task feedback | SHOULD | Low |
|
||||
| IMP-8 | Shell completions | SHOULD | Medium |
|
||||
| IMP-9 | Relative dates in CLI reports | SHOULD | Low |
|
||||
| IMP-10 | Dry-run flag for actions | SHOULD | Low |
|
||||
| IMP-11 | Task annotations | SHOULD | Medium |
|
||||
| IMP-12 | Task history | COULD | Medium |
|
||||
| IMP-13 | Version command | COULD | Trivial |
|
||||
@@ -1,482 +0,0 @@
|
||||
# Config System Redesign
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
`LoadConfig()` panics on the server because the config system assumes a writable
|
||||
config directory. On the server, `OPAL_CONFIG_DIR=/etc/opal` is read-only
|
||||
(systemd `ReadOnlyPaths`), `opal.yml` doesn't exist (Ansible only deploys
|
||||
`opal.env`), and the attempt to create it fails. The nil `*Config` propagates
|
||||
unchecked through `GetConfig()` callers to `BuildUrgencyCoefficients(nil)`,
|
||||
causing a nil-pointer panic.
|
||||
|
||||
### Panic chain
|
||||
|
||||
```
|
||||
sortByUrgency() report.go:426
|
||||
cfg, _ := GetConfig() ← error discarded, cfg = nil
|
||||
coeffs := BuildUrgencyCoefficients(cfg)
|
||||
return &UrgencyCoefficients{
|
||||
Due: cfg.UrgencyDue, ← nil dereference → PANIC
|
||||
}
|
||||
```
|
||||
|
||||
### Root cause
|
||||
|
||||
The config system was designed for the CLI (user-writable `~/.config/opal/`)
|
||||
and has three interacting issues:
|
||||
|
||||
1. **Write-on-read**: `LoadConfig()` creates directories and writes a default
|
||||
`opal.yml` as a side effect of *reading* config. This fails when the
|
||||
filesystem is read-only.
|
||||
2. **Error swallowing**: All internal callers use `cfg, _ := GetConfig()`,
|
||||
turning a load failure into a nil-pointer panic instead of a graceful error.
|
||||
3. **No mode awareness**: The same code path runs for CLI users (writable home
|
||||
dir, interactive, config file is useful) and for the server (read-only
|
||||
`/etc/opal`, headless, defaults are fine).
|
||||
|
||||
---
|
||||
|
||||
## 2. Current Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Config Sources"
|
||||
ENV["opal.env<br/>(systemd EnvironmentFile)"]
|
||||
YML["opal.yml<br/>(Viper / YAML)"]
|
||||
DEFAULTS["Hardcoded defaults<br/>(in LoadConfig)"]
|
||||
end
|
||||
|
||||
subgraph "Loading Mechanisms"
|
||||
AUTH_LOAD["auth.LoadConfig()<br/>os.Getenv() → auth.Config"]
|
||||
ENGINE_LOAD["engine.LoadConfig()<br/>Viper → engine.Config"]
|
||||
end
|
||||
|
||||
subgraph "Consumers"
|
||||
SERVER["API Server<br/>(handlers, middleware)"]
|
||||
CLI["CLI Commands<br/>(cmd/ package)"]
|
||||
end
|
||||
|
||||
ENV --> AUTH_LOAD
|
||||
YML --> ENGINE_LOAD
|
||||
DEFAULTS --> ENGINE_LOAD
|
||||
|
||||
AUTH_LOAD --> SERVER
|
||||
ENGINE_LOAD --> SERVER
|
||||
ENGINE_LOAD --> CLI
|
||||
```
|
||||
|
||||
### Two independent config subsystems
|
||||
|
||||
| Aspect | `engine.Config` | `auth.Config` |
|
||||
|--------|----------------|---------------|
|
||||
| **Source** | `opal.yml` via Viper | Environment variables |
|
||||
| **Loaded by** | `engine.LoadConfig()` | `auth.LoadConfig()` |
|
||||
| **Caching** | Singleton (`globalConfig`) | None (re-read each call) |
|
||||
| **Write side effects** | Creates dir + file on load | None |
|
||||
| **Error model** | Returns `(*Config, error)` | Returns `*Config` (no error) |
|
||||
| **Used by** | CLI + Server (lazy) | Server only |
|
||||
|
||||
### `engine.Config` field categories
|
||||
|
||||
| Category | Fields | CLI | Server | Notes |
|
||||
|----------|--------|-----|--------|-------|
|
||||
| **Display** | `DefaultFilter`, `DefaultSort`, `DefaultReport`, `ColorOutput` | Yes | No | Terminal-only |
|
||||
| **Date** | `WeekStartDay`, `DefaultDueTime` | Yes | Yes | Used by `ParseDate()` |
|
||||
| **Urgency** | 13 urgency coefficients | Yes | Yes | Core scoring logic |
|
||||
| **Limits** | `NextLimit` | Yes | Indirectly | Report limit |
|
||||
| **Sync** | 6 sync fields | Yes | No | Client-side sync config |
|
||||
|
||||
Key observation: The server only needs **urgency coefficients** and **date
|
||||
settings** from `engine.Config`. It doesn't need display preferences or sync
|
||||
settings. But all 30 fields are loaded through the same mechanism.
|
||||
|
||||
### `engine.LoadConfig()` flow
|
||||
|
||||
```
|
||||
1. Check globalConfig singleton → return if cached
|
||||
2. GetConfigDir() → resolve directory path
|
||||
3. os.MkdirAll(configDir) ← FAILS on read-only FS
|
||||
4. Viper.ReadInConfig()
|
||||
5. If read fails:
|
||||
a. Viper.WriteConfigAs() ← FAILS on read-only FS
|
||||
b. Re-read written file
|
||||
6. Unmarshal → Config struct
|
||||
7. Cache in globalConfig
|
||||
```
|
||||
|
||||
Steps 3 and 5a are the failure points on the server.
|
||||
|
||||
### Caller error handling audit
|
||||
|
||||
| Caller | File:Line | Error handling |
|
||||
|--------|-----------|----------------|
|
||||
| `FormatTaskListWithFormat` | display.go:24 | `cfg, _ := GetConfig()` — **ignored** |
|
||||
| `formatMinimalLine` | display.go:119 | `cfg, _ := GetConfig()` — **ignored** |
|
||||
| `NextReport.SortFunc` | report.go:168 | `cfg, _ := GetConfig()` — **ignored** |
|
||||
| `NextReport.LimitFunc` | report.go:187 | `cfg, _ := GetConfig()` — **ignored** |
|
||||
| `sortByUrgency` | report.go:426 | `cfg, _ := GetConfig()` — **ignored** |
|
||||
| `PopulateUrgency` | task.go:769 | `cfg, _ := GetConfig()` — **ignored** |
|
||||
| `getWeekStart` | dateparse.go:484 | Checked — falls back to Monday |
|
||||
| All cmd/ callers | root.go, sync.go | Checked — exits on error |
|
||||
|
||||
Every engine-internal caller except `dateparse.go` discards the error.
|
||||
|
||||
---
|
||||
|
||||
## 3. Additional Issues Found
|
||||
|
||||
### 3a. Config file clobbering
|
||||
|
||||
`LoadConfig()` line 296-306 catches **any** `ReadInConfig` error (not just
|
||||
"file not found") and overwrites the config file with defaults. A YAML syntax
|
||||
error in the user's `opal.yml` silently destroys their customizations.
|
||||
|
||||
### 3b. `SaveConfig()` manual field sync
|
||||
|
||||
Adding a new config field requires updating three places:
|
||||
1. `Config` struct definition
|
||||
2. `LoadConfig()` — `v.SetDefault(...)` call
|
||||
3. `SaveConfig()` — `v.Set(...)` call
|
||||
|
||||
Missing any one of these causes silent data loss on save or missing defaults.
|
||||
|
||||
### 3c. `auth.LoadConfig()` silent parse failures
|
||||
|
||||
```go
|
||||
jwtExpiry, _ := strconv.Atoi(getEnv("JWT_EXPIRY", "3600"))
|
||||
```
|
||||
|
||||
If `JWT_EXPIRY=abc`, `Atoi` returns `(0, error)`, error is discarded, and
|
||||
`JWTExpiry` silently becomes 0 (tokens expire immediately).
|
||||
|
||||
### 3d. Insecure JWT default
|
||||
|
||||
`JWT_SECRET` defaults to `"change-me-in-production"`. While `validateServerConfig()`
|
||||
checks for empty values, it doesn't check for the insecure default.
|
||||
|
||||
### 3e. Sync API key in plaintext
|
||||
|
||||
`sync_api_key` is stored in `opal.yml` with no special file permissions.
|
||||
|
||||
### 3f. No env-var override of YAML config
|
||||
|
||||
Viper's `AutomaticEnv()` is never called, so there's no way to override YAML
|
||||
config values via environment variables. This is an obstacle for 12-factor
|
||||
server deployments.
|
||||
|
||||
---
|
||||
|
||||
## 4. Design Options
|
||||
|
||||
### Option A: Minimal fix — graceful fallback to defaults
|
||||
|
||||
**Change**: Make `LoadConfig()` return a `DefaultConfig()` when it can't read
|
||||
or write the config file, instead of returning nil.
|
||||
|
||||
```go
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
DefaultFilter: "status:pending",
|
||||
DefaultSort: "due,priority",
|
||||
// ... all defaults ...
|
||||
UrgencyDue: 12.0,
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
func LoadConfig() (*Config, error) {
|
||||
if globalConfig != nil {
|
||||
return globalConfig, nil
|
||||
}
|
||||
|
||||
cfg, err := loadFromFile()
|
||||
if err != nil {
|
||||
// File not available — use defaults (common in server mode)
|
||||
cfg = DefaultConfig()
|
||||
}
|
||||
|
||||
globalConfig = cfg
|
||||
return cfg, nil
|
||||
}
|
||||
```
|
||||
|
||||
**Pros**: Smallest change, fixes the panic, no behavior change for CLI users.
|
||||
**Cons**: Doesn't address the architectural confusion. Error-swallowing callers
|
||||
remain. Write-on-read side effect remains for CLI. Three-source config stays.
|
||||
|
||||
---
|
||||
|
||||
### Option B: Separate load paths for CLI and server
|
||||
|
||||
**Change**: Split `LoadConfig()` into two functions:
|
||||
- `LoadConfigFromFile()` — current behavior for CLI (read/write YAML)
|
||||
- `LoadConfigFromDefaults()` — returns `DefaultConfig()` for server
|
||||
|
||||
The server startup code calls `LoadConfigFromDefaults()` eagerly during init,
|
||||
populating the singleton before any lazy `GetConfig()` call.
|
||||
|
||||
```go
|
||||
// cmd/server.go — in serverStartCmd.Run, before InitDB()
|
||||
engine.LoadConfigFromDefaults()
|
||||
```
|
||||
|
||||
**Pros**: Fixes the panic. Server path never touches the filesystem for config.
|
||||
Explicit about which mode is running. CLI behavior unchanged.
|
||||
**Cons**: Two code paths to maintain. Server can't be customized without code
|
||||
changes (urgency tuning, etc.).
|
||||
|
||||
---
|
||||
|
||||
### Option C: Read-only load with env-var overrides (recommended)
|
||||
|
||||
**Change**: Restructure `LoadConfig()` to:
|
||||
1. Start with hardcoded defaults (always succeeds)
|
||||
2. Layer YAML file on top **if it exists** (read-only, never create)
|
||||
3. Layer environment variables on top (via Viper `AutomaticEnv`)
|
||||
4. Never write to the filesystem as a side effect of loading
|
||||
|
||||
```go
|
||||
func LoadConfig() (*Config, error) {
|
||||
if globalConfig != nil {
|
||||
return globalConfig, nil
|
||||
}
|
||||
|
||||
v := viper.New()
|
||||
v.SetConfigType("yaml")
|
||||
|
||||
// 1. Hardcoded defaults (always present)
|
||||
setDefaults(v)
|
||||
|
||||
// 2. YAML file overlay (optional, read-only)
|
||||
if configPath, err := GetConfigPath(); err == nil {
|
||||
v.SetConfigFile(configPath)
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
// File exists but is malformed — report, don't clobber
|
||||
if _, statErr := os.Stat(configPath); statErr == nil {
|
||||
return nil, fmt.Errorf("config file %s is invalid: %w", configPath, err)
|
||||
}
|
||||
// File doesn't exist — that's fine, use defaults
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Environment variable overlay
|
||||
v.SetEnvPrefix("OPAL")
|
||||
v.AutomaticEnv()
|
||||
|
||||
cfg := &Config{}
|
||||
if err := v.Unmarshal(cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
globalConfig = cfg
|
||||
return cfg, nil
|
||||
}
|
||||
```
|
||||
|
||||
File creation moves to an explicit `InitConfig()` function, called only during
|
||||
`opal setup` and CLI first-run:
|
||||
|
||||
```go
|
||||
func InitConfig() error {
|
||||
configDir, err := GetConfigDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
return SaveConfig(DefaultConfig())
|
||||
}
|
||||
```
|
||||
|
||||
Environment variable mapping:
|
||||
|
||||
| YAML key | Env var |
|
||||
|----------|---------|
|
||||
| `urgency_due_coefficient` | `OPAL_URGENCY_DUE_COEFFICIENT` |
|
||||
| `default_filter` | `OPAL_DEFAULT_FILTER` |
|
||||
| `week_start_day` | `OPAL_WEEK_START_DAY` |
|
||||
| ... | `OPAL_<UPPER_SNAKE>` |
|
||||
|
||||
**Pros**:
|
||||
- Fixes the panic — loading never fails fatally (missing file = defaults)
|
||||
- Malformed YAML is reported, not silently clobbered
|
||||
- Server can tune urgency via `opal.env` without deploying `opal.yml`
|
||||
- 12-factor compatible (env vars override everything)
|
||||
- CLI behavior unchanged (reads existing `opal.yml` + env overrides)
|
||||
- Single code path for both CLI and server
|
||||
- No filesystem write side effects during load
|
||||
|
||||
**Cons**:
|
||||
- `SaveConfig()` still needs the manual field-sync (separate improvement)
|
||||
- Slightly more Viper configuration
|
||||
|
||||
---
|
||||
|
||||
### Option D: Full restructure with typed config sections
|
||||
|
||||
**Change**: Split `Config` into domain-specific sub-configs and unify the
|
||||
loading mechanism for all config (merge `auth.Config` into the same system).
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Display DisplayConfig `mapstructure:"display"`
|
||||
Urgency UrgencyConfig `mapstructure:"urgency"`
|
||||
Sync SyncConfig `mapstructure:"sync"`
|
||||
Server ServerConfig `mapstructure:"server"` // absorbs auth.Config
|
||||
}
|
||||
```
|
||||
|
||||
**Pros**: Clean separation. Single config system. Extensible.
|
||||
**Cons**: Large refactor. Breaks existing `opal.yml` format. `auth.Config`
|
||||
works fine as-is for its purpose. Over-engineered for the current problem.
|
||||
|
||||
---
|
||||
|
||||
## 5. Recommendation
|
||||
|
||||
**Option C** — read-only load with env-var overrides.
|
||||
|
||||
It fixes the immediate panic, eliminates the write-on-read footgun, enables
|
||||
server customization via environment variables, and does it with a focused
|
||||
change to one function. It doesn't over-engineer or restructure things that
|
||||
work.
|
||||
|
||||
### Implementation plan
|
||||
|
||||
#### Step 1: Extract `DefaultConfig()` constructor
|
||||
|
||||
Create a `DefaultConfig()` function that returns a `*Config` with all defaults
|
||||
populated. This is the single source of truth for default values — used by
|
||||
`LoadConfig()`, `InitConfig()`, and `SaveConfig()`.
|
||||
|
||||
**Files**: `internal/engine/config.go`
|
||||
|
||||
#### Step 2: Rewrite `LoadConfig()` to be read-only
|
||||
|
||||
Remove directory creation and file writing. Layer: defaults → YAML (if
|
||||
exists) → env vars. Return `DefaultConfig()` on missing file instead of nil.
|
||||
Return error only on malformed YAML (file exists but can't be parsed).
|
||||
|
||||
**Files**: `internal/engine/config.go`
|
||||
|
||||
#### Step 3: Extract `InitConfig()` for file creation
|
||||
|
||||
Move the "create config directory + write defaults" logic into `InitConfig()`.
|
||||
Call it from `initializeApp()` (CLI first-run path) and `opal setup`.
|
||||
|
||||
**Files**: `internal/engine/config.go`, `cmd/root.go`, `cmd/setup.go`
|
||||
|
||||
#### Step 4: Fix error-swallowing callers
|
||||
|
||||
Two options, from least to most invasive:
|
||||
|
||||
**4a (recommended)**: Make `GetConfig()` never return nil. Since `LoadConfig()`
|
||||
now always returns a valid `*Config` (defaults on failure), `GetConfig()` also
|
||||
always returns non-nil. The `cfg, _ := GetConfig()` pattern becomes safe. The
|
||||
error return is kept for callers that want to distinguish "loaded from file" vs
|
||||
"using defaults" but nil is never returned.
|
||||
|
||||
**4b (defensive)**: Also make `BuildUrgencyCoefficients()` accept nil and return
|
||||
default coefficients. Belt-and-suspenders.
|
||||
|
||||
**Files**: `internal/engine/config.go`, `internal/engine/urgency.go`
|
||||
|
||||
#### Step 5: Enable env-var overrides via Viper
|
||||
|
||||
Add `v.SetEnvPrefix("OPAL")` and `v.AutomaticEnv()` so that any config key
|
||||
can be overridden by `OPAL_<KEY>`. Update `opal.env` in deployment docs to
|
||||
show urgency tuning examples.
|
||||
|
||||
**Files**: `internal/engine/config.go`, `docs/deployment.md`
|
||||
|
||||
#### Step 6: Eliminate `SaveConfig()` field duplication
|
||||
|
||||
Replace the manual `v.Set()` calls with Viper's `mapstructure` round-trip or
|
||||
a reflect-based helper so new fields are automatically included.
|
||||
|
||||
```go
|
||||
func SaveConfig(cfg *Config) error {
|
||||
v := viper.New()
|
||||
v.SetConfigFile(configPath)
|
||||
v.SetConfigType("yaml")
|
||||
|
||||
// Use mapstructure tags to populate viper from struct
|
||||
data, _ := mapstructure.Decode(cfg) // or manual marshal
|
||||
for k, val := range data {
|
||||
v.Set(k, val)
|
||||
}
|
||||
return v.WriteConfig()
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, marshal to YAML directly with `yaml.Marshal` and write the
|
||||
bytes, bypassing Viper for the write path entirely.
|
||||
|
||||
**Files**: `internal/engine/config.go`
|
||||
|
||||
### Deployment changes
|
||||
|
||||
After this redesign, the Ansible role needs **no changes** — `opal.env` is
|
||||
sufficient. To customize urgency coefficients on the server, add lines to
|
||||
`opal.env`:
|
||||
|
||||
```bash
|
||||
# Server-side urgency tuning (optional)
|
||||
OPAL_URGENCY_DUE_COEFFICIENT=12.0
|
||||
OPAL_URGENCY_AGE_MAX=180
|
||||
```
|
||||
|
||||
No `opal.yml` deployment needed. The server runs on defaults + env overrides.
|
||||
|
||||
---
|
||||
|
||||
## 6. Technical Decisions
|
||||
|
||||
### ADR-1: Config loading must not write to the filesystem
|
||||
|
||||
- **Context**: `LoadConfig()` creates directories and writes `opal.yml` as a
|
||||
side effect. This fails on read-only filesystems (server) and is surprising
|
||||
behavior for a "load" function.
|
||||
- **Decision**: `LoadConfig()` becomes pure read. File creation moves to
|
||||
`InitConfig()`, called explicitly during setup/first-run.
|
||||
- **Alternatives**: Deploy `opal.yml` via Ansible; make `/etc/opal` writable.
|
||||
- **Consequences**: CLI first-run code must call `InitConfig()` explicitly.
|
||||
`IsFirstRun()` check remains in `initializeApp()`.
|
||||
|
||||
### ADR-2: Missing config file returns defaults, not an error
|
||||
|
||||
- **Context**: On the server, `opal.yml` doesn't exist and doesn't need to.
|
||||
The current code treats this as a fatal error.
|
||||
- **Decision**: Missing file = use defaults silently. Malformed file = return
|
||||
error (don't clobber). `GetConfig()` never returns nil.
|
||||
- **Alternatives**: Require `opal.yml` everywhere; use separate load functions
|
||||
per mode.
|
||||
- **Consequences**: Callers that discard errors (`cfg, _ := GetConfig()`) are
|
||||
now safe. The error return still exists for callers that need to distinguish
|
||||
"file loaded" from "using defaults".
|
||||
|
||||
### ADR-3: Environment variables can override any config key
|
||||
|
||||
- **Context**: The server is configured entirely via env vars (`opal.env` +
|
||||
systemd). Currently urgency coefficients can only be set via `opal.yml`.
|
||||
- **Decision**: Enable Viper's `AutomaticEnv()` with prefix `OPAL_`. Any YAML
|
||||
key `foo_bar` can be overridden by `OPAL_FOO_BAR`.
|
||||
- **Alternatives**: Add a server-specific config file; keep config values
|
||||
hardcoded for server.
|
||||
- **Consequences**: Layered config: defaults < YAML file < env vars. Aligns
|
||||
with 12-factor app principles. Deployment can tune behavior without deploying
|
||||
additional files.
|
||||
|
||||
### ADR-4: Do not merge auth.Config into engine.Config
|
||||
|
||||
- **Context**: `auth.Config` and `engine.Config` are completely independent
|
||||
systems. Merging them would create a single unified config.
|
||||
- **Decision**: Keep them separate. `auth.Config` loads from env vars, works
|
||||
correctly, and is server-only. No reason to change it.
|
||||
- **Alternatives**: Unified config struct with sections.
|
||||
- **Consequences**: Two config types remain. This is acceptable because they
|
||||
serve different purposes, have different lifecycles, and the current
|
||||
`auth.Config` has no bugs.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,533 @@
|
||||
# Web CLI Parity — Requirements Spec
|
||||
|
||||
**Status:** Draft
|
||||
**Last updated:** 2026-02-19
|
||||
**Related:** [`cli-ux-improvements.md`](cli-ux-improvements.md) — CLI UX
|
||||
improvements being developed in parallel. Features marked with **(CLI dep)**
|
||||
depend on or benefit from CLI-side work landing first.
|
||||
|
||||
This document covers the features the CLI exposes that the web frontend does
|
||||
not. Each section maps a CLI capability to a proposed web feature, with
|
||||
acceptance criteria and priority.
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The opal CLI is the primary interface and offers rich task management: in-place
|
||||
editing, start/stop timers, detailed task info, project/tag browsing, bulk
|
||||
operations, and full sync controls. The web frontend currently only supports
|
||||
create, complete, delete, and report-based listing. Users who switch between
|
||||
CLI and web hit a wall — most tasks that go beyond "add" and "done" require
|
||||
falling back to the terminal.
|
||||
|
||||
---
|
||||
|
||||
## Tier 1 — Core Gaps (the web feels broken without these)
|
||||
|
||||
### 1.1 Task Detail View (`info` equivalent)
|
||||
|
||||
**CLI:** `opal info 2` shows every field on a task — UUID, status, description,
|
||||
urgency, priority, project, all timestamps (created, modified, started, ended,
|
||||
due, scheduled, wait, until), recurrence pattern, parent UUID, and tags.
|
||||
|
||||
**Web gap:** Tapping a task does nothing. There is no way to see a task's full
|
||||
state.
|
||||
|
||||
**User story:** As a user, I want to tap a task to see all of its fields so
|
||||
that I can understand its full context without switching to the CLI.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Given a task in the list, when I tap it, then a detail view opens showing
|
||||
every non-null field on the task
|
||||
- Given a task with a recurrence pattern, when I view its detail, then the
|
||||
recurrence interval and parent template link are visible
|
||||
- Given a task with scheduled/wait/until dates, when I view its detail, then
|
||||
those dates are displayed with labels
|
||||
- Given a task detail view, when I tap outside or press a close/back control,
|
||||
then the detail view closes and the list is restored
|
||||
|
||||
**Interaction:** Bottom sheet — slides up from the bottom of the screen.
|
||||
Natural on mobile, avoids a route change, and leaves the task list partially
|
||||
visible behind it. Light-dismiss (tap scrim to close).
|
||||
|
||||
**Priority:** MUST
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Task Editing (`modify` / `edit` equivalent)
|
||||
|
||||
**CLI:** `opal 2 modify priority:H due:friday` changes attributes inline.
|
||||
`opal 2 edit` opens `$EDITOR` with all fields in a structured format.
|
||||
|
||||
**Web gap:** There is no way to edit a task after creation. Users must delete
|
||||
and recreate to fix a typo or change a due date.
|
||||
|
||||
**User story:** As a user, I want to edit any field on an existing task so that
|
||||
I can adjust it as my plans change.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Given a task detail view, when I tap an editable field, then I can modify its
|
||||
value
|
||||
- Given I change a field and confirm, when the update succeeds, then the task
|
||||
list reflects the change
|
||||
- Given I change a field and confirm, when the update fails, then the error is
|
||||
shown and the original value is preserved
|
||||
- Editable fields: description, project, priority, due, scheduled, wait, until,
|
||||
tags, recurrence (on templates)
|
||||
- Read-only fields (displayed but not editable): UUID, created, modified, end,
|
||||
parent UUID, status
|
||||
|
||||
**Implementation notes:**
|
||||
- Uses `PUT /tasks/:uuid` — already exists in the API
|
||||
- For tags, uses `POST /tasks/:uuid/tags` and
|
||||
`DELETE /tasks/:uuid/tags/:tag` — already exist
|
||||
- Field editing could be inline (tap field to edit) or form-based (edit mode
|
||||
that makes all fields editable). Inline feels lighter for single-field tweaks.
|
||||
|
||||
**Priority:** MUST
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Start / Stop Timer (`start` / `stop` equivalent)
|
||||
|
||||
**CLI:** `opal 1 start` marks a task as actively being worked on (sets `start`
|
||||
timestamp). `opal 1 stop` clears it. The `active` report lists started tasks.
|
||||
|
||||
**Web gap:** The API endpoints (`POST /tasks/:uuid/start`,
|
||||
`POST /tasks/:uuid/stop`) are wired in `endpoints.js` but there are no UI
|
||||
controls. The `active` report works in the report picker but users can't
|
||||
actually start tasks.
|
||||
|
||||
**User story:** As a user, I want to mark a task as "in progress" so that I can
|
||||
track what I'm actively working on and see it in the Active report.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Given a pending task, when I tap a start control, then the task's start time
|
||||
is set and it appears in the Active report
|
||||
- Given a started task, when I tap a stop control, then the start time is
|
||||
cleared
|
||||
- Given a started task, when I view it in the list, then there is a visual
|
||||
indicator that it is active (distinguishable from non-started tasks)
|
||||
- The start/stop action should be accessible from both the task row and the
|
||||
detail view
|
||||
|
||||
**Interaction:** Swipe left to toggle start/stop — mirrors swipe right for
|
||||
complete. Also accessible from the detail view (1.1). No visible button on the
|
||||
task row.
|
||||
|
||||
**Priority:** MUST
|
||||
|
||||
---
|
||||
|
||||
### 1.4 Uncomplete / Revert (`modify status:pending` equivalent)
|
||||
|
||||
**(CLI dep)** — CLI is getting a dedicated `uncomplete` command and a generic
|
||||
`undo` (see [IMP-1](cli-ux-improvements.md#imp-1-undo--uncomplete)). The web
|
||||
feature should align with however the CLI exposes this so the mental model is
|
||||
consistent.
|
||||
|
||||
**CLI:** `opal <id> modify status:pending` reverts a completed task. IMP-1
|
||||
proposes `opal <id> uncomplete` and `opal undo` as dedicated commands.
|
||||
|
||||
**Web gap:** Once a task is completed it disappears from the pending list. The
|
||||
only recovery is the `completed` report + CLI. This is also called out in
|
||||
`BUGS.md` as a missing feature.
|
||||
|
||||
**User story:** As a user, I want to undo an accidental completion so that the
|
||||
task reappears in my pending list.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Given a completed task (visible in the Completed report), when I tap an
|
||||
uncomplete action, then the task's status reverts to pending and it
|
||||
reappears in the pending list
|
||||
- Given I just completed a task, when I tap undo within a brief window (e.g.
|
||||
toast with undo button), then the task is reverted without navigating to the
|
||||
Completed report
|
||||
|
||||
**Implementation notes:**
|
||||
- Uses `PUT /tasks/:uuid` with `{"status": "pending"}` — works today
|
||||
- The undo toast after completion is a UX nicety but not strictly required for
|
||||
parity
|
||||
- If CLI IMP-1 lands an undo log table, the web could use the same mechanism
|
||||
for a more robust undo (revert any action, not just completion)
|
||||
|
||||
**Priority:** MUST
|
||||
|
||||
---
|
||||
|
||||
### 1.5 Delete Confirmation
|
||||
|
||||
**(CLI dep)** — CLI is improving confirmation prompts to show matched tasks
|
||||
before confirming (see [IMP-3](cli-ux-improvements.md#imp-3-show-matched-tasks-in-confirmations)).
|
||||
The web confirmation should follow the same pattern.
|
||||
|
||||
**CLI:** `opal delete` always prompts `Proceed? (y/N)` before deleting. IMP-3
|
||||
proposes showing the affected task(s) in the confirmation.
|
||||
|
||||
**Web gap:** Delete is instant with no confirmation. There is no undo.
|
||||
|
||||
**User story:** As a user, I want a confirmation before deleting a task so that
|
||||
I don't lose work by accident.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Given I trigger a delete action, when the confirmation appears, then I must
|
||||
explicitly confirm before the delete proceeds
|
||||
- Given I dismiss the confirmation, then the task is not deleted
|
||||
- The confirmation should show the task description (matching CLI IMP-3's
|
||||
approach of showing what will be affected)
|
||||
|
||||
**Priority:** MUST
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 — CLI Parity (brings the web up to feature-complete)
|
||||
|
||||
### ~~2.1 Projects View~~ — DEFERRED
|
||||
|
||||
### ~~2.2 Tags View~~ — DEFERRED
|
||||
|
||||
Projects and tags browsing is deferred. The existing filter syntax
|
||||
(`project:foo`, `+tag`) covers this adequately for now. When filter
|
||||
autocomplete is added later, discoverability will improve without needing
|
||||
dedicated views.
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Display All Date Fields on Task Items
|
||||
|
||||
**CLI:** `opal info` and the table display show scheduled, wait, until, and
|
||||
start dates when present. The `waiting` report makes sense because you can see
|
||||
*when* the wait expires.
|
||||
|
||||
**Web gap:** Only the due date is shown on task rows. Scheduled, wait, until,
|
||||
and start dates are invisible. Users can set them via CLI syntax in the input
|
||||
bar but can't see them afterward.
|
||||
|
||||
**User story:** As a user, I want to see all relevant dates on a task so that I
|
||||
understand when it's scheduled, when it becomes visible, and when it expires.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Given a task with a `scheduled` date, then a "Scheduled: <date>" indicator
|
||||
appears on the task row
|
||||
- Given a task with a `wait` date, then a "Wait: <date>" indicator appears
|
||||
- Given a task with an `until` date, then an "Until: <date>" indicator appears
|
||||
- Given a task with a `start` time set, then an "Active since <time>" or
|
||||
similar indicator appears
|
||||
- Date fields that are null are not shown (no empty labels)
|
||||
- The detail view (1.1) shows all dates; the task row shows them in a
|
||||
compact/abbreviated form
|
||||
|
||||
**Priority:** SHOULD
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Recurrence Display and Management
|
||||
|
||||
**(CLI dep)** — CLI is improving recurring task feedback on completion (see
|
||||
[IMP-7](cli-ux-improvements.md#imp-7-recurring-task-feedback)). The web should
|
||||
show equivalent feedback when completing a recurring task.
|
||||
|
||||
**CLI:** `opal add "standup" due:mon recur:1w` creates a recurring template +
|
||||
first instance. `opal template` and `opal recurring` list them. Completing an
|
||||
instance spawns the next one. `opal edit <id>` on an instance can update the
|
||||
template's recurrence pattern.
|
||||
|
||||
**Web gap:** Recurring tasks can be created via CLI syntax in the input bar, and
|
||||
the `recurring`/`template` reports work, but:
|
||||
- There is no visual indicator that a task is a recurring instance
|
||||
- There is no way to see or navigate to the parent template
|
||||
- There is no way to edit the recurrence pattern
|
||||
- Completing a recurring task gives no feedback about the next instance
|
||||
|
||||
**User story:** As a user, I want to see which tasks are recurring, view their
|
||||
schedule, and manage the recurrence pattern so that I can adjust repeating
|
||||
commitments.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Given a recurring instance, when I view it in the list, then a recurrence
|
||||
icon or badge is visible
|
||||
- Given a recurring instance, when I open its detail view, then the recurrence
|
||||
interval and parent template are shown
|
||||
- Given a recurring template, when I open its detail view, then I can edit the
|
||||
recurrence interval
|
||||
- Given I complete a recurring instance, then the next instance is
|
||||
automatically created (this already works server-side; just verify the list
|
||||
refreshes to show it)
|
||||
- Given I complete a recurring instance, then a toast or inline message shows
|
||||
the next instance's due date (mirrors CLI IMP-7)
|
||||
|
||||
**Priority:** SHOULD
|
||||
|
||||
---
|
||||
|
||||
### ~~2.5 Sync Controls~~ — DEFERRED
|
||||
|
||||
Sync is deprioritized. The web has no client-side task parsing, so offline
|
||||
functionality is non-functioning. The web fetches directly from the server on
|
||||
every action — sync is only relevant if we move to an offline-first model,
|
||||
which is not planned.
|
||||
|
||||
---
|
||||
|
||||
### 2.6 Bulk Operations (`opal 1 2 3 modify ...` / `opal +tag done`)
|
||||
|
||||
**CLI:** Filters and numeric IDs can target multiple tasks.
|
||||
`opal +urgent done` completes all tasks tagged `urgent`.
|
||||
`opal 1 2 3 modify project:sprint-2` moves three tasks at once.
|
||||
The CLI prompts for confirmation when multiple tasks are affected.
|
||||
|
||||
**Web gap:** All actions are single-task only. No multi-select, no batch
|
||||
complete, no batch modify.
|
||||
|
||||
**User story:** As a user, I want to select multiple tasks and perform an
|
||||
action on all of them so that I can manage tasks efficiently.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Given I enter selection mode (e.g., long-press a task), then I can tap
|
||||
additional tasks to add them to the selection
|
||||
- Given I have tasks selected, then I can batch-complete, batch-delete, or
|
||||
batch-modify (at minimum: change project, add/remove tag, change priority)
|
||||
- Given a batch action targets 2+ tasks, then a confirmation prompt appears
|
||||
before executing
|
||||
- Given I tap outside the selection or press a cancel control, then selection
|
||||
mode is exited
|
||||
|
||||
**Priority:** SHOULD
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 — Power User & Polish
|
||||
|
||||
### 3.1 Keyboard Shortcuts
|
||||
|
||||
**CLI:** The CLI is entirely keyboard-driven by nature.
|
||||
|
||||
**Web gap:** No keyboard shortcuts exist. Desktop users must use the mouse for
|
||||
everything.
|
||||
|
||||
**User story:** As a desktop user, I want keyboard shortcuts so that I can
|
||||
manage tasks without reaching for the mouse.
|
||||
|
||||
**Suggested bindings:**
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `n` | Focus the input bar (new task) |
|
||||
| `j` / `k` | Move selection down / up in task list |
|
||||
| `x` | Complete selected task |
|
||||
| `e` | Open detail/edit for selected task |
|
||||
| `d` | Delete selected task (with confirmation) |
|
||||
| `s` | Start/stop selected task |
|
||||
| `Escape` | Close detail view / deselect / blur input |
|
||||
| `/` | Open filter modal |
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Given I press `n` when not focused on an input, then the input bar is focused
|
||||
- Given I press `j`/`k`, then the visual selection moves through the task list
|
||||
- Given I press `x` with a task selected, then that task is completed
|
||||
- Shortcuts are disabled when an input or textarea is focused (except Escape)
|
||||
|
||||
**Priority:** COULD
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Description Search
|
||||
|
||||
**CLI:** Filtering by tags, project, priority, and status covers structured
|
||||
attributes, but there's no full-text description search in the CLI either.
|
||||
|
||||
**Web opportunity:** The web could add a search capability that the CLI lacks.
|
||||
|
||||
**User story:** As a user, I want to search tasks by description text so that I
|
||||
can find a specific task without remembering its tags or project.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Given I type in a search field, then the task list filters to tasks whose
|
||||
description contains the search text (case-insensitive)
|
||||
- Search can be client-side (filter the loaded report) or server-side (new
|
||||
query param)
|
||||
|
||||
**Interaction:** Extends the existing filter syntax. Bare text in the filter
|
||||
input is interpreted as a description search. The filter modal becomes
|
||||
filter/search — no separate search bar.
|
||||
|
||||
**Priority:** COULD
|
||||
|
||||
---
|
||||
|
||||
### ~~3.3 Display IDs~~ — DROPPED
|
||||
|
||||
Not needed. The web uses touch/tap interaction, not keyboard-driven ID
|
||||
selection. Display IDs are a CLI affordance that doesn't translate to the web
|
||||
interaction model.
|
||||
|
||||
---
|
||||
|
||||
### ~~3.4 API Key Management~~ — ON HOLD
|
||||
|
||||
Blocked on user management investigation. Open questions: Are API keys
|
||||
user-scoped? Should we move to per-user databases? This feature should wait
|
||||
until the auth/user model is settled.
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Task Annotations (`annotate` equivalent)
|
||||
|
||||
**(CLI dep)** — The CLI is adding `opal <id> annotate "<text>"` (see
|
||||
[IMP-11](cli-ux-improvements.md#imp-11-task-annotations)). This requires a
|
||||
backend schema change (annotations storage). Once that lands, the web should
|
||||
surface annotations.
|
||||
|
||||
**CLI (proposed):** `opal 3 annotate "Traced to token expiry"` adds a
|
||||
timestamped note. `opal 3 info` shows annotations. IMP-11 notes potential
|
||||
integration with jade-depo (the gems note management system).
|
||||
|
||||
**Web gap:** No concept of annotations exists. Tasks have only a description
|
||||
field for text.
|
||||
|
||||
**User story:** As a user, I want to add and view notes on a task so that I can
|
||||
record progress and context over time.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Given a task detail view (1.1), then any existing annotations are shown as a
|
||||
timestamped list below the task fields
|
||||
- Given a task detail view, when I tap an "Add note" control, then I can enter
|
||||
annotation text and it is saved with a timestamp
|
||||
- Annotations are ordered newest-first or oldest-first (decide which)
|
||||
- Annotations are read-only after creation (no inline editing — `denotate`
|
||||
removes the latest)
|
||||
|
||||
**Decisions:**
|
||||
- Annotations are visible only in the detail view (1.1), not on the task row.
|
||||
- jade-depo integration is not relevant to the web UI at this time.
|
||||
|
||||
**Priority:** COULD (blocked on CLI IMP-11 landing the backend schema)
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Task History (`log` equivalent)
|
||||
|
||||
**(CLI dep)** — The CLI is adding `opal <id> log` (see
|
||||
[IMP-12](cli-ux-improvements.md#imp-12-task-history)). This reads the existing
|
||||
`change_log` table which already exists for sync.
|
||||
|
||||
**CLI (proposed):** `opal 3 log` shows timestamped change history (created,
|
||||
modified, completed, etc.).
|
||||
|
||||
**Web gap:** No way to see what happened to a task over time.
|
||||
|
||||
**User story:** As a user, I want to see a task's change history so that I can
|
||||
understand when and how it was modified.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Given a task detail view (1.1), then a "History" section or tab shows the
|
||||
change log for that task
|
||||
- Each entry shows: timestamp, change type, and what changed (e.g.,
|
||||
"priority: default -> high")
|
||||
- History is read-only
|
||||
|
||||
**Implementation notes:**
|
||||
- The `change_log` table already exists for sync. This likely needs a new API
|
||||
endpoint (`GET /tasks/:uuid/log` or similar) to expose it.
|
||||
- Alternatively, the change log could be included in the task detail response.
|
||||
|
||||
**Priority:** COULD (blocked on CLI IMP-12 / API endpoint)
|
||||
|
||||
---
|
||||
|
||||
### 3.7 Configurable Default Report
|
||||
|
||||
**CLI:** `default_report` in `opal.yml` controls what `opal` with no arguments
|
||||
shows (default: `list`). `default_filter` controls the base filter.
|
||||
|
||||
**Web gap:** The web always starts on the `list` report. There is no user
|
||||
preference for the landing view.
|
||||
|
||||
**User story:** As a user, I want to choose which report I see when I open the
|
||||
app so that my most-used view loads first.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Given I choose a default report in settings, then the app opens to that
|
||||
report on next launch
|
||||
- The preference is stored in localStorage (no backend change needed)
|
||||
|
||||
**Priority:** COULD
|
||||
|
||||
---
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
| # | Requirement | Priority |
|
||||
|---|-------------|----------|
|
||||
| NFR-1 | All new features must work on Android Chrome and desktop Chrome/Firefox (per architecture doc) | MUST |
|
||||
| NFR-2 | Task detail and edit interactions must be touch-friendly (44px minimum tap targets) | MUST |
|
||||
| NFR-3 | Editing a task must be optimistic — UI updates immediately, rolls back on failure | SHOULD |
|
||||
| NFR-4 | Keyboard shortcuts must not conflict with browser defaults (Ctrl+T, etc.) | MUST |
|
||||
| NFR-5 | New features must work with all three themes (Obsidian, Paper, Midnight) | MUST |
|
||||
| NFR-6 | No new API endpoints are required for Tier 1 — all endpoints already exist | N/A |
|
||||
|
||||
---
|
||||
|
||||
## Constraints & Assumptions
|
||||
|
||||
**Constraints:**
|
||||
- Single-screen architecture per the existing design doc — no new routes for
|
||||
projects/tags (use sheets/modals/filters instead)
|
||||
- Server-side parsing and sorting — the frontend stays a thin shell
|
||||
- SvelteKit + Vite stack, Svelte 5 runes
|
||||
|
||||
**Assumptions:**
|
||||
- The `PUT /tasks/:uuid` endpoint accepts partial updates (only fields present
|
||||
in the request body are changed)
|
||||
- The working set display IDs are not currently exposed via the API; Tier 3.3
|
||||
would require an API change
|
||||
- Sync transport (pull/push) works correctly; the gap is only in applying
|
||||
pulled changes to the store
|
||||
|
||||
---
|
||||
|
||||
## Cross-Reference: CLI UX Improvements
|
||||
|
||||
The following items from [`cli-ux-improvements.md`](cli-ux-improvements.md)
|
||||
directly affect web features in this spec:
|
||||
|
||||
| CLI IMP | CLI Feature | Web Impact |
|
||||
|---------|-------------|------------|
|
||||
| IMP-1 | Undo / uncomplete | Enables 1.4 (uncomplete). If undo log is stored in DB, web can use same mechanism. |
|
||||
| IMP-2 | Better `add` feedback | Web already shows the created task in-list. No direct web change, but if the API response for `POST /tasks/parse` is enriched (display ID, parsed modifiers), the web could show a richer confirmation toast. |
|
||||
| IMP-3 | Show matched tasks in confirmations | Pattern for 1.5 (delete confirmation) and 2.6 (bulk ops). |
|
||||
| IMP-5 | Handle colons in descriptions | Affects web input bar — same parsing runs server-side via `/tasks/parse`. No web change needed, but web benefits automatically. |
|
||||
| IMP-7 | Recurring task feedback | Directly feeds 2.4 (recurrence display). |
|
||||
| IMP-9 | Relative dates in CLI | Web already does this. No change needed. |
|
||||
| IMP-11 | Task annotations | Enables 3.5 (annotations on web). Blocked on backend schema. |
|
||||
| IMP-12 | Task history | Enables 3.6 (history on web). Blocked on API endpoint. |
|
||||
|
||||
Items from the CLI spec with **no web impact**: IMP-4 (delete ID resolution
|
||||
bug), IMP-6 (consistent error codes), IMP-8 (shell completions), IMP-10
|
||||
(dry-run flag), IMP-13 (version command).
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Collaboration / multi-user sharing
|
||||
- Notifications, reminders, or push alerts
|
||||
- Custom fields or metadata
|
||||
- Drag-to-reorder (ordering is report/urgency-driven)
|
||||
- Offline-first / IndexedDB task storage (tasks are server-side only)
|
||||
- iOS Safari support
|
||||
|
||||
---
|
||||
|
||||
## Open Questions Summary
|
||||
|
||||
| # | Question | Blocks | Status |
|
||||
|---|----------|--------|--------|
|
||||
| ~~Q1~~ | ~~Detail view format~~ | ~~1.1~~ | **Resolved** — bottom sheet |
|
||||
| ~~Q2~~ | ~~Start/stop control placement~~ | ~~1.3~~ | **Resolved** — swipe left |
|
||||
| ~~Q3~~ | ~~Projects/tags view format~~ | ~~2.1, 2.2~~ | **Resolved** — deferred, use filters |
|
||||
| ~~Q4~~ | ~~Description search format~~ | ~~3.2~~ | **Resolved** — extend filter syntax |
|
||||
| ~~Q5~~ | ~~Display IDs~~ | ~~3.3~~ | **Resolved** — dropped |
|
||||
| ~~Q6~~ | ~~Annotation visibility~~ | ~~3.5~~ | **Resolved** — detail view only |
|
||||
| ~~Q7~~ | ~~jade-depo integration~~ | ~~3.5~~ | **Resolved** — not for web |
|
||||
@@ -0,0 +1 @@
|
||||
0.2.0
|
||||
+32
-14
@@ -63,31 +63,42 @@ func addTask(args []string) error {
|
||||
return fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Created task %s\n", task.UUID)
|
||||
if len(task.Tags) > 0 {
|
||||
fmt.Printf("Tags: %s\n", strings.Join(task.Tags, ", "))
|
||||
}
|
||||
engine.RecordUndo("add", task.UUID)
|
||||
|
||||
displayID, err := engine.AppendTask(task)
|
||||
if err != nil {
|
||||
// Non-fatal: task was created, just can't assign display ID
|
||||
fmt.Printf("Created task %s\n", task.UUID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseAddArgs extracts description and modifiers from args
|
||||
// Description = all non-filter/modifier words joined with spaces
|
||||
// Filters/Modifiers = args with +, -, or containing :
|
||||
fmt.Print(engine.FormatAddFeedback(task, displayID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseAddArgs extracts description and modifiers from args.
|
||||
// Tags (+tag, -tag) are always modifiers. For key:value tokens, only
|
||||
// recognized attribute keys (engine.ValidAttributeKeys) are treated as
|
||||
// modifiers — everything else becomes part of the description.
|
||||
func parseAddArgs(args []string) (string, []string, error) {
|
||||
var descParts []string
|
||||
var modifiers []string
|
||||
|
||||
for _, arg := range args {
|
||||
isFilterOrModifier := strings.HasPrefix(arg, "+") ||
|
||||
strings.HasPrefix(arg, "-") ||
|
||||
strings.Contains(arg, ":")
|
||||
|
||||
if isFilterOrModifier {
|
||||
if strings.HasPrefix(arg, "+") || strings.HasPrefix(arg, "-") {
|
||||
modifiers = append(modifiers, arg)
|
||||
} else {
|
||||
descParts = append(descParts, arg)
|
||||
continue
|
||||
}
|
||||
|
||||
if idx := strings.Index(arg, ":"); idx > 0 {
|
||||
key := arg[:idx]
|
||||
if engine.ValidAttributeKeys[key] {
|
||||
modifiers = append(modifiers, arg)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
descParts = append(descParts, arg)
|
||||
}
|
||||
|
||||
if len(descParts) == 0 {
|
||||
@@ -104,8 +115,15 @@ func addRecurringTask(description string, mod *engine.Modifier) error {
|
||||
return err
|
||||
}
|
||||
|
||||
engine.RecordUndo("add", instance.UUID)
|
||||
|
||||
displayID, err := engine.AppendTask(instance)
|
||||
if err != nil {
|
||||
fmt.Printf("Created recurring task %s\n", *instance.ParentUUID)
|
||||
fmt.Printf("First instance: %s\n", instance.UUID)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Print(engine.FormatRecurringAddFeedback(instance, displayID))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.jnss.me/joakim/opal/internal/engine"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var annotateCmd = &cobra.Command{
|
||||
Use: "annotate [filter...] [text]",
|
||||
Short: "Add an annotation to a task",
|
||||
Long: `Add a timestamped annotation to a task.
|
||||
|
||||
Examples:
|
||||
opal 2 annotate Traced to token expiry in middleware
|
||||
opal annotate +bug Found root cause in auth handler`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
parsed := getParsedArgs(cmd)
|
||||
if err := annotateTask(parsed.Filters, parsed.Modifiers); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func annotateTask(filterArgs, textArgs []string) error {
|
||||
if len(filterArgs) == 0 {
|
||||
return fmt.Errorf("no task specified")
|
||||
}
|
||||
|
||||
if len(textArgs) == 0 {
|
||||
return fmt.Errorf("annotation text is required")
|
||||
}
|
||||
|
||||
text := strings.Join(textArgs, " ")
|
||||
|
||||
filter, err := engine.ParseFilter(filterArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse filter: %w", err)
|
||||
}
|
||||
|
||||
ws, err := engine.LoadWorkingSet()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load working set: %w", err)
|
||||
}
|
||||
|
||||
var task *engine.Task
|
||||
|
||||
if len(filter.IDs) > 0 {
|
||||
if len(filter.IDs) != 1 {
|
||||
return fmt.Errorf("annotate requires exactly one task")
|
||||
}
|
||||
task, err = ws.GetTaskByDisplayID(filter.IDs[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
tasks, err := engine.GetTasks(filter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get tasks: %w", err)
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return fmt.Errorf("no tasks matched filter")
|
||||
}
|
||||
if len(tasks) > 1 {
|
||||
return fmt.Errorf("annotate requires exactly one task (filter matched %d)", len(tasks))
|
||||
}
|
||||
task = tasks[0]
|
||||
}
|
||||
|
||||
if err := task.Annotate(text); err != nil {
|
||||
return fmt.Errorf("failed to annotate task: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Annotated task %s\n", engine.FormatTaskSummary(task, ws))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.jnss.me/joakim/opal/internal/engine"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var olderFlag string
|
||||
|
||||
var cleanCmd = &cobra.Command{
|
||||
Use: "clean",
|
||||
Short: "Purge soft-deleted tasks from the database",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := cleanTasks(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
cleanCmd.Flags().StringVar(&olderFlag, "older", "", "Only purge tasks deleted longer than this duration ago (e.g. 30d, 1w)")
|
||||
}
|
||||
|
||||
func cleanTasks() error {
|
||||
var olderThan *time.Duration
|
||||
if olderFlag != "" {
|
||||
d, err := engine.ParseRecurrencePattern(olderFlag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid duration %q: %w", olderFlag, err)
|
||||
}
|
||||
olderThan = &d
|
||||
}
|
||||
|
||||
tasks, err := engine.GetDeletedTasks(olderThan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
fmt.Println("No deleted tasks to purge.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if dryRunFlag {
|
||||
fmt.Printf("Would permanently remove %d deleted task(s).\n", len(tasks))
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(tasks) > 1 {
|
||||
fmt.Printf("Permanently remove %d deleted task(s)? This cannot be undone. (y/N): ", len(tasks))
|
||||
var confirm string
|
||||
fmt.Scanln(&confirm)
|
||||
if confirm != "y" && confirm != "Y" {
|
||||
fmt.Println("Cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
if err := task.Delete(true); err != nil {
|
||||
return fmt.Errorf("failed to purge task %s: %w", task.UUID, err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Purged %d deleted task(s).\n", len(tasks))
|
||||
|
||||
if err := engine.CleanupChangeLog(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to clean up change log: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var completionCmd = &cobra.Command{
|
||||
Use: "completion [bash|zsh|fish|powershell]",
|
||||
Short: "Generate shell completions",
|
||||
Long: `Generate shell completion scripts for opal.
|
||||
|
||||
To load completions:
|
||||
|
||||
Bash:
|
||||
$ source <(opal completion bash)
|
||||
# To load on startup, add to ~/.bashrc:
|
||||
$ echo 'source <(opal completion bash)' >> ~/.bashrc
|
||||
|
||||
Zsh:
|
||||
# Add to ~/.zshrc:
|
||||
eval "$(opal completion zsh)"
|
||||
|
||||
Fish:
|
||||
$ opal completion fish | source
|
||||
# To load on startup:
|
||||
$ opal completion fish > ~/.config/fish/completions/opal.fish`,
|
||||
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
||||
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
switch args[0] {
|
||||
case "bash":
|
||||
rootCmd.GenBashCompletionV2(os.Stdout, true)
|
||||
case "zsh":
|
||||
rootCmd.GenZshCompletion(os.Stdout)
|
||||
case "fish":
|
||||
rootCmd.GenFishCompletion(os.Stdout, true)
|
||||
case "powershell":
|
||||
rootCmd.GenPowerShellCompletionWithDesc(os.Stdout)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unknown shell: %s\n", args[0])
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.jnss.me/joakim/opal/internal/engine"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// taskFilterCompletion provides dynamic completions for task filter arguments.
|
||||
// Suggests +tag, project:name, and attribute value completions from the database.
|
||||
func taskFilterCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
// If typing a key:value, complete the value part
|
||||
if idx := strings.IndexByte(toComplete, ':'); idx >= 0 {
|
||||
key := toComplete[:idx]
|
||||
if engine.ValidAttributeKeys[key] {
|
||||
return attributeValueCompletions(key, toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
}
|
||||
|
||||
// If toComplete is a prefix of an attribute key, return only key:
|
||||
// completions with NoSpace so the cursor stays after the colon.
|
||||
if toComplete != "" && !strings.HasPrefix(toComplete, "+") && !strings.HasPrefix(toComplete, "-") {
|
||||
var keyCompletions []string
|
||||
for key := range engine.ValidAttributeKeys {
|
||||
if strings.HasPrefix(key, toComplete) {
|
||||
keyCompletions = append(keyCompletions, fmt.Sprintf("%s:", key))
|
||||
}
|
||||
}
|
||||
if len(keyCompletions) > 0 {
|
||||
return keyCompletions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
}
|
||||
|
||||
var completions []string
|
||||
|
||||
tags, err := engine.GetAllTags()
|
||||
if err == nil {
|
||||
for _, tag := range tags {
|
||||
completions = append(completions, fmt.Sprintf("+%s", tag))
|
||||
}
|
||||
}
|
||||
|
||||
projects, err := engine.GetAllProjects()
|
||||
if err == nil {
|
||||
for _, proj := range projects {
|
||||
completions = append(completions, fmt.Sprintf("project:%s", proj))
|
||||
}
|
||||
}
|
||||
|
||||
// Add known attribute keys
|
||||
for key := range engine.ValidAttributeKeys {
|
||||
completions = append(completions, fmt.Sprintf("%s:", key))
|
||||
}
|
||||
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
|
||||
// attributeValueCompletions returns key:value completions for a known attribute key.
|
||||
// Cobra filters by prefix automatically, so we return all values prefixed with "key:".
|
||||
func attributeValueCompletions(key, toComplete string) []string {
|
||||
var values []string
|
||||
|
||||
switch key {
|
||||
case "status":
|
||||
values = []string{"pending", "completed", "deleted", "recurring"}
|
||||
case "priority":
|
||||
values = []string{"H", "M", "L"}
|
||||
case "project":
|
||||
projects, err := engine.GetAllProjects()
|
||||
if err == nil {
|
||||
values = projects
|
||||
}
|
||||
case "due", "wait", "scheduled", "until":
|
||||
values = []string{
|
||||
"today", "tomorrow", "yesterday", "now",
|
||||
"eod", "sow", "eow", "som", "eom",
|
||||
"mon", "tue", "wed", "thu", "fri", "sat", "sun",
|
||||
}
|
||||
case "recur":
|
||||
values = []string{"daily", "weekly", "monthly", "yearly", "1d", "1w", "2w", "1m", "1y"}
|
||||
}
|
||||
|
||||
completions := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
completions = append(completions, fmt.Sprintf("%s:%s", key, v))
|
||||
}
|
||||
return completions
|
||||
}
|
||||
|
||||
// rootValidArgsFunction provides completions for root-level arguments,
|
||||
// enabling flexible syntax like "opal 1 de<TAB>" to complete to "delete".
|
||||
func rootValidArgsFunction(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
// Delegate to taskFilterCompletion first — if toComplete is a partial
|
||||
// attribute key, it returns early with NoSpace and we should honour that.
|
||||
filterCompletions, directive := taskFilterCompletion(cmd, args, toComplete)
|
||||
|
||||
var completions []string
|
||||
|
||||
// Suggest command names
|
||||
for _, name := range commandNames {
|
||||
completions = append(completions, name)
|
||||
}
|
||||
|
||||
// Suggest report names
|
||||
for _, name := range reportNames {
|
||||
completions = append(completions, name)
|
||||
}
|
||||
|
||||
completions = append(completions, filterCompletions...)
|
||||
|
||||
return completions, directive
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Root command completions for flexible syntax (e.g., "opal 1 de<TAB>")
|
||||
rootCmd.ValidArgsFunction = rootValidArgsFunction
|
||||
|
||||
// Register dynamic completions for commands that accept filters
|
||||
addCmd.ValidArgsFunction = taskFilterCompletion
|
||||
doneCmd.ValidArgsFunction = taskFilterCompletion
|
||||
deleteCmd.ValidArgsFunction = taskFilterCompletion
|
||||
modifyCmd.ValidArgsFunction = taskFilterCompletion
|
||||
startCmd.ValidArgsFunction = taskFilterCompletion
|
||||
stopCmd.ValidArgsFunction = taskFilterCompletion
|
||||
editCmd.ValidArgsFunction = taskFilterCompletion
|
||||
infoCmd.ValidArgsFunction = taskFilterCompletion
|
||||
annotateCmd.ValidArgsFunction = taskFilterCompletion
|
||||
denotateCmd.ValidArgsFunction = taskFilterCompletion
|
||||
logCmd.ValidArgsFunction = taskFilterCompletion
|
||||
}
|
||||
+60
-7
@@ -8,45 +8,98 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var hardDeleteFlag bool
|
||||
|
||||
var deleteCmd = &cobra.Command{
|
||||
Use: "delete [filter...]",
|
||||
Short: "Delete tasks",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
parsed := getParsedArgs(cmd)
|
||||
if err := deleteTasks(parsed.Filters); err != nil {
|
||||
if err := deleteTasks(parsed.Filters, hardDeleteFlag); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func deleteTasks(args []string) error {
|
||||
func init() {
|
||||
deleteCmd.Flags().BoolVar(&hardDeleteFlag, "hard", false, "Permanently remove task from database")
|
||||
}
|
||||
|
||||
func deleteTasks(args []string, hard bool) error {
|
||||
filter, err := engine.ParseFilter(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tasks, err := engine.GetTasks(filter)
|
||||
// Load working set to resolve display IDs (matches done/modify pattern)
|
||||
ws, err := engine.LoadWorkingSet()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load working set: %w", err)
|
||||
}
|
||||
|
||||
var tasks []*engine.Task
|
||||
|
||||
if len(filter.IDs) > 0 {
|
||||
for _, id := range filter.IDs {
|
||||
task, err := ws.GetTaskByDisplayID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
} else {
|
||||
tasks, err = engine.GetTasks(filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
fmt.Println("No tasks matched.")
|
||||
return fmt.Errorf("no tasks matched filter")
|
||||
}
|
||||
|
||||
action := "delete"
|
||||
if hard {
|
||||
action = "permanently delete"
|
||||
}
|
||||
|
||||
if dryRunFlag {
|
||||
fmt.Print(engine.FormatTaskConfirmList(action, tasks, ws))
|
||||
fmt.Println("Dry run — no changes made.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Delete %d task(s)? (y/N): ", len(tasks))
|
||||
if len(tasks) > 1 || hard {
|
||||
fmt.Print(engine.FormatTaskConfirmList(action, tasks, ws))
|
||||
if hard {
|
||||
fmt.Printf("This cannot be undone. Proceed? (y/N): ")
|
||||
} else {
|
||||
fmt.Printf("Proceed? (y/N): ")
|
||||
}
|
||||
var confirm string
|
||||
fmt.Scanln(&confirm)
|
||||
if confirm != "y" && confirm != "Y" {
|
||||
fmt.Println("Cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
task.Delete(false) // Soft delete
|
||||
task.Delete(hard)
|
||||
if !hard {
|
||||
engine.RecordUndo("delete", task.UUID)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Deleted %d task(s).\n", len(tasks))
|
||||
verb := "Deleted"
|
||||
if hard {
|
||||
verb = "Permanently deleted"
|
||||
}
|
||||
if len(tasks) == 1 {
|
||||
fmt.Printf("%s task %s\n", verb, engine.FormatTaskSummary(tasks[0], ws))
|
||||
} else {
|
||||
fmt.Printf("%s %d task(s).\n", verb, len(tasks))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.jnss.me/joakim/opal/internal/engine"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var denotateCmd = &cobra.Command{
|
||||
Use: "denotate [filter...]",
|
||||
Short: "Remove the most recent annotation from a task",
|
||||
Long: `Remove the most recent annotation from a task.
|
||||
|
||||
Examples:
|
||||
opal 2 denotate
|
||||
opal denotate +bug`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
parsed := getParsedArgs(cmd)
|
||||
if err := denotateTask(parsed.Filters); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func denotateTask(filterArgs []string) error {
|
||||
if len(filterArgs) == 0 {
|
||||
return fmt.Errorf("no task specified")
|
||||
}
|
||||
|
||||
filter, err := engine.ParseFilter(filterArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse filter: %w", err)
|
||||
}
|
||||
|
||||
ws, err := engine.LoadWorkingSet()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load working set: %w", err)
|
||||
}
|
||||
|
||||
var task *engine.Task
|
||||
|
||||
if len(filter.IDs) > 0 {
|
||||
if len(filter.IDs) != 1 {
|
||||
return fmt.Errorf("denotate requires exactly one task")
|
||||
}
|
||||
task, err = ws.GetTaskByDisplayID(filter.IDs[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
tasks, err := engine.GetTasks(filter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get tasks: %w", err)
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return fmt.Errorf("no tasks matched filter")
|
||||
}
|
||||
if len(tasks) > 1 {
|
||||
return fmt.Errorf("denotate requires exactly one task (filter matched %d)", len(tasks))
|
||||
}
|
||||
task = tasks[0]
|
||||
}
|
||||
|
||||
removed, err := task.Denotate()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Removed annotation: %s\n", removed.Text)
|
||||
return nil
|
||||
}
|
||||
+14
-4
@@ -63,13 +63,18 @@ func completeTasks(args []string) error {
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
fmt.Println("No tasks matched.")
|
||||
return fmt.Errorf("no tasks matched filter")
|
||||
}
|
||||
|
||||
if dryRunFlag {
|
||||
fmt.Print(engine.FormatTaskConfirmList("complete", tasks, ws))
|
||||
fmt.Println("Dry run — no changes made.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Confirm if multiple tasks
|
||||
if len(tasks) > 1 {
|
||||
fmt.Printf("About to complete %d tasks. Proceed? (y/N): ", len(tasks))
|
||||
fmt.Print(engine.FormatTaskConfirmList("complete", tasks, ws))
|
||||
fmt.Printf("Proceed? (y/N): ")
|
||||
var confirm string
|
||||
fmt.Scanln(&confirm)
|
||||
if confirm != "y" && confirm != "Y" {
|
||||
@@ -81,14 +86,19 @@ func completeTasks(args []string) error {
|
||||
// Complete tasks
|
||||
completed := 0
|
||||
for _, task := range tasks {
|
||||
if err := task.Complete(); err != nil {
|
||||
if _, err := task.Complete(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to complete task %s: %v\n", task.UUID, err)
|
||||
} else {
|
||||
engine.RecordUndo("done", task.UUID)
|
||||
completed++
|
||||
}
|
||||
}
|
||||
|
||||
if len(tasks) == 1 {
|
||||
fmt.Printf("Completed task %s\n", engine.FormatTaskSummary(tasks[0], ws))
|
||||
} else {
|
||||
fmt.Printf("Completed %d task(s).\n", completed)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+39
-1
@@ -198,6 +198,17 @@ func generateEditableContent(task *engine.Task) string {
|
||||
sb.WriteString("tags: \n")
|
||||
}
|
||||
|
||||
// Annotations
|
||||
sb.WriteString("\n# Annotations (add/remove/modify lines below)\n")
|
||||
if len(task.Annotations) > 0 {
|
||||
for i, ann := range task.Annotations {
|
||||
ts := time.Unix(ann.Timestamp, 0).Format("2006-01-02 15:04")
|
||||
sb.WriteString(fmt.Sprintf("annotation.%d: %s | %s\n", i+1, ts, ann.Text))
|
||||
}
|
||||
}
|
||||
sb.WriteString("# To add: annotation.N: YYYY-MM-DD HH:MM | text\n")
|
||||
sb.WriteString("# To remove: delete the line\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -240,7 +251,8 @@ func applyEditedFields(task *engine.Task, fields map[string]string) error {
|
||||
return err
|
||||
}
|
||||
// Then complete (which saves automatically)
|
||||
return task.Complete()
|
||||
_, err := task.Complete()
|
||||
return err
|
||||
}
|
||||
|
||||
// If changing to deleted, use Delete() method
|
||||
@@ -361,6 +373,32 @@ func applyNonStatusFields(task *engine.Task, fields map[string]string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Annotations - rebuild from annotation.N fields
|
||||
var newAnnotations []engine.Annotation
|
||||
for key, value := range fields {
|
||||
if strings.HasPrefix(key, "annotation.") && value != "" {
|
||||
parts := strings.SplitN(value, " | ", 2)
|
||||
if len(parts) == 2 {
|
||||
ts, err := time.Parse("2006-01-02 15:04", strings.TrimSpace(parts[0]))
|
||||
if err != nil {
|
||||
// If we can't parse the timestamp, use current time
|
||||
ts = time.Now()
|
||||
}
|
||||
newAnnotations = append(newAnnotations, engine.Annotation{
|
||||
Timestamp: ts.Unix(),
|
||||
Text: strings.TrimSpace(parts[1]),
|
||||
})
|
||||
} else {
|
||||
// No timestamp separator, treat entire value as text
|
||||
newAnnotations = append(newAnnotations, engine.Annotation{
|
||||
Timestamp: time.Now().Unix(),
|
||||
Text: strings.TrimSpace(value),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
task.Annotations = newAnnotations
|
||||
|
||||
// Tags - replace all tags
|
||||
if tagsStr, ok := fields["tags"]; ok {
|
||||
// Remove all existing tags
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.jnss.me/joakim/opal/internal/engine"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var logCmd = &cobra.Command{
|
||||
Use: "log [filter]",
|
||||
Short: "Show change history for a task",
|
||||
Long: `Show the change history for a single task, pulled from the change log.
|
||||
|
||||
Examples:
|
||||
opal 2 log
|
||||
opal log +bug`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
parsed := getParsedArgs(cmd)
|
||||
if err := showTaskLog(parsed.Filters); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func showTaskLog(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("no task specified for log command")
|
||||
}
|
||||
|
||||
filter, err := engine.ParseFilter(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse filter: %w", err)
|
||||
}
|
||||
|
||||
ws, err := engine.LoadWorkingSet()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load working set: %w", err)
|
||||
}
|
||||
|
||||
var task *engine.Task
|
||||
|
||||
if len(filter.IDs) > 0 {
|
||||
if len(filter.IDs) != 1 {
|
||||
return fmt.Errorf("log requires exactly one task")
|
||||
}
|
||||
task, err = ws.GetTaskByDisplayID(filter.IDs[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
tasks, err := engine.GetTasks(filter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get tasks: %w", err)
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return fmt.Errorf("no tasks matched filter")
|
||||
}
|
||||
if len(tasks) > 1 {
|
||||
return fmt.Errorf("log requires exactly one task (filter matched %d)", len(tasks))
|
||||
}
|
||||
task = tasks[0]
|
||||
}
|
||||
|
||||
entries, err := engine.GetTaskHistory(task.UUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("History for: %s\n\n", task.Description)
|
||||
fmt.Print(engine.FormatTaskHistory(entries))
|
||||
return nil
|
||||
}
|
||||
+10
-2
@@ -82,12 +82,19 @@ func modifyTasks(filterArgs, modifierArgs []string) error {
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
return fmt.Errorf("no tasks matched")
|
||||
return fmt.Errorf("no tasks matched filter")
|
||||
}
|
||||
|
||||
if dryRunFlag {
|
||||
fmt.Print(engine.FormatTaskConfirmList("modify", tasks, ws))
|
||||
fmt.Println("Dry run — no changes made.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Confirm if multiple tasks or no filters specified
|
||||
if len(tasks) > 1 || len(filterArgs) == 0 {
|
||||
fmt.Printf("About to modify %d task(s). Proceed? (y/N): ", len(tasks))
|
||||
fmt.Print(engine.FormatTaskConfirmList("modify", tasks, ws))
|
||||
fmt.Printf("Proceed? (y/N): ")
|
||||
var confirm string
|
||||
fmt.Scanln(&confirm)
|
||||
if confirm != "y" && confirm != "Y" {
|
||||
@@ -107,6 +114,7 @@ func modifyTasks(filterArgs, modifierArgs []string) error {
|
||||
if err := mod.Apply(task); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to modify task %s: %v\n", task.UUID, err)
|
||||
} else {
|
||||
engine.RecordUndo("modify", task.UUID)
|
||||
modified++
|
||||
}
|
||||
}
|
||||
|
||||
+127
-26
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.jnss.me/joakim/opal/internal/engine"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -14,6 +15,7 @@ type ParsedArgs struct {
|
||||
Command string
|
||||
Filters []string
|
||||
Modifiers []string
|
||||
CmdArgIndex int // position of command in os.Args[1:], -1 if not found
|
||||
}
|
||||
|
||||
// Context key for parsed args
|
||||
@@ -25,13 +27,15 @@ const parsedArgsKey contextKey = "parsedArgs"
|
||||
var (
|
||||
configDirFlag string
|
||||
dataDirFlag string
|
||||
dryRunFlag bool
|
||||
)
|
||||
|
||||
// Command classification
|
||||
var commandNames = []string{
|
||||
"add", "done", "modify", "delete",
|
||||
"add", "done", "modify", "delete", "clean",
|
||||
"start", "stop", "count", "projects", "tags",
|
||||
"info", "edit", "server", "sync", "reports", "setup",
|
||||
"version", "annotate", "denotate", "undo", "uncomplete", "log", "completion",
|
||||
}
|
||||
|
||||
// Report names (dynamically populated)
|
||||
@@ -44,13 +48,15 @@ var reportNames = []string{
|
||||
var commandsWithModifiers = map[string]bool{
|
||||
"add": true,
|
||||
"modify": true,
|
||||
"annotate": true,
|
||||
}
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "opal",
|
||||
Use: "opal [filter] [command|report] [modifiers]",
|
||||
Short: "Opal task manager - taskwarrior-inspired CLI task management",
|
||||
Long: `Opal is a powerful command-line task manager inspired by taskwarrior.
|
||||
Long: `Opal is a powerful command-line task manager.
|
||||
It supports filtering, tags, priorities, projects, and recurring tasks.`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// Default behavior: run configured default report (defaults to "list")
|
||||
parsed := getParsedArgs(cmd)
|
||||
@@ -80,28 +86,34 @@ func Execute() error {
|
||||
if len(os.Args) > 1 {
|
||||
firstArg := os.Args[1]
|
||||
if firstArg == "-h" || firstArg == "--help" || firstArg == "help" {
|
||||
// Let Cobra handle help - skip preprocessing
|
||||
return rootCmd.Execute()
|
||||
}
|
||||
// Let Cobra's built-in completion machinery handle shell completions
|
||||
// directly, bypassing preprocessing that would create tasks.
|
||||
if firstArg == "__complete" || firstArg == "__completeNoDesc" {
|
||||
return rootCmd.Execute()
|
||||
}
|
||||
}
|
||||
|
||||
// Preprocess arguments BEFORE Cobra routing
|
||||
// Preprocess arguments (read-only scan — os.Args is never mutated)
|
||||
if len(os.Args) > 1 {
|
||||
parsed := preprocessArgs(os.Args[1:])
|
||||
|
||||
// Store in context for commands to use
|
||||
ctx := context.WithValue(context.Background(), parsedArgsKey, parsed)
|
||||
rootCmd.SetContext(ctx)
|
||||
|
||||
// Rewrite os.Args for Cobra based on parsed command
|
||||
// This allows Cobra to route to the correct command
|
||||
if parsed.Command != "list" || len(parsed.Filters) > 0 || len(parsed.Modifiers) > 0 {
|
||||
// Reconstruct args: [command, ...filters, ...modifiers]
|
||||
newArgs := []string{os.Args[0], parsed.Command}
|
||||
newArgs = append(newArgs, parsed.Filters...)
|
||||
newArgs = append(newArgs, parsed.Modifiers...)
|
||||
os.Args = newArgs
|
||||
// Build clean args for Cobra via SetArgs (os.Args stays untouched).
|
||||
if parsed.CmdArgIndex >= 0 {
|
||||
i := parsed.CmdArgIndex + 1 // offset for binary name in os.Args
|
||||
cmdAndAfter := os.Args[i:] // command + subcommands + their flags
|
||||
preCmdFlags := collectFlags(os.Args[1:i]) // persistent flags before command
|
||||
|
||||
cobraArgs := make([]string, 0, len(cmdAndAfter)+len(preCmdFlags))
|
||||
cobraArgs = append(cobraArgs, cmdAndAfter...)
|
||||
cobraArgs = append(cobraArgs, preCmdFlags...)
|
||||
rootCmd.SetArgs(cobraArgs)
|
||||
}
|
||||
// CmdArgIndex == -1: no command found, don't call SetArgs.
|
||||
// Cobra processes os.Args naturally → root command → default report.
|
||||
}
|
||||
|
||||
return rootCmd.Execute()
|
||||
@@ -119,20 +131,25 @@ func getParsedArgs(cmd *cobra.Command) *ParsedArgs {
|
||||
|
||||
// preprocessArgs parses command-line arguments before Cobra routing
|
||||
// Returns: command name, filters, modifiers
|
||||
// Flags (--foo) are stripped from filters/modifiers; Cobra handles them from os.Args.
|
||||
func preprocessArgs(args []string) *ParsedArgs {
|
||||
if len(args) == 0 {
|
||||
return &ParsedArgs{
|
||||
Command: "list", // Default command
|
||||
Filters: []string{},
|
||||
Modifiers: []string{},
|
||||
CmdArgIndex: -1,
|
||||
}
|
||||
}
|
||||
|
||||
// Find command position (check both regular commands and reports)
|
||||
// Find command position, skipping flag-like args
|
||||
cmdIdx := -1
|
||||
cmdName := ""
|
||||
|
||||
for i, arg := range args {
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
continue // Skip flags — Cobra handles them
|
||||
}
|
||||
// Check regular commands
|
||||
for _, name := range commandNames {
|
||||
if arg == name {
|
||||
@@ -160,68 +177,152 @@ func preprocessArgs(args []string) *ParsedArgs {
|
||||
if cmdIdx == -1 {
|
||||
return &ParsedArgs{
|
||||
Command: "list",
|
||||
Filters: args,
|
||||
Filters: stripFlags(args),
|
||||
Modifiers: []string{},
|
||||
CmdArgIndex: -1,
|
||||
}
|
||||
}
|
||||
|
||||
// Split arguments around command
|
||||
leftArgs := args[:cmdIdx] // Everything before command
|
||||
leftArgs := stripFlags(args[:cmdIdx])
|
||||
rightArgs := []string{}
|
||||
if cmdIdx+1 < len(args) {
|
||||
rightArgs = args[cmdIdx+1:] // Everything after command
|
||||
rightArgs = stripFlags(args[cmdIdx+1:])
|
||||
}
|
||||
|
||||
// Determine how to interpret right args
|
||||
if commandsWithModifiers[cmdName] {
|
||||
// Command accepts modifiers
|
||||
// Left = filters, Right = modifiers
|
||||
return &ParsedArgs{
|
||||
Command: cmdName,
|
||||
Filters: leftArgs,
|
||||
Modifiers: rightArgs,
|
||||
CmdArgIndex: cmdIdx,
|
||||
}
|
||||
} else {
|
||||
// Command doesn't accept modifiers
|
||||
// Both left and right are filters
|
||||
allFilters := append(leftArgs, rightArgs...)
|
||||
return &ParsedArgs{
|
||||
Command: cmdName,
|
||||
Filters: allFilters,
|
||||
Modifiers: []string{},
|
||||
CmdArgIndex: cmdIdx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stripFlags removes flag-like args (starting with -) from a slice
|
||||
func stripFlags(args []string) []string {
|
||||
var result []string
|
||||
for _, arg := range args {
|
||||
if !strings.HasPrefix(arg, "-") {
|
||||
result = append(result, arg)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// collectFlags extracts flag arguments (with their values) from a slice.
|
||||
// Uses Cobra's persistent flag registry to determine if a flag takes a value.
|
||||
func collectFlags(args []string) []string {
|
||||
var flags []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
if !strings.HasPrefix(args[i], "-") {
|
||||
continue
|
||||
}
|
||||
flags = append(flags, args[i])
|
||||
// If flag uses = syntax, value is already included
|
||||
if strings.Contains(args[i], "=") {
|
||||
continue
|
||||
}
|
||||
// Check if this flag takes a value argument
|
||||
if i+1 < len(args) {
|
||||
name := strings.TrimLeft(args[i], "-")
|
||||
f := rootCmd.PersistentFlags().Lookup(name)
|
||||
if f == nil && len(name) == 1 {
|
||||
f = rootCmd.PersistentFlags().ShorthandLookup(name)
|
||||
}
|
||||
if f != nil && f.Value.Type() != "bool" {
|
||||
i++
|
||||
flags = append(flags, args[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Add persistent flags for directory overrides
|
||||
rootCmd.PersistentFlags().StringVar(&configDirFlag, "config-dir", "",
|
||||
"Config directory (default: $XDG_CONFIG_HOME/opal or ~/.config/opal)")
|
||||
rootCmd.PersistentFlags().StringVar(&dataDirFlag, "data-dir", "",
|
||||
"Data directory (default: $XDG_DATA_HOME/opal or ~/.local/share/opal)")
|
||||
rootCmd.PersistentFlags().BoolVar(&dryRunFlag, "dry-run", false,
|
||||
"Show matched tasks without performing the action")
|
||||
|
||||
// Use PersistentPreRun for initialization (runs for all subcommands unless overridden)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
initializeApp()
|
||||
}
|
||||
|
||||
// Add regular subcommands
|
||||
// Command groups for organized help output
|
||||
rootCmd.AddGroup(
|
||||
&cobra.Group{ID: "task", Title: "Task Commands:"},
|
||||
&cobra.Group{ID: "report", Title: "Reports:"},
|
||||
&cobra.Group{ID: "other", Title: "Other:"},
|
||||
)
|
||||
|
||||
// Task commands
|
||||
addCmd.GroupID = "task"
|
||||
doneCmd.GroupID = "task"
|
||||
modifyCmd.GroupID = "task"
|
||||
deleteCmd.GroupID = "task"
|
||||
startCmd.GroupID = "task"
|
||||
stopCmd.GroupID = "task"
|
||||
editCmd.GroupID = "task"
|
||||
infoCmd.GroupID = "task"
|
||||
annotateCmd.GroupID = "task"
|
||||
denotateCmd.GroupID = "task"
|
||||
undoCmd.GroupID = "task"
|
||||
uncompleteCmd.GroupID = "task"
|
||||
logCmd.GroupID = "task"
|
||||
cleanCmd.GroupID = "task"
|
||||
|
||||
rootCmd.AddCommand(addCmd)
|
||||
rootCmd.AddCommand(doneCmd)
|
||||
rootCmd.AddCommand(modifyCmd)
|
||||
rootCmd.AddCommand(deleteCmd)
|
||||
rootCmd.AddCommand(startCmd)
|
||||
rootCmd.AddCommand(stopCmd)
|
||||
rootCmd.AddCommand(infoCmd)
|
||||
rootCmd.AddCommand(editCmd)
|
||||
rootCmd.AddCommand(annotateCmd)
|
||||
rootCmd.AddCommand(denotateCmd)
|
||||
rootCmd.AddCommand(undoCmd)
|
||||
rootCmd.AddCommand(uncompleteCmd)
|
||||
rootCmd.AddCommand(logCmd)
|
||||
rootCmd.AddCommand(cleanCmd)
|
||||
|
||||
// Other commands
|
||||
countCmd.GroupID = "other"
|
||||
projectsCmd.GroupID = "other"
|
||||
tagsCmd.GroupID = "other"
|
||||
reportsCmd.GroupID = "other"
|
||||
versionCmd.GroupID = "other"
|
||||
completionCmd.GroupID = "other"
|
||||
|
||||
rootCmd.AddCommand(countCmd)
|
||||
rootCmd.AddCommand(projectsCmd)
|
||||
rootCmd.AddCommand(tagsCmd)
|
||||
rootCmd.AddCommand(infoCmd)
|
||||
rootCmd.AddCommand(editCmd)
|
||||
rootCmd.AddCommand(reportsCmd)
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
rootCmd.AddCommand(completionCmd)
|
||||
|
||||
// Enable --version flag on root command
|
||||
rootCmd.Version = Version
|
||||
|
||||
// Add report commands dynamically
|
||||
reportCommands := CreateReportCommands()
|
||||
for _, cmd := range reportCommands {
|
||||
cmd.GroupID = "report"
|
||||
rootCmd.AddCommand(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +198,7 @@ Examples:
|
||||
}
|
||||
|
||||
func init() {
|
||||
serverCmd.GroupID = "other"
|
||||
rootCmd.AddCommand(serverCmd)
|
||||
serverCmd.AddCommand(serverStartCmd)
|
||||
serverCmd.AddCommand(keygenCmd)
|
||||
|
||||
@@ -49,6 +49,7 @@ Examples:
|
||||
}
|
||||
|
||||
func init() {
|
||||
setupCmd.GroupID = "other"
|
||||
rootCmd.AddCommand(setupCmd)
|
||||
|
||||
setupCmd.Flags().BoolVar(&showSystemdFlag, "show-systemd", false, "Show systemd service template")
|
||||
|
||||
@@ -43,8 +43,19 @@ func startTasks(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
return fmt.Errorf("no tasks matched filter")
|
||||
}
|
||||
|
||||
if dryRunFlag {
|
||||
fmt.Print(engine.FormatTaskConfirmList("start", tasks, ws))
|
||||
fmt.Println("Dry run — no changes made.")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
task.StartTask()
|
||||
engine.RecordUndo("start", task.UUID)
|
||||
fmt.Printf("Started task: %s\n", task.Description)
|
||||
}
|
||||
|
||||
|
||||
@@ -43,8 +43,19 @@ func stopTasks(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
return fmt.Errorf("no tasks matched filter")
|
||||
}
|
||||
|
||||
if dryRunFlag {
|
||||
fmt.Print(engine.FormatTaskConfirmList("stop", tasks, ws))
|
||||
fmt.Println("Dry run — no changes made.")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
task.StopTask()
|
||||
engine.RecordUndo("stop", task.UUID)
|
||||
fmt.Printf("Stopped task: %s\n", task.Description)
|
||||
}
|
||||
|
||||
|
||||
+82
-62
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.jnss.me/joakim/opal/internal/engine"
|
||||
"git.jnss.me/joakim/opal/internal/sync"
|
||||
@@ -223,8 +224,8 @@ var syncUpCmd = &cobra.Command{
|
||||
client := sync.NewClient(cfg.SyncURL, cfg.SyncAPIKey, cfg.SyncClientID)
|
||||
|
||||
// Get local changes
|
||||
lastSync := getLastSyncTime(cfg.SyncClientID)
|
||||
localChanges, err := getLocalChanges(lastSync)
|
||||
lastSync := sync.GetLastSyncTime(cfg.SyncClientID)
|
||||
localChanges, err := sync.GetLocalChanges(lastSync)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting local changes: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -263,7 +264,7 @@ var syncDownCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
client := sync.NewClient(cfg.SyncURL, cfg.SyncAPIKey, cfg.SyncClientID)
|
||||
lastSync := getLastSyncTime(cfg.SyncClientID)
|
||||
lastSync := sync.GetLastSyncTime(cfg.SyncClientID)
|
||||
|
||||
changes, err := client.PullChanges(lastSync)
|
||||
if err != nil {
|
||||
@@ -276,7 +277,55 @@ var syncDownCmd = &cobra.Command{
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Pulled %d changes from server\n", len(changes))
|
||||
// Parse changes into tasks
|
||||
tasks, err := client.ParseChanges(changes)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing changes: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Apply each task locally
|
||||
var applied int
|
||||
for _, task := range tasks {
|
||||
if err := task.Save(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to save task %s: %v\n", task.UUID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Mark as sync-originated to prevent feedback loop
|
||||
_ = engine.MarkChangeLogAsSync(task.UUID.String())
|
||||
|
||||
// Sync tags
|
||||
savedTask, err := engine.GetTask(task.UUID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to reload task %s: %v\n", task.UUID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
currentTags, _ := savedTask.GetTags()
|
||||
currentSet := make(map[string]bool)
|
||||
for _, tag := range currentTags {
|
||||
currentSet[tag] = true
|
||||
}
|
||||
desiredSet := make(map[string]bool)
|
||||
for _, tag := range task.Tags {
|
||||
desiredSet[tag] = true
|
||||
}
|
||||
for tag := range currentSet {
|
||||
if !desiredSet[tag] {
|
||||
savedTask.RemoveTag(tag)
|
||||
}
|
||||
}
|
||||
for tag := range desiredSet {
|
||||
if !currentSet[tag] {
|
||||
savedTask.AddTag(tag)
|
||||
}
|
||||
}
|
||||
|
||||
applied++
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Pulled %d changes, applied %d tasks from server\n", len(changes), applied)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -391,6 +440,7 @@ Examples:
|
||||
}
|
||||
|
||||
func init() {
|
||||
syncCmd.GroupID = "other"
|
||||
rootCmd.AddCommand(syncCmd)
|
||||
|
||||
syncCmd.AddCommand(syncInitCmd)
|
||||
@@ -412,63 +462,33 @@ func init() {
|
||||
syncCmd.PersistentFlags().BoolVarP(&quietFlag, "quiet", "q", false, "Suppress progress output")
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func getLastSyncTime(clientID string) int64 {
|
||||
db := engine.GetDB()
|
||||
if db == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
var lastSync int64
|
||||
err := db.QueryRow("SELECT last_sync FROM sync_state WHERE client_id = ?", clientID).Scan(&lastSync)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return lastSync
|
||||
}
|
||||
|
||||
func getLocalChanges(since int64) ([]*engine.Task, error) {
|
||||
db := engine.GetDB()
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("database not initialized")
|
||||
}
|
||||
|
||||
rows, err := db.Query(`
|
||||
SELECT DISTINCT task_uuid
|
||||
FROM change_log
|
||||
WHERE changed_at > ?
|
||||
ORDER BY changed_at ASC
|
||||
`, since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tasks []*engine.Task
|
||||
for rows.Next() {
|
||||
var uuidStr string
|
||||
if err := rows.Scan(&uuidStr); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
taskUUID, err := uuid.Parse(uuidStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
task, err := engine.GetTask(taskUUID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func formatTimestamp(ts int64) string {
|
||||
return fmt.Sprintf("%d", ts) // Simple for now, can enhance later
|
||||
t := time.Unix(ts, 0)
|
||||
now := time.Now()
|
||||
diff := now.Sub(t)
|
||||
|
||||
switch {
|
||||
case diff < time.Minute:
|
||||
return "just now"
|
||||
case diff < time.Hour:
|
||||
m := int(diff.Minutes())
|
||||
if m == 1 {
|
||||
return "1 minute ago"
|
||||
}
|
||||
return fmt.Sprintf("%d minutes ago", m)
|
||||
case diff < 24*time.Hour:
|
||||
h := int(diff.Hours())
|
||||
if h == 1 {
|
||||
return "1 hour ago"
|
||||
}
|
||||
return fmt.Sprintf("%d hours ago", h)
|
||||
case diff < 7*24*time.Hour:
|
||||
d := int(diff.Hours() / 24)
|
||||
if d == 1 {
|
||||
return "yesterday"
|
||||
}
|
||||
return fmt.Sprintf("%d days ago", d)
|
||||
default:
|
||||
return t.Format("2006-01-02 15:04")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.jnss.me/joakim/opal/internal/engine"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var uncompleteCmd = &cobra.Command{
|
||||
Use: "uncomplete [filter...]",
|
||||
Short: "Restore a completed task to pending",
|
||||
Long: `Restore a completed task back to pending status.
|
||||
|
||||
Unlike undo, this is a targeted action that works on any completed task
|
||||
regardless of when it was completed.
|
||||
|
||||
Examples:
|
||||
opal 2 uncomplete
|
||||
opal uncomplete +errand`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
parsed := getParsedArgs(cmd)
|
||||
if err := uncompleteTasks(parsed.Filters); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func uncompleteTasks(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("no task specified")
|
||||
}
|
||||
|
||||
filter, err := engine.ParseFilter(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse filter: %w", err)
|
||||
}
|
||||
|
||||
ws, err := engine.LoadWorkingSet()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load working set: %w", err)
|
||||
}
|
||||
|
||||
var tasks []*engine.Task
|
||||
|
||||
if len(filter.IDs) > 0 {
|
||||
for _, id := range filter.IDs {
|
||||
task, err := ws.GetTaskByDisplayID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
} else {
|
||||
tasks, err = engine.GetTasks(filter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get tasks: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
return fmt.Errorf("no tasks matched filter")
|
||||
}
|
||||
|
||||
uncompleted := 0
|
||||
for _, task := range tasks {
|
||||
if task.Status != engine.StatusCompleted {
|
||||
fmt.Fprintf(os.Stderr, "Warning: task %s is not completed, skipping\n", task.UUID)
|
||||
continue
|
||||
}
|
||||
task.Status = engine.StatusPending
|
||||
task.End = nil
|
||||
if err := task.Save(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to uncomplete task %s: %v\n", task.UUID, err)
|
||||
} else {
|
||||
uncompleted++
|
||||
}
|
||||
}
|
||||
|
||||
if uncompleted == 1 {
|
||||
fmt.Printf("Restored task %s to pending\n", engine.FormatTaskSummary(tasks[0], ws))
|
||||
} else {
|
||||
fmt.Printf("Restored %d task(s) to pending.\n", uncompleted)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.jnss.me/joakim/opal/internal/engine"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var undoCmd = &cobra.Command{
|
||||
Use: "undo",
|
||||
Short: "Undo the last action",
|
||||
Long: `Undo the most recent mutating action (add, done, delete, modify, start, stop).
|
||||
|
||||
The undo stack keeps the last 10 operations. Each undo pops one operation.
|
||||
|
||||
Examples:
|
||||
opal undo`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
description, err := engine.PopUndo()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(description)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Set via ldflags at build time:
|
||||
//
|
||||
// go build -ldflags "-X git.jnss.me/joakim/opal/cmd.Version=$(cat VERSION)
|
||||
// -X git.jnss.me/joakim/opal/cmd.Commit=$(git rev-parse --short HEAD)
|
||||
// -X git.jnss.me/joakim/opal/cmd.BuildDate=$(date -u +%Y-%m-%d)"
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "unknown"
|
||||
BuildDate = "unknown"
|
||||
)
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print version information",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("opal %s (%s) built %s\n", Version, Commit, BuildDate)
|
||||
},
|
||||
}
|
||||
@@ -104,6 +104,8 @@ func PushChanges(w http.ResponseWriter, r *http.Request) {
|
||||
if err := task.Save(); err != nil {
|
||||
continue
|
||||
}
|
||||
// Mark as sync-originated to prevent feedback loop
|
||||
_ = engine.MarkChangeLogAsSync(task.UUID.String())
|
||||
// Add tags
|
||||
for _, tag := range task.Tags {
|
||||
_ = task.AddTag(tag)
|
||||
@@ -114,15 +116,18 @@ func PushChanges(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Task exists - check timestamps for conflicts
|
||||
if existing.Modified.Unix() > task.Modified.Unix() {
|
||||
// Server version is newer - conflict (but we'll apply last-write-wins)
|
||||
// Server version is newer - skip this push
|
||||
conflicts++
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply changes (last-write-wins)
|
||||
// Apply changes (client is newer or equal)
|
||||
task.ID = existing.ID // Preserve database ID
|
||||
if err := task.Save(); err != nil {
|
||||
continue
|
||||
}
|
||||
// Mark as sync-originated to prevent feedback loop
|
||||
_ = engine.MarkChangeLogAsSync(task.UUID.String())
|
||||
|
||||
// Sync tags
|
||||
existingTags := make(map[string]bool)
|
||||
|
||||
@@ -83,6 +83,11 @@ func ListTasks(w http.ResponseWriter, r *http.Request) {
|
||||
filter.IncludeTags = tags
|
||||
}
|
||||
|
||||
// Exclude tag filters
|
||||
if excludeTags := query["exclude_tag"]; len(excludeTags) > 0 {
|
||||
filter.ExcludeTags = excludeTags
|
||||
}
|
||||
|
||||
// Get tasks
|
||||
tasks, err := engine.GetTasks(filter)
|
||||
if err != nil {
|
||||
@@ -125,35 +130,35 @@ func CreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
mod.AddTags = req.Tags
|
||||
|
||||
if req.Project != nil {
|
||||
mod.SetAttributes["project"] = req.Project
|
||||
mod.Set("project", req.Project)
|
||||
}
|
||||
|
||||
if req.Priority != nil {
|
||||
mod.SetAttributes["priority"] = req.Priority
|
||||
mod.Set("priority", req.Priority)
|
||||
}
|
||||
|
||||
if req.Due != nil {
|
||||
dueStr := fmt.Sprintf("%d", *req.Due)
|
||||
mod.SetAttributes["due"] = &dueStr
|
||||
mod.Set("due", &dueStr)
|
||||
}
|
||||
|
||||
if req.Scheduled != nil {
|
||||
scheduledStr := fmt.Sprintf("%d", *req.Scheduled)
|
||||
mod.SetAttributes["scheduled"] = &scheduledStr
|
||||
mod.Set("scheduled", &scheduledStr)
|
||||
}
|
||||
|
||||
if req.Wait != nil {
|
||||
waitStr := fmt.Sprintf("%d", *req.Wait)
|
||||
mod.SetAttributes["wait"] = &waitStr
|
||||
mod.Set("wait", &waitStr)
|
||||
}
|
||||
|
||||
if req.Until != nil {
|
||||
untilStr := fmt.Sprintf("%d", *req.Until)
|
||||
mod.SetAttributes["until"] = &untilStr
|
||||
mod.Set("until", &untilStr)
|
||||
}
|
||||
|
||||
if req.Recurrence != nil {
|
||||
mod.SetAttributes["recurrence"] = req.Recurrence
|
||||
mod.Set("recur", req.Recurrence)
|
||||
}
|
||||
|
||||
// Create task
|
||||
@@ -227,39 +232,39 @@ func UpdateTask(w http.ResponseWriter, r *http.Request) {
|
||||
mod := engine.NewModifier()
|
||||
|
||||
if req.Description != nil {
|
||||
mod.SetAttributes["description"] = req.Description
|
||||
mod.Set("description", req.Description)
|
||||
}
|
||||
|
||||
if req.Status != nil {
|
||||
mod.SetAttributes["status"] = req.Status
|
||||
mod.Set("status", req.Status)
|
||||
}
|
||||
|
||||
if req.Priority != nil {
|
||||
mod.SetAttributes["priority"] = req.Priority
|
||||
mod.Set("priority", req.Priority)
|
||||
}
|
||||
|
||||
if req.Project != nil {
|
||||
mod.SetAttributes["project"] = req.Project
|
||||
mod.Set("project", req.Project)
|
||||
}
|
||||
|
||||
if req.Due != nil {
|
||||
dueStr := fmt.Sprintf("%d", *req.Due)
|
||||
mod.SetAttributes["due"] = &dueStr
|
||||
mod.Set("due", &dueStr)
|
||||
}
|
||||
|
||||
if req.Scheduled != nil {
|
||||
scheduledStr := fmt.Sprintf("%d", *req.Scheduled)
|
||||
mod.SetAttributes["scheduled"] = &scheduledStr
|
||||
mod.Set("scheduled", &scheduledStr)
|
||||
}
|
||||
|
||||
if req.Wait != nil {
|
||||
waitStr := fmt.Sprintf("%d", *req.Wait)
|
||||
mod.SetAttributes["wait"] = &waitStr
|
||||
mod.Set("wait", &waitStr)
|
||||
}
|
||||
|
||||
if req.Until != nil {
|
||||
untilStr := fmt.Sprintf("%d", *req.Until)
|
||||
mod.SetAttributes["until"] = &untilStr
|
||||
mod.Set("until", &untilStr)
|
||||
}
|
||||
|
||||
if req.Start != nil {
|
||||
@@ -268,7 +273,7 @@ func UpdateTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if req.Recurrence != nil {
|
||||
mod.SetAttributes["recurrence"] = req.Recurrence
|
||||
mod.Set("recur", req.Recurrence)
|
||||
}
|
||||
|
||||
// Apply modifier
|
||||
@@ -324,7 +329,7 @@ func CompleteTask(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := task.Complete(); err != nil {
|
||||
if _, err := task.Complete(); err != nil {
|
||||
errorResponse(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package engine
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Annotate appends a timestamped annotation to the task and saves.
|
||||
func (t *Task) Annotate(text string) error {
|
||||
annotation := Annotation{
|
||||
Timestamp: timeNow().Unix(),
|
||||
Text: text,
|
||||
}
|
||||
t.Annotations = append(t.Annotations, annotation)
|
||||
return t.Save()
|
||||
}
|
||||
|
||||
// Denotate removes the most recent annotation from the task and saves.
|
||||
// Returns the removed annotation, or an error if there are none.
|
||||
func (t *Task) Denotate() (*Annotation, error) {
|
||||
if len(t.Annotations) == 0 {
|
||||
return nil, fmt.Errorf("task has no annotations")
|
||||
}
|
||||
removed := t.Annotations[len(t.Annotations)-1]
|
||||
t.Annotations = t.Annotations[:len(t.Annotations)-1]
|
||||
if err := t.Save(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &removed, nil
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAnnotate(t *testing.T) {
|
||||
task, err := CreateTask("Annotation test task")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create task: %v", err)
|
||||
}
|
||||
defer func() { task.Delete(true) }()
|
||||
|
||||
// Initially no annotations
|
||||
if len(task.Annotations) != 0 {
|
||||
t.Fatalf("new task should have 0 annotations, got %d", len(task.Annotations))
|
||||
}
|
||||
|
||||
// Add first annotation
|
||||
if err := task.Annotate("First note"); err != nil {
|
||||
t.Fatalf("Annotate failed: %v", err)
|
||||
}
|
||||
if len(task.Annotations) != 1 {
|
||||
t.Fatalf("expected 1 annotation, got %d", len(task.Annotations))
|
||||
}
|
||||
if task.Annotations[0].Text != "First note" {
|
||||
t.Errorf("annotation text = %q, want %q", task.Annotations[0].Text, "First note")
|
||||
}
|
||||
if task.Annotations[0].Timestamp == 0 {
|
||||
t.Error("annotation timestamp should be non-zero")
|
||||
}
|
||||
|
||||
// Add second annotation
|
||||
if err := task.Annotate("Second note"); err != nil {
|
||||
t.Fatalf("Annotate failed: %v", err)
|
||||
}
|
||||
if len(task.Annotations) != 2 {
|
||||
t.Fatalf("expected 2 annotations, got %d", len(task.Annotations))
|
||||
}
|
||||
if task.Annotations[1].Text != "Second note" {
|
||||
t.Errorf("second annotation text = %q, want %q", task.Annotations[1].Text, "Second note")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotate_Persistence(t *testing.T) {
|
||||
task, err := CreateTask("Annotation persistence test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create task: %v", err)
|
||||
}
|
||||
defer func() { task.Delete(true) }()
|
||||
|
||||
if err := task.Annotate("Persisted note"); err != nil {
|
||||
t.Fatalf("Annotate failed: %v", err)
|
||||
}
|
||||
|
||||
// Reload from DB
|
||||
loaded, err := GetTask(task.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask failed: %v", err)
|
||||
}
|
||||
if len(loaded.Annotations) != 1 {
|
||||
t.Fatalf("loaded task: expected 1 annotation, got %d", len(loaded.Annotations))
|
||||
}
|
||||
if loaded.Annotations[0].Text != "Persisted note" {
|
||||
t.Errorf("loaded annotation text = %q, want %q", loaded.Annotations[0].Text, "Persisted note")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotate_TimestampOrdering(t *testing.T) {
|
||||
origTimeNow := timeNow
|
||||
defer func() { timeNow = origTimeNow }()
|
||||
|
||||
task, err := CreateTask("Timestamp ordering test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create task: %v", err)
|
||||
}
|
||||
defer func() { timeNow = origTimeNow; task.Delete(true) }()
|
||||
|
||||
// Add annotations at different times
|
||||
t1 := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
t2 := time.Date(2026, 1, 1, 11, 0, 0, 0, time.UTC)
|
||||
|
||||
timeNow = func() time.Time { return t1 }
|
||||
task.Annotate("First")
|
||||
|
||||
timeNow = func() time.Time { return t2 }
|
||||
task.Annotate("Second")
|
||||
|
||||
if task.Annotations[0].Timestamp >= task.Annotations[1].Timestamp {
|
||||
t.Error("annotations should be in chronological order")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDenotate(t *testing.T) {
|
||||
task, err := CreateTask("Denotate test task")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create task: %v", err)
|
||||
}
|
||||
defer func() { task.Delete(true) }()
|
||||
|
||||
task.Annotate("First")
|
||||
task.Annotate("Second")
|
||||
task.Annotate("Third")
|
||||
|
||||
// Denotate removes the last
|
||||
removed, err := task.Denotate()
|
||||
if err != nil {
|
||||
t.Fatalf("Denotate failed: %v", err)
|
||||
}
|
||||
if removed.Text != "Third" {
|
||||
t.Errorf("removed text = %q, want %q", removed.Text, "Third")
|
||||
}
|
||||
if len(task.Annotations) != 2 {
|
||||
t.Fatalf("expected 2 annotations after denotate, got %d", len(task.Annotations))
|
||||
}
|
||||
|
||||
// Remove second
|
||||
removed, err = task.Denotate()
|
||||
if err != nil {
|
||||
t.Fatalf("Denotate failed: %v", err)
|
||||
}
|
||||
if removed.Text != "Second" {
|
||||
t.Errorf("removed text = %q, want %q", removed.Text, "Second")
|
||||
}
|
||||
|
||||
// Remove first
|
||||
removed, err = task.Denotate()
|
||||
if err != nil {
|
||||
t.Fatalf("Denotate failed: %v", err)
|
||||
}
|
||||
if removed.Text != "First" {
|
||||
t.Errorf("removed text = %q, want %q", removed.Text, "First")
|
||||
}
|
||||
|
||||
// Nothing left — should error
|
||||
_, err = task.Denotate()
|
||||
if err == nil {
|
||||
t.Error("Denotate on empty annotations should return error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDenotate_Empty(t *testing.T) {
|
||||
task, err := CreateTask("Denotate empty test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create task: %v", err)
|
||||
}
|
||||
defer func() { task.Delete(true) }()
|
||||
|
||||
_, err = task.Denotate()
|
||||
if err == nil {
|
||||
t.Error("Denotate on task with no annotations should return error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDenotate_Persistence(t *testing.T) {
|
||||
task, err := CreateTask("Denotate persistence test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create task: %v", err)
|
||||
}
|
||||
defer func() { task.Delete(true) }()
|
||||
|
||||
task.Annotate("Keep this")
|
||||
task.Annotate("Remove this")
|
||||
task.Denotate()
|
||||
|
||||
// Reload and verify
|
||||
loaded, err := GetTask(task.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask failed: %v", err)
|
||||
}
|
||||
if len(loaded.Annotations) != 1 {
|
||||
t.Fatalf("loaded: expected 1 annotation, got %d", len(loaded.Annotations))
|
||||
}
|
||||
if loaded.Annotations[0].Text != "Keep this" {
|
||||
t.Errorf("remaining annotation = %q, want %q", loaded.Annotations[0].Text, "Keep this")
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,7 @@ func runMigrations() error {
|
||||
|
||||
recurrence_duration INTEGER,
|
||||
parent_uuid TEXT,
|
||||
annotations TEXT DEFAULT NULL,
|
||||
|
||||
FOREIGN KEY (parent_uuid) REFERENCES tasks(uuid) ON DELETE CASCADE
|
||||
);
|
||||
@@ -209,6 +210,15 @@ func runMigrations() error {
|
||||
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash);
|
||||
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||
|
||||
-- Undo stack (local-only, references change_log entries)
|
||||
CREATE TABLE undo_stack (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at INTEGER NOT NULL,
|
||||
op_type TEXT NOT NULL,
|
||||
task_uuid TEXT NOT NULL,
|
||||
change_log_id INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Triggers to populate change_log
|
||||
CREATE TRIGGER track_task_create AFTER INSERT ON tasks
|
||||
BEGIN
|
||||
@@ -241,6 +251,7 @@ func runMigrations() error {
|
||||
CASE WHEN NEW.until_date IS NOT NULL THEN 'until: ' || NEW.until_date || CHAR(10) ELSE '' END ||
|
||||
CASE WHEN NEW.recurrence_duration IS NOT NULL THEN 'recurrence: ' || NEW.recurrence_duration || CHAR(10) ELSE '' END ||
|
||||
CASE WHEN NEW.parent_uuid IS NOT NULL THEN 'parent_uuid: ' || NEW.parent_uuid || CHAR(10) ELSE '' END ||
|
||||
CASE WHEN NEW.annotations IS NOT NULL THEN 'annotations: ' || NEW.annotations || CHAR(10) ELSE '' END ||
|
||||
(SELECT CASE WHEN COUNT(*) > 0
|
||||
THEN 'tags: ' || GROUP_CONCAT(tag, ',') || CHAR(10)
|
||||
ELSE ''
|
||||
@@ -280,6 +291,7 @@ func runMigrations() error {
|
||||
CASE WHEN NEW.until_date IS NOT NULL THEN 'until: ' || NEW.until_date || CHAR(10) ELSE '' END ||
|
||||
CASE WHEN NEW.recurrence_duration IS NOT NULL THEN 'recurrence: ' || NEW.recurrence_duration || CHAR(10) ELSE '' END ||
|
||||
CASE WHEN NEW.parent_uuid IS NOT NULL THEN 'parent_uuid: ' || NEW.parent_uuid || CHAR(10) ELSE '' END ||
|
||||
CASE WHEN NEW.annotations IS NOT NULL THEN 'annotations: ' || NEW.annotations || CHAR(10) ELSE '' END ||
|
||||
(SELECT CASE WHEN COUNT(*) > 0
|
||||
THEN 'tags: ' || GROUP_CONCAT(tag, ',') || CHAR(10)
|
||||
ELSE ''
|
||||
@@ -295,6 +307,13 @@ func runMigrations() error {
|
||||
END;
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
sql: `
|
||||
ALTER TABLE change_log ADD COLUMN source TEXT NOT NULL DEFAULT 'local';
|
||||
CREATE INDEX idx_change_log_source ON change_log(source);
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
// Apply pending migrations
|
||||
@@ -315,7 +334,7 @@ func runMigrations() error {
|
||||
if _, err := tx.Exec(
|
||||
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
|
||||
migration.version,
|
||||
getCurrentTimestamp(),
|
||||
GetCurrentTimestamp(),
|
||||
); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed to record migration %d: %w", migration.version, err)
|
||||
@@ -330,14 +349,9 @@ func runMigrations() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCurrentTimestamp returns the current Unix timestamp
|
||||
func getCurrentTimestamp() int64 {
|
||||
return timeNow().Unix()
|
||||
}
|
||||
|
||||
// GetCurrentTimestamp returns the current Unix timestamp (exported for API use)
|
||||
func GetCurrentTimestamp() int64 {
|
||||
return getCurrentTimestamp()
|
||||
return timeNow().Unix()
|
||||
}
|
||||
|
||||
// CleanupChangeLog removes old change log entries based on retention policy
|
||||
@@ -397,3 +411,24 @@ func SetChangeLogRetentionDays(days int) error {
|
||||
_, err := db.Exec("INSERT OR REPLACE INTO sync_config (key, value) VALUES ('change_log_retention_days', ?)", days)
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkChangeLogAsSync marks the most recent change_log entry for a task UUID
|
||||
// as originating from sync (not local), preventing the feedback loop where
|
||||
// synced changes get re-pushed as local changes.
|
||||
func MarkChangeLogAsSync(taskUUID string) error {
|
||||
db := GetDB()
|
||||
if db == nil {
|
||||
return fmt.Errorf("database not initialized")
|
||||
}
|
||||
|
||||
_, err := db.Exec(`
|
||||
UPDATE change_log SET source = 'sync'
|
||||
WHERE id = (
|
||||
SELECT id FROM change_log
|
||||
WHERE task_uuid = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
`, taskUUID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -7,6 +7,21 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var monthNames = map[string]time.Month{
|
||||
"jan": time.January, "january": time.January,
|
||||
"feb": time.February, "february": time.February,
|
||||
"mar": time.March, "march": time.March,
|
||||
"apr": time.April, "april": time.April,
|
||||
"may": time.May,
|
||||
"jun": time.June, "june": time.June,
|
||||
"jul": time.July, "july": time.July,
|
||||
"aug": time.August, "august": time.August,
|
||||
"sep": time.September, "september": time.September,
|
||||
"oct": time.October, "october": time.October,
|
||||
"nov": time.November, "november": time.November,
|
||||
"dec": time.December, "december": time.December,
|
||||
}
|
||||
|
||||
// DateParser handles all date/time/duration parsing with configurable options
|
||||
type DateParser struct {
|
||||
base time.Time
|
||||
@@ -54,7 +69,7 @@ func (p *DateParser) parseDateOnly(s string) (time.Time, error) {
|
||||
}
|
||||
|
||||
// Try ISO format first
|
||||
if t, err := time.Parse("2006-01-02", s); err == nil {
|
||||
if t, err := time.ParseInLocation("2006-01-02", s, p.base.Location()); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
@@ -238,22 +253,7 @@ func (p *DateParser) parseWeekday(s string) (time.Time, bool) {
|
||||
|
||||
// parseMonthName handles month names (jan, january, feb, february, etc.)
|
||||
func (p *DateParser) parseMonthName(s string) (time.Time, bool) {
|
||||
months := map[string]time.Month{
|
||||
"jan": time.January, "january": time.January,
|
||||
"feb": time.February, "february": time.February,
|
||||
"mar": time.March, "march": time.March,
|
||||
"apr": time.April, "april": time.April,
|
||||
"may": time.May,
|
||||
"jun": time.June, "june": time.June,
|
||||
"jul": time.July, "july": time.July,
|
||||
"aug": time.August, "august": time.August,
|
||||
"sep": time.September, "september": time.September,
|
||||
"oct": time.October, "october": time.October,
|
||||
"nov": time.November, "november": time.November,
|
||||
"dec": time.December, "december": time.December,
|
||||
}
|
||||
|
||||
month, ok := months[s]
|
||||
month, ok := monthNames[s]
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
}
|
||||
@@ -316,22 +316,7 @@ func (p *DateParser) parseDayAndMonth(dayStr, monthStr string) (int, time.Month,
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
months := map[string]time.Month{
|
||||
"jan": time.January, "january": time.January,
|
||||
"feb": time.February, "february": time.February,
|
||||
"mar": time.March, "march": time.March,
|
||||
"apr": time.April, "april": time.April,
|
||||
"may": time.May,
|
||||
"jun": time.June, "june": time.June,
|
||||
"jul": time.July, "july": time.July,
|
||||
"aug": time.August, "august": time.August,
|
||||
"sep": time.September, "september": time.September,
|
||||
"oct": time.October, "october": time.October,
|
||||
"nov": time.November, "november": time.November,
|
||||
"dec": time.December, "december": time.December,
|
||||
}
|
||||
|
||||
month, ok := months[monthStr]
|
||||
month, ok := monthNames[monthStr]
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
@@ -226,6 +226,127 @@ func TestParseDateWithTime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitDateTime(t *testing.T) {
|
||||
base := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC)
|
||||
parser := NewDateParser(base, time.Monday)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantDate string
|
||||
wantTime string
|
||||
wantHas bool
|
||||
}{
|
||||
{"plain weekday", "mon", "mon", "", false},
|
||||
{"plain date", "2026-01-15", "2026-01-15", "", false},
|
||||
{"weekday+HHMM", "mon:0800", "mon", "0800", true},
|
||||
{"weekday+HH:MM", "mon:15:35", "mon", "15:35", true},
|
||||
{"date+HHMM", "21jan:1430", "21jan", "1430", true},
|
||||
{"just time HH:MM", "15:35", "", "15:35", true},
|
||||
{"tomorrow+time", "tomorrow:0800", "tomorrow", "0800", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dateStr, timeStr, hasTime := parser.splitDateTime(tt.input)
|
||||
if hasTime != tt.wantHas {
|
||||
t.Errorf("hasTime = %v, want %v", hasTime, tt.wantHas)
|
||||
}
|
||||
if hasTime {
|
||||
if dateStr != tt.wantDate {
|
||||
t.Errorf("dateStr = %q, want %q", dateStr, tt.wantDate)
|
||||
}
|
||||
if timeStr != tt.wantTime {
|
||||
t.Errorf("timeStr = %q, want %q", timeStr, tt.wantTime)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseISODate(t *testing.T) {
|
||||
// Use a non-UTC timezone to verify ISO dates respect the parser's location
|
||||
loc := time.FixedZone("UTC+10", 10*60*60)
|
||||
base := time.Date(2026, 1, 5, 12, 0, 0, 0, loc)
|
||||
parser := NewDateParser(base, time.Monday)
|
||||
|
||||
result, err := parser.ParseDate("2026-02-20")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse ISO date: %v", err)
|
||||
}
|
||||
|
||||
expected := time.Date(2026, 2, 20, 0, 0, 0, 0, loc)
|
||||
if !result.Equal(expected) {
|
||||
t.Errorf("Expected %v, got %v", expected, result)
|
||||
}
|
||||
if result.Location() != loc {
|
||||
t.Errorf("Expected location %v, got %v", loc, result.Location())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDateInvalid(t *testing.T) {
|
||||
base := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC)
|
||||
parser := NewDateParser(base, time.Monday)
|
||||
|
||||
invalids := []string{
|
||||
"notadate",
|
||||
"xyz123",
|
||||
"",
|
||||
"32jan",
|
||||
"feb30",
|
||||
}
|
||||
|
||||
for _, input := range invalids {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
_, err := parser.ParseDate(input)
|
||||
if err == nil && input != "" {
|
||||
// Some of these might parse as durations or keywords
|
||||
// but truly invalid ones should error
|
||||
t.Logf("ParseDate(%q) did not error (may be valid as keyword/duration)", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextWeekday_Exhaustive(t *testing.T) {
|
||||
// Test all 7 starting days × 7 target days
|
||||
// Mon Jan 5 2026
|
||||
monday := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
allDays := []time.Weekday{
|
||||
time.Sunday, time.Monday, time.Tuesday, time.Wednesday,
|
||||
time.Thursday, time.Friday, time.Saturday,
|
||||
}
|
||||
|
||||
for fromOffset := 0; fromOffset < 7; fromOffset++ {
|
||||
from := monday.AddDate(0, 0, fromOffset)
|
||||
parser := NewDateParser(from, time.Monday)
|
||||
|
||||
for _, target := range allDays {
|
||||
t.Run(from.Weekday().String()+"_to_"+target.String(), func(t *testing.T) {
|
||||
result := parser.nextWeekday(target)
|
||||
|
||||
// Must land on the correct weekday
|
||||
if result.Weekday() != target {
|
||||
t.Errorf("weekday = %v, want %v", result.Weekday(), target)
|
||||
}
|
||||
|
||||
// Must be 1-7 days in the future
|
||||
fromMidnight := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, from.Location())
|
||||
days := int(result.Sub(fromMidnight).Hours() / 24)
|
||||
if days < 1 || days > 7 {
|
||||
t.Errorf("days ahead = %d, want 1-7 (from %v to %v)", days, from, result)
|
||||
}
|
||||
|
||||
// Same weekday should always be 7 days ahead
|
||||
if from.Weekday() == target && days != 7 {
|
||||
t.Errorf("same weekday should be 7 days, got %d", days)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandedDurationFormats(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -2,6 +2,7 @@ package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
@@ -28,15 +29,9 @@ func FormatTaskListWithFormat(tasks []*Task, ws *WorkingSet, format string) stri
|
||||
if format == "minimal" {
|
||||
result := ""
|
||||
for i, task := range tasks {
|
||||
displayID := i + 1
|
||||
if ws != nil {
|
||||
// Use working set display ID if available
|
||||
for id, uuid := range ws.byID {
|
||||
if uuid == task.UUID {
|
||||
displayID = id
|
||||
break
|
||||
}
|
||||
}
|
||||
displayID := resolveDisplayID(task, ws)
|
||||
if displayID == 0 {
|
||||
displayID = i + 1
|
||||
}
|
||||
urgency := task.CalculateUrgency(coeffs)
|
||||
urgencyColor := getUrgencyColor(urgency)
|
||||
@@ -70,15 +65,9 @@ func FormatTaskListWithFormat(tasks []*Task, ws *WorkingSet, format string) stri
|
||||
|
||||
// Add rows
|
||||
for i, task := range tasks {
|
||||
displayID := i + 1
|
||||
if ws != nil {
|
||||
// Use working set display ID if available
|
||||
for id, uuid := range ws.byID {
|
||||
if uuid == task.UUID {
|
||||
displayID = id
|
||||
break
|
||||
}
|
||||
}
|
||||
displayID := resolveDisplayID(task, ws)
|
||||
if displayID == 0 {
|
||||
displayID = i + 1
|
||||
}
|
||||
|
||||
urgency := task.CalculateUrgency(coeffs)
|
||||
@@ -143,19 +132,23 @@ func FormatTaskDetail(task *Task) string {
|
||||
}
|
||||
|
||||
if task.Due != nil {
|
||||
t.AppendRow(table.Row{"Due", formatTimeWithColor(*task.Due)})
|
||||
dueStr := FormatDateWithRelative(*task.Due)
|
||||
if task.Due.Before(timeNow()) {
|
||||
dueStr = color.RedString(dueStr)
|
||||
}
|
||||
t.AppendRow(table.Row{"Due", dueStr})
|
||||
}
|
||||
|
||||
if task.Scheduled != nil {
|
||||
t.AppendRow(table.Row{"Scheduled", formatTime(*task.Scheduled)})
|
||||
t.AppendRow(table.Row{"Scheduled", FormatDateWithRelative(*task.Scheduled)})
|
||||
}
|
||||
|
||||
if task.Wait != nil {
|
||||
t.AppendRow(table.Row{"Wait", formatTime(*task.Wait)})
|
||||
t.AppendRow(table.Row{"Wait", FormatDateWithRelative(*task.Wait)})
|
||||
}
|
||||
|
||||
if task.Until != nil {
|
||||
t.AppendRow(table.Row{"Until", formatTime(*task.Until)})
|
||||
t.AppendRow(table.Row{"Until", FormatDateWithRelative(*task.Until)})
|
||||
}
|
||||
|
||||
if task.RecurrenceDuration != nil {
|
||||
@@ -171,6 +164,37 @@ func FormatTaskDetail(task *Task) string {
|
||||
t.AppendRow(table.Row{"Tags", formatTags(task.Tags)})
|
||||
}
|
||||
|
||||
if len(task.Annotations) > 0 {
|
||||
t.AppendSeparator()
|
||||
for i, ann := range task.Annotations {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
label = "Annotations"
|
||||
}
|
||||
ts := time.Unix(ann.Timestamp, 0).Format("2006-01-02 15:04")
|
||||
t.AppendRow(table.Row{label, fmt.Sprintf("%s %s", ts, ann.Text)})
|
||||
}
|
||||
}
|
||||
|
||||
// Recent changes from change_log (last 5)
|
||||
if entries, err := GetTaskHistory(task.UUID); err == nil && len(entries) > 0 {
|
||||
t.AppendSeparator()
|
||||
// Show last 5 entries
|
||||
start := 0
|
||||
if len(entries) > 5 {
|
||||
start = len(entries) - 5
|
||||
}
|
||||
historyStr := FormatTaskHistory(entries[start:])
|
||||
lines := strings.Split(strings.TrimSpace(historyStr), "\n")
|
||||
for i, line := range lines {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
label = "History"
|
||||
}
|
||||
t.AppendRow(table.Row{label, line})
|
||||
}
|
||||
}
|
||||
|
||||
return t.Render()
|
||||
}
|
||||
|
||||
@@ -234,8 +258,6 @@ func FormatTagCounts(tagCounts map[string]int) string {
|
||||
return t.Render()
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func formatStatus(status Status) string {
|
||||
switch status {
|
||||
case StatusPending:
|
||||
@@ -286,7 +308,6 @@ func formatUrgency(urgency float64) string {
|
||||
}
|
||||
|
||||
func getUrgencyColor(urgency float64) *color.Color {
|
||||
// Returns color for minimal format
|
||||
if urgency >= 10.0 {
|
||||
return color.New(color.FgHiRed, color.Bold)
|
||||
} else if urgency >= 5.0 {
|
||||
@@ -310,24 +331,20 @@ func formatDue(due *time.Time) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
rel := FormatRelativeDate(*due)
|
||||
|
||||
now := timeNow()
|
||||
if due.Before(now) {
|
||||
return color.RedString(due.Format("2006-01-02"))
|
||||
return color.RedString(rel)
|
||||
}
|
||||
|
||||
if due.Before(now.Add(24 * time.Hour)) {
|
||||
return color.YellowString(due.Format("2006-01-02"))
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
tomorrow := today.Add(24 * time.Hour)
|
||||
if due.Before(tomorrow) {
|
||||
return color.YellowString(rel)
|
||||
}
|
||||
|
||||
return due.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func formatTimeWithColor(t time.Time) string {
|
||||
now := time.Now()
|
||||
if t.Before(now) {
|
||||
return color.RedString(t.Format("2006-01-02 15:04"))
|
||||
}
|
||||
return t.Format("2006-01-02 15:04")
|
||||
return rel
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
// FormatTaskSummary returns a one-line summary for action feedback.
|
||||
// Example: `3 "Buy groceries" due:tomorrow +errand`
|
||||
func FormatTaskSummary(task *Task, ws *WorkingSet) string {
|
||||
displayID := resolveDisplayID(task, ws)
|
||||
|
||||
parts := []string{fmt.Sprintf("%d — %q", displayID, task.Description)}
|
||||
|
||||
if task.Due != nil {
|
||||
parts = append(parts, fmt.Sprintf("due:%s", FormatRelativeDate(*task.Due)))
|
||||
}
|
||||
if task.Project != nil {
|
||||
parts = append(parts, fmt.Sprintf("project:%s", *task.Project))
|
||||
}
|
||||
if len(task.Tags) > 0 {
|
||||
for _, tag := range task.Tags {
|
||||
parts = append(parts, color.CyanString("+"+tag))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// FormatTaskConfirmList returns the multi-task confirmation block.
|
||||
// Shows up to 10 tasks, then "...and N more".
|
||||
func FormatTaskConfirmList(action string, tasks []*Task, ws *WorkingSet) string {
|
||||
var b strings.Builder
|
||||
|
||||
limit := 10
|
||||
if len(tasks) < limit {
|
||||
limit = len(tasks)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, "About to %s %d task(s):\n", action, len(tasks))
|
||||
for i := 0; i < limit; i++ {
|
||||
task := tasks[i]
|
||||
displayID := resolveDisplayID(task, ws)
|
||||
line := fmt.Sprintf(" %3d %-40s", displayID, truncate(task.Description, 40))
|
||||
|
||||
if task.Due != nil {
|
||||
line += fmt.Sprintf(" due:%-10s", FormatRelativeDate(*task.Due))
|
||||
}
|
||||
if len(task.Tags) > 0 {
|
||||
tags := make([]string, len(task.Tags))
|
||||
for j, tag := range task.Tags {
|
||||
tags[j] = "+" + tag
|
||||
}
|
||||
line += " " + strings.Join(tags, " ")
|
||||
}
|
||||
fmt.Fprintln(&b, line)
|
||||
}
|
||||
|
||||
if len(tasks) > 10 {
|
||||
fmt.Fprintf(&b, " ...and %d more\n", len(tasks)-10)
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// FormatAddFeedback returns the detailed post-add feedback block.
|
||||
func FormatAddFeedback(task *Task, displayID int) string {
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, "Created task %d — %q\n", displayID, task.Description)
|
||||
|
||||
if task.Due != nil {
|
||||
fmt.Fprintf(&b, " Due: %s\n", FormatDateWithRelative(*task.Due))
|
||||
}
|
||||
if task.Project != nil {
|
||||
fmt.Fprintf(&b, " Project: %s\n", *task.Project)
|
||||
}
|
||||
if task.Priority != PriorityDefault {
|
||||
fmt.Fprintf(&b, " Priority: %s\n", priorityIntToString(task.Priority))
|
||||
}
|
||||
if task.Scheduled != nil {
|
||||
fmt.Fprintf(&b, " Scheduled: %s\n", FormatDateWithRelative(*task.Scheduled))
|
||||
}
|
||||
if task.Wait != nil {
|
||||
fmt.Fprintf(&b, " Wait: %s\n", FormatDateWithRelative(*task.Wait))
|
||||
}
|
||||
if len(task.Tags) > 0 {
|
||||
tags := make([]string, len(task.Tags))
|
||||
for i, tag := range task.Tags {
|
||||
tags[i] = "+" + tag
|
||||
}
|
||||
fmt.Fprintf(&b, " Tags: %s\n", strings.Join(tags, " "))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// FormatRecurringAddFeedback returns feedback for a newly created recurring task.
|
||||
func FormatRecurringAddFeedback(instance *Task, displayID int) string {
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, "Created recurring task %d — %q\n", displayID, instance.Description)
|
||||
|
||||
if instance.RecurrenceDuration != nil {
|
||||
fmt.Fprintf(&b, " Recurrence: %s\n", FormatRecurrenceDuration(*instance.RecurrenceDuration))
|
||||
} else if instance.ParentUUID != nil {
|
||||
// Instance: get recurrence from parent
|
||||
parent, err := GetTask(*instance.ParentUUID)
|
||||
if err == nil && parent.RecurrenceDuration != nil {
|
||||
fmt.Fprintf(&b, " Recurrence: %s\n", FormatRecurrenceDuration(*parent.RecurrenceDuration))
|
||||
}
|
||||
}
|
||||
if instance.Due != nil {
|
||||
fmt.Fprintf(&b, " Due: %s\n", FormatDateWithRelative(*instance.Due))
|
||||
}
|
||||
if len(instance.Tags) > 0 {
|
||||
tags := make([]string, len(instance.Tags))
|
||||
for i, tag := range instance.Tags {
|
||||
tags[i] = "+" + tag
|
||||
}
|
||||
fmt.Fprintf(&b, " Tags: %s\n", strings.Join(tags, " "))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// FormatCompletionFeedback returns completion feedback with recurrence info.
|
||||
func FormatCompletionFeedback(task *Task, displayID int, nextInstance *Task) string {
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, "Completed task %d — %q\n", displayID, task.Description)
|
||||
|
||||
if nextInstance != nil {
|
||||
if nextInstance.Due != nil {
|
||||
fmt.Fprintf(&b, "Next instance created — due: %s\n", FormatDateWithRelative(*nextInstance.Due))
|
||||
} else {
|
||||
fmt.Fprintf(&b, "Next instance created\n")
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func resolveDisplayID(task *Task, ws *WorkingSet) int {
|
||||
if ws == nil {
|
||||
return 0
|
||||
}
|
||||
for id, uuid := range ws.byID {
|
||||
if uuid == task.UUID {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max-1] + "…"
|
||||
}
|
||||
@@ -42,17 +42,18 @@ func ParseFilter(args []string) (*Filter, error) {
|
||||
} else if strings.HasPrefix(arg, "-") && !strings.Contains(arg, ":") {
|
||||
// Exclude tag (but not negative modifiers like priority:-)
|
||||
f.ExcludeTags = append(f.ExcludeTags, strings.TrimPrefix(arg, "-"))
|
||||
} else if strings.Contains(arg, ":") {
|
||||
// Attribute filter
|
||||
parts := strings.SplitN(arg, ":", 2)
|
||||
key := parts[0]
|
||||
value := parts[1]
|
||||
} else if idx := strings.Index(arg, ":"); idx > 0 {
|
||||
key := arg[:idx]
|
||||
value := arg[idx+1:]
|
||||
|
||||
if key == "uuid" {
|
||||
if FilterOnlyKeys[key] {
|
||||
// Filter-only keys (e.g., uuid)
|
||||
f.UUIDs = append(f.UUIDs, value)
|
||||
} else {
|
||||
} else if ValidAttributeKeys[key] {
|
||||
// Known attribute filter
|
||||
f.Attributes[key] = value
|
||||
}
|
||||
// Unrecognized key:value tokens are silently ignored
|
||||
} else {
|
||||
// Try parsing as numeric ID
|
||||
id, err := strconv.Atoi(arg)
|
||||
@@ -70,10 +71,8 @@ func (f *Filter) ToSQL() (string, []interface{}) {
|
||||
conditions := []string{}
|
||||
args := []interface{}{}
|
||||
|
||||
// Track if we have an explicit status filter
|
||||
hasStatusFilter := false
|
||||
|
||||
// Status filter
|
||||
if status, ok := f.Attributes["status"]; ok {
|
||||
hasStatusFilter = true
|
||||
|
||||
@@ -103,13 +102,11 @@ func (f *Filter) ToSQL() (string, []interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Project filter
|
||||
if project, ok := f.Attributes["project"]; ok {
|
||||
conditions = append(conditions, "project = ?")
|
||||
args = append(args, project)
|
||||
}
|
||||
|
||||
// Priority filter
|
||||
if priority, ok := f.Attributes["priority"]; ok {
|
||||
priorityInt := priorityStringToInt(priority)
|
||||
conditions = append(conditions, "priority = ?")
|
||||
@@ -137,7 +134,6 @@ func (f *Filter) ToSQL() (string, []interface{}) {
|
||||
args = append(args, tag)
|
||||
}
|
||||
|
||||
// UUID filter
|
||||
if len(f.UUIDs) > 0 {
|
||||
placeholders := strings.Repeat("?,", len(f.UUIDs))
|
||||
placeholders = placeholders[:len(placeholders)-1]
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// HistoryEntry represents a change_log entry for display.
|
||||
type HistoryEntry struct {
|
||||
ID int
|
||||
Timestamp time.Time
|
||||
ChangeType string // "create", "update", "delete"
|
||||
Data string // raw key:value data from change_log
|
||||
}
|
||||
|
||||
// GetTaskHistory returns change_log entries for a task UUID, ordered chronologically.
|
||||
func GetTaskHistory(taskUUID uuid.UUID) ([]HistoryEntry, error) {
|
||||
db := GetDB()
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("database not initialized")
|
||||
}
|
||||
|
||||
rows, err := db.Query(
|
||||
"SELECT id, changed_at, change_type, data FROM change_log WHERE task_uuid = ? ORDER BY id ASC",
|
||||
taskUUID.String(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query change_log: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []HistoryEntry
|
||||
for rows.Next() {
|
||||
var e HistoryEntry
|
||||
var changedAt int64
|
||||
if err := rows.Scan(&e.ID, &changedAt, &e.ChangeType, &e.Data); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan change_log entry: %w", err)
|
||||
}
|
||||
e.Timestamp = time.Unix(changedAt, 0)
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// FormatTaskHistory returns a diff-style history display.
|
||||
// Compares consecutive entries and shows only what changed.
|
||||
func FormatTaskHistory(entries []HistoryEntry) string {
|
||||
if len(entries) == 0 {
|
||||
return "No history found.\n"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
var prevFields map[string]string
|
||||
|
||||
for _, entry := range entries {
|
||||
ts := entry.Timestamp.Format("2006-01-02 15:04")
|
||||
currentFields := parseChangeData(entry.Data)
|
||||
|
||||
if entry.ChangeType == "create" {
|
||||
// Show creation summary
|
||||
desc := currentFields["description"]
|
||||
priority := currentFields["priority"]
|
||||
tags := currentFields["tags"]
|
||||
line := fmt.Sprintf("%s created \"%s\"", ts, desc)
|
||||
if priority != "" && priority != "D" {
|
||||
line += fmt.Sprintf(" priority:%s", priority)
|
||||
}
|
||||
if tags != "" {
|
||||
for _, tag := range strings.Split(tags, ",") {
|
||||
line += fmt.Sprintf(" +%s", strings.TrimSpace(tag))
|
||||
}
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
} else if entry.ChangeType == "delete" {
|
||||
sb.WriteString(fmt.Sprintf("%s deleted\n", ts))
|
||||
} else if entry.ChangeType == "update" {
|
||||
if prevFields == nil {
|
||||
// No previous entry to diff against, show as generic update
|
||||
sb.WriteString(fmt.Sprintf("%s updated\n", ts))
|
||||
} else {
|
||||
// Diff against previous
|
||||
changes := diffFields(prevFields, currentFields)
|
||||
if len(changes) == 0 {
|
||||
sb.WriteString(fmt.Sprintf("%s updated (tags changed)\n", ts))
|
||||
} else {
|
||||
for i, change := range changes {
|
||||
if i == 0 {
|
||||
sb.WriteString(fmt.Sprintf("%s modified %s\n", ts, change))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", change))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prevFields = currentFields
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// parseChangeData parses key:value lines from change_log data.
|
||||
func parseChangeData(data string) map[string]string {
|
||||
fields := make(map[string]string)
|
||||
for _, line := range strings.Split(data, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, ": ", 2)
|
||||
if len(parts) == 2 {
|
||||
fields[parts[0]] = parts[1]
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// diffFields compares two field maps and returns human-readable change descriptions.
|
||||
func diffFields(prev, curr map[string]string) []string {
|
||||
var changes []string
|
||||
|
||||
// Skip internal/timestamp fields
|
||||
skip := map[string]bool{
|
||||
"uuid": true, "created": true, "modified": true,
|
||||
}
|
||||
|
||||
// Check fields in current that differ from prev
|
||||
for key, currVal := range curr {
|
||||
if skip[key] {
|
||||
continue
|
||||
}
|
||||
prevVal, existed := prev[key]
|
||||
if !existed {
|
||||
changes = append(changes, fmt.Sprintf("%s: (none) → %s", key, formatFieldValue(key, currVal)))
|
||||
} else if prevVal != currVal {
|
||||
changes = append(changes, fmt.Sprintf("%s: %s → %s", key, formatFieldValue(key, prevVal), formatFieldValue(key, currVal)))
|
||||
}
|
||||
}
|
||||
|
||||
// Check fields removed (in prev but not in current)
|
||||
for key, prevVal := range prev {
|
||||
if skip[key] {
|
||||
continue
|
||||
}
|
||||
if _, exists := curr[key]; !exists {
|
||||
changes = append(changes, fmt.Sprintf("%s: %s → (none)", key, formatFieldValue(key, prevVal)))
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
// formatFieldValue formats a change_log field value for human display.
|
||||
func formatFieldValue(key, value string) string {
|
||||
// For timestamp fields, try to format as dates
|
||||
switch key {
|
||||
case "due", "scheduled", "wait", "until", "start", "end":
|
||||
if t, err := parseUnixString(value); err == nil {
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
case "status":
|
||||
return value // already human-readable
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// parseUnixString parses a unix timestamp string.
|
||||
func parseUnixString(s string) (time.Time, error) {
|
||||
var ts int64
|
||||
_, err := fmt.Sscanf(s, "%d", &ts)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return time.Unix(ts, 0), nil
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseChangeData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected map[string]string
|
||||
}{
|
||||
{
|
||||
"basic key-value pairs",
|
||||
"description: Buy groceries\nstatus: pending\npriority: H",
|
||||
map[string]string{"description": "Buy groceries", "status": "pending", "priority": "H"},
|
||||
},
|
||||
{
|
||||
"empty string",
|
||||
"",
|
||||
map[string]string{},
|
||||
},
|
||||
{
|
||||
"whitespace only",
|
||||
" \n \n ",
|
||||
map[string]string{},
|
||||
},
|
||||
{
|
||||
"value with colon",
|
||||
"description: Fix bug: crash on startup\nstatus: pending",
|
||||
map[string]string{"description": "Fix bug: crash on startup", "status": "pending"},
|
||||
},
|
||||
{
|
||||
"trailing newlines",
|
||||
"description: Test\nstatus: pending\n\n",
|
||||
map[string]string{"description": "Test", "status": "pending"},
|
||||
},
|
||||
{
|
||||
"no separator",
|
||||
"this line has no colon-space separator",
|
||||
map[string]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := parseChangeData(tt.input)
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Errorf("len = %d, want %d; got %v", len(result), len(tt.expected), result)
|
||||
return
|
||||
}
|
||||
for k, v := range tt.expected {
|
||||
if result[k] != v {
|
||||
t.Errorf("key %q = %q, want %q", k, result[k], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prev map[string]string
|
||||
curr map[string]string
|
||||
expectChanges int
|
||||
expectSubstr []string // substrings that should appear in changes
|
||||
}{
|
||||
{
|
||||
"no changes",
|
||||
map[string]string{"description": "Test", "status": "pending"},
|
||||
map[string]string{"description": "Test", "status": "pending"},
|
||||
0, nil,
|
||||
},
|
||||
{
|
||||
"status change",
|
||||
map[string]string{"status": "pending"},
|
||||
map[string]string{"status": "completed"},
|
||||
1, []string{"status: pending → completed"},
|
||||
},
|
||||
{
|
||||
"field added",
|
||||
map[string]string{"description": "Test"},
|
||||
map[string]string{"description": "Test", "priority": "H"},
|
||||
1, []string{"priority: (none) → H"},
|
||||
},
|
||||
{
|
||||
"field removed",
|
||||
map[string]string{"description": "Test", "priority": "H"},
|
||||
map[string]string{"description": "Test"},
|
||||
1, []string{"priority: H → (none)"},
|
||||
},
|
||||
{
|
||||
"skips uuid and timestamps",
|
||||
map[string]string{"uuid": "abc", "created": "123", "modified": "456", "status": "pending"},
|
||||
map[string]string{"uuid": "def", "created": "789", "modified": "012", "status": "completed"},
|
||||
1, []string{"status"},
|
||||
},
|
||||
{
|
||||
"multiple changes",
|
||||
map[string]string{"description": "Old", "status": "pending", "priority": "L"},
|
||||
map[string]string{"description": "New", "status": "active", "priority": "H"},
|
||||
3, nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
changes := diffFields(tt.prev, tt.curr)
|
||||
if len(changes) != tt.expectChanges {
|
||||
t.Errorf("got %d changes, want %d: %v", len(changes), tt.expectChanges, changes)
|
||||
}
|
||||
for _, substr := range tt.expectSubstr {
|
||||
found := false
|
||||
for _, c := range changes {
|
||||
if strings.Contains(c, substr) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected change containing %q, got %v", substr, changes)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatFieldValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
value string
|
||||
expected string
|
||||
}{
|
||||
{"status passthrough", "status", "pending", "pending"},
|
||||
{"description passthrough", "description", "Buy milk", "Buy milk"},
|
||||
{"due as unix timestamp", "due", "1771977600", "2026-02-25"},
|
||||
{"invalid timestamp", "due", "not-a-number", "not-a-number"},
|
||||
{"scheduled as timestamp", "scheduled", "1771977600", "2026-02-25"},
|
||||
{"start as timestamp", "start", "1771977600", "2026-02-25"},
|
||||
{"end as timestamp", "end", "1771977600", "2026-02-25"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := formatFieldValue(tt.key, tt.value)
|
||||
if result != tt.expected {
|
||||
t.Errorf("formatFieldValue(%q, %q) = %q, want %q", tt.key, tt.value, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTaskHistory_Empty(t *testing.T) {
|
||||
result := FormatTaskHistory(nil)
|
||||
if result != "No history found.\n" {
|
||||
t.Errorf("expected 'No history found.\\n', got %q", result)
|
||||
}
|
||||
|
||||
result = FormatTaskHistory([]HistoryEntry{})
|
||||
if result != "No history found.\n" {
|
||||
t.Errorf("expected 'No history found.\\n', got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTaskHistory_CreateEntry(t *testing.T) {
|
||||
entries := []HistoryEntry{
|
||||
{
|
||||
ID: 1,
|
||||
Timestamp: time.Date(2026, 2, 18, 10, 0, 0, 0, time.UTC),
|
||||
ChangeType: "create",
|
||||
Data: "description: Buy groceries\nstatus: pending\npriority: H\ntags: errand,shopping",
|
||||
},
|
||||
}
|
||||
|
||||
result := FormatTaskHistory(entries)
|
||||
|
||||
if !strings.Contains(result, "created") {
|
||||
t.Error("expected 'created' in output")
|
||||
}
|
||||
if !strings.Contains(result, "Buy groceries") {
|
||||
t.Error("expected description in output")
|
||||
}
|
||||
if !strings.Contains(result, "priority:H") {
|
||||
t.Error("expected priority in output")
|
||||
}
|
||||
if !strings.Contains(result, "+errand") {
|
||||
t.Error("expected +errand tag in output")
|
||||
}
|
||||
if !strings.Contains(result, "+shopping") {
|
||||
t.Error("expected +shopping tag in output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTaskHistory_CreateWithDefaultPriority(t *testing.T) {
|
||||
entries := []HistoryEntry{
|
||||
{
|
||||
ID: 1,
|
||||
Timestamp: time.Date(2026, 2, 18, 10, 0, 0, 0, time.UTC),
|
||||
ChangeType: "create",
|
||||
Data: "description: Simple task\nstatus: pending\npriority: D",
|
||||
},
|
||||
}
|
||||
|
||||
result := FormatTaskHistory(entries)
|
||||
|
||||
// Default priority "D" should not be shown
|
||||
if strings.Contains(result, "priority") {
|
||||
t.Errorf("default priority should not appear in output: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTaskHistory_UpdateDiff(t *testing.T) {
|
||||
entries := []HistoryEntry{
|
||||
{
|
||||
ID: 1,
|
||||
Timestamp: time.Date(2026, 2, 18, 10, 0, 0, 0, time.UTC),
|
||||
ChangeType: "create",
|
||||
Data: "description: Buy groceries\nstatus: pending\npriority: D",
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Timestamp: time.Date(2026, 2, 18, 11, 0, 0, 0, time.UTC),
|
||||
ChangeType: "update",
|
||||
Data: "description: Buy groceries\nstatus: pending\npriority: H",
|
||||
},
|
||||
}
|
||||
|
||||
result := FormatTaskHistory(entries)
|
||||
|
||||
if !strings.Contains(result, "modified") {
|
||||
t.Error("expected 'modified' in output for update with diff")
|
||||
}
|
||||
// Should show priority change
|
||||
if !strings.Contains(result, "priority") {
|
||||
t.Errorf("expected priority change in diff output: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTaskHistory_DeleteEntry(t *testing.T) {
|
||||
entries := []HistoryEntry{
|
||||
{
|
||||
ID: 1,
|
||||
Timestamp: time.Date(2026, 2, 18, 10, 0, 0, 0, time.UTC),
|
||||
ChangeType: "create",
|
||||
Data: "description: Task\nstatus: pending",
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Timestamp: time.Date(2026, 2, 18, 12, 0, 0, 0, time.UTC),
|
||||
ChangeType: "delete",
|
||||
Data: "",
|
||||
},
|
||||
}
|
||||
|
||||
result := FormatTaskHistory(entries)
|
||||
if !strings.Contains(result, "deleted") {
|
||||
t.Error("expected 'deleted' in output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTaskHistory_UpdateWithNoPrev(t *testing.T) {
|
||||
// Update entry without a preceding create (edge case)
|
||||
entries := []HistoryEntry{
|
||||
{
|
||||
ID: 1,
|
||||
Timestamp: time.Date(2026, 2, 18, 10, 0, 0, 0, time.UTC),
|
||||
ChangeType: "update",
|
||||
Data: "description: Task\nstatus: completed",
|
||||
},
|
||||
}
|
||||
|
||||
result := FormatTaskHistory(entries)
|
||||
if !strings.Contains(result, "updated") {
|
||||
t.Errorf("expected 'updated' for update with no prev: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTaskHistory_TimestampFormat(t *testing.T) {
|
||||
entries := []HistoryEntry{
|
||||
{
|
||||
ID: 1,
|
||||
Timestamp: time.Date(2026, 2, 18, 14, 30, 0, 0, time.UTC),
|
||||
ChangeType: "create",
|
||||
Data: "description: Test\nstatus: pending",
|
||||
},
|
||||
}
|
||||
|
||||
result := FormatTaskHistory(entries)
|
||||
if !strings.Contains(result, "2026-02-18 14:30") {
|
||||
t.Errorf("expected timestamp '2026-02-18 14:30' in output: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUnixString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
year int
|
||||
}{
|
||||
{"valid timestamp", "1771977600", false, 2026},
|
||||
{"zero", "0", false, 1970},
|
||||
{"invalid", "abc", true, 0},
|
||||
{"empty", "", true, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := parseUnixString(tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Year() != tt.year {
|
||||
t.Errorf("year = %d, want %d", result.Year(), tt.year)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package engine
|
||||
|
||||
// ValidAttributeKeys are the recognized key:value attribute names.
|
||||
// Used by parseAddArgs (cmd/add.go), ParseFilter, and ParseModifier
|
||||
// to distinguish modifiers from description text.
|
||||
var ValidAttributeKeys = map[string]bool{
|
||||
"description": true,
|
||||
"due": true,
|
||||
"priority": true,
|
||||
"project": true,
|
||||
"recur": true,
|
||||
"status": true,
|
||||
"wait": true,
|
||||
"scheduled": true,
|
||||
"until": true,
|
||||
}
|
||||
|
||||
// DateKeys is the subset of ValidAttributeKeys that hold date values.
|
||||
// Used by Modifier.Apply and Modifier.ApplyToNew for date parsing.
|
||||
var DateKeys = map[string]bool{
|
||||
"due": true,
|
||||
"scheduled": true,
|
||||
"wait": true,
|
||||
"until": true,
|
||||
}
|
||||
|
||||
// FilterOnlyKeys are additional keys valid in filter context but not as
|
||||
// modifiers (e.g., uuid is a filter target, not something you set via modify).
|
||||
var FilterOnlyKeys = map[string]bool{
|
||||
"uuid": true,
|
||||
}
|
||||
@@ -23,7 +23,16 @@ func NewModifier() *Modifier {
|
||||
}
|
||||
}
|
||||
|
||||
// ParseModifier parses command-line args into Modifier
|
||||
// Set adds an attribute to the modifier, maintaining the SetAttributes and
|
||||
// AttributeOrder invariant. Pass nil to clear the attribute.
|
||||
func (m *Modifier) Set(key string, value *string) {
|
||||
m.SetAttributes[key] = value
|
||||
m.AttributeOrder = append(m.AttributeOrder, key)
|
||||
}
|
||||
|
||||
// ParseModifier parses command-line args into Modifier.
|
||||
// Only recognized attribute keys (ValidAttributeKeys) are accepted;
|
||||
// unrecognized key:value tokens produce an error.
|
||||
func ParseModifier(args []string) (*Modifier, error) {
|
||||
m := NewModifier()
|
||||
|
||||
@@ -34,11 +43,13 @@ func ParseModifier(args []string) (*Modifier, error) {
|
||||
} else if strings.HasPrefix(arg, "-") && !strings.Contains(arg, ":") {
|
||||
// Remove tag
|
||||
m.RemoveTags = append(m.RemoveTags, strings.TrimPrefix(arg, "-"))
|
||||
} else if strings.Contains(arg, ":") {
|
||||
// Attribute modification
|
||||
parts := strings.SplitN(arg, ":", 2)
|
||||
key := parts[0]
|
||||
value := parts[1]
|
||||
} else if idx := strings.Index(arg, ":"); idx > 0 {
|
||||
key := arg[:idx]
|
||||
value := arg[idx+1:]
|
||||
|
||||
if !ValidAttributeKeys[key] {
|
||||
return nil, fmt.Errorf("unknown modifier: %q (known: due, priority, project, recur, status, wait, scheduled, until)", key)
|
||||
}
|
||||
|
||||
if value == "" {
|
||||
// Clear attribute (priority: with no value)
|
||||
@@ -82,13 +93,20 @@ func (m *Modifier) Apply(task *Task) error {
|
||||
resolvedDates["created"] = task.Created
|
||||
resolvedDates["modified"] = task.Modified
|
||||
|
||||
// Safety net: if SetAttributes were populated without AttributeOrder,
|
||||
// reconstruct order from map keys so updates aren't silently dropped.
|
||||
if len(m.AttributeOrder) == 0 && len(m.SetAttributes) > 0 {
|
||||
for key := range m.SetAttributes {
|
||||
m.AttributeOrder = append(m.AttributeOrder, key)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply attributes in the order they were specified (important for relative references)
|
||||
dateKeys := map[string]bool{"due": true, "scheduled": true, "wait": true, "until": true}
|
||||
dateKeys := DateKeys
|
||||
|
||||
for _, key := range m.AttributeOrder {
|
||||
valuePtr := m.SetAttributes[key]
|
||||
|
||||
// Handle date attributes with relative expression support
|
||||
if dateKeys[key] {
|
||||
if err := applyDateAttribute(key, valuePtr, task, resolvedDates); err != nil {
|
||||
return err
|
||||
@@ -96,30 +114,11 @@ func (m *Modifier) Apply(task *Task) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle non-date attributes
|
||||
switch key {
|
||||
case "priority":
|
||||
if valuePtr == nil {
|
||||
task.Priority = PriorityDefault
|
||||
} else {
|
||||
task.Priority = Priority(priorityStringToInt(*valuePtr))
|
||||
}
|
||||
case "project":
|
||||
task.Project = valuePtr
|
||||
case "recur":
|
||||
if valuePtr == nil {
|
||||
task.RecurrenceDuration = nil
|
||||
} else {
|
||||
duration, err := ParseRecurrencePattern(*valuePtr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid recurrence: %w", err)
|
||||
}
|
||||
task.RecurrenceDuration = &duration
|
||||
}
|
||||
if err := applyNonDateAttribute(key, valuePtr, task); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Apply tag changes
|
||||
for _, tag := range m.AddTags {
|
||||
if err := task.AddTag(tag); err != nil {
|
||||
return err
|
||||
@@ -150,13 +149,20 @@ func (m *Modifier) ApplyToNew(task *Task) error {
|
||||
resolvedDates["created"] = task.Created
|
||||
}
|
||||
|
||||
// Safety net: if SetAttributes were populated without AttributeOrder,
|
||||
// reconstruct order from map keys so updates aren't silently dropped.
|
||||
if len(m.AttributeOrder) == 0 && len(m.SetAttributes) > 0 {
|
||||
for key := range m.SetAttributes {
|
||||
m.AttributeOrder = append(m.AttributeOrder, key)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply attributes in the order they were specified (important for relative references)
|
||||
dateKeys := map[string]bool{"due": true, "scheduled": true, "wait": true, "until": true}
|
||||
dateKeys := DateKeys
|
||||
|
||||
for _, key := range m.AttributeOrder {
|
||||
valuePtr := m.SetAttributes[key]
|
||||
|
||||
// Handle date attributes with relative expression support
|
||||
if dateKeys[key] {
|
||||
if err := applyDateAttribute(key, valuePtr, task, resolvedDates); err != nil {
|
||||
return err
|
||||
@@ -164,8 +170,26 @@ func (m *Modifier) ApplyToNew(task *Task) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle non-date attributes
|
||||
if err := applyNonDateAttribute(key, valuePtr, task); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Tags are added after task is saved (in CreateTask function)
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyNonDateAttribute applies a non-date attribute to a task.
|
||||
func applyNonDateAttribute(key string, valuePtr *string, task *Task) error {
|
||||
switch key {
|
||||
case "description":
|
||||
if valuePtr != nil {
|
||||
task.Description = *valuePtr
|
||||
}
|
||||
case "status":
|
||||
if valuePtr != nil && len(*valuePtr) > 0 {
|
||||
task.Status = Status((*valuePtr)[0])
|
||||
}
|
||||
case "priority":
|
||||
if valuePtr == nil {
|
||||
task.Priority = PriorityDefault
|
||||
@@ -185,9 +209,6 @@ func (m *Modifier) ApplyToNew(task *Task) error {
|
||||
task.RecurrenceDuration = &duration
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Tags are added after task is saved (in CreateTask function)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -14,20 +14,16 @@ func ParseKeyValueFormat(data string, skipComments bool) (map[string]string, err
|
||||
lines := strings.Split(data, "\n")
|
||||
|
||||
for i, line := range lines {
|
||||
// Trim whitespace
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// Skip empty lines
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip comments if requested
|
||||
if skipComments && strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Split on first ':'
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("line %d: invalid format (expected 'key:value')", i+1)
|
||||
|
||||
@@ -188,20 +188,21 @@ func CreateRecurringTask(description string, mod *Modifier) (*Task, error) {
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
// SpawnNextInstance creates a new task instance from completed recurring task
|
||||
func SpawnNextInstance(completedInstance *Task) error {
|
||||
// SpawnNextInstance creates a new task instance from completed recurring task.
|
||||
// Returns the newly created instance, or nil if recurrence has expired.
|
||||
func SpawnNextInstance(completedInstance *Task) (*Task, error) {
|
||||
if completedInstance.ParentUUID == nil {
|
||||
return fmt.Errorf("task is not a recurring instance")
|
||||
return nil, fmt.Errorf("task is not a recurring instance")
|
||||
}
|
||||
|
||||
// Load template
|
||||
template, err := GetTask(*completedInstance.ParentUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load template: %w", err)
|
||||
return nil, fmt.Errorf("failed to load template: %w", err)
|
||||
}
|
||||
|
||||
if template.RecurrenceDuration == nil {
|
||||
return fmt.Errorf("template has no recurrence duration")
|
||||
return nil, fmt.Errorf("template has no recurrence duration")
|
||||
}
|
||||
|
||||
// Calculate next due date
|
||||
@@ -212,7 +213,7 @@ func SpawnNextInstance(completedInstance *Task) error {
|
||||
} else if completedInstance.Due != nil {
|
||||
baseDate = *completedInstance.Due
|
||||
} else {
|
||||
return fmt.Errorf("recurring instance has no due or end date")
|
||||
return nil, fmt.Errorf("recurring instance has no due or end date")
|
||||
}
|
||||
|
||||
next := CalculateNextDue(baseDate, *template.RecurrenceDuration)
|
||||
@@ -221,7 +222,7 @@ func SpawnNextInstance(completedInstance *Task) error {
|
||||
// Check if we're past 'until' date
|
||||
if template.Until != nil && nextDue != nil && nextDue.After(*template.Until) {
|
||||
// Don't spawn, recurrence has expired
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Create new instance
|
||||
@@ -243,20 +244,20 @@ func SpawnNextInstance(completedInstance *Task) error {
|
||||
}
|
||||
|
||||
if err := newInstance.Save(); err != nil {
|
||||
return fmt.Errorf("failed to save new instance: %w", err)
|
||||
return nil, fmt.Errorf("failed to save new instance: %w", err)
|
||||
}
|
||||
|
||||
// Copy tags from template
|
||||
templateTags, err := template.GetTags()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get template tags: %w", err)
|
||||
return nil, fmt.Errorf("failed to get template tags: %w", err)
|
||||
}
|
||||
|
||||
for _, tag := range templateTags {
|
||||
if err := newInstance.AddTag(tag); err != nil {
|
||||
return fmt.Errorf("failed to add tag: %w", err)
|
||||
return nil, fmt.Errorf("failed to add tag: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return newInstance, nil
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ func TestSpawnNextInstance(t *testing.T) {
|
||||
}
|
||||
|
||||
// Complete the instance (should spawn next)
|
||||
if err := instance1.Complete(); err != nil {
|
||||
if _, err := instance1.Complete(); err != nil {
|
||||
t.Fatalf("Failed to complete instance: %v", err)
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ func TestRecurrenceWithUntilDate(t *testing.T) {
|
||||
}
|
||||
|
||||
// Complete instance - should NOT spawn new one (past until date)
|
||||
if err := instance.Complete(); err != nil {
|
||||
if _, err := instance.Complete(); err != nil {
|
||||
t.Fatalf("Failed to complete instance: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FormatRelativeDate returns a human-readable relative date string.
|
||||
// Within 14 days: "today", "tomorrow", "yesterday", "in 3d", "2d ago"
|
||||
// Beyond 14 days: "Feb 28", "Mar 15"
|
||||
// Cross-year: "Feb 28 2027"
|
||||
func FormatRelativeDate(t time.Time) string {
|
||||
now := timeNow()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
target := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
|
||||
days := int(math.Round(target.Sub(today).Hours() / 24))
|
||||
|
||||
switch {
|
||||
case days == 0:
|
||||
return "today"
|
||||
case days == 1:
|
||||
return "tomorrow"
|
||||
case days == -1:
|
||||
return "yesterday"
|
||||
case days > 1 && days <= 14:
|
||||
return fmt.Sprintf("in %dd", days)
|
||||
case days < -1 && days >= -14:
|
||||
return fmt.Sprintf("%dd ago", -days)
|
||||
default:
|
||||
if t.Year() != now.Year() {
|
||||
return t.Format("Jan 2 2006")
|
||||
}
|
||||
return t.Format("Jan 2")
|
||||
}
|
||||
}
|
||||
|
||||
// FormatDateWithRelative returns "2026-02-20 (in 2 days)" style.
|
||||
// Used in info/detail views where both absolute and relative are useful.
|
||||
func FormatDateWithRelative(t time.Time) string {
|
||||
absolute := t.Format("2006-01-02 15:04")
|
||||
relative := FormatRelativeDate(t)
|
||||
return fmt.Sprintf("%s (%s)", absolute, relative)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFormatRelativeDate(t *testing.T) {
|
||||
// Fix timeNow for deterministic tests
|
||||
origTimeNow := timeNow
|
||||
defer func() { timeNow = origTimeNow }()
|
||||
|
||||
// Wednesday, Feb 18, 2026 at 14:30 local time
|
||||
now := time.Date(2026, 2, 18, 14, 30, 0, 0, time.Local)
|
||||
timeNow = func() time.Time { return now }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input time.Time
|
||||
expected string
|
||||
}{
|
||||
// Core relative dates
|
||||
{"today", time.Date(2026, 2, 18, 0, 0, 0, 0, time.Local), "today"},
|
||||
{"today with time", time.Date(2026, 2, 18, 23, 59, 0, 0, time.Local), "today"},
|
||||
{"tomorrow", time.Date(2026, 2, 19, 0, 0, 0, 0, time.Local), "tomorrow"},
|
||||
{"yesterday", time.Date(2026, 2, 17, 0, 0, 0, 0, time.Local), "yesterday"},
|
||||
|
||||
// Near future
|
||||
{"in 2d", time.Date(2026, 2, 20, 0, 0, 0, 0, time.Local), "in 2d"},
|
||||
{"in 7d", time.Date(2026, 2, 25, 0, 0, 0, 0, time.Local), "in 7d"},
|
||||
{"in 14d", time.Date(2026, 3, 4, 0, 0, 0, 0, time.Local), "in 14d"},
|
||||
|
||||
// Near past
|
||||
{"2d ago", time.Date(2026, 2, 16, 0, 0, 0, 0, time.Local), "2d ago"},
|
||||
{"7d ago", time.Date(2026, 2, 11, 0, 0, 0, 0, time.Local), "7d ago"},
|
||||
{"14d ago", time.Date(2026, 2, 4, 0, 0, 0, 0, time.Local), "14d ago"},
|
||||
|
||||
// Beyond 14 days - same year
|
||||
{"15d future", time.Date(2026, 3, 5, 0, 0, 0, 0, time.Local), "Mar 5"},
|
||||
{"15d past", time.Date(2026, 2, 3, 0, 0, 0, 0, time.Local), "Feb 3"},
|
||||
|
||||
// Cross-year
|
||||
{"next year", time.Date(2027, 6, 15, 0, 0, 0, 0, time.Local), "Jun 15 2027"},
|
||||
{"last year", time.Date(2025, 12, 1, 0, 0, 0, 0, time.Local), "Dec 1 2025"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := FormatRelativeDate(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("FormatRelativeDate(%v) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatRelativeDate_WeekdayPipeline(t *testing.T) {
|
||||
// This test reproduces the reported bug:
|
||||
// On Wednesday, "due:friday" should show "in 2d", not "tomorrow"
|
||||
origTimeNow := timeNow
|
||||
defer func() { timeNow = origTimeNow }()
|
||||
|
||||
// Wednesday, Feb 18, 2026
|
||||
wednesday := time.Date(2026, 2, 18, 10, 0, 0, 0, time.Local)
|
||||
timeNow = func() time.Time { return wednesday }
|
||||
|
||||
parser := NewDateParser(wednesday, time.Monday)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
weekday string
|
||||
expectedRel string
|
||||
expectedDay time.Weekday
|
||||
}{
|
||||
{"friday from wednesday", "friday", "in 2d", time.Friday},
|
||||
{"fri from wednesday", "fri", "in 2d", time.Friday},
|
||||
{"thursday from wednesday", "thu", "tomorrow", time.Thursday},
|
||||
{"saturday from wednesday", "sat", "in 3d", time.Saturday},
|
||||
{"sunday from wednesday", "sun", "in 4d", time.Sunday},
|
||||
{"monday from wednesday", "mon", "in 5d", time.Monday},
|
||||
{"tuesday from wednesday", "tue", "in 6d", time.Tuesday},
|
||||
{"wednesday from wednesday", "wed", "in 7d", time.Wednesday},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parsed, err := parser.ParseDate(tt.weekday)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDate(%q) error: %v", tt.weekday, err)
|
||||
}
|
||||
|
||||
// Verify correct weekday
|
||||
if parsed.Weekday() != tt.expectedDay {
|
||||
t.Errorf("ParseDate(%q) weekday = %v, want %v", tt.weekday, parsed.Weekday(), tt.expectedDay)
|
||||
}
|
||||
|
||||
// Verify relative display
|
||||
rel := FormatRelativeDate(parsed)
|
||||
if rel != tt.expectedRel {
|
||||
t.Errorf("FormatRelativeDate(ParseDate(%q)) = %q, want %q (parsed date: %v)",
|
||||
tt.weekday, rel, tt.expectedRel, parsed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatRelativeDate_AllWeekdaysFromAllDays(t *testing.T) {
|
||||
// Exhaustive: parse every weekday name from every starting day of the week
|
||||
origTimeNow := timeNow
|
||||
defer func() { timeNow = origTimeNow }()
|
||||
|
||||
// Week starting Monday Feb 16 2026
|
||||
weekStart := time.Date(2026, 2, 16, 12, 0, 0, 0, time.Local) // Monday
|
||||
|
||||
weekdays := []string{"mon", "tue", "wed", "thu", "fri", "sat", "sun"}
|
||||
targetWeekdays := []time.Weekday{
|
||||
time.Monday, time.Tuesday, time.Wednesday, time.Thursday,
|
||||
time.Friday, time.Saturday, time.Sunday,
|
||||
}
|
||||
|
||||
for fromOffset := 0; fromOffset < 7; fromOffset++ {
|
||||
fromDate := weekStart.AddDate(0, 0, fromOffset)
|
||||
fromName := fromDate.Weekday().String()
|
||||
|
||||
timeNow = func() time.Time { return fromDate }
|
||||
parser := NewDateParser(fromDate, time.Monday)
|
||||
|
||||
for i, dayName := range weekdays {
|
||||
t.Run(fromName+"_to_"+dayName, func(t *testing.T) {
|
||||
parsed, err := parser.ParseDate(dayName)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDate(%q) from %s: %v", dayName, fromName, err)
|
||||
}
|
||||
|
||||
// Must be the correct weekday
|
||||
if parsed.Weekday() != targetWeekdays[i] {
|
||||
t.Errorf("wrong weekday: got %v, want %v", parsed.Weekday(), targetWeekdays[i])
|
||||
}
|
||||
|
||||
// Must be in the future (1-7 days from now)
|
||||
diff := parsed.Sub(time.Date(fromDate.Year(), fromDate.Month(), fromDate.Day(), 0, 0, 0, 0, time.Local))
|
||||
days := int(diff.Hours() / 24)
|
||||
if days < 1 || days > 7 {
|
||||
t.Errorf("ParseDate(%q) from %s: expected 1-7 days ahead, got %d (parsed: %v)",
|
||||
dayName, fromName, days, parsed)
|
||||
}
|
||||
|
||||
// FormatRelativeDate must match the days offset
|
||||
rel := FormatRelativeDate(parsed)
|
||||
if days == 1 && rel != "tomorrow" {
|
||||
t.Errorf("1 day ahead should be 'tomorrow', got %q", rel)
|
||||
}
|
||||
if days > 1 && days <= 7 {
|
||||
expected := "in " + string(rune('0'+days)) + "d"
|
||||
if days >= 10 {
|
||||
// won't happen for weekdays (max 7)
|
||||
}
|
||||
if rel != expected {
|
||||
t.Errorf("from %s, %q: %d days ahead, got rel=%q, want %q",
|
||||
fromName, dayName, days, rel, expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatRelativeDate_TimezoneConsistency(t *testing.T) {
|
||||
// Verify that dates in UTC vs Local don't produce wrong relative strings
|
||||
origTimeNow := timeNow
|
||||
defer func() { timeNow = origTimeNow }()
|
||||
|
||||
now := time.Date(2026, 2, 18, 14, 0, 0, 0, time.Local)
|
||||
timeNow = func() time.Time { return now }
|
||||
|
||||
// A date 2 days from now, but in UTC
|
||||
targetUTC := time.Date(2026, 2, 20, 0, 0, 0, 0, time.UTC)
|
||||
// Same date in Local
|
||||
targetLocal := time.Date(2026, 2, 20, 0, 0, 0, 0, time.Local)
|
||||
|
||||
relUTC := FormatRelativeDate(targetUTC)
|
||||
relLocal := FormatRelativeDate(targetLocal)
|
||||
|
||||
// Both should show "in 2d" - if UTC shows something different, that's a bug
|
||||
if relLocal != "in 2d" {
|
||||
t.Errorf("Local target: expected 'in 2d', got %q", relLocal)
|
||||
}
|
||||
|
||||
// Note: UTC target may differ depending on system timezone.
|
||||
// This test documents the behavior.
|
||||
t.Logf("Local timezone: now=%v", now)
|
||||
t.Logf("UTC target relative: %q, Local target relative: %q", relUTC, relLocal)
|
||||
|
||||
if relUTC != relLocal {
|
||||
t.Logf("WARNING: timezone mismatch detected — UTC shows %q vs Local shows %q", relUTC, relLocal)
|
||||
t.Logf("This could explain the 'due:friday shows tomorrow' bug if dates are stored/loaded in wrong timezone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDateWithRelative(t *testing.T) {
|
||||
origTimeNow := timeNow
|
||||
defer func() { timeNow = origTimeNow }()
|
||||
|
||||
now := time.Date(2026, 2, 18, 14, 0, 0, 0, time.Local)
|
||||
timeNow = func() time.Time { return now }
|
||||
|
||||
input := time.Date(2026, 2, 20, 15, 30, 0, 0, time.Local)
|
||||
result := FormatDateWithRelative(input)
|
||||
|
||||
// Should contain both absolute and relative
|
||||
if result != "2026-02-20 15:30 (in 2d)" {
|
||||
t.Errorf("FormatDateWithRelative = %q, want %q", result, "2026-02-20 15:30 (in 2d)")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// DisplayFormat defines how tasks should be displayed
|
||||
@@ -20,6 +21,7 @@ type Report struct {
|
||||
DisplayFormat DisplayFormat // How to display results
|
||||
SortFunc func([]*Task) []*Task
|
||||
LimitFunc func([]*Task) []*Task
|
||||
ShowWaiting bool // If false (default), tasks with future wait dates are hidden
|
||||
}
|
||||
|
||||
// AllReports returns all predefined reports
|
||||
@@ -76,6 +78,7 @@ func AllReport() *Report {
|
||||
Description: "All tasks",
|
||||
BaseFilter: filter,
|
||||
DisplayFormat: DisplayFormatTable,
|
||||
ShowWaiting: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,16 +134,11 @@ func NewestReport() *Report {
|
||||
BaseFilter: filter,
|
||||
DisplayFormat: DisplayFormatTable,
|
||||
SortFunc: func(tasks []*Task) []*Task {
|
||||
// Sort by created descending
|
||||
sorted := make([]*Task, len(tasks))
|
||||
copy(sorted, tasks)
|
||||
for i := 0; i < len(sorted)-1; i++ {
|
||||
for j := i + 1; j < len(sorted); j++ {
|
||||
if sorted[i].Created.Before(sorted[j].Created) {
|
||||
sorted[i], sorted[j] = sorted[j], sorted[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].Created.After(sorted[j].Created)
|
||||
})
|
||||
return sorted
|
||||
},
|
||||
LimitFunc: func(tasks []*Task) []*Task {
|
||||
@@ -164,23 +162,14 @@ func NextReport() *Report {
|
||||
BaseFilter: filter,
|
||||
DisplayFormat: DisplayFormatTable,
|
||||
SortFunc: func(tasks []*Task) []*Task {
|
||||
// Sort by urgency descending
|
||||
cfg, _ := GetConfig()
|
||||
coeffs := BuildUrgencyCoefficients(cfg)
|
||||
|
||||
sorted := make([]*Task, len(tasks))
|
||||
copy(sorted, tasks)
|
||||
|
||||
for i := 0; i < len(sorted)-1; i++ {
|
||||
for j := i + 1; j < len(sorted); j++ {
|
||||
urgI := sorted[i].CalculateUrgency(coeffs)
|
||||
urgJ := sorted[j].CalculateUrgency(coeffs)
|
||||
if urgI < urgJ {
|
||||
sorted[i], sorted[j] = sorted[j], sorted[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].CalculateUrgency(coeffs) > sorted[j].CalculateUrgency(coeffs)
|
||||
})
|
||||
return sorted
|
||||
},
|
||||
LimitFunc: func(tasks []*Task) []*Task {
|
||||
@@ -208,16 +197,11 @@ func OldestReport() *Report {
|
||||
BaseFilter: filter,
|
||||
DisplayFormat: DisplayFormatTable,
|
||||
SortFunc: func(tasks []*Task) []*Task {
|
||||
// Sort by created ascending (already default, but explicit)
|
||||
sorted := make([]*Task, len(tasks))
|
||||
copy(sorted, tasks)
|
||||
for i := 0; i < len(sorted)-1; i++ {
|
||||
for j := i + 1; j < len(sorted); j++ {
|
||||
if sorted[i].Created.After(sorted[j].Created) {
|
||||
sorted[i], sorted[j] = sorted[j], sorted[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].Created.Before(sorted[j].Created)
|
||||
})
|
||||
return sorted
|
||||
},
|
||||
}
|
||||
@@ -291,6 +275,7 @@ func WaitingReport() *Report {
|
||||
Description: "Hidden/waiting tasks",
|
||||
BaseFilter: filter,
|
||||
DisplayFormat: DisplayFormatTable,
|
||||
ShowWaiting: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +325,12 @@ func (r *Report) applyPostFilters(tasks []*Task) []*Task {
|
||||
for _, task := range tasks {
|
||||
include := true
|
||||
|
||||
// By default, hide tasks with a future wait date (like taskwarrior).
|
||||
// Reports that need to show waiting tasks set ShowWaiting = true.
|
||||
if !r.ShowWaiting && task.Wait != nil && task.Wait.After(now) {
|
||||
include = false
|
||||
}
|
||||
|
||||
// Check for _started marker
|
||||
if r.BaseFilter.Attributes["_started"] == "true" {
|
||||
if task.Start == nil {
|
||||
@@ -429,18 +420,13 @@ func sortByUrgency(tasks []*Task) []*Task {
|
||||
sorted := make([]*Task, len(tasks))
|
||||
copy(sorted, tasks)
|
||||
|
||||
// Calculate and store urgency on each task
|
||||
for _, t := range sorted {
|
||||
t.Urgency = t.CalculateUrgency(coeffs)
|
||||
}
|
||||
|
||||
for i := 0; i < len(sorted)-1; i++ {
|
||||
for j := i + 1; j < len(sorted); j++ {
|
||||
if sorted[i].Urgency < sorted[j].Urgency {
|
||||
sorted[i], sorted[j] = sorted[j], sorted[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].Urgency > sorted[j].Urgency
|
||||
})
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
+132
-118
@@ -44,6 +44,12 @@ const (
|
||||
PriorityHigh Priority = 3
|
||||
)
|
||||
|
||||
// Annotation represents a timestamped note on a task
|
||||
type Annotation struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
// Identity
|
||||
UUID uuid.UUID `json:"uuid"`
|
||||
@@ -69,13 +75,15 @@ type Task struct {
|
||||
RecurrenceDuration *time.Duration `json:"recurrence_duration,omitempty"`
|
||||
ParentUUID *uuid.UUID `json:"parent_uuid,omitempty"`
|
||||
|
||||
// Annotations (stored as JSON in DB)
|
||||
Annotations []Annotation `json:"annotations,omitempty"`
|
||||
|
||||
// Derived fields (not stored in DB)
|
||||
Tags []string `json:"tags"`
|
||||
Urgency float64 `json:"urgency"`
|
||||
}
|
||||
|
||||
// MarshalJSON emits Task with unix timestamps (int64) instead of RFC3339 strings.
|
||||
func (t Task) MarshalJSON() ([]byte, error) {
|
||||
// taskJSON is the wire format for Task, using unix timestamps instead of time.Time.
|
||||
type taskJSON struct {
|
||||
UUID uuid.UUID `json:"uuid"`
|
||||
ID int `json:"id"`
|
||||
@@ -93,10 +101,13 @@ func (t Task) MarshalJSON() ([]byte, error) {
|
||||
Until *int64 `json:"until,omitempty"`
|
||||
RecurrenceDuration *int64 `json:"recurrence_duration,omitempty"`
|
||||
ParentUUID *uuid.UUID `json:"parent_uuid,omitempty"`
|
||||
Annotations []Annotation `json:"annotations,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
Urgency float64 `json:"urgency"`
|
||||
}
|
||||
|
||||
// MarshalJSON emits Task with unix timestamps (int64) instead of RFC3339 strings.
|
||||
func (t Task) MarshalJSON() ([]byte, error) {
|
||||
toUnix := func(tp *time.Time) *int64 {
|
||||
if tp == nil {
|
||||
return nil
|
||||
@@ -128,6 +139,7 @@ func (t Task) MarshalJSON() ([]byte, error) {
|
||||
Until: toUnix(t.Until),
|
||||
RecurrenceDuration: recurDur,
|
||||
ParentUUID: t.ParentUUID,
|
||||
Annotations: t.Annotations,
|
||||
Tags: t.Tags,
|
||||
Urgency: t.Urgency,
|
||||
})
|
||||
@@ -135,27 +147,6 @@ func (t Task) MarshalJSON() ([]byte, error) {
|
||||
|
||||
// UnmarshalJSON parses Task from JSON with unix timestamps (int64) and duration in seconds.
|
||||
func (t *Task) UnmarshalJSON(data []byte) error {
|
||||
type taskJSON struct {
|
||||
UUID uuid.UUID `json:"uuid"`
|
||||
ID int `json:"id"`
|
||||
Status Status `json:"status"`
|
||||
Description string `json:"description"`
|
||||
Project *string `json:"project"`
|
||||
Priority Priority `json:"priority"`
|
||||
Created int64 `json:"created"`
|
||||
Modified int64 `json:"modified"`
|
||||
Start *int64 `json:"start,omitempty"`
|
||||
End *int64 `json:"end,omitempty"`
|
||||
Due *int64 `json:"due,omitempty"`
|
||||
Scheduled *int64 `json:"scheduled,omitempty"`
|
||||
Wait *int64 `json:"wait,omitempty"`
|
||||
Until *int64 `json:"until,omitempty"`
|
||||
RecurrenceDuration *int64 `json:"recurrence_duration,omitempty"`
|
||||
ParentUUID *uuid.UUID `json:"parent_uuid,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
Urgency float64 `json:"urgency"`
|
||||
}
|
||||
|
||||
var raw taskJSON
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
@@ -184,6 +175,7 @@ func (t *Task) UnmarshalJSON(data []byte) error {
|
||||
t.Wait = fromUnix(raw.Wait)
|
||||
t.Until = fromUnix(raw.Until)
|
||||
t.ParentUUID = raw.ParentUUID
|
||||
t.Annotations = raw.Annotations
|
||||
t.Tags = raw.Tags
|
||||
t.Urgency = raw.Urgency
|
||||
|
||||
@@ -263,6 +255,32 @@ func uuidPtrToSQL(u *uuid.UUID) interface{} {
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func annotationsToSQL(annotations []Annotation) interface{} {
|
||||
if len(annotations) == 0 {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(annotations)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func sqlToAnnotations(v interface{}) []Annotation {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var annotations []Annotation
|
||||
if err := json.Unmarshal([]byte(str), &annotations); err != nil {
|
||||
return nil
|
||||
}
|
||||
return annotations
|
||||
}
|
||||
|
||||
func sqlToUUIDPtr(v interface{}) *uuid.UUID {
|
||||
if v == nil {
|
||||
return nil
|
||||
@@ -327,21 +345,13 @@ func CreateTaskWithModifier(description string, mod *Modifier) (*Task, error) {
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// GetTask retrieves a task by UUID
|
||||
func GetTask(taskUUID uuid.UUID) (*Task, error) {
|
||||
db := GetDB()
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("database not initialized")
|
||||
// scanner is satisfied by both *sql.Row and *sql.Rows.
|
||||
type scanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, uuid, status, description, project, priority,
|
||||
created, modified, start, end, due, scheduled, wait, until_date,
|
||||
recurrence_duration, parent_uuid
|
||||
FROM tasks
|
||||
WHERE uuid = ?
|
||||
`
|
||||
|
||||
// scanTask reads a single task row from a scanner and populates all fields including tags.
|
||||
func scanTask(s scanner) (*Task, error) {
|
||||
task := &Task{}
|
||||
var (
|
||||
uuidStr string
|
||||
@@ -356,9 +366,10 @@ func GetTask(taskUUID uuid.UUID) (*Task, error) {
|
||||
until interface{}
|
||||
recurDuration interface{}
|
||||
parentUUIDStr interface{}
|
||||
annotationsStr interface{}
|
||||
)
|
||||
|
||||
err := db.QueryRow(query, taskUUID.String()).Scan(
|
||||
err := s.Scan(
|
||||
&task.ID,
|
||||
&uuidStr,
|
||||
&task.Status,
|
||||
@@ -375,23 +386,20 @@ func GetTask(taskUUID uuid.UUID) (*Task, error) {
|
||||
&until,
|
||||
&recurDuration,
|
||||
&parentUUIDStr,
|
||||
&annotationsStr,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get task: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse UUID
|
||||
task.UUID, err = uuid.Parse(uuidStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse UUID: %w", err)
|
||||
}
|
||||
|
||||
// Convert timestamps
|
||||
task.Created = time.Unix(created, 0)
|
||||
task.Modified = time.Unix(modified, 0)
|
||||
|
||||
// Convert nullable fields
|
||||
task.Project = sqlToStringPtr(project)
|
||||
task.Start = sqlToTime(start)
|
||||
task.End = sqlToTime(end)
|
||||
@@ -401,8 +409,8 @@ func GetTask(taskUUID uuid.UUID) (*Task, error) {
|
||||
task.Until = sqlToTime(until)
|
||||
task.RecurrenceDuration = sqlToDuration(recurDuration)
|
||||
task.ParentUUID = sqlToUUIDPtr(parentUUIDStr)
|
||||
task.Annotations = sqlToAnnotations(annotationsStr)
|
||||
|
||||
// Load tags
|
||||
tags, err := task.GetTags()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tags: %w", err)
|
||||
@@ -412,6 +420,29 @@ func GetTask(taskUUID uuid.UUID) (*Task, error) {
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// GetTask retrieves a task by UUID
|
||||
func GetTask(taskUUID uuid.UUID) (*Task, error) {
|
||||
db := GetDB()
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("database not initialized")
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, uuid, status, description, project, priority,
|
||||
created, modified, start, end, due, scheduled, wait, until_date,
|
||||
recurrence_duration, parent_uuid, annotations
|
||||
FROM tasks
|
||||
WHERE uuid = ?
|
||||
`
|
||||
|
||||
task, err := scanTask(db.QueryRow(query, taskUUID.String()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get task: %w", err)
|
||||
}
|
||||
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// GetTasks retrieves all tasks with optional filtering
|
||||
func GetTasks(filter *Filter) ([]*Task, error) {
|
||||
db := GetDB()
|
||||
@@ -429,7 +460,7 @@ func GetTasks(filter *Filter) ([]*Task, error) {
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id, uuid, status, description, project, priority,
|
||||
created, modified, start, end, due, scheduled, wait, until_date,
|
||||
recurrence_duration, parent_uuid
|
||||
recurrence_duration, parent_uuid, annotations
|
||||
FROM tasks
|
||||
WHERE %s
|
||||
ORDER BY
|
||||
@@ -447,73 +478,10 @@ func GetTasks(filter *Filter) ([]*Task, error) {
|
||||
tasks := []*Task{}
|
||||
|
||||
for rows.Next() {
|
||||
task := &Task{}
|
||||
var (
|
||||
uuidStr string
|
||||
project interface{}
|
||||
created int64
|
||||
modified int64
|
||||
start interface{}
|
||||
end interface{}
|
||||
due interface{}
|
||||
scheduled interface{}
|
||||
wait interface{}
|
||||
until interface{}
|
||||
recurDuration interface{}
|
||||
parentUUIDStr interface{}
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
&task.ID,
|
||||
&uuidStr,
|
||||
&task.Status,
|
||||
&task.Description,
|
||||
&project,
|
||||
&task.Priority,
|
||||
&created,
|
||||
&modified,
|
||||
&start,
|
||||
&end,
|
||||
&due,
|
||||
&scheduled,
|
||||
&wait,
|
||||
&until,
|
||||
&recurDuration,
|
||||
&parentUUIDStr,
|
||||
)
|
||||
|
||||
task, err := scanTask(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan task: %w", err)
|
||||
}
|
||||
|
||||
// Parse UUID
|
||||
task.UUID, err = uuid.Parse(uuidStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse UUID: %w", err)
|
||||
}
|
||||
|
||||
// Convert timestamps
|
||||
task.Created = time.Unix(created, 0)
|
||||
task.Modified = time.Unix(modified, 0)
|
||||
|
||||
// Convert nullable fields
|
||||
task.Project = sqlToStringPtr(project)
|
||||
task.Start = sqlToTime(start)
|
||||
task.End = sqlToTime(end)
|
||||
task.Due = sqlToTime(due)
|
||||
task.Scheduled = sqlToTime(scheduled)
|
||||
task.Wait = sqlToTime(wait)
|
||||
task.Until = sqlToTime(until)
|
||||
task.RecurrenceDuration = sqlToDuration(recurDuration)
|
||||
task.ParentUUID = sqlToUUIDPtr(parentUUIDStr)
|
||||
|
||||
// Load tags
|
||||
tags, err := task.GetTags()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tags: %w", err)
|
||||
}
|
||||
task.Tags = tags
|
||||
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
|
||||
@@ -543,7 +511,7 @@ func (t *Task) Save() error {
|
||||
status = ?, description = ?, project = ?, priority = ?,
|
||||
modified = ?, start = ?, end = ?, due = ?,
|
||||
scheduled = ?, wait = ?, until_date = ?,
|
||||
recurrence_duration = ?, parent_uuid = ?
|
||||
recurrence_duration = ?, parent_uuid = ?, annotations = ?
|
||||
WHERE uuid = ?
|
||||
`
|
||||
|
||||
@@ -561,6 +529,7 @@ func (t *Task) Save() error {
|
||||
timeToSQL(t.Until),
|
||||
durationToSQL(t.RecurrenceDuration),
|
||||
uuidPtrToSQL(t.ParentUUID),
|
||||
annotationsToSQL(t.Annotations),
|
||||
t.UUID.String(),
|
||||
)
|
||||
|
||||
@@ -573,8 +542,8 @@ func (t *Task) Save() error {
|
||||
INSERT INTO tasks (
|
||||
uuid, status, description, project, priority,
|
||||
created, modified, start, end, due, scheduled, wait, until_date,
|
||||
recurrence_duration, parent_uuid
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
recurrence_duration, parent_uuid, annotations
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
result, err := db.Exec(query,
|
||||
@@ -593,6 +562,7 @@ func (t *Task) Save() error {
|
||||
timeToSQL(t.Until),
|
||||
durationToSQL(t.RecurrenceDuration),
|
||||
uuidPtrToSQL(t.ParentUUID),
|
||||
annotationsToSQL(t.Annotations),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -694,25 +664,27 @@ func (t *Task) GetTags() ([]string, error) {
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
// Complete marks a task as completed
|
||||
func (t *Task) Complete() error {
|
||||
// Complete marks a task as completed.
|
||||
// Returns the next recurring instance if one was spawned, or nil.
|
||||
func (t *Task) Complete() (*Task, error) {
|
||||
t.Status = StatusCompleted
|
||||
now := timeNow()
|
||||
t.End = &now
|
||||
|
||||
if err := t.Save(); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If this is a recurring instance, spawn next instance
|
||||
if t.ParentUUID != nil {
|
||||
if err := SpawnNextInstance(t); err != nil {
|
||||
// Log error but don't fail the completion
|
||||
return fmt.Errorf("completed task but failed to spawn next instance: %w", err)
|
||||
next, err := SpawnNextInstance(t)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("completed task but failed to spawn next instance: %w", err)
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Delete marks a task as deleted (soft delete)
|
||||
@@ -764,6 +736,48 @@ func (t *Task) IsRecurringInstance() bool {
|
||||
return t.ParentUUID != nil
|
||||
}
|
||||
|
||||
// GetDeletedTasks retrieves soft-deleted tasks, optionally filtered by age.
|
||||
// If olderThan is non-nil, only returns tasks deleted more than that duration ago.
|
||||
func GetDeletedTasks(olderThan *time.Duration) ([]*Task, error) {
|
||||
db := GetDB()
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("database not initialized")
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, uuid, status, description, project, priority,
|
||||
created, modified, start, end, due, scheduled, wait, until_date,
|
||||
recurrence_duration, parent_uuid, annotations
|
||||
FROM tasks
|
||||
WHERE status = ?`
|
||||
args := []interface{}{byte(StatusDeleted)}
|
||||
|
||||
if olderThan != nil {
|
||||
cutoff := timeNow().Add(-*olderThan).Unix()
|
||||
query += ` AND end < ?`
|
||||
args = append(args, cutoff)
|
||||
}
|
||||
|
||||
query += ` ORDER BY end ASC`
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query deleted tasks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tasks []*Task
|
||||
for rows.Next() {
|
||||
task, err := scanTask(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan task: %w", err)
|
||||
}
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// PopulateUrgency computes and sets the Urgency field on the given tasks.
|
||||
func PopulateUrgency(tasks ...*Task) {
|
||||
cfg, _ := GetConfig()
|
||||
|
||||
@@ -165,7 +165,7 @@ func TestTaskComplete(t *testing.T) {
|
||||
t.Fatalf("Failed to create task: %v", err)
|
||||
}
|
||||
|
||||
if err := task.Complete(); err != nil {
|
||||
if _, err := task.Complete(); err != nil {
|
||||
t.Fatalf("Failed to complete task: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const undoStackLimit = 10
|
||||
|
||||
// RecordUndo records a CLI operation as undoable.
|
||||
// Called AFTER the mutation so the change_log entry exists.
|
||||
func RecordUndo(opType string, taskUUID uuid.UUID) error {
|
||||
db := GetDB()
|
||||
if db == nil {
|
||||
return fmt.Errorf("database not initialized")
|
||||
}
|
||||
|
||||
// Find the change_log entry just created by this mutation
|
||||
var changeLogID int64
|
||||
err := db.QueryRow(
|
||||
"SELECT MAX(id) FROM change_log WHERE task_uuid = ?",
|
||||
taskUUID.String(),
|
||||
).Scan(&changeLogID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find change_log entry: %w", err)
|
||||
}
|
||||
|
||||
// Insert into undo_stack
|
||||
_, err = db.Exec(
|
||||
"INSERT INTO undo_stack (created_at, op_type, task_uuid, change_log_id) VALUES (?, ?, ?, ?)",
|
||||
timeNow().Unix(), opType, taskUUID.String(), changeLogID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record undo: %w", err)
|
||||
}
|
||||
|
||||
// Evict old entries beyond the limit
|
||||
_, err = db.Exec(
|
||||
"DELETE FROM undo_stack WHERE id NOT IN (SELECT id FROM undo_stack ORDER BY id DESC LIMIT ?)",
|
||||
undoStackLimit,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to evict old undo entries: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PopUndo pops the most recent undo entry and reverts the task.
|
||||
// Returns a description of what was undone.
|
||||
func PopUndo() (string, error) {
|
||||
db := GetDB()
|
||||
if db == nil {
|
||||
return "", fmt.Errorf("database not initialized")
|
||||
}
|
||||
|
||||
// Get the most recent undo entry
|
||||
var (
|
||||
undoID int64
|
||||
opType string
|
||||
taskUUIDStr string
|
||||
changeLogID int64
|
||||
)
|
||||
err := db.QueryRow(
|
||||
"SELECT id, op_type, task_uuid, change_log_id FROM undo_stack ORDER BY id DESC LIMIT 1",
|
||||
).Scan(&undoID, &opType, &taskUUIDStr, &changeLogID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("nothing to undo")
|
||||
}
|
||||
|
||||
taskUUID, err := uuid.Parse(taskUUIDStr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid task UUID in undo stack: %w", err)
|
||||
}
|
||||
|
||||
// Remove the entry from the stack
|
||||
_, err = db.Exec("DELETE FROM undo_stack WHERE id = ?", undoID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to pop undo entry: %w", err)
|
||||
}
|
||||
|
||||
// Perform the revert based on op type
|
||||
switch opType {
|
||||
case "add":
|
||||
return undoAdd(taskUUID)
|
||||
case "done", "delete", "modify", "start", "stop":
|
||||
return undoRestore(opType, taskUUID, changeLogID)
|
||||
default:
|
||||
return "", fmt.Errorf("unknown undo operation: %s", opType)
|
||||
}
|
||||
}
|
||||
|
||||
// undoAdd reverts an add by hard-deleting the task.
|
||||
// For recurring tasks, also deletes the template.
|
||||
func undoAdd(taskUUID uuid.UUID) (string, error) {
|
||||
db := GetDB()
|
||||
|
||||
task, err := GetTask(taskUUID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load task for undo: %w", err)
|
||||
}
|
||||
|
||||
desc := task.Description
|
||||
|
||||
// If this is a recurring instance, also delete the template
|
||||
if task.ParentUUID != nil {
|
||||
_, err = db.Exec("DELETE FROM tasks WHERE uuid = ?", task.ParentUUID.String())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to delete recurring template: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Hard delete the task
|
||||
_, err = db.Exec("DELETE FROM tasks WHERE uuid = ?", taskUUID.String())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to delete task: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Undid add: removed \"%s\"", desc), nil
|
||||
}
|
||||
|
||||
// undoRestore reverts a mutation by restoring the prior state from change_log.
|
||||
func undoRestore(opType string, taskUUID uuid.UUID, changeLogID int64) (string, error) {
|
||||
db := GetDB()
|
||||
|
||||
// Find the change_log entry BEFORE this one for the same task
|
||||
var priorData string
|
||||
err := db.QueryRow(
|
||||
"SELECT data FROM change_log WHERE task_uuid = ? AND id < ? ORDER BY id DESC LIMIT 1",
|
||||
taskUUID.String(), changeLogID,
|
||||
).Scan(&priorData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no prior state found in change_log (cannot undo)")
|
||||
}
|
||||
|
||||
// Parse the prior state
|
||||
task, err := GetTask(taskUUID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load task: %w", err)
|
||||
}
|
||||
|
||||
// Apply the prior state from change_log data
|
||||
if err := applyChangeLogData(task, priorData); err != nil {
|
||||
return "", fmt.Errorf("failed to restore prior state: %w", err)
|
||||
}
|
||||
|
||||
// Save the restored task
|
||||
if err := task.Save(); err != nil {
|
||||
return "", fmt.Errorf("failed to save restored task: %w", err)
|
||||
}
|
||||
|
||||
// Reconcile tags
|
||||
if err := reconcileTagsFromChangeLog(task, priorData); err != nil {
|
||||
return "", fmt.Errorf("failed to reconcile tags: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Undid %s: restored \"%s\"", opType, task.Description), nil
|
||||
}
|
||||
|
||||
// applyChangeLogData parses change_log data and applies it to a task.
|
||||
// The data format is "key: value\n" lines (same format used by sync).
|
||||
func applyChangeLogData(task *Task, data string) error {
|
||||
lines := strings.Split(data, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, ": ", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := parts[0]
|
||||
value := parts[1]
|
||||
|
||||
switch key {
|
||||
case "description":
|
||||
task.Description = value
|
||||
case "status":
|
||||
switch value {
|
||||
case "pending":
|
||||
task.Status = StatusPending
|
||||
case "completed":
|
||||
task.Status = StatusCompleted
|
||||
case "deleted":
|
||||
task.Status = StatusDeleted
|
||||
case "recurring":
|
||||
task.Status = StatusRecurring
|
||||
}
|
||||
case "priority":
|
||||
switch value {
|
||||
case "H":
|
||||
task.Priority = PriorityHigh
|
||||
case "M":
|
||||
task.Priority = PriorityMedium
|
||||
case "L":
|
||||
task.Priority = PriorityLow
|
||||
default:
|
||||
task.Priority = PriorityDefault
|
||||
}
|
||||
case "project":
|
||||
task.Project = &value
|
||||
case "created":
|
||||
if ts, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
task.Created = time.Unix(ts, 0)
|
||||
}
|
||||
case "modified":
|
||||
// Don't restore modified — it'll be set by Save()
|
||||
case "start":
|
||||
if ts, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
t := time.Unix(ts, 0)
|
||||
task.Start = &t
|
||||
}
|
||||
case "end":
|
||||
if ts, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
t := time.Unix(ts, 0)
|
||||
task.End = &t
|
||||
}
|
||||
case "due":
|
||||
if ts, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
t := time.Unix(ts, 0)
|
||||
task.Due = &t
|
||||
}
|
||||
case "scheduled":
|
||||
if ts, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
t := time.Unix(ts, 0)
|
||||
task.Scheduled = &t
|
||||
}
|
||||
case "wait":
|
||||
if ts, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
t := time.Unix(ts, 0)
|
||||
task.Wait = &t
|
||||
}
|
||||
case "until":
|
||||
if ts, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
t := time.Unix(ts, 0)
|
||||
task.Until = &t
|
||||
}
|
||||
case "recurrence":
|
||||
if ns, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
d := time.Duration(ns)
|
||||
task.RecurrenceDuration = &d
|
||||
}
|
||||
case "parent_uuid":
|
||||
if u, err := uuid.Parse(value); err == nil {
|
||||
task.ParentUUID = &u
|
||||
}
|
||||
case "annotations":
|
||||
// Annotations are stored as JSON in the change_log
|
||||
task.Annotations = sqlToAnnotations(value)
|
||||
case "tags":
|
||||
// Tags are handled separately by reconcileTagsFromChangeLog
|
||||
}
|
||||
}
|
||||
|
||||
// Clear fields that aren't present in the change_log data (they were NULL)
|
||||
fieldPresent := make(map[string]bool)
|
||||
for _, line := range lines {
|
||||
parts := strings.SplitN(strings.TrimSpace(line), ": ", 2)
|
||||
if len(parts) == 2 {
|
||||
fieldPresent[parts[0]] = true
|
||||
}
|
||||
}
|
||||
if !fieldPresent["project"] {
|
||||
task.Project = nil
|
||||
}
|
||||
if !fieldPresent["start"] {
|
||||
task.Start = nil
|
||||
}
|
||||
if !fieldPresent["end"] {
|
||||
task.End = nil
|
||||
}
|
||||
if !fieldPresent["due"] {
|
||||
task.Due = nil
|
||||
}
|
||||
if !fieldPresent["scheduled"] {
|
||||
task.Scheduled = nil
|
||||
}
|
||||
if !fieldPresent["wait"] {
|
||||
task.Wait = nil
|
||||
}
|
||||
if !fieldPresent["until"] {
|
||||
task.Until = nil
|
||||
}
|
||||
if !fieldPresent["recurrence"] {
|
||||
task.RecurrenceDuration = nil
|
||||
}
|
||||
if !fieldPresent["parent_uuid"] {
|
||||
task.ParentUUID = nil
|
||||
}
|
||||
if !fieldPresent["annotations"] {
|
||||
task.Annotations = nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcileTagsFromChangeLog restores tags from change_log data.
|
||||
func reconcileTagsFromChangeLog(task *Task, data string) error {
|
||||
// Parse desired tags from change_log
|
||||
var desiredTags []string
|
||||
for _, line := range strings.Split(data, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "tags: ") {
|
||||
tagStr := strings.TrimPrefix(line, "tags: ")
|
||||
for _, tag := range strings.Split(tagStr, ",") {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag != "" {
|
||||
desiredTags = append(desiredTags, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get current tags
|
||||
currentTags, _ := task.GetTags()
|
||||
|
||||
// Remove tags not in desired set
|
||||
desired := make(map[string]bool)
|
||||
for _, t := range desiredTags {
|
||||
desired[t] = true
|
||||
}
|
||||
for _, tag := range currentTags {
|
||||
if !desired[tag] {
|
||||
task.RemoveTag(tag)
|
||||
}
|
||||
}
|
||||
|
||||
// Add missing tags
|
||||
current := make(map[string]bool)
|
||||
for _, t := range currentTags {
|
||||
current[t] = true
|
||||
}
|
||||
for _, tag := range desiredTags {
|
||||
if !current[tag] {
|
||||
task.AddTag(tag)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRecordUndo_And_PopUndo_Add(t *testing.T) {
|
||||
// Create a task, record undo, then pop undo — should hard-delete the task
|
||||
task, err := CreateTask("Undo add test")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
|
||||
if err := RecordUndo("add", task.UUID); err != nil {
|
||||
t.Fatalf("RecordUndo: %v", err)
|
||||
}
|
||||
|
||||
desc, err := PopUndo()
|
||||
if err != nil {
|
||||
t.Fatalf("PopUndo: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(desc, "Undo") || !strings.Contains(desc, "add") {
|
||||
t.Errorf("unexpected undo description: %s", desc)
|
||||
}
|
||||
|
||||
// Task should be gone
|
||||
_, err = GetTask(task.UUID)
|
||||
if err == nil {
|
||||
t.Error("task should have been hard-deleted after undo add")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUndo_And_PopUndo_Done(t *testing.T) {
|
||||
task, err := CreateTask("Undo done test")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
defer func() { task.Delete(true) }()
|
||||
|
||||
// Record undo for the initial creation (so we have a prior change_log entry)
|
||||
// Note: the change_log trigger auto-records on creation, so we just need to
|
||||
// complete and record undo for the completion.
|
||||
|
||||
// Complete the task
|
||||
task.Status = StatusCompleted
|
||||
now := timeNow()
|
||||
task.End = &now
|
||||
if err := task.Save(); err != nil {
|
||||
t.Fatalf("Save completed: %v", err)
|
||||
}
|
||||
|
||||
if err := RecordUndo("done", task.UUID); err != nil {
|
||||
t.Fatalf("RecordUndo: %v", err)
|
||||
}
|
||||
|
||||
// Undo should restore to pending
|
||||
desc, err := PopUndo()
|
||||
if err != nil {
|
||||
t.Fatalf("PopUndo: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(desc, "done") {
|
||||
t.Errorf("expected 'done' in description: %s", desc)
|
||||
}
|
||||
|
||||
// Reload and check status
|
||||
reloaded, err := GetTask(task.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask after undo: %v", err)
|
||||
}
|
||||
if reloaded.Status != StatusPending {
|
||||
t.Errorf("status after undo done = %d, want %d (pending)", reloaded.Status, StatusPending)
|
||||
}
|
||||
if reloaded.End != nil {
|
||||
t.Error("End should be nil after undo done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUndo_And_PopUndo_Modify(t *testing.T) {
|
||||
task, err := CreateTask("Undo modify test")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
defer func() { task.Delete(true) }()
|
||||
|
||||
// Modify the task
|
||||
task.Description = "Modified description"
|
||||
task.Priority = PriorityHigh
|
||||
if err := task.Save(); err != nil {
|
||||
t.Fatalf("Save modified: %v", err)
|
||||
}
|
||||
|
||||
if err := RecordUndo("modify", task.UUID); err != nil {
|
||||
t.Fatalf("RecordUndo: %v", err)
|
||||
}
|
||||
|
||||
// Undo should restore original description and priority
|
||||
_, err = PopUndo()
|
||||
if err != nil {
|
||||
t.Fatalf("PopUndo: %v", err)
|
||||
}
|
||||
|
||||
reloaded, err := GetTask(task.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask after undo: %v", err)
|
||||
}
|
||||
if reloaded.Description != "Undo modify test" {
|
||||
t.Errorf("description after undo = %q, want %q", reloaded.Description, "Undo modify test")
|
||||
}
|
||||
if reloaded.Priority != PriorityDefault {
|
||||
t.Errorf("priority after undo = %d, want %d (default)", reloaded.Priority, PriorityDefault)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopUndo_EmptyStack(t *testing.T) {
|
||||
// Clear the undo stack
|
||||
db := GetDB()
|
||||
db.Exec("DELETE FROM undo_stack")
|
||||
|
||||
_, err := PopUndo()
|
||||
if err == nil {
|
||||
t.Error("PopUndo on empty stack should return error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "nothing to undo") {
|
||||
t.Errorf("expected 'nothing to undo' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndoStackEviction(t *testing.T) {
|
||||
// Clear existing undo entries
|
||||
db := GetDB()
|
||||
db.Exec("DELETE FROM undo_stack")
|
||||
|
||||
// Create 12 tasks and record undo for each
|
||||
for i := 0; i < 12; i++ {
|
||||
task, err := CreateTask("Eviction test task")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask %d: %v", i, err)
|
||||
}
|
||||
if err := RecordUndo("add", task.UUID); err != nil {
|
||||
t.Fatalf("RecordUndo %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Stack should be capped at 10
|
||||
var count int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM undo_stack").Scan(&count); err != nil {
|
||||
t.Fatalf("count query: %v", err)
|
||||
}
|
||||
if count != 10 {
|
||||
t.Errorf("undo stack count = %d, want 10 (limit)", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyChangeLogData(t *testing.T) {
|
||||
task, err := CreateTask("Apply changelog test")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
defer func() { task.Delete(true) }()
|
||||
|
||||
// Apply changelog data that sets various fields
|
||||
due := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
|
||||
data := "description: Changed description\nstatus: completed\npriority: H\nproject: work\ndue: " +
|
||||
strings.TrimSpace(time.Unix(due.Unix(), 0).Format("")) + "\n"
|
||||
|
||||
// Construct proper data string
|
||||
data = "description: Changed description\nstatus: completed\npriority: H\nproject: work"
|
||||
|
||||
if err := applyChangeLogData(task, data); err != nil {
|
||||
t.Fatalf("applyChangeLogData: %v", err)
|
||||
}
|
||||
|
||||
if task.Description != "Changed description" {
|
||||
t.Errorf("description = %q, want %q", task.Description, "Changed description")
|
||||
}
|
||||
if task.Status != StatusCompleted {
|
||||
t.Errorf("status = %d, want %d", task.Status, StatusCompleted)
|
||||
}
|
||||
if task.Priority != PriorityHigh {
|
||||
t.Errorf("priority = %d, want %d", task.Priority, PriorityHigh)
|
||||
}
|
||||
if task.Project == nil || *task.Project != "work" {
|
||||
t.Error("project should be 'work'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyChangeLogData_ClearsAbsentFields(t *testing.T) {
|
||||
task, err := CreateTask("Clear fields test")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
defer func() { task.Delete(true) }()
|
||||
|
||||
// Set some fields first
|
||||
proj := "work"
|
||||
task.Project = &proj
|
||||
now := timeNow()
|
||||
task.Due = &now
|
||||
task.Start = &now
|
||||
|
||||
// Apply data without project, due, or start — they should be cleared
|
||||
data := "description: Clear fields test\nstatus: pending"
|
||||
if err := applyChangeLogData(task, data); err != nil {
|
||||
t.Fatalf("applyChangeLogData: %v", err)
|
||||
}
|
||||
|
||||
if task.Project != nil {
|
||||
t.Error("project should be nil after applying data without project")
|
||||
}
|
||||
if task.Due != nil {
|
||||
t.Error("due should be nil after applying data without due")
|
||||
}
|
||||
if task.Start != nil {
|
||||
t.Error("start should be nil after applying data without start")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileTagsFromChangeLog(t *testing.T) {
|
||||
task, err := CreateTask("Tag reconcile test")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
defer func() { task.Delete(true) }()
|
||||
|
||||
// Set current tags
|
||||
task.AddTag("keep")
|
||||
task.AddTag("remove")
|
||||
|
||||
// Reconcile with data that has "keep" and "add" but not "remove"
|
||||
data := "tags: keep,add"
|
||||
if err := reconcileTagsFromChangeLog(task, data); err != nil {
|
||||
t.Fatalf("reconcileTagsFromChangeLog: %v", err)
|
||||
}
|
||||
|
||||
tags, _ := task.GetTags()
|
||||
tagSet := make(map[string]bool)
|
||||
for _, tag := range tags {
|
||||
tagSet[tag] = true
|
||||
}
|
||||
|
||||
if !tagSet["keep"] {
|
||||
t.Error("tag 'keep' should still be present")
|
||||
}
|
||||
if !tagSet["add"] {
|
||||
t.Error("tag 'add' should have been added")
|
||||
}
|
||||
if tagSet["remove"] {
|
||||
t.Error("tag 'remove' should have been removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileTagsFromChangeLog_NoTags(t *testing.T) {
|
||||
task, err := CreateTask("No tags reconcile test")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
defer func() { task.Delete(true) }()
|
||||
|
||||
task.AddTag("should-be-removed")
|
||||
|
||||
// Data with no tags line — all tags should be removed
|
||||
data := "description: No tags reconcile test\nstatus: pending"
|
||||
if err := reconcileTagsFromChangeLog(task, data); err != nil {
|
||||
t.Fatalf("reconcileTagsFromChangeLog: %v", err)
|
||||
}
|
||||
|
||||
tags, _ := task.GetTags()
|
||||
if len(tags) != 0 {
|
||||
t.Errorf("expected 0 tags after reconcile with no tags, got %v", tags)
|
||||
}
|
||||
}
|
||||
@@ -142,3 +142,32 @@ func (ws *WorkingSet) GetTasks() []*Task {
|
||||
func (ws *WorkingSet) Size() int {
|
||||
return len(ws.byID)
|
||||
}
|
||||
|
||||
// ByID returns the display_id -> UUID mapping.
|
||||
func (ws *WorkingSet) ByID() map[int]uuid.UUID {
|
||||
return ws.byID
|
||||
}
|
||||
|
||||
// AppendTask inserts a task into the working set at MAX(display_id) + 1.
|
||||
// Returns the assigned display ID. The ID is valid until the next report
|
||||
// render, at which point the entire working set is rebuilt.
|
||||
func AppendTask(task *Task) (int, error) {
|
||||
db := GetDB()
|
||||
if db == nil {
|
||||
return 0, fmt.Errorf("database not initialized")
|
||||
}
|
||||
|
||||
var maxID int
|
||||
err := db.QueryRow("SELECT COALESCE(MAX(display_id), 0) FROM working_set").Scan(&maxID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to query max display_id: %w", err)
|
||||
}
|
||||
|
||||
newID := maxID + 1
|
||||
_, err = db.Exec("INSERT INTO working_set (display_id, task_uuid) VALUES (?, ?)", newID, task.UUID.String())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to append to working set: %w", err)
|
||||
}
|
||||
|
||||
return newID, nil
|
||||
}
|
||||
|
||||
@@ -102,14 +102,20 @@ func (c *Client) PullChanges(since int64) ([]ChangeLogEntry, error) {
|
||||
func (c *Client) PushChanges(tasks []*engine.Task) error {
|
||||
// Convert tasks to JSON
|
||||
var taskData []json.RawMessage
|
||||
var marshalErrors []string
|
||||
for _, task := range tasks {
|
||||
data, err := json.Marshal(task)
|
||||
if err != nil {
|
||||
marshalErrors = append(marshalErrors, fmt.Sprintf("task %s: %v", task.UUID, err))
|
||||
continue
|
||||
}
|
||||
taskData = append(taskData, data)
|
||||
}
|
||||
|
||||
if len(taskData) == 0 && len(marshalErrors) > 0 {
|
||||
return fmt.Errorf("all tasks failed to marshal: %s", strings.Join(marshalErrors, "; "))
|
||||
}
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"tasks": taskData,
|
||||
"client_id": c.clientID,
|
||||
@@ -139,6 +145,11 @@ func (c *Client) PushChanges(tasks []*engine.Task) error {
|
||||
return fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
if len(marshalErrors) > 0 {
|
||||
return fmt.Errorf("pushed %d tasks but %d failed to marshal: %s",
|
||||
len(taskData), len(marshalErrors), strings.Join(marshalErrors, "; "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -219,7 +230,7 @@ func (c *Client) Sync(strategy ConflictResolution, reporter ProgressReporter) (*
|
||||
}
|
||||
|
||||
// Convert changes to tasks
|
||||
remoteTasks, err := c.parseChanges(changes)
|
||||
remoteTasks, err := c.ParseChanges(changes)
|
||||
if err != nil {
|
||||
if len(changes) > 0 {
|
||||
reporter.CompletePhase()
|
||||
@@ -283,6 +294,11 @@ func (c *Client) Sync(strategy ConflictResolution, reporter ProgressReporter) (*
|
||||
continue
|
||||
}
|
||||
|
||||
// Mark change_log entry as sync-originated to prevent feedback loop
|
||||
if err := engine.MarkChangeLogAsSync(task.UUID.String()); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("failed to mark change as sync for %s: %v", task.UUID, err))
|
||||
}
|
||||
|
||||
// Reload task to ensure we have the database ID
|
||||
savedTask, err := engine.GetTask(task.UUID)
|
||||
if err != nil {
|
||||
@@ -347,18 +363,7 @@ func (c *Client) Sync(strategy ConflictResolution, reporter ProgressReporter) (*
|
||||
|
||||
// getLastSyncTime retrieves the last sync timestamp from database
|
||||
func (c *Client) getLastSyncTime() int64 {
|
||||
db := engine.GetDB()
|
||||
if db == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
var lastSync int64
|
||||
err := db.QueryRow("SELECT last_sync FROM sync_state WHERE client_id = ?", c.clientID).Scan(&lastSync)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return lastSync
|
||||
return GetLastSyncTime(c.clientID)
|
||||
}
|
||||
|
||||
// updateLastSyncTime updates the last sync timestamp
|
||||
@@ -376,6 +381,27 @@ func (c *Client) updateLastSyncTime(timestamp int64) {
|
||||
|
||||
// getLocalChanges retrieves local changes since a timestamp
|
||||
func (c *Client) getLocalChanges(since int64) ([]*engine.Task, error) {
|
||||
return GetLocalChanges(since)
|
||||
}
|
||||
|
||||
// GetLastSyncTime retrieves the last sync timestamp for a client ID from the database.
|
||||
func GetLastSyncTime(clientID string) int64 {
|
||||
db := engine.GetDB()
|
||||
if db == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
var lastSync int64
|
||||
err := db.QueryRow("SELECT last_sync FROM sync_state WHERE client_id = ?", clientID).Scan(&lastSync)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return lastSync
|
||||
}
|
||||
|
||||
// GetLocalChanges retrieves local (non-sync-originated) changes since a timestamp.
|
||||
func GetLocalChanges(since int64) ([]*engine.Task, error) {
|
||||
db := engine.GetDB()
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("database not initialized")
|
||||
@@ -384,7 +410,7 @@ func (c *Client) getLocalChanges(since int64) ([]*engine.Task, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT DISTINCT task_uuid
|
||||
FROM change_log
|
||||
WHERE changed_at > ?
|
||||
WHERE changed_at > ? AND source = 'local'
|
||||
ORDER BY changed_at ASC
|
||||
`, since)
|
||||
if err != nil {
|
||||
@@ -415,8 +441,8 @@ func (c *Client) getLocalChanges(since int64) ([]*engine.Task, error) {
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// parseChanges converts change log entries to tasks
|
||||
func (c *Client) parseChanges(changes []ChangeLogEntry) ([]*engine.Task, error) {
|
||||
// ParseChanges converts change log entries to tasks
|
||||
func (c *Client) ParseChanges(changes []ChangeLogEntry) ([]*engine.Task, error) {
|
||||
// Sort changes by timestamp (primary) and ID (secondary) to ensure correct order
|
||||
// This handles same-second updates (e.g., CREATE followed by UPDATE with tags)
|
||||
sort.Slice(changes, func(i, j int) bool {
|
||||
@@ -666,16 +692,31 @@ func parseTagsFromChangeLog(s string) []string {
|
||||
// pushQueuedChanges sends queued changes to server
|
||||
func (c *Client) pushQueuedChanges(changes []QueuedChange) error {
|
||||
var tasks []*engine.Task
|
||||
var unmarshalErrors []string
|
||||
|
||||
for _, change := range changes {
|
||||
var task engine.Task
|
||||
if err := json.Unmarshal(change.Data, &task); err != nil {
|
||||
unmarshalErrors = append(unmarshalErrors, fmt.Sprintf("queued change: %v", err))
|
||||
continue
|
||||
}
|
||||
tasks = append(tasks, &task)
|
||||
}
|
||||
|
||||
return c.PushChanges(tasks)
|
||||
if len(tasks) == 0 && len(unmarshalErrors) > 0 {
|
||||
return fmt.Errorf("all queued changes failed to unmarshal: %s", strings.Join(unmarshalErrors, "; "))
|
||||
}
|
||||
|
||||
if err := c.PushChanges(tasks); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(unmarshalErrors) > 0 {
|
||||
return fmt.Errorf("pushed %d tasks but %d queued changes failed to unmarshal: %s",
|
||||
len(tasks), len(unmarshalErrors), strings.Join(unmarshalErrors, "; "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncResult represents the result of a sync operation
|
||||
|
||||
@@ -57,7 +57,11 @@ func MergeTasks(local, remote []*engine.Task, strategy ConflictResolution) ([]*e
|
||||
if DetectConflict(task, remoteTask) {
|
||||
conflicts++
|
||||
winner := resolveConflict(task, remoteTask, strategy)
|
||||
logConflict(task, remoteTask, winner)
|
||||
winnerLabel := "local"
|
||||
if winner == remoteTask {
|
||||
winnerLabel = "remote"
|
||||
}
|
||||
logConflict(task, remoteTask, winnerLabel)
|
||||
result = append(result, winner)
|
||||
} else {
|
||||
// No conflict - use either (same content)
|
||||
@@ -110,17 +114,12 @@ func resolveConflict(local, remote *engine.Task, strategy ConflictResolution) *e
|
||||
}
|
||||
|
||||
// logConflict writes conflict information to log file
|
||||
func logConflict(local, remote *engine.Task, winner *engine.Task) {
|
||||
func logConflict(local, remote *engine.Task, winnerLabel string) {
|
||||
logPath, err := engine.GetSyncConflictLogPath()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
winnerLabel := "local"
|
||||
if winner.UUID == remote.UUID && winner.Modified.Equal(remote.Modified) {
|
||||
winnerLabel = "remote"
|
||||
}
|
||||
|
||||
entry := fmt.Sprintf(
|
||||
"[%s] Conflict on task %s\n"+
|
||||
" Local: modified %s - %s\n"+
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Wait/Scheduled not working correctly
|
||||
`Buy milk due:8d wait:5d` still showing up
|
||||
|
||||
# Missing uncomplete feat
|
||||
Undo / uncomplete fails in web-ui. task still has checked box and strikethrough.
|
||||
|
||||
# Cycling priority - Web
|
||||
Trying to edit a task priority results in following console error:
|
||||
GQLaRcBw.js:1 PUT https://opal.jnss.me/api/tasks/8814798c-97af-4134-9786-47e027d164c8 400 (Bad Request)
|
||||
window.fetch @ GQLaRcBw.js:1
|
||||
n @ nlPNz7OE.js:1
|
||||
update @ nlPNz7OE.js:1
|
||||
updateTask @ 2.qiXA3Mmu.js:1
|
||||
X @ 2.qiXA3Mmu.js:3
|
||||
Q @ 2.qiXA3Mmu.js:1
|
||||
(anonymous) @ idxpmzXF.js:1
|
||||
Qe @ jWcw5lls.js:1
|
||||
n @ idxpmzXF.js:1
|
||||
nlPNz7OE.js:1 API Error [/tasks/8814798c-97af-4134-9786-47e027d164c8]: Error: HTTP 400:
|
||||
at n (nlPNz7OE.js:1:1556)
|
||||
at async Object.updateTask (2.qiXA3Mmu.js:1:4502)
|
||||
at async X (2.qiXA3Mmu.js:3:3519)
|
||||
at async HTMLDivElement.Q (2.qiXA3Mmu.js:1:58579)
|
||||
n @ nlPNz7OE.js:1
|
||||
await in n
|
||||
update @ nlPNz7OE.js:1
|
||||
updateTask @ 2.qiXA3Mmu.js:1
|
||||
X @ 2.qiXA3Mmu.js:3
|
||||
Q @ 2.qiXA3Mmu.js:1
|
||||
(anonymous) @ idxpmzXF.js:1
|
||||
Qe @ jWcw5lls.js:1
|
||||
n @ idxpmzXF.js:1
|
||||
2.qiXA3Mmu.js:3 Failed to update task: Error: HTTP 400:
|
||||
at n (nlPNz7OE.js:1:1556)
|
||||
at async Object.updateTask (2.qiXA3Mmu.js:1:4502)
|
||||
at async X (2.qiXA3Mmu.js:3:3519)
|
||||
at async HTMLDivElement.Q (2.qiXA3Mmu.js:1:58579)
|
||||
|
||||
# Ambiguity complete and details tap.
|
||||
Pressing the task description completes the task. only the checkbox click should complete task, otherwise open details view. to complete swipe left
|
||||
@@ -68,6 +68,8 @@
|
||||
--color-overdue-text: #f85149;
|
||||
--color-tag-bg: rgba(139, 148, 158, 0.1);
|
||||
--color-tag-text: #8b949e;
|
||||
--color-active-bg: rgba(57, 208, 186, 0.15);
|
||||
--color-active-text: #39d0ba;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
@@ -118,6 +120,8 @@
|
||||
--color-overdue-text: #be123c;
|
||||
--color-tag-bg: #f5f5f4;
|
||||
--color-tag-text: #78716c;
|
||||
--color-active-bg: rgba(99, 102, 241, 0.12);
|
||||
--color-active-text: #4f46e5;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
@@ -168,6 +172,8 @@
|
||||
--color-overdue-text: #ef4444;
|
||||
--color-tag-bg: rgba(148, 163, 184, 0.1);
|
||||
--color-tag-text: #94a3b8;
|
||||
--color-active-bg: rgba(139, 92, 246, 0.15);
|
||||
--color-active-text: #8b5cf6;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover" />
|
||||
<meta name="description" content="Mobile-first task management with offline support" />
|
||||
<meta name="theme-color" content="#4f46e5" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
|
||||
@@ -6,7 +6,7 @@ import { get } from 'svelte/store';
|
||||
* @typedef {import('./types.js').AuthTokens} AuthTokens
|
||||
*/
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||
export const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||
|
||||
/**
|
||||
* Make authenticated API request
|
||||
@@ -18,12 +18,12 @@ const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||
export async function apiRequest(endpoint, options = {}) {
|
||||
const auth = get(authStore);
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
.../** @type {Record<string, string>} */ (options.headers)
|
||||
};
|
||||
|
||||
// Add auth token if available
|
||||
if (auth.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${auth.accessToken}`;
|
||||
}
|
||||
@@ -34,11 +34,9 @@ export async function apiRequest(endpoint, options = {}) {
|
||||
headers
|
||||
});
|
||||
|
||||
// Token expired - try refresh
|
||||
if (response.status === 401 && auth.refreshToken) {
|
||||
const refreshed = await refreshAccessToken(auth.refreshToken);
|
||||
if (refreshed) {
|
||||
// Retry with new token
|
||||
headers['Authorization'] = `Bearer ${refreshed.access_token}`;
|
||||
return apiRequest(endpoint, { ...options, headers });
|
||||
}
|
||||
@@ -78,7 +76,6 @@ async function refreshAccessToken(refreshToken) {
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
// Update auth store
|
||||
authStore.setTokens(result.data);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiRequest } from './client.js';
|
||||
import { apiRequest, API_BASE } from './client.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('./types.js').Task} Task
|
||||
@@ -8,12 +8,8 @@ import { apiRequest } from './client.js';
|
||||
* @typedef {import('./types.js').User} User
|
||||
*/
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||
|
||||
// Tasks API
|
||||
export const tasks = {
|
||||
/**
|
||||
* List all tasks with optional filters
|
||||
* @param {TaskFilters} [filters]
|
||||
* @returns {Promise<Task[]>}
|
||||
*/
|
||||
@@ -25,25 +21,20 @@ export const tasks = {
|
||||
if (filters.tags) {
|
||||
filters.tags.forEach(tag => params.append('tag', tag));
|
||||
}
|
||||
if (filters.excludeTags) {
|
||||
filters.excludeTags.forEach(tag => params.append('exclude_tag', tag));
|
||||
}
|
||||
|
||||
const query = params.toString();
|
||||
return apiRequest(`/tasks${query ? `?${query}` : ''}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get single task by UUID
|
||||
* @param {string} uuid
|
||||
* @returns {Promise<Task>}
|
||||
*/
|
||||
/** @param {string} uuid @returns {Promise<Task>} */
|
||||
async get(uuid) {
|
||||
return apiRequest(`/tasks/${uuid}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create new task
|
||||
* @param {Partial<Task>} task
|
||||
* @returns {Promise<Task>}
|
||||
*/
|
||||
/** @param {Partial<Task>} task @returns {Promise<Task>} */
|
||||
async create(task) {
|
||||
return apiRequest('/tasks', {
|
||||
method: 'POST',
|
||||
@@ -52,7 +43,6 @@ export const tasks = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Update existing task
|
||||
* @param {string} uuid
|
||||
* @param {Partial<Task>} updates
|
||||
* @returns {Promise<Task>}
|
||||
@@ -64,46 +54,29 @@ export const tasks = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete task
|
||||
* @param {string} uuid
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
/** @param {string} uuid @returns {Promise<void>} */
|
||||
async delete(uuid) {
|
||||
return apiRequest(`/tasks/${uuid}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
/**
|
||||
* Complete task
|
||||
* @param {string} uuid
|
||||
* @returns {Promise<Task>}
|
||||
*/
|
||||
/** @param {string} uuid @returns {Promise<Task>} */
|
||||
async complete(uuid) {
|
||||
return apiRequest(`/tasks/${uuid}/complete`, { method: 'POST' });
|
||||
},
|
||||
|
||||
/**
|
||||
* Start task timer
|
||||
* @param {string} uuid
|
||||
* @returns {Promise<Task>}
|
||||
*/
|
||||
/** @param {string} uuid @returns {Promise<Task>} */
|
||||
async start(uuid) {
|
||||
return apiRequest(`/tasks/${uuid}/start`, { method: 'POST' });
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop task timer
|
||||
* @param {string} uuid
|
||||
* @returns {Promise<Task>}
|
||||
*/
|
||||
/** @param {string} uuid @returns {Promise<Task>} */
|
||||
async stop(uuid) {
|
||||
return apiRequest(`/tasks/${uuid}/stop`, { method: 'POST' });
|
||||
},
|
||||
|
||||
/**
|
||||
* Parse CLI input and create task
|
||||
* @param {string} input - Raw opal CLI syntax
|
||||
* @returns {Promise<Task>}
|
||||
* @returns {Promise<{task?: Task} & Task>}
|
||||
*/
|
||||
async parse(input) {
|
||||
return apiRequest('/tasks/parse', {
|
||||
@@ -112,29 +85,20 @@ export const tasks = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* List tasks by report name
|
||||
* @param {string} reportName
|
||||
* @returns {Promise<Task[]>}
|
||||
*/
|
||||
/** @param {string} reportName @returns {Promise<Task[]>} */
|
||||
async listByReport(reportName) {
|
||||
const result = await apiRequest(`/tasks?report=${encodeURIComponent(reportName)}`);
|
||||
return result.tasks ?? result;
|
||||
}
|
||||
};
|
||||
|
||||
// Tags API
|
||||
export const tags = {
|
||||
/**
|
||||
* List all unique tags
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
/** @returns {Promise<string[]>} */
|
||||
async list() {
|
||||
return apiRequest('/tags');
|
||||
},
|
||||
|
||||
/**
|
||||
* Add tag to task
|
||||
* @param {string} uuid
|
||||
* @param {string} tag
|
||||
* @returns {Promise<void>}
|
||||
@@ -147,7 +111,6 @@ export const tags = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove tag from task
|
||||
* @param {string} uuid
|
||||
* @param {string} tag
|
||||
* @returns {Promise<void>}
|
||||
@@ -159,21 +122,15 @@ export const tags = {
|
||||
}
|
||||
};
|
||||
|
||||
// Projects API
|
||||
export const projects = {
|
||||
/**
|
||||
* List all projects
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
/** @returns {Promise<string[]>} */
|
||||
async list() {
|
||||
return apiRequest('/projects');
|
||||
}
|
||||
};
|
||||
|
||||
// Sync API
|
||||
export const sync = {
|
||||
/**
|
||||
* Get changes since timestamp
|
||||
* @param {number} since - Unix timestamp
|
||||
* @param {string} clientId
|
||||
* @returns {Promise<any[]>}
|
||||
@@ -186,8 +143,7 @@ export const sync = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Push local changes to server
|
||||
* @param {Task[]} tasks
|
||||
* @param {Partial<Task>[]} tasks
|
||||
* @param {string} clientId
|
||||
* @returns {Promise<{processed: number, conflicts: number}>}
|
||||
*/
|
||||
@@ -199,12 +155,8 @@ export const sync = {
|
||||
}
|
||||
};
|
||||
|
||||
// Auth API
|
||||
export const auth = {
|
||||
/**
|
||||
* Get OAuth login URL
|
||||
* @returns {Promise<{url: string, state: string}>}
|
||||
*/
|
||||
/** @returns {Promise<{url: string, state: string}>} */
|
||||
async getLoginUrl() {
|
||||
const response = await fetch(`${API_BASE}/auth/login`);
|
||||
const result = await response.json();
|
||||
@@ -215,7 +167,6 @@ export const auth = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Exchange OAuth code for tokens
|
||||
* @param {string} code
|
||||
* @returns {Promise<{access_token: string, refresh_token: string, expires_at: number, token_type: string, user: User}>}
|
||||
*/
|
||||
@@ -233,11 +184,7 @@ export const auth = {
|
||||
return result.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Logout (revoke refresh token)
|
||||
* @param {string} refreshToken
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
/** @param {string} refreshToken @returns {Promise<void>} */
|
||||
async logout(refreshToken) {
|
||||
return apiRequest('/auth/logout', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
* @property {string} [project]
|
||||
* @property {string} [priority]
|
||||
* @property {string[]} [tags]
|
||||
* @property {string[]} [excludeTags]
|
||||
*/
|
||||
|
||||
export {};
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
<script>
|
||||
import { onMount, afterUpdate } from 'svelte';
|
||||
|
||||
/** @type {boolean} */
|
||||
export let open = false;
|
||||
|
||||
/** @type {() => void} */
|
||||
export let onClose;
|
||||
|
||||
let dragOffset = 0;
|
||||
let dragging = false;
|
||||
let locked = false;
|
||||
let mounted = false;
|
||||
|
||||
/** @type {number|null} */
|
||||
let startY = null;
|
||||
/** @type {number|null} */
|
||||
let startX = null;
|
||||
/** @type {number|null} */
|
||||
let lastY = null;
|
||||
/** @type {number} */
|
||||
let lastTime = 0;
|
||||
/** @type {number} */
|
||||
let velocity = 0;
|
||||
|
||||
const DISMISS_THRESHOLD = 150;
|
||||
const VELOCITY_THRESHOLD = 0.5;
|
||||
|
||||
/** @type {HTMLDivElement|null} */
|
||||
let sheetEl = null;
|
||||
|
||||
afterUpdate(() => {
|
||||
if (!mounted) return;
|
||||
if (open) {
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.documentElement.style.overflow = '';
|
||||
dragOffset = 0;
|
||||
dragging = false;
|
||||
locked = false;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} e
|
||||
*/
|
||||
function handleKeydown(e) {
|
||||
if (!open) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
if (e.key === 'Tab' && sheetEl) {
|
||||
const focusable = sheetEl.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
if (focusable.length === 0) return;
|
||||
const first = /** @type {HTMLElement} */ (focusable[0]);
|
||||
const last = /** @type {HTMLElement} */ (focusable[focusable.length - 1]);
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
document.documentElement.style.overflow = '';
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {TouchEvent} e
|
||||
*/
|
||||
function handleDragStart(e) {
|
||||
const touch = e.touches[0];
|
||||
startY = touch.clientY;
|
||||
startX = touch.clientX;
|
||||
lastY = touch.clientY;
|
||||
lastTime = Date.now();
|
||||
locked = false;
|
||||
dragging = false;
|
||||
velocity = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TouchEvent} e
|
||||
*/
|
||||
function handleDragMove(e) {
|
||||
if (startY === null || startX === null) return;
|
||||
const touch = e.touches[0];
|
||||
const deltaY = touch.clientY - startY;
|
||||
const deltaX = touch.clientX - startX;
|
||||
|
||||
if (!locked && !dragging) {
|
||||
if (Math.abs(deltaY) > 10 && Math.abs(deltaY) > Math.abs(deltaX) * 2) {
|
||||
dragging = true;
|
||||
locked = true;
|
||||
} else if (Math.abs(deltaX) > 10) {
|
||||
startY = null;
|
||||
startX = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (dragging) {
|
||||
if (e.cancelable) e.preventDefault();
|
||||
dragOffset = Math.max(0, deltaY);
|
||||
|
||||
const now = Date.now();
|
||||
const dt = now - lastTime;
|
||||
if (dt > 0 && lastY !== null) {
|
||||
velocity = (touch.clientY - lastY) / dt;
|
||||
}
|
||||
lastY = touch.clientY;
|
||||
lastTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
if (!dragging) {
|
||||
startY = null;
|
||||
startX = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (dragOffset > DISMISS_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
|
||||
onClose();
|
||||
}
|
||||
dragOffset = 0;
|
||||
dragging = false;
|
||||
startY = null;
|
||||
startX = null;
|
||||
}
|
||||
|
||||
function handleScrimClick() {
|
||||
onClose();
|
||||
}
|
||||
|
||||
$: transitioning = !dragging && dragOffset === 0;
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div class="sheet-scrim" class:open on:click={handleScrimClick}>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions a11y-interactive-supports-focus -->
|
||||
<div
|
||||
bind:this={sheetEl}
|
||||
class="sheet"
|
||||
class:open
|
||||
class:transitioning
|
||||
style:transform={open
|
||||
? `translateY(${dragOffset}px)`
|
||||
: undefined}
|
||||
on:click|stopPropagation
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
class="sheet-handle"
|
||||
on:touchstart={handleDragStart}
|
||||
on:touchmove={handleDragMove}
|
||||
on:touchend={handleDragEnd}
|
||||
>
|
||||
<div class="handle-bar"></div>
|
||||
</div>
|
||||
<div class="sheet-content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sheet-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.sheet-scrim.open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 85dvh;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
.sheet.transitioning {
|
||||
transition: transform 0.3s ease-out;
|
||||
}
|
||||
|
||||
.sheet.open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.sheet-handle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-sm) 0;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.handle-bar {
|
||||
width: 2.5rem;
|
||||
height: 0.25rem;
|
||||
background: var(--text-tertiary);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.sheet-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0 var(--spacing-md) var(--spacing-lg);
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.sheet {
|
||||
max-width: 480px;
|
||||
left: 50%;
|
||||
margin-left: -240px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,178 @@
|
||||
<script>
|
||||
/** @type {string} */
|
||||
export let title;
|
||||
|
||||
/** @type {string} */
|
||||
export let message;
|
||||
|
||||
/** @type {string|undefined} */
|
||||
export let detail = undefined;
|
||||
|
||||
/** @type {string} */
|
||||
export let confirmLabel;
|
||||
|
||||
/** @type {'danger'|'primary'} */
|
||||
export let confirmVariant = 'danger';
|
||||
|
||||
/** @type {() => void} */
|
||||
export let onConfirm;
|
||||
|
||||
/** @type {() => void} */
|
||||
export let onCancel;
|
||||
|
||||
/** @type {HTMLDialogElement|null} */
|
||||
let dialogEl = null;
|
||||
|
||||
/** @type {HTMLButtonElement|null} */
|
||||
let cancelBtn = null;
|
||||
|
||||
export function open() {
|
||||
dialogEl?.showModal();
|
||||
requestAnimationFrame(() => cancelBtn?.focus());
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
dialogEl?.close();
|
||||
onConfirm();
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
dialogEl?.close();
|
||||
onCancel();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
function handleBackdropClick(e) {
|
||||
if (e.target === dialogEl) {
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
|
||||
<dialog
|
||||
bind:this={dialogEl}
|
||||
class="confirm-dialog"
|
||||
on:click={handleBackdropClick}
|
||||
on:cancel|preventDefault={handleCancel}
|
||||
>
|
||||
<div class="dialog-content">
|
||||
<h3 class="dialog-title">{title}</h3>
|
||||
|
||||
<p class="dialog-message">"{message}"</p>
|
||||
|
||||
{#if detail}
|
||||
<p class="dialog-detail">{detail}</p>
|
||||
{/if}
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button
|
||||
bind:this={cancelBtn}
|
||||
class="btn btn-ghost"
|
||||
on:click={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-{confirmVariant}"
|
||||
on:click={handleConfirm}
|
||||
type="button"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.confirm-dialog {
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: auto;
|
||||
max-width: min(360px, calc(100vw - 2rem));
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.confirm-dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
}
|
||||
|
||||
.dialog-message {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--spacing-xs) 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.dialog-detail {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
min-height: 40px;
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--color-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,303 @@
|
||||
<script>
|
||||
import { recentFilters, setFilter, removeRecent } from '$lib/stores/filters.js';
|
||||
|
||||
/** @type {() => void} */
|
||||
export let onClose;
|
||||
|
||||
let filterInput = '';
|
||||
|
||||
/** @type {HTMLDialogElement|null} */
|
||||
let dialogEl = null;
|
||||
|
||||
/** @type {HTMLInputElement|null} */
|
||||
let inputEl = null;
|
||||
|
||||
export function open() {
|
||||
filterInput = '';
|
||||
dialogEl?.showModal();
|
||||
// Focus after dialog animation
|
||||
requestAnimationFrame(() => inputEl?.focus());
|
||||
}
|
||||
|
||||
function apply() {
|
||||
const trimmed = filterInput.trim();
|
||||
if (trimmed) {
|
||||
setFilter(trimmed);
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
function close() {
|
||||
dialogEl?.close();
|
||||
onClose();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filter
|
||||
*/
|
||||
function applyRecent(filter) {
|
||||
setFilter(filter);
|
||||
close();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
* @param {string} filter
|
||||
*/
|
||||
function handleRemoveRecent(e, filter) {
|
||||
e.stopPropagation();
|
||||
removeRecent(filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} e
|
||||
*/
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
apply();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
function handleBackdropClick(e) {
|
||||
if (e.target === dialogEl) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
|
||||
<dialog
|
||||
bind:this={dialogEl}
|
||||
class="filter-modal"
|
||||
on:click={handleBackdropClick}
|
||||
>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Filter</h3>
|
||||
<button class="close-btn" on:click={close} type="button" aria-label="Close">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" width="20" height="20">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={filterInput}
|
||||
on:keydown={handleKeydown}
|
||||
type="text"
|
||||
placeholder="e.g. +grocer project:home"
|
||||
class="filter-input"
|
||||
/>
|
||||
<button
|
||||
class="apply-btn"
|
||||
on:click={apply}
|
||||
disabled={!filterInput.trim()}
|
||||
type="button"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if $recentFilters.length > 0}
|
||||
<div class="recents">
|
||||
<div class="recents-label">Recent</div>
|
||||
{#each $recentFilters as recent}
|
||||
<button
|
||||
class="recent-row"
|
||||
on:click={() => applyRecent(recent)}
|
||||
type="button"
|
||||
>
|
||||
<span class="recent-text">{recent}</span>
|
||||
<span
|
||||
class="recent-remove"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:click={(e) => handleRemoveRecent(e, recent)}
|
||||
on:keydown={(e) => e.key === 'Enter' && handleRemoveRecent(e, recent)}
|
||||
aria-label="Remove {recent} from recents"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" width="14" height="14">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.filter-modal {
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: auto;
|
||||
max-width: min(400px, calc(100vw - 2rem));
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-modal::backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.filter-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;
|
||||
}
|
||||
|
||||
.filter-input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.filter-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--focus-ring);
|
||||
}
|
||||
|
||||
.apply-btn {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
min-width: unset;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.apply-btn:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.apply-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.recents {
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
.recents-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.recent-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
min-height: 40px;
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.recent-row:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.recent-text {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.recent-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recent-remove:hover {
|
||||
color: var(--color-danger);
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script>
|
||||
import { activeFilter, setFilter } from '$lib/stores/filters.js';
|
||||
|
||||
/** @param {string} token */
|
||||
function removeToken(token) {
|
||||
if (!$activeFilter) return;
|
||||
const tokens = $activeFilter.trim().split(/\s+/);
|
||||
const remaining = tokens.filter(t => t !== token);
|
||||
if (remaining.length === 0) {
|
||||
activeFilter.set(null);
|
||||
} else {
|
||||
setFilter(remaining.join(' '));
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} token */
|
||||
function tokenType(token) {
|
||||
if (token.startsWith('+')) return 'tag';
|
||||
if (token.startsWith('-')) return 'exclude';
|
||||
if (token.includes(':')) return 'attr';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
$: tokens = $activeFilter ? $activeFilter.trim().split(/\s+/) : [];
|
||||
</script>
|
||||
|
||||
{#if tokens.length > 0}
|
||||
<div class="filter-pills">
|
||||
{#each tokens as token}
|
||||
<button
|
||||
class="filter-pill {tokenType(token)}"
|
||||
type="button"
|
||||
on:mousedown|preventDefault={() => removeToken(token)}
|
||||
title="Remove filter: {token}"
|
||||
>
|
||||
<span class="pill-text">{token}</span>
|
||||
<svg class="pill-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" width="12" height="12">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.filter-pills {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 1px;
|
||||
align-items: stretch;
|
||||
align-self: stretch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filter-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0 0.5rem;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-mono);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
min-height: unset;
|
||||
min-width: unset;
|
||||
line-height: 1.3;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.filter-pill:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.filter-pill.tag {
|
||||
background-color: var(--color-tag-bg);
|
||||
color: var(--color-tag-text);
|
||||
}
|
||||
|
||||
.filter-pill.exclude {
|
||||
background-color: var(--color-priority-high-bg);
|
||||
color: var(--color-priority-high-text);
|
||||
}
|
||||
|
||||
.filter-pill.attr {
|
||||
background-color: var(--color-project-bg);
|
||||
color: var(--color-project-text);
|
||||
}
|
||||
|
||||
.filter-pill.unknown {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pill-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill-x {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.filter-pill:hover .pill-x {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,7 @@
|
||||
<script>
|
||||
import ReportPicker from './ReportPicker.svelte';
|
||||
import FilterModal from './FilterModal.svelte';
|
||||
import { activeFilter } from '$lib/stores/filters.js';
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
@@ -14,7 +16,9 @@
|
||||
/** @type {ReportPicker} */
|
||||
let picker;
|
||||
|
||||
/** Map backend report names to display labels */
|
||||
/** @type {FilterModal} */
|
||||
let filterModal;
|
||||
|
||||
const reportLabels = /** @type {Record<string, string>} */ ({
|
||||
list: 'Pending',
|
||||
next: 'Next',
|
||||
@@ -30,9 +34,11 @@
|
||||
});
|
||||
|
||||
$: displayLabel = reportLabels[activeReport] || activeReport;
|
||||
$: hasActiveFilter = !!$activeFilter;
|
||||
</script>
|
||||
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<button
|
||||
class="report-btn"
|
||||
on:click={() => picker.toggle()}
|
||||
@@ -43,6 +49,21 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="filter-btn"
|
||||
class:active={hasActiveFilter}
|
||||
on:click={() => filterModal.open()}
|
||||
aria-label="Filter tasks"
|
||||
>
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
{#if hasActiveFilter}
|
||||
<span class="filter-dot"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<a href="/settings" class="settings-btn" aria-label="Settings">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
@@ -59,6 +80,11 @@
|
||||
onSelect={onReportChange}
|
||||
/>
|
||||
|
||||
<FilterModal
|
||||
bind:this={filterModal}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
grid-area: header;
|
||||
@@ -67,7 +93,14 @@
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-bottom: calc(var(--spacing-sm) + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.report-btn {
|
||||
@@ -100,6 +133,40 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
min-width: unset;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.filter-dot {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script>
|
||||
import PropertyPills from './PropertyPills.svelte';
|
||||
import FilterPills from './FilterPills.svelte';
|
||||
import { activeFilter } from '$lib/stores/filters.js';
|
||||
import { mergeInputWithFilter } from '$lib/utils/filters.js';
|
||||
|
||||
/**
|
||||
* @type {(input: string) => Promise<void>}
|
||||
@@ -21,8 +24,11 @@
|
||||
async function handleSubmit() {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || loading) return;
|
||||
|
||||
const merged = mergeInputWithFilter(trimmed, $activeFilter || '');
|
||||
|
||||
try {
|
||||
await onSubmit(trimmed);
|
||||
await onSubmit(merged);
|
||||
value = '';
|
||||
} catch {
|
||||
// Value preserved for retry
|
||||
@@ -54,22 +60,17 @@
|
||||
}, 150);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert text at cursor position
|
||||
* @param {string} text
|
||||
*/
|
||||
/** @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) {
|
||||
@@ -78,16 +79,30 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @returns {string} */
|
||||
export function getInputValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** @param {string} newValue */
|
||||
export function setInputValue(newValue) {
|
||||
value = newValue;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="input-bar">
|
||||
<PropertyPills visible={focused} onInsert={insertAtCursor} />
|
||||
<PropertyPills visible={focused} onInsert={insertAtCursor} inputValue={value} onInputChange={(v) => { value = v; }} />
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="input-row">
|
||||
<div class="input-row" class:focused>
|
||||
{#if $activeFilter}
|
||||
<FilterPills />
|
||||
<div class="separator"></div>
|
||||
{/if}
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value
|
||||
@@ -95,7 +110,7 @@
|
||||
on:focus={handleFocus}
|
||||
on:blur={handleBlur}
|
||||
type="text"
|
||||
placeholder="Add task... (e.g. Buy milk due:tomorrow priority:H)"
|
||||
placeholder={$activeFilter ? "Add task..." : "Add task... (e.g. Buy milk due:tomorrow priority:H)"}
|
||||
disabled={loading}
|
||||
class="input"
|
||||
/>
|
||||
@@ -121,25 +136,39 @@
|
||||
.input-bar {
|
||||
grid-area: input;
|
||||
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;
|
||||
align-items: stretch;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--bg-secondary);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.input-row.focused {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--focus-ring);
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
background-color: var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
font-size: var(--font-size-base);
|
||||
font-family: inherit;
|
||||
background-color: var(--bg-secondary);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -150,8 +179,6 @@
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--focus-ring);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
@@ -163,7 +190,7 @@
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
border-radius: 0 calc(var(--border-radius) - 1px) calc(var(--border-radius) - 1px) 0;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
min-width: 44px;
|
||||
|
||||
@@ -1,21 +1,43 @@
|
||||
<script>
|
||||
import { removeTokenByPrefix } from '$lib/utils/filters.js';
|
||||
|
||||
/**
|
||||
* @type {(text: string) => void}
|
||||
*/
|
||||
export let onInsert;
|
||||
|
||||
export let inputValue = '';
|
||||
export let onInputChange = /** @type {(value: string) => void} */ (() => {});
|
||||
|
||||
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:" },
|
||||
{ label: "Due", text: "due:", isTag: false },
|
||||
{ label: "Pri", text: "priority:", isTag: false },
|
||||
{ label: "Project", text: "project:", isTag: false },
|
||||
{ label: "Tag", text: "+", isTag: true },
|
||||
{ label: "Recur", text: "recur:", isTag: false },
|
||||
{ label: "Scheduled", text: "scheduled:", isTag: false },
|
||||
{ label: "Wait", text: "wait:", isTag: false },
|
||||
{ label: "Until", text: "until:", isTag: false },
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {{ text: string, isTag: boolean }} pill
|
||||
*/
|
||||
function handleInsert(pill) {
|
||||
if (!pill.isTag && inputValue) {
|
||||
const prefix = pill.text; // e.g. "due:"
|
||||
const cleaned = removeTokenByPrefix(inputValue, prefix);
|
||||
if (cleaned !== inputValue) {
|
||||
onInputChange(cleaned);
|
||||
// Let the DOM update, then insert
|
||||
requestAnimationFrame(() => onInsert(pill.text));
|
||||
return;
|
||||
}
|
||||
}
|
||||
onInsert(pill.text);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
@@ -24,7 +46,7 @@
|
||||
<button
|
||||
class="pill"
|
||||
type="button"
|
||||
on:mousedown|preventDefault={() => onInsert(pill.text)}
|
||||
on:mousedown|preventDefault={() => handleInsert(pill)}
|
||||
>
|
||||
{pill.label}
|
||||
</button>
|
||||
|
||||
@@ -2,12 +2,23 @@
|
||||
/**
|
||||
* @type {() => void}
|
||||
*/
|
||||
export let onSwipe;
|
||||
export let onSwipeRight;
|
||||
|
||||
/**
|
||||
* @type {() => void}
|
||||
*/
|
||||
export let onSwipeLeft;
|
||||
|
||||
/**
|
||||
* @type {'start' | 'stop'}
|
||||
*/
|
||||
export let leftIcon;
|
||||
|
||||
let offsetX = 0;
|
||||
let swiping = false;
|
||||
let locked = false;
|
||||
let completed = false;
|
||||
let triggered = false;
|
||||
|
||||
/** @type {number|null} */
|
||||
let startX = null;
|
||||
@@ -38,12 +49,10 @@
|
||||
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;
|
||||
@@ -52,8 +61,7 @@
|
||||
|
||||
if (swiping) {
|
||||
if (e.cancelable) e.preventDefault();
|
||||
// Only allow right swipe
|
||||
offsetX = Math.max(0, deltaX);
|
||||
offsetX = deltaX;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,10 +73,17 @@
|
||||
|
||||
if (offsetX >= THRESHOLD) {
|
||||
completed = true;
|
||||
// Animate to full width before firing callback
|
||||
offsetX = window.innerWidth;
|
||||
setTimeout(() => {
|
||||
onSwipe();
|
||||
onSwipeRight();
|
||||
}, 200);
|
||||
} else if (offsetX <= -THRESHOLD) {
|
||||
triggered = true;
|
||||
offsetX = -window.innerWidth;
|
||||
setTimeout(() => {
|
||||
onSwipeLeft();
|
||||
offsetX = 0;
|
||||
triggered = false;
|
||||
}, 200);
|
||||
} else {
|
||||
offsetX = 0;
|
||||
@@ -86,7 +101,8 @@
|
||||
startY = null;
|
||||
}
|
||||
|
||||
$: progress = Math.min(offsetX / THRESHOLD, 1);
|
||||
$: rightProgress = offsetX > 0 ? Math.min(offsetX / THRESHOLD, 1) : 0;
|
||||
$: leftProgress = offsetX < 0 ? Math.min(Math.abs(offsetX) / THRESHOLD, 1) : 0;
|
||||
$: transitioning = !swiping && offsetX !== 0;
|
||||
</script>
|
||||
|
||||
@@ -97,15 +113,28 @@
|
||||
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">
|
||||
<!-- Left background (revealed on RIGHT swipe — complete) -->
|
||||
<div class="swipe-bg swipe-bg-right"
|
||||
style:opacity={rightProgress}>
|
||||
<svg class="swipe-icon 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>
|
||||
|
||||
<!-- Right background (revealed on LEFT swipe — start/stop) -->
|
||||
<div class="swipe-bg swipe-bg-left"
|
||||
style:opacity={leftProgress}>
|
||||
{#if leftIcon === 'stop'}
|
||||
<svg class="swipe-icon fill-icon" viewBox="0 0 24 24">
|
||||
<rect x="6" y="6" width="12" height="12" rx="1" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="swipe-icon fill-icon" viewBox="0 0 24 24">
|
||||
<polygon points="6,4 20,12 6,20" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="swipe-content"
|
||||
class:transitioning
|
||||
@@ -122,21 +151,44 @@
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.swipe-background {
|
||||
.swipe-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--color-success);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
.swipe-bg-right {
|
||||
background-color: var(--color-success);
|
||||
padding-left: var(--spacing-lg);
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.swipe-bg-left {
|
||||
background-color: var(--color-primary);
|
||||
padding-right: var(--spacing-lg);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.swipe-icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.fill-icon {
|
||||
fill: white;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.swipe-content {
|
||||
position: relative;
|
||||
background-color: var(--bg-primary);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,10 +14,21 @@
|
||||
*/
|
||||
export let onComplete;
|
||||
|
||||
/**
|
||||
* @type {(task: import('$lib/api/types.js').Task) => void}
|
||||
*/
|
||||
export let onTap;
|
||||
|
||||
/**
|
||||
* @type {(uuid: string) => void}
|
||||
*/
|
||||
export let onStartStop;
|
||||
|
||||
let completing = false;
|
||||
|
||||
$: overdue = task.due && isOverdue(task.due);
|
||||
$: dueToday = task.due && isTodayFn(new Date(task.due * 1000));
|
||||
$: active = task.start !== null && task.start !== undefined;
|
||||
|
||||
function handleCheckbox() {
|
||||
if (completing) return;
|
||||
@@ -34,13 +45,18 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<SwipeAction onSwipe={() => onComplete(task.uuid)}>
|
||||
<div class="task-item" class:completing on:transitionend={handleTransitionEnd}>
|
||||
<SwipeAction
|
||||
onSwipeRight={() => onComplete(task.uuid)}
|
||||
onSwipeLeft={() => onStartStop(task.uuid)}
|
||||
leftIcon={active ? 'stop' : 'start'}
|
||||
>
|
||||
<div class="task-item" class:completing class:active on:transitionend={handleTransitionEnd}>
|
||||
<button class="task-checkbox" on:click|stopPropagation={handleCheckbox} type="button" aria-label="Complete task">
|
||||
<Checkbox checked={task.status === 'C'} />
|
||||
</button>
|
||||
|
||||
<div class="task-content">
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div class="task-content" on:click={() => onTap(task)}>
|
||||
<div class="task-header">
|
||||
<span class="task-description" class:completed={task.status === 'C'}>
|
||||
{task.description}
|
||||
@@ -48,6 +64,10 @@
|
||||
</div>
|
||||
|
||||
<div class="task-meta">
|
||||
{#if active}
|
||||
<span class="meta-item active-pill">Active</span>
|
||||
{/if}
|
||||
|
||||
{#if task.project}
|
||||
<span class="meta-item project">{task.project}</span>
|
||||
{/if}
|
||||
@@ -109,6 +129,11 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-item.active {
|
||||
border-left: 3px solid var(--color-primary);
|
||||
padding-left: calc(var(--spacing-md) - 3px);
|
||||
}
|
||||
|
||||
.task-checkbox {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
@@ -127,6 +152,7 @@
|
||||
.task-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
@@ -158,6 +184,11 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.active-pill {
|
||||
background-color: var(--color-active-bg);
|
||||
color: var(--color-active-text);
|
||||
}
|
||||
|
||||
.project {
|
||||
background-color: var(--color-project-bg);
|
||||
color: var(--color-project-text);
|
||||
|
||||
@@ -11,6 +11,16 @@
|
||||
*/
|
||||
export let onComplete;
|
||||
|
||||
/**
|
||||
* @type {(task: import('$lib/api/types.js').Task) => void}
|
||||
*/
|
||||
export let onTap;
|
||||
|
||||
/**
|
||||
* @type {(uuid: string) => void}
|
||||
*/
|
||||
export let onStartStop;
|
||||
|
||||
export let loading = false;
|
||||
export let activeReport = 'list';
|
||||
|
||||
@@ -50,6 +60,8 @@
|
||||
<TaskItem
|
||||
{task}
|
||||
{onComplete}
|
||||
{onTap}
|
||||
{onStartStop}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
/** @type {string} */
|
||||
export let message;
|
||||
|
||||
/** @type {{ label: string, handler: () => void }|undefined} */
|
||||
export let action = undefined;
|
||||
|
||||
/** @type {number} */
|
||||
export let duration = 5000;
|
||||
|
||||
/** @type {() => void} */
|
||||
export let onDismiss;
|
||||
|
||||
/** @type {ReturnType<typeof setTimeout>|null} */
|
||||
let timer = null;
|
||||
|
||||
function startTimer() {
|
||||
if (duration > 0) {
|
||||
timer = setTimeout(() => {
|
||||
onDismiss();
|
||||
}, duration);
|
||||
}
|
||||
}
|
||||
|
||||
function clearTimer() {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAction() {
|
||||
clearTimer();
|
||||
if (action) action.handler();
|
||||
onDismiss();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
startTimer();
|
||||
return () => clearTimer();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="toast" transition:fly={{ y: 50, duration: 200 }}>
|
||||
<span class="toast-message">{message}</span>
|
||||
{#if action}
|
||||
<button class="toast-action" on:click={handleAction} type="button">
|
||||
{action.label}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: calc(3.5rem + var(--spacing-md));
|
||||
left: var(--spacing-md);
|
||||
right: var(--spacing-md);
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toast-action {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: 0.25rem;
|
||||
white-space: nowrap;
|
||||
min-width: unset;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.toast-action:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,73 +0,0 @@
|
||||
<script>
|
||||
/**
|
||||
* @type {Array<{value: string, label: string}>}
|
||||
*/
|
||||
export let options = [];
|
||||
export let value = '';
|
||||
export let label = '';
|
||||
export let placeholder = 'Select...';
|
||||
export let disabled = false;
|
||||
export let id = '';
|
||||
</script>
|
||||
|
||||
<div class="select-group">
|
||||
{#if label}
|
||||
<label for={id} class="label">{label}</label>
|
||||
{/if}
|
||||
|
||||
<select
|
||||
{id}
|
||||
bind:value
|
||||
{disabled}
|
||||
class="select"
|
||||
on:change
|
||||
>
|
||||
<option value="" disabled selected>{placeholder}</option>
|
||||
{#each options as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.select-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-size: var(--font-size-base);
|
||||
font-family: inherit;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%236b7280'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.5rem center;
|
||||
background-size: 1.5rem;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
.select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.select:disabled {
|
||||
background-color: var(--bg-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -1 +0,0 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -19,10 +19,16 @@ import { getItem, setItem, removeItem } from '$lib/utils/storage.js';
|
||||
const STORAGE_KEY = 'opal_auth';
|
||||
const DEV_MODE = import.meta.env.DEV;
|
||||
|
||||
/**
|
||||
* Load auth state from localStorage
|
||||
* @returns {AuthState}
|
||||
*/
|
||||
/** @type {AuthState} */
|
||||
const EMPTY_STATE = {
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
user: null,
|
||||
isAuthenticated: false
|
||||
};
|
||||
|
||||
/** @returns {AuthState} */
|
||||
function loadAuth() {
|
||||
// In dev mode, auto-authenticate with a dev user.
|
||||
// API requests still go to the real backend (which runs with auth disabled).
|
||||
@@ -36,21 +42,11 @@ function loadAuth() {
|
||||
};
|
||||
}
|
||||
|
||||
if (!browser) {
|
||||
return {
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
user: null,
|
||||
isAuthenticated: false
|
||||
};
|
||||
}
|
||||
if (!browser) return EMPTY_STATE;
|
||||
|
||||
const stored = getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
// Check if token expired
|
||||
if (stored.expiresAt && stored.expiresAt < Date.now() / 1000) {
|
||||
// Token expired - clear
|
||||
removeItem(STORAGE_KEY);
|
||||
return loadAuth();
|
||||
}
|
||||
@@ -60,28 +56,21 @@ function loadAuth() {
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
user: null,
|
||||
isAuthenticated: false
|
||||
};
|
||||
return EMPTY_STATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create auth store
|
||||
*/
|
||||
function createAuthStore() {
|
||||
const { subscribe, set, update } = writable(loadAuth());
|
||||
|
||||
/** Persist state to localStorage */
|
||||
function persist(/** @type {AuthState} */ state) {
|
||||
if (browser) setItem(STORAGE_KEY, state);
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
/**
|
||||
* Set authentication tokens
|
||||
* @param {AuthTokens} tokens
|
||||
*/
|
||||
/** @param {AuthTokens} tokens */
|
||||
setTokens(tokens) {
|
||||
update(state => {
|
||||
const newState = {
|
||||
@@ -91,33 +80,21 @@ function createAuthStore() {
|
||||
expiresAt: tokens.expires_at,
|
||||
isAuthenticated: true
|
||||
};
|
||||
|
||||
if (browser) {
|
||||
setItem(STORAGE_KEY, newState);
|
||||
}
|
||||
|
||||
persist(newState);
|
||||
return newState;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Set user info
|
||||
* @param {User} user
|
||||
*/
|
||||
/** @param {User} user */
|
||||
setUser(user) {
|
||||
update(state => {
|
||||
const newState = { ...state, user };
|
||||
if (browser) {
|
||||
setItem(STORAGE_KEY, newState);
|
||||
}
|
||||
persist(newState);
|
||||
return newState;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Set full auth data (tokens + user)
|
||||
* @param {AuthTokens & {user: User}} data
|
||||
*/
|
||||
/** @param {AuthTokens & {user: User}} data */
|
||||
setAuth(data) {
|
||||
const newState = {
|
||||
accessToken: data.access_token,
|
||||
@@ -126,28 +103,13 @@ function createAuthStore() {
|
||||
user: data.user,
|
||||
isAuthenticated: true
|
||||
};
|
||||
|
||||
if (browser) {
|
||||
setItem(STORAGE_KEY, newState);
|
||||
}
|
||||
|
||||
persist(newState);
|
||||
set(newState);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear auth (logout)
|
||||
*/
|
||||
clear() {
|
||||
if (browser) {
|
||||
removeItem(STORAGE_KEY);
|
||||
}
|
||||
set({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
user: null,
|
||||
isAuthenticated: false
|
||||
});
|
||||
if (browser) removeItem(STORAGE_KEY);
|
||||
set(EMPTY_STATE);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { getItem, setItem } from '$lib/utils/storage.js';
|
||||
|
||||
const ACTIVE_KEY = 'opal_active_filter';
|
||||
const RECENT_KEY = 'opal_recent_filters';
|
||||
const MAX_RECENT = 8;
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {string} key
|
||||
* @param {T} fallback
|
||||
*/
|
||||
function persisted(key, fallback) {
|
||||
const initial = getItem(key) ?? fallback;
|
||||
const store = writable(/** @type {T} */ (initial));
|
||||
store.subscribe(value => setItem(key, value));
|
||||
return store;
|
||||
}
|
||||
|
||||
export const activeFilter = persisted(ACTIVE_KEY, /** @type {string|null} */ (null));
|
||||
export const recentFilters = persisted(RECENT_KEY, /** @type {string[]} */ ([]));
|
||||
|
||||
/** @param {string} str */
|
||||
export function setFilter(str) {
|
||||
const trimmed = str.trim();
|
||||
if (!trimmed) {
|
||||
clearFilter();
|
||||
return;
|
||||
}
|
||||
activeFilter.set(trimmed);
|
||||
|
||||
recentFilters.update(recents => {
|
||||
const deduped = recents.filter(r => r !== trimmed);
|
||||
return [trimmed, ...deduped].slice(0, MAX_RECENT);
|
||||
});
|
||||
}
|
||||
|
||||
export function clearFilter() {
|
||||
activeFilter.set(null);
|
||||
}
|
||||
|
||||
/** @param {string} str */
|
||||
export function removeRecent(str) {
|
||||
recentFilters.update(recents => recents.filter(r => r !== str));
|
||||
}
|
||||
@@ -20,10 +20,7 @@ import { generateUUID } from '$lib/utils/uuid.js';
|
||||
const SYNC_STATE_KEY = 'opal_sync_state';
|
||||
const CLIENT_ID_KEY = 'opal_client_id';
|
||||
|
||||
/**
|
||||
* Get or create client ID
|
||||
* @returns {string}
|
||||
*/
|
||||
/** @returns {string} */
|
||||
function getClientId() {
|
||||
let clientId = getItem(CLIENT_ID_KEY);
|
||||
if (!clientId) {
|
||||
@@ -33,10 +30,7 @@ function getClientId() {
|
||||
return clientId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load sync state
|
||||
* @returns {SyncState}
|
||||
*/
|
||||
/** @returns {SyncState} */
|
||||
function loadSyncState() {
|
||||
const stored = getItem(SYNC_STATE_KEY);
|
||||
return {
|
||||
@@ -48,19 +42,13 @@ function loadSyncState() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create sync store
|
||||
*/
|
||||
function createSyncStore() {
|
||||
const { subscribe, set, update } = writable(loadSyncState());
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
/**
|
||||
* Perform sync
|
||||
* @returns {Promise<SyncResult>}
|
||||
*/
|
||||
/** @returns {Promise<SyncResult>} */
|
||||
async sync() {
|
||||
update(state => ({ ...state, status: 'syncing', error: null }));
|
||||
|
||||
@@ -68,7 +56,8 @@ function createSyncStore() {
|
||||
const state = loadSyncState();
|
||||
const queue = getQueue();
|
||||
|
||||
let result = {
|
||||
/** @type {SyncResult} */
|
||||
const result = {
|
||||
pulled: 0,
|
||||
pushed: 0,
|
||||
conflicts_resolved: 0,
|
||||
@@ -76,28 +65,25 @@ function createSyncStore() {
|
||||
errors: []
|
||||
};
|
||||
|
||||
// Push queued changes
|
||||
if (queue.length > 0) {
|
||||
const tasks = queue.map(q => q.data);
|
||||
try {
|
||||
await syncAPI.push(tasks, state.clientId);
|
||||
clearQueue();
|
||||
result.pushed = queue.length;
|
||||
} catch (error) {
|
||||
} catch (/** @type {any} */ error) {
|
||||
result.errors.push(`Failed to push queue: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Pull changes from server
|
||||
try {
|
||||
const changes = await syncAPI.getChanges(state.lastSync, state.clientId);
|
||||
result.pulled = changes.length;
|
||||
// TODO: Apply changes to local state
|
||||
} catch (error) {
|
||||
} catch (/** @type {any} */ error) {
|
||||
result.errors.push(`Failed to pull changes: ${error.message}`);
|
||||
}
|
||||
|
||||
// Update sync state
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
setItem(SYNC_STATE_KEY, { lastSync: now });
|
||||
|
||||
@@ -110,7 +96,7 @@ function createSyncStore() {
|
||||
}));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
} catch (/** @type {any} */ error) {
|
||||
update(state => ({
|
||||
...state,
|
||||
status: 'error',
|
||||
@@ -120,9 +106,6 @@ function createSyncStore() {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update queue size
|
||||
*/
|
||||
updateQueueSize() {
|
||||
update(state => ({
|
||||
...state,
|
||||
|
||||
@@ -7,17 +7,22 @@ import { queueChange } from '$lib/utils/sync-queue.js';
|
||||
* @typedef {import('$lib/api/types.js').TaskFilters} TaskFilters
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create tasks store
|
||||
*/
|
||||
function createTasksStore() {
|
||||
const { subscribe, set, update } = writable(/** @type {Task[]} */ ([]));
|
||||
|
||||
/**
|
||||
* Replace a single task in the array by UUID.
|
||||
* @param {string} uuid
|
||||
* @param {(task: Task) => Task} fn
|
||||
*/
|
||||
function updateByUuid(uuid, fn) {
|
||||
update(tasks => tasks.map(t => t.uuid === uuid ? fn(t) : t));
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
/**
|
||||
* Load tasks by report name
|
||||
* @param {string} reportName - Backend report name (e.g. 'list', 'next', 'completed')
|
||||
*/
|
||||
async loadReport(reportName) {
|
||||
@@ -31,14 +36,13 @@ function createTasksStore() {
|
||||
},
|
||||
|
||||
/**
|
||||
* Parse CLI input and create a task
|
||||
* @param {string} input - Raw opal CLI syntax
|
||||
* @returns {Promise<Task>}
|
||||
*/
|
||||
async parseAndCreate(input) {
|
||||
try {
|
||||
const result = await tasksAPI.parse(input);
|
||||
const created = result.task ?? result;
|
||||
const created = /** @type {Task} */ (result.task ?? result);
|
||||
update(tasks => [created, ...tasks]);
|
||||
return created;
|
||||
} catch (error) {
|
||||
@@ -47,10 +51,7 @@ function createTasksStore() {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load all tasks from API
|
||||
* @param {TaskFilters} [filters]
|
||||
*/
|
||||
/** @param {TaskFilters} [filters] */
|
||||
async load(filters = {}) {
|
||||
try {
|
||||
const tasks = await tasksAPI.list(filters);
|
||||
@@ -62,8 +63,8 @@ function createTasksStore() {
|
||||
},
|
||||
|
||||
/**
|
||||
* Add new task (optimistic update)
|
||||
* @param {Partial<Task>} task
|
||||
* Optimistic create — queues offline on failure.
|
||||
* @param {Task} task
|
||||
*/
|
||||
async add(task) {
|
||||
try {
|
||||
@@ -71,79 +72,75 @@ function createTasksStore() {
|
||||
update(tasks => [...tasks, created]);
|
||||
return created;
|
||||
} catch (error) {
|
||||
// Queue for offline sync
|
||||
queueChange({
|
||||
type: 'create',
|
||||
task_uuid: task.uuid,
|
||||
data: task
|
||||
});
|
||||
|
||||
// Still update UI optimistically
|
||||
queueChange({ type: 'create', task_uuid: task.uuid, data: task });
|
||||
update(tasks => [...tasks, task]);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update task (optimistic update)
|
||||
* Optimistic update — queues offline on failure.
|
||||
* @param {string} uuid
|
||||
* @param {Partial<Task>} updates
|
||||
*/
|
||||
async updateTask(uuid, updates) {
|
||||
// Optimistic update
|
||||
update(tasks => {
|
||||
const index = tasks.findIndex(t => t.uuid === uuid);
|
||||
if (index >= 0) {
|
||||
tasks[index] = { ...tasks[index], ...updates, modified: Date.now() / 1000 };
|
||||
}
|
||||
return tasks;
|
||||
});
|
||||
updateByUuid(uuid, t => ({ ...t, ...updates, modified: Date.now() / 1000 }));
|
||||
|
||||
try {
|
||||
const updated = await tasksAPI.update(uuid, updates);
|
||||
// Sync with server response
|
||||
update(tasks => {
|
||||
const index = tasks.findIndex(t => t.uuid === uuid);
|
||||
if (index >= 0) {
|
||||
tasks[index] = updated;
|
||||
}
|
||||
return tasks;
|
||||
});
|
||||
updateByUuid(uuid, () => updated);
|
||||
} catch (error) {
|
||||
// Queue for offline sync
|
||||
queueChange({
|
||||
type: 'update',
|
||||
task_uuid: uuid,
|
||||
data: updates
|
||||
});
|
||||
queueChange({ type: 'update', task_uuid: uuid, data: updates });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete task
|
||||
* @param {string} uuid
|
||||
*/
|
||||
/** @param {string} uuid */
|
||||
async deleteTask(uuid) {
|
||||
// Optimistic removal
|
||||
update(tasks => tasks.filter(t => t.uuid !== uuid));
|
||||
|
||||
try {
|
||||
await tasksAPI.delete(uuid);
|
||||
} catch (error) {
|
||||
queueChange({
|
||||
type: 'delete',
|
||||
task_uuid: uuid,
|
||||
data: {}
|
||||
});
|
||||
queueChange({ type: 'delete', task_uuid: uuid, data: {} });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Complete task
|
||||
* @param {string} uuid
|
||||
*/
|
||||
/** @param {string} uuid */
|
||||
async startTask(uuid) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
updateByUuid(uuid, t => ({ ...t, start: now }));
|
||||
try {
|
||||
const updated = await tasksAPI.start(uuid);
|
||||
updateByUuid(uuid, () => updated);
|
||||
} catch (error) {
|
||||
updateByUuid(uuid, t => ({ ...t, start: null }));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/** @param {string} uuid */
|
||||
async stopTask(uuid) {
|
||||
/** @type {number|null} */
|
||||
let prevStart = null;
|
||||
update(tasks => tasks.map(t => {
|
||||
if (t.uuid === uuid) {
|
||||
prevStart = t.start;
|
||||
return { ...t, start: null };
|
||||
}
|
||||
return t;
|
||||
}));
|
||||
try {
|
||||
const updated = await tasksAPI.stop(uuid);
|
||||
updateByUuid(uuid, () => updated);
|
||||
} catch (error) {
|
||||
updateByUuid(uuid, t => ({ ...t, start: prevStart }));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/** @param {string} uuid */
|
||||
async complete(uuid) {
|
||||
try {
|
||||
await tasksAPI.complete(uuid);
|
||||
@@ -162,7 +159,6 @@ function createTasksStore() {
|
||||
|
||||
export const tasksStore = createTasksStore();
|
||||
|
||||
// Derived stores for filtered views
|
||||
export const pendingTasks = derived(
|
||||
tasksStore,
|
||||
$tasks => $tasks.filter(t => t.status === 'P')
|
||||
@@ -176,6 +172,7 @@ export const completedTasks = derived(
|
||||
export const tasksByProject = derived(
|
||||
tasksStore,
|
||||
$tasks => {
|
||||
/** @type {Record<string, Task[]>} */
|
||||
const grouped = {};
|
||||
$tasks.forEach(task => {
|
||||
const project = task.project || 'No Project';
|
||||
|
||||
@@ -11,10 +11,7 @@ const DEFAULT_THEME = 'obsidian';
|
||||
/** @type {ThemeName[]} */
|
||||
export const THEMES = ['obsidian', 'paper', 'midnight'];
|
||||
|
||||
/**
|
||||
* Read stored theme, falling back to default
|
||||
* @returns {ThemeName}
|
||||
*/
|
||||
/** @returns {ThemeName} */
|
||||
function getInitial() {
|
||||
if (!browser) return DEFAULT_THEME;
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
@@ -27,7 +24,6 @@ function getInitial() {
|
||||
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;
|
||||
@@ -35,7 +31,6 @@ function createThemeStore() {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply on every change
|
||||
subscribe(apply);
|
||||
|
||||
return {
|
||||
@@ -44,7 +39,6 @@ function createThemeStore() {
|
||||
set(theme) {
|
||||
set(theme);
|
||||
},
|
||||
/** Cycle to the next theme */
|
||||
cycle() {
|
||||
update(current => {
|
||||
const idx = THEMES.indexOf(current);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { format, formatDistance, isToday, isTomorrow, isPast } from 'date-fns';
|
||||
|
||||
/**
|
||||
* Format Unix timestamp to readable date
|
||||
* @param {number|null} timestamp - Unix timestamp (seconds)
|
||||
* @param {string} formatStr - date-fns format string
|
||||
* @returns {string}
|
||||
@@ -12,8 +11,7 @@ export function formatDate(timestamp, formatStr = 'MMM d, yyyy') {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Unix timestamp to relative time
|
||||
* @param {number|null} timestamp
|
||||
* @param {number|null} timestamp - Unix timestamp (seconds)
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatRelative(timestamp) {
|
||||
@@ -27,8 +25,7 @@ export function formatRelative(timestamp) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if timestamp is overdue
|
||||
* @param {number|null} timestamp
|
||||
* @param {number|null} timestamp - Unix timestamp (seconds)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isOverdue(timestamp) {
|
||||
@@ -36,20 +33,12 @@ export function isOverdue(timestamp) {
|
||||
return isPast(new Date(timestamp * 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Date object to Unix timestamp
|
||||
* @param {Date} date
|
||||
* @returns {number}
|
||||
*/
|
||||
/** @param {Date} date @returns {number} */
|
||||
export function toUnix(date) {
|
||||
return Math.floor(date.getTime() / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Unix timestamp to Date object
|
||||
* @param {number} timestamp
|
||||
* @returns {Date}
|
||||
*/
|
||||
/** @param {number} timestamp @returns {Date} */
|
||||
export function fromUnix(timestamp) {
|
||||
return new Date(timestamp * 1000);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/** Valid filter attribute keys that work as query filters in the engine */
|
||||
const FILTER_ATTRS = new Set(['status', 'project', 'priority']);
|
||||
|
||||
/**
|
||||
* @typedef {Object} ParsedFilter
|
||||
* @property {string[]} tags
|
||||
* @property {string[]} excludeTags
|
||||
* @property {Record<string, string>} attributes
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a filter string into structured tokens.
|
||||
* Recognizes +tag, -tag, and key:value for supported filter attributes.
|
||||
* @param {string} str
|
||||
* @returns {ParsedFilter}
|
||||
*/
|
||||
export function parseFilterString(str) {
|
||||
/** @type {ParsedFilter} */
|
||||
const result = { tags: [], excludeTags: [], attributes: {} };
|
||||
if (!str || !str.trim()) return result;
|
||||
|
||||
const tokens = str.trim().split(/\s+/);
|
||||
for (const token of tokens) {
|
||||
if (token.startsWith('+') && token.length > 1) {
|
||||
result.tags.push(token.slice(1));
|
||||
} else if (token.startsWith('-') && token.length > 1 && !token.includes(':')) {
|
||||
result.excludeTags.push(token.slice(1));
|
||||
} else if (token.includes(':')) {
|
||||
const idx = token.indexOf(':');
|
||||
const key = token.slice(0, idx).toLowerCase();
|
||||
const value = token.slice(idx + 1);
|
||||
if (FILTER_ATTRS.has(key) && value) {
|
||||
result.attributes[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ParsedFilter} parsed
|
||||
* @returns {import('$lib/api/types.js').TaskFilters}
|
||||
*/
|
||||
export function filterToParams(parsed) {
|
||||
/** @type {import('$lib/api/types.js').TaskFilters} */
|
||||
const params = {};
|
||||
if (parsed.tags.length > 0) params.tags = parsed.tags;
|
||||
if (parsed.excludeTags.length > 0) params.excludeTags = parsed.excludeTags;
|
||||
if (parsed.attributes.status) params.status = /** @type {any} */ (parsed.attributes.status);
|
||||
if (parsed.attributes.project) params.project = parsed.attributes.project;
|
||||
if (parsed.attributes.priority) params.priority = parsed.attributes.priority;
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a token matching a given prefix from a string.
|
||||
* e.g. removeTokenByPrefix("buy milk due:tomorrow", "due:") → "buy milk"
|
||||
* @param {string} input
|
||||
* @param {string} prefix
|
||||
* @returns {string}
|
||||
*/
|
||||
export function removeTokenByPrefix(input, prefix) {
|
||||
const tokens = input.split(/\s+/);
|
||||
const filtered = tokens.filter(t => !t.toLowerCase().startsWith(prefix.toLowerCase()));
|
||||
return filtered.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate filter tokens from user input that are already in the active filter.
|
||||
* @param {string} userInput
|
||||
* @param {string} filterStr
|
||||
* @returns {string}
|
||||
*/
|
||||
export function mergeInputWithFilter(userInput, filterStr) {
|
||||
if (!filterStr || !filterStr.trim()) return userInput;
|
||||
if (!userInput || !userInput.trim()) return filterStr;
|
||||
|
||||
const filterTokens = new Set(filterStr.trim().split(/\s+/).map(t => t.toLowerCase()));
|
||||
const inputTokens = userInput.trim().split(/\s+/);
|
||||
const deduped = inputTokens.filter(t => !filterTokens.has(t.toLowerCase()));
|
||||
|
||||
const combined = [...deduped, filterStr.trim()].filter(Boolean).join(' ');
|
||||
return combined;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
/**
|
||||
* Get item from localStorage
|
||||
* @param {string} key
|
||||
* @returns {any}
|
||||
*/
|
||||
@@ -18,7 +17,6 @@ export function getItem(key) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set item in localStorage
|
||||
* @param {string} key
|
||||
* @param {any} value
|
||||
*/
|
||||
@@ -32,10 +30,7 @@ export function setItem(key, value) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove item from localStorage
|
||||
* @param {string} key
|
||||
*/
|
||||
/** @param {string} key */
|
||||
export function removeItem(key) {
|
||||
if (!browser) return;
|
||||
|
||||
@@ -46,9 +41,6 @@ export function removeItem(key) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all items
|
||||
*/
|
||||
export function clear() {
|
||||
if (!browser) return;
|
||||
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { getItem, setItem } from './storage.js';
|
||||
import { generateUUID } from './uuid.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('$lib/api/types.js').QueuedChange} QueuedChange
|
||||
*/
|
||||
/** @typedef {import('$lib/api/types.js').QueuedChange} QueuedChange */
|
||||
|
||||
const QUEUE_KEY = 'opal_sync_queue';
|
||||
|
||||
/**
|
||||
* Add change to sync queue
|
||||
* @param {Omit<QueuedChange, 'id'|'timestamp'>} change
|
||||
*/
|
||||
/** @param {Omit<QueuedChange, 'id'|'timestamp'>} change */
|
||||
export function queueChange(change) {
|
||||
const queue = getQueue();
|
||||
|
||||
@@ -23,33 +18,16 @@ export function queueChange(change) {
|
||||
setItem(QUEUE_KEY, queue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all queued changes
|
||||
* @returns {QueuedChange[]}
|
||||
*/
|
||||
/** @returns {QueuedChange[]} */
|
||||
export function getQueue() {
|
||||
return getItem(QUEUE_KEY) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear sync queue
|
||||
*/
|
||||
export function clearQueue() {
|
||||
setItem(QUEUE_KEY, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue size
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getQueueSize() {
|
||||
return getQueue().length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove specific change from queue
|
||||
* @param {string} id
|
||||
*/
|
||||
/** @param {string} id */
|
||||
export function removeFromQueue(id) {
|
||||
const queue = getQueue().filter((change) => change.id !== id);
|
||||
setItem(QUEUE_KEY, queue);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Generate UUID v4
|
||||
* @returns {string}
|
||||
*/
|
||||
/** @returns {string} */
|
||||
export function generateUUID() {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
|
||||
@@ -29,11 +29,11 @@
|
||||
height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr min(var(--content-max-width), 100%) 1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
grid-template-rows: 1fr auto auto;
|
||||
grid-template-areas:
|
||||
". header ."
|
||||
". content ."
|
||||
". input .";
|
||||
". input ."
|
||||
". header .";
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
|
||||
@@ -3,9 +3,15 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.js';
|
||||
import { tasksStore } from '$lib/stores/tasks.js';
|
||||
import { activeFilter, clearFilter } from '$lib/stores/filters.js';
|
||||
import { parseFilterString, filterToParams } from '$lib/utils/filters.js';
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import TaskList from '$lib/components/TaskList.svelte';
|
||||
import InputBar from '$lib/components/InputBar.svelte';
|
||||
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
||||
import TaskDetail from '$lib/components/TaskDetail.svelte';
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
|
||||
let activeReport = 'list';
|
||||
/** @type {import('$lib/api/types.js').Task[]} */
|
||||
@@ -13,9 +19,29 @@
|
||||
let loading = true;
|
||||
let inputError = '';
|
||||
|
||||
// Bottom sheet state
|
||||
/** @type {import('$lib/api/types.js').Task|null} */
|
||||
let selectedTask = null;
|
||||
|
||||
// Undo toast state
|
||||
/** @type {{ uuid: string, description: string }|null} */
|
||||
let undoToast = null;
|
||||
|
||||
// Delete confirmation state
|
||||
/** @type {{ uuid: string, description: string }|null} */
|
||||
let deleteTarget = null;
|
||||
|
||||
/** @type {ConfirmDialog} */
|
||||
let confirmDialog;
|
||||
|
||||
// Subscribe to store
|
||||
const unsubscribe = tasksStore.subscribe(value => {
|
||||
tasks = value;
|
||||
// Keep selectedTask in sync with store changes
|
||||
if (selectedTask) {
|
||||
const updated = value.find(t => t.uuid === selectedTask.uuid);
|
||||
if (updated) selectedTask = updated;
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
@@ -24,11 +50,61 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Load with existing active filter if any
|
||||
if ($activeFilter) {
|
||||
loadWithFilter($activeFilter);
|
||||
} else {
|
||||
loadReport(activeReport);
|
||||
}
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
// React to filter changes
|
||||
$: if ($activeFilter !== undefined) {
|
||||
handleFilterChange($activeFilter);
|
||||
}
|
||||
|
||||
/** @type {string|null|undefined} */
|
||||
let lastFilter = undefined;
|
||||
|
||||
/**
|
||||
* @param {string|null} filter
|
||||
*/
|
||||
function handleFilterChange(filter) {
|
||||
// Skip initial (undefined -> initial value)
|
||||
if (lastFilter === undefined) {
|
||||
lastFilter = filter;
|
||||
return;
|
||||
}
|
||||
// Skip if same value
|
||||
if (filter === lastFilter) return;
|
||||
lastFilter = filter;
|
||||
|
||||
if (filter) {
|
||||
loadWithFilter(filter);
|
||||
} else {
|
||||
loadReport(activeReport);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filterStr
|
||||
*/
|
||||
async function loadWithFilter(filterStr) {
|
||||
loading = true;
|
||||
inputError = '';
|
||||
try {
|
||||
const parsed = parseFilterString(filterStr);
|
||||
const params = filterToParams(parsed);
|
||||
await tasksStore.load(params);
|
||||
} catch (error) {
|
||||
console.error('Failed to load filtered tasks:', error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} reportName
|
||||
*/
|
||||
@@ -49,6 +125,10 @@
|
||||
*/
|
||||
function handleReportChange(reportName) {
|
||||
activeReport = reportName;
|
||||
// Changing report clears any active filter
|
||||
if ($activeFilter) {
|
||||
clearFilter();
|
||||
}
|
||||
loadReport(reportName);
|
||||
}
|
||||
|
||||
@@ -68,12 +148,93 @@
|
||||
* @param {string} uuid
|
||||
*/
|
||||
async function handleComplete(uuid) {
|
||||
const task = tasks.find(t => t.uuid === uuid);
|
||||
if (!task) return;
|
||||
|
||||
try {
|
||||
await tasksStore.complete(uuid);
|
||||
undoToast = { uuid: task.uuid, description: task.description };
|
||||
} catch (error) {
|
||||
console.error('Failed to complete task:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUndo() {
|
||||
if (!undoToast) return;
|
||||
const { uuid } = undoToast;
|
||||
undoToast = null;
|
||||
|
||||
try {
|
||||
await tasksStore.updateTask(uuid, { status: 'P', end: null });
|
||||
await loadReport(activeReport);
|
||||
} catch (error) {
|
||||
console.error('Failed to undo completion:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} uuid
|
||||
*/
|
||||
async function handleStartStop(uuid) {
|
||||
const task = tasks.find(t => t.uuid === uuid);
|
||||
if (!task) return;
|
||||
|
||||
try {
|
||||
if (task.start) {
|
||||
await tasksStore.stopTask(uuid);
|
||||
} else {
|
||||
await tasksStore.startTask(uuid);
|
||||
}
|
||||
// Update selectedTask if it's the same task
|
||||
if (selectedTask?.uuid === uuid) {
|
||||
const updated = tasks.find(t => t.uuid === uuid);
|
||||
if (updated) selectedTask = updated;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to start/stop task:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} uuid
|
||||
* @param {Partial<import('$lib/api/types.js').Task>} updates
|
||||
*/
|
||||
async function handleUpdate(uuid, updates) {
|
||||
try {
|
||||
await tasksStore.updateTask(uuid, updates);
|
||||
} catch (error) {
|
||||
console.error('Failed to update task:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} uuid
|
||||
*/
|
||||
function handleDeleteRequest(uuid) {
|
||||
const task = tasks.find(t => t.uuid === uuid)
|
||||
?? (selectedTask?.uuid === uuid ? selectedTask : null);
|
||||
if (!task) return;
|
||||
|
||||
deleteTarget = { uuid: task.uuid, description: task.description };
|
||||
confirmDialog.open();
|
||||
}
|
||||
|
||||
async function handleDeleteConfirm() {
|
||||
if (!deleteTarget) return;
|
||||
const { uuid } = deleteTarget;
|
||||
deleteTarget = null;
|
||||
selectedTask = null;
|
||||
|
||||
try {
|
||||
await tasksStore.deleteTask(uuid);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete task:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteCancel() {
|
||||
deleteTarget = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Header {activeReport} onReportChange={handleReportChange} />
|
||||
@@ -83,9 +244,44 @@
|
||||
{loading}
|
||||
{activeReport}
|
||||
onComplete={handleComplete}
|
||||
onTap={(task) => selectedTask = task}
|
||||
onStartStop={handleStartStop}
|
||||
/>
|
||||
|
||||
<InputBar
|
||||
onSubmit={handleSubmit}
|
||||
error={inputError}
|
||||
/>
|
||||
|
||||
<BottomSheet open={selectedTask !== null} onClose={() => selectedTask = null}>
|
||||
{#if selectedTask}
|
||||
<TaskDetail
|
||||
task={selectedTask}
|
||||
onUpdate={handleUpdate}
|
||||
onStart={(uuid) => handleStartStop(uuid)}
|
||||
onStop={(uuid) => handleStartStop(uuid)}
|
||||
onDelete={handleDeleteRequest}
|
||||
onComplete={handleComplete}
|
||||
onClose={() => selectedTask = null}
|
||||
/>
|
||||
{/if}
|
||||
</BottomSheet>
|
||||
|
||||
{#if undoToast}
|
||||
<Toast
|
||||
message={'Completed "' + undoToast.description + '"'}
|
||||
action={{ label: 'Undo', handler: handleUndo }}
|
||||
onDismiss={() => undoToast = null}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ConfirmDialog
|
||||
bind:this={confirmDialog}
|
||||
title="Delete task?"
|
||||
message={deleteTarget?.description ?? ''}
|
||||
detail="This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
confirmVariant="danger"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
/>
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
let saving = false;
|
||||
let error = '';
|
||||
|
||||
/**
|
||||
* Save API key as manual auth
|
||||
*/
|
||||
async function saveApiKey() {
|
||||
if (!apiKey.trim()) {
|
||||
error = 'API key is required';
|
||||
@@ -24,7 +21,6 @@
|
||||
error = '';
|
||||
|
||||
try {
|
||||
// Store API key as access token (for manual auth mode)
|
||||
authStore.setAuth({
|
||||
access_token: apiKey,
|
||||
refresh_token: '',
|
||||
@@ -45,9 +41,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
async function logout() {
|
||||
if ($authStore.refreshToken) {
|
||||
try {
|
||||
@@ -61,9 +54,6 @@
|
||||
goto('/auth/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger manual sync
|
||||
*/
|
||||
async function triggerSync() {
|
||||
try {
|
||||
await syncStore.sync();
|
||||
|
||||
Reference in New Issue
Block a user