Compare commits

...

21 Commits

Author SHA1 Message Date
joakim cd77443a07 Merge branch 'feat/web-tier1-features' 2026-02-19 18:01:46 +01:00
joakim b7e0d434ba fix: restore fly transition on Toast, clean up debug logs
Add fly transition back to Toast component and move cleanup from
onDestroy to onMount return for SSR safety. Remove debug console.logs
from page orchestrator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:47:51 +01:00
joakim 04fa9222d8 test: add comprehensive tests for new UX features and fix ISO date timezone bug
Add 33 new test functions covering annotations, undo system, history
formatting, relative date display, and weekday parsing pipeline. Fix ISO
date parsing to use ParseInLocation instead of Parse to respect the
parser's timezone context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:42:49 +01:00
joakim 5301fbf706 fix: use onMount return for cleanup instead of onDestroy
onDestroy runs during SSR in Svelte 5, causing document reference
errors. The onMount return function only runs on client teardown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:35:18 +01:00
joakim 4b35753fc7 fix: move scroll lock to afterUpdate to avoid SSR document access
The $: reactive block runs during SSR component init. Use afterUpdate
with a mounted guard instead so document is only accessed client-side.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:30:20 +01:00
joakim f5a5323c15 fix: guard BottomSheet document access for SSR compatibility
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:25:20 +01:00
joakim 0ff0db642a fix: resolve a11y warning on BottomSheet dialog role
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:50:41 +01:00
joakim 24e9883f68 feat: integrate all Tier 1 features in page orchestrator
Wire BottomSheet+TaskDetail, undo Toast, ConfirmDialog for delete,
and start/stop handlers into +page.svelte.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:48:16 +01:00
joakim aa2ca9aec3 feat: add TaskDetail, bidirectional swipe, and active indicator
- SwipeAction: bidirectional with onSwipeRight/onSwipeLeft, dual backgrounds
- TaskItem: onTap for bottom sheet, onStartStop, active border + pill
- TaskDetail: full field layout with inline editing, action buttons
- TaskList: passes onTap and onStartStop through to TaskItem

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:47:39 +01:00
joakim b53e77a8ec feat: add foundational Tier 1 components and store methods
Add generic BottomSheet, Toast, and ConfirmDialog components.
Add startTask/stopTask optimistic methods to tasks store.
Add --color-active-bg/text theme tokens for all three themes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:44:30 +01:00
joakim 07d1a78dfc feat: add uncomplete command to restore completed tasks to pending
Dedicated command that sets status back to pending and clears End time.
Unlike undo, works on any completed task regardless of when it was
completed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:22:51 +01:00
joakim 2fa1316f0d docs: simplify zsh completion instructions to eval in .zshrc
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:10:28 +01:00
joakim 3c0d4ee471 fix: use proper zsh/bash completion methods and correct zsh install instructions
Use GenZshCompletion (native zsh) instead of the old bash-wrapper method.
Use GenBashCompletionV2 for modern bash completions. Fix help text to show
proper fpath-based zsh installation instead of `source`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:08:27 +01:00
joakim feb5406077 feat: add shell completions, command grouping, and dynamic completions
Add completion command for bash/zsh/fish/powershell generation. Organize
help text using Cobra command groups (Task Commands, Reports, Other).
Register dynamic ValidArgsFunction on filter-accepting commands to
suggest +tag and project:name completions from the database.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:59:21 +01:00
joakim 32cc05a546 feat: add task history via log command and info integration
Add engine/history.go with GetTaskHistory and diff-style FormatTaskHistory
that compares consecutive change_log entries to show only what changed.
Add cmd/log.go command for full task history. Integrate last 5 history
entries into FormatTaskDetail (info view) as a "Recent Changes" section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:56:55 +01:00
joakim 7aaaa86a0a feat: add annotations, undo system, and schema updates
Add annotations as JSON column on tasks table with Annotate/Denotate
methods and CLI commands. Add undo system backed by change_log with
lightweight undo_stack table (capped at 10 entries). All mutating CLI
commands (add, done, delete, modify, start, stop) now record undo
entries. Undo restores prior task state from change_log data.

Schema changes (in v1 migration):
- annotations TEXT column on tasks
- undo_stack table
- annotations field in change_log triggers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:54:58 +01:00
joakim 6fb8a40a43 feat: add --dry-run flag to action commands
Adds a persistent --dry-run flag that shows matched tasks without
performing mutations. Supported on done, delete, modify, start, and stop
commands. Also fixes preprocessArgs to skip flag-like args when
identifying commands, preventing flags from being treated as filters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:47:39 +01:00
joakim b02c40f716 feat: improve CLI output with relative dates, rich feedback, and recurring task info
Add relative date formatting (today, tomorrow, in 3d, etc.) for list and
detail views. Add structured feedback helpers for add/complete/delete
operations showing display IDs and parsed modifiers. Change Complete() to
return spawned recurring instance so callers can display recurrence info.
Add AppendTask to working set for immediate display ID assignment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:44:56 +01:00
joakim 779da6ddfd feat: IMP-13 — add version command with build-time variables
opal version (and opal --version) prints version, commit, and build
date. Values are set via ldflags at build time; defaults to "dev" for
local builds. VERSION file added at 0.1.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:38:25 +01:00
joakim f57baee6bc fix: IMP-4/5/6 — parser allowlist, delete ID resolution, consistent errors
IMP-5: Replace strings.Contains(arg, ":") heuristic with an allowlist
of recognized attribute keys (ValidAttributeKeys). Colons in task
descriptions (URLs, "Meeting: topic") are no longer misinterpreted as
modifiers. Canonical key sets live in engine/keys.go and are shared
across parseAddArgs, ParseFilter, and ParseModifier. ParseModifier now
errors on unknown keys.

IMP-4: delete command now loads the working set and resolves display IDs
via GetTaskByDisplayID, matching the pattern used by done/modify.

IMP-6: All action commands (done, delete, modify, start, stop) now
return an error on no-match (stderr, exit 1). Previously done/delete
printed to stdout and exited 0; start/stop had no check at all.

Also adds requirements and design docs for the CLI UX improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:37:33 +01:00
joakim a551f50cef ui updates 2026-02-18 23:16:00 +01:00
66 changed files with 7018 additions and 228 deletions
File diff suppressed because it is too large Load Diff
+470
View File
@@ -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
View File
@@ -0,0 +1 @@
0.1.0
+30 -12
View File
@@ -63,31 +63,42 @@ func addTask(args []string) error {
return fmt.Errorf("failed to create task: %w", err)
}
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)
if len(task.Tags) > 0 {
fmt.Printf("Tags: %s\n", strings.Join(task.Tags, ", "))
return nil
}
fmt.Print(engine.FormatAddFeedback(task, displayID))
return nil
}
// parseAddArgs extracts description and modifiers from args
// Description = all non-filter/modifier words joined with spaces
// Filters/Modifiers = args with +, -, or containing :
// 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
}
+80
View File
@@ -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
}
+47
View File
@@ -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)
}
},
}
+50
View File
@@ -0,0 +1,50 @@
package cmd
import (
"fmt"
"git.jnss.me/joakim/opal/internal/engine"
"github.com/spf13/cobra"
)
// taskFilterCompletion provides dynamic completions for task filter arguments.
// Suggests +tag and project:name completions from the database.
func taskFilterCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
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
}
func init() {
// 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
}
+35 -3
View File
@@ -26,27 +26,59 @@ func deleteTasks(args []string) error {
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")
}
if dryRunFlag {
fmt.Print(engine.FormatTaskConfirmList("delete", 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 {
fmt.Print(engine.FormatTaskConfirmList("delete", tasks, ws))
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
engine.RecordUndo("delete", task.UUID)
}
if len(tasks) == 1 {
fmt.Printf("Deleted task %s\n", engine.FormatTaskSummary(tasks[0], ws))
} else {
fmt.Printf("Deleted %d task(s).\n", len(tasks))
}
return nil
}
+74
View File
@@ -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
View File
@@ -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
View File
@@ -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
+75
View File
@@ -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
View File
@@ -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++
}
}
+71 -13
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"strings"
"git.jnss.me/joakim/opal/internal/engine"
"github.com/spf13/cobra"
@@ -25,6 +26,7 @@ const parsedArgsKey contextKey = "parsedArgs"
var (
configDirFlag string
dataDirFlag string
dryRunFlag bool
)
// Command classification
@@ -32,6 +34,7 @@ var commandNames = []string{
"add", "done", "modify", "delete",
"start", "stop", "count", "projects", "tags",
"info", "edit", "server", "sync", "reports", "setup",
"version", "annotate", "denotate", "undo", "uncomplete", "log", "completion",
}
// Report names (dynamically populated)
@@ -44,12 +47,13 @@ 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.`,
Run: func(cmd *cobra.Command, args []string) {
// Default behavior: run configured default report (defaults to "list")
@@ -119,6 +123,7 @@ 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{
@@ -128,11 +133,14 @@ func preprocessArgs(args []string) *ParsedArgs {
}
}
// 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,30 +168,26 @@ func preprocessArgs(args []string) *ParsedArgs {
if cmdIdx == -1 {
return &ParsedArgs{
Command: "list",
Filters: args,
Filters: stripFlags(args),
Modifiers: []string{},
}
}
// 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,
}
} else {
// Command doesn't accept modifiers
// Both left and right are filters
allFilters := append(leftArgs, rightArgs...)
return &ParsedArgs{
Command: cmdName,
@@ -193,35 +197,89 @@ func preprocessArgs(args []string) *ParsedArgs {
}
}
// 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
}
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"
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)
// 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)
}
}
+1
View File
@@ -198,6 +198,7 @@ Examples:
}
func init() {
serverCmd.GroupID = "other"
rootCmd.AddCommand(serverCmd)
serverCmd.AddCommand(serverStartCmd)
serverCmd.AddCommand(keygenCmd)
+1
View File
@@ -49,6 +49,7 @@ Examples:
}
func init() {
setupCmd.GroupID = "other"
rootCmd.AddCommand(setupCmd)
setupCmd.Flags().BoolVar(&showSystemdFlag, "show-systemd", false, "Show systemd service template")
+11
View File
@@ -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)
}
+11
View File
@@ -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)
}
+1
View File
@@ -391,6 +391,7 @@ Examples:
}
func init() {
syncCmd.GroupID = "other"
rootCmd.AddCommand(syncCmd)
syncCmd.AddCommand(syncInitCmd)
+88
View File
@@ -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
}
+28
View File
@@ -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)
},
}
+26
View File
@@ -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)
},
}
+6 -1
View File
@@ -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 {
@@ -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
}
+27
View File
@@ -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
}
+178
View File
@@ -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")
}
}
+12
View File
@@ -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 ''
+1 -1
View File
@@ -54,7 +54,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
}
+121
View File
@@ -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
+49 -9
View File
@@ -2,6 +2,7 @@ package engine
import (
"fmt"
"strings"
"time"
"github.com/fatih/color"
@@ -143,19 +144,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 +176,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()
}
@@ -310,16 +346,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")
return rel
}
func formatTimeWithColor(t time.Time) string {
+163
View File
@@ -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] + "…"
}
+8 -7
View File
@@ -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)
+180
View File
@@ -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
}
+328
View File
@@ -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)
}
})
}
}
+30
View File
@@ -0,0 +1,30 @@
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{
"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,
}
+12 -8
View File
@@ -23,7 +23,9 @@ func NewModifier() *Modifier {
}
}
// ParseModifier parses command-line args into Modifier
// 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 +36,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)
@@ -83,7 +87,7 @@ func (m *Modifier) Apply(task *Task) error {
resolvedDates["modified"] = task.Modified
// 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]
@@ -151,7 +155,7 @@ func (m *Modifier) ApplyToNew(task *Task) error {
}
// 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]
+12 -11
View File
@@ -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
}
+2 -2
View File
@@ -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)
}
+45
View File
@@ -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)
}
+214
View File
@@ -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)")
}
}
+61 -12
View File
@@ -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,6 +75,9 @@ 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"`
@@ -93,6 +102,7 @@ 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"`
}
@@ -128,6 +138,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,
})
@@ -152,6 +163,7 @@ func (t *Task) UnmarshalJSON(data []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"`
}
@@ -184,6 +196,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 +276,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
@@ -337,7 +376,7 @@ func GetTask(taskUUID uuid.UUID) (*Task, error) {
query := `
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 uuid = ?
`
@@ -356,6 +395,7 @@ func GetTask(taskUUID uuid.UUID) (*Task, error) {
until interface{}
recurDuration interface{}
parentUUIDStr interface{}
annotationsStr interface{}
)
err := db.QueryRow(query, taskUUID.String()).Scan(
@@ -375,6 +415,7 @@ func GetTask(taskUUID uuid.UUID) (*Task, error) {
&until,
&recurDuration,
&parentUUIDStr,
&annotationsStr,
)
if err != nil {
@@ -401,6 +442,7 @@ 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()
@@ -429,7 +471,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
@@ -461,6 +503,7 @@ func GetTasks(filter *Filter) ([]*Task, error) {
until interface{}
recurDuration interface{}
parentUUIDStr interface{}
annotationsStr interface{}
)
err := rows.Scan(
@@ -480,6 +523,7 @@ func GetTasks(filter *Filter) ([]*Task, error) {
&until,
&recurDuration,
&parentUUIDStr,
&annotationsStr,
)
if err != nil {
@@ -506,6 +550,7 @@ func GetTasks(filter *Filter) ([]*Task, error) {
task.Until = sqlToTime(until)
task.RecurrenceDuration = sqlToDuration(recurDuration)
task.ParentUUID = sqlToUUIDPtr(parentUUIDStr)
task.Annotations = sqlToAnnotations(annotationsStr)
// Load tags
tags, err := task.GetTags()
@@ -543,7 +588,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 +606,7 @@ func (t *Task) Save() error {
timeToSQL(t.Until),
durationToSQL(t.RecurrenceDuration),
uuidPtrToSQL(t.ParentUUID),
annotationsToSQL(t.Annotations),
t.UUID.String(),
)
@@ -573,8 +619,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 +639,7 @@ func (t *Task) Save() error {
timeToSQL(t.Until),
durationToSQL(t.RecurrenceDuration),
uuidPtrToSQL(t.ParentUUID),
annotationsToSQL(t.Annotations),
)
if err != nil {
@@ -694,25 +741,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)
+1 -1
View File
@@ -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)
}
+347
View File
@@ -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
}
+275
View File
@@ -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)
}
}
+29
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
# Wait/Scheduled not working correctly
`Buy milk due:8d wait:5d` still showing up
# Missing uncomplete feat
+6
View File
@@ -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;
}
+1 -1
View File
@@ -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" />
+3
View File
@@ -25,6 +25,9 @@ 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}` : ''}`);
+1
View File
@@ -77,6 +77,7 @@
* @property {string} [project]
* @property {string} [priority]
* @property {string[]} [tags]
* @property {string[]} [excludeTags]
*/
export {};
@@ -0,0 +1,253 @@
<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;
// Body scroll lock — managed in afterUpdate to avoid SSR document access
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();
}
// Focus trap
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();
// Only allow dragging down (positive)
dragOffset = Math.max(0, deltaY);
// Track velocity
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,112 @@
<script>
import { activeFilter, setFilter } from '$lib/stores/filters.js';
/**
* Remove a single token from the active filter string.
* If it's the last token, clear the entire filter.
* @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>
+69 -1
View File
@@ -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,6 +16,9 @@
/** @type {ReportPicker} */
let picker;
/** @type {FilterModal} */
let filterModal;
/** Map backend report names to display labels */
const reportLabels = /** @type {Record<string, string>} */ ({
list: 'Pending',
@@ -30,9 +35,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 +50,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 +81,11 @@
onSelect={onReportChange}
/>
<FilterModal
bind:this={filterModal}
onClose={() => {}}
/>
<style>
.header {
grid-area: header;
@@ -67,7 +94,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 +134,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;
+52 -13
View File
@@ -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,12 @@
async function handleSubmit() {
const trimmed = value.trim();
if (!trimmed || loading) return;
// Merge user input with active filter, deduplicating tokens
const merged = mergeInputWithFilter(trimmed, $activeFilter || '');
try {
await onSubmit(trimmed);
await onSubmit(merged);
value = '';
} catch {
// Value preserved for retry
@@ -78,16 +85,36 @@
}
});
}
/**
* Get the current input value (for PropertyPills smart replace)
* @returns {string}
*/
export function getInputValue() {
return value;
}
/**
* Set the input value (for PropertyPills smart replace)
* @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 +122,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 +148,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 +191,6 @@
.input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--focus-ring);
}
.submit-btn {
@@ -163,7 +202,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,47 @@
<script>
import { removeTokenByPrefix } from '$lib/utils/filters.js';
/**
* @type {(text: string) => void}
*/
export let onInsert;
/** Current input value for smart replace */
export let inputValue = '';
/** Callback to update the input value when doing smart replace */
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) {
// Tags are always additive — no smart replace
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 +50,7 @@
<button
class="pill"
type="button"
on:mousedown|preventDefault={() => onInsert(pill.text)}
on:mousedown|preventDefault={() => handleInsert(pill)}
>
{pill.label}
</button>
+71 -15
View File
@@ -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;
@@ -52,8 +63,7 @@
if (swiping) {
if (e.cancelable) e.preventDefault();
// Only allow right swipe
offsetX = Math.max(0, deltaX);
offsetX = deltaX;
}
}
@@ -64,11 +74,20 @@
}
if (offsetX >= THRESHOLD) {
// Right swipe — complete (row collapses)
completed = true;
// Animate to full width before firing callback
offsetX = window.innerWidth;
setTimeout(() => {
onSwipe();
onSwipeRight();
}, 200);
} else if (offsetX <= -THRESHOLD) {
// Left swipe — start/stop (row stays)
triggered = true;
offsetX = -window.innerWidth;
setTimeout(() => {
onSwipeLeft();
offsetX = 0;
triggered = false;
}, 200);
} else {
offsetX = 0;
@@ -86,7 +105,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 +117,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 +155,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
+34 -3
View File
@@ -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}
+102
View File
@@ -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>
+55
View File
@@ -0,0 +1,55 @@
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;
/**
* Create a localStorage-backed writable store
* @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[]} */ ([]));
/**
* Set the active filter and add it to recents
* @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);
});
}
/**
* Clear the active filter
*/
export function clearFilter() {
activeFilter.set(null);
}
/**
* Remove a specific entry from recents
* @param {string} str
*/
export function removeRecent(str) {
recentFilters.update(recents => recents.filter(r => r !== str));
}
+49
View File
@@ -140,6 +140,55 @@ function createTasksStore() {
}
},
/**
* Start task timer (optimistic)
* @param {string} uuid
*/
async startTask(uuid) {
const now = Math.floor(Date.now() / 1000);
update(tasks => tasks.map(t =>
t.uuid === uuid ? { ...t, start: now } : t
));
try {
const updated = await tasksAPI.start(uuid);
update(tasks => tasks.map(t =>
t.uuid === uuid ? updated : t
));
} catch (error) {
update(tasks => tasks.map(t =>
t.uuid === uuid ? { ...t, start: null } : t
));
throw error;
}
},
/**
* Stop task timer (optimistic)
* @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);
update(tasks => tasks.map(t =>
t.uuid === uuid ? updated : t
));
} catch (error) {
update(tasks => tasks.map(t =>
t.uuid === uuid ? { ...t, start: prevStart } : t
));
throw error;
}
},
/**
* Complete task
* @param {string} uuid
+90
View File
@@ -0,0 +1,90 @@
/**
* Valid filter attribute keys (these actually 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.
* Unknown tokens (like due:3d) are preserved as raw tokens for pass-through.
* @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;
}
/**
* Convert a parsed filter to TaskFilters for the API
* @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.
* Used by PropertyPills smart replace: e.g. removeTokenByPrefix("buy milk due:tomorrow", "due:")
* returns "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.
* Prevents submitting "+grocer +grocer" when filter is +grocer and user also typed +grocer.
* @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;
}
+3 -3
View File
@@ -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,
+195
View File
@@ -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,6 +19,21 @@
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;
@@ -24,11 +45,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 +120,10 @@
*/
function handleReportChange(reportName) {
activeReport = reportName;
// Changing report clears any active filter
if ($activeFilter) {
clearFilter();
}
loadReport(reportName);
}
@@ -68,12 +143,97 @@
* @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);
// Keep selectedTask fresh
if (selectedTask?.uuid === uuid) {
selectedTask = { ...selectedTask, ...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 +243,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}
/>