Compare commits

..

43 Commits

Author SHA1 Message Date
joakim 0d57aed8ce version bump 2026-02-25 23:07:44 +01:00
joakim 393b7a144a fix: use Modifier.Set() to maintain AttributeOrder invariant in API handlers
API handlers were populating SetAttributes directly without appending to
AttributeOrder. Since Apply() only iterates AttributeOrder, all updates
via PUT/POST were silently dropped — causing edits to revert and tasks
to disappear on reload.

Adds Modifier.Set() helper, safety net in Apply()/ApplyToNew(), adds
description and status to applyNonDateAttribute, and fixes the
recurrence->recur key mismatch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:42:57 +01:00
joakim 201f32d095 Merge branch 'worktree-web-ui' 2026-02-25 22:39:32 +01:00
joakim e86d063912 fix: show Set... placeholder for scheduled/wait/until date fields
Also fix selectedTask going stale after store updates by syncing
it from the store subscription instead of manual patching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:39:18 +01:00
joakim 10421b0ec6 feat: add hard delete flag and opal clean command
Add --hard flag to `opal delete` for permanent removal and a new
`opal clean` command to bulk-purge soft-deleted tasks with optional
--older duration filter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:30:50 +01:00
joakim 08123aa3c5 fix: shell autocomplete bypasses preprocessing and completes attribute values
Bypass Execute() preprocessing for __complete/__completeNoDesc so Cobra's
built-in completion handles shell TAB without creating tasks. Add root
ValidArgsFunction for flexible syntax (e.g. "opal 1 de<TAB>" → delete),
attribute value completions (status:pending, priority:H, date synonyms),
and NoSpace directive on key: completions to avoid trailing space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:05:15 +01:00
joakim 6c28e4d24a fix: Hide waiting tasks from reports as default behaviour 2026-02-25 21:46:50 +01:00
joakim 9973631df0 refactor: deduplicate engine internals, replace bubble sorts, remove dead code
Extract shared code that was duplicated across functions:
- taskJSON struct (MarshalJSON/UnmarshalJSON) to package-level type
- scanTask(scanner) helper for GetTask/GetTasks (~70 identical lines)
- monthNames map for parseMonthName/parseDayAndMonth
- applyNonDateAttribute helper for Apply/ApplyToNew
- resolveDisplayID calls replace inline loops in FormatTaskListWithFormat

Replace O(n²) bubble sorts with sort.Slice in all four report sort
functions (sortByUrgency, NewestReport, NextReport, OldestReport).

Remove dead code: formatTimeWithColor (unused, also used time.Now()
instead of timeNow()), getCurrentTimestamp (unnecessary wrapper).

Remove ~20 comments that restated the next line of code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:46:46 +01:00
joakim a11f452d3b fix: break sync feedback loop, respect timestamps, surface errors
- Add migration v2: source column on change_log to distinguish local
  vs sync-originated entries, preventing the echo loop where synced
  tasks get re-pushed as local changes
- PushChanges handler now skips save when server version is newer
- Client PushChanges/pushQueuedChanges collect and report marshal errors
  instead of silently dropping them
- De-duplicate getLocalChanges/getLastSyncTime into exported sync
  package functions
- Fix logConflict winner detection via pointer identity instead of
  fragile UUID+timestamp comparison
- Fix sync down to actually parse, save, and tag-sync pulled changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:11:04 +01:00
joakim 0e3750e755 fix: resolve all 15 svelte-check type errors
- Type headers as Record<string, string> in apiRequest (client.js)
- Annotate SyncResult on result object, cast catch vars to any (sync.js)
- Widen sync.push param to Partial<Task>[] (endpoints.js)
- Fix parse() return type to reflect {task?: Task} shape (endpoints.js)
- Narrow add() param from Partial<Task> to Task (tasks.js)
- Cast parseAndCreate result to Task (tasks.js)
- Type tasksByProject grouped object as Record<string, Task[]> (tasks.js)

svelte-check now reports 0 errors and 0 warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:09:18 +01:00
joakim 8693681660 refactor: clean up opal-web duplication, dead code, and comment noise
- Deduplicate API_BASE (was defined in both client.js and endpoints.js)
- Extract EMPTY_STATE and persist() helper in auth store (DRY)
- Extract updateByUuid() in tasks store, normalize to .map() pattern
- Remove unused getQueueSize(), Select.svelte, and lib/index.js
- Modernize uuid.js to prefer crypto.randomUUID()
- Strip ~60 redundant comments that restated self-evident code

No behavior changes. Build passes, pre-existing type errors unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:03:08 +01:00
joakim 41a12fe7a9 fix: human readable timestamps 2026-02-21 00:20:32 +01:00
joakim acab4333a7 Updated interaction between custom CLI syntax and Cobra flags 2026-02-19 18:31:30 +01:00
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
joakim f05d6e154e gradient gutters 2026-02-17 21:57:34 +01:00
joakim 4dfef88f19 refactor: use Vite built-in DEV flag instead of VITE_OAUTH_ENABLED
import.meta.env.DEV is already true during `bun run dev` and false in
production builds, so a separate env var is unnecessary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 21:19:34 +01:00
joakim d51c6da18d feat: replace mock mode with real backend dev mode
Add --dev flag to `opal server start` that disables auth (injects
userID=1 for all requests) and exposes a /auth/dev-session endpoint,
so the frontend can develop against a real backend without OAuth
config. Remove VITE_MOCK_MODE and all mock data/branches from the
frontend stores. Add scripts/dev.sh to start both services locally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:07:34 +01:00
joakim 80ea17227d fix: prevent nil-panic on server and improve OAuth callback handling
Load config eagerly during server startup so sortByUrgency never
hits a nil config. Add nil-guard in BuildUrgencyCoefficients as
belt-and-suspenders defense. Fix OAuth callback to support both
GET and POST, and resolve issuer URLs properly with path.Dir.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:40:53 +01:00
joakim c5a963bfd9 fix: make LoadConfig read-only to prevent panic on read-only filesystems
LoadConfig() tried to create directories and write opal.yml as a side
effect of loading config. On the server (where /etc/opal is in systemd
ReadOnlyPaths), this failed, returning nil. All internal GetConfig()
callers discarded the error, passing nil to BuildUrgencyCoefficients()
which panicked on nil dereference.

Redesign the config system with layered, read-only loading:
- Defaults (always present) → YAML file (if exists) → OPAL_ env vars
- LoadConfig never writes to the filesystem or returns nil
- File creation moved to explicit InitConfig() for CLI first-run/setup
- SaveConfig uses yaml.Marshal instead of manual field-by-field Viper
  calls, eliminating the three-place maintenance burden

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:04:54 +01:00
joakim b3c30738bd fix(web): minor UI refinements across header, pills, swipe, and settings
- Remove ThemeSwitcher from header (already accessible via settings)
- Increase pill padding and font size for better tap targets
- Guard non-cancelable touchmove preventDefault in SwipeAction
- Restyle settings page with grid-area layout and inline sign-out button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 14:59:58 +01:00
joakim 3bb2ef2759 feat: add JSON serialization, urgency field, and snake_case API contract
Fix latent API bug where multi-word fields (RecurrenceDuration, ParentUUID,
CreatedAt) serialized as PascalCase, breaking the frontend. Add explicit
snake_case json tags and custom MarshalJSON/UnmarshalJSON on Task, Status,
and APIKey to emit unix timestamps and string status codes.

Add Urgency float64 as a derived field on Task, populated via
PopulateUrgency helper in all handlers before serialization. The report
engine's sortByUrgency now also retains the computed score.

Frontend updated with urgency type, color-coded badge in TaskItem, and
mock data values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 14:58:34 +01:00
joakim 924b66bc64 docs: add opal-task REST API reference
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 14:58:01 +01:00
joakim d86501e4e6 feat(web): use CSS grid-areas layout with anchor-positioned report picker
Replace flexbox layout with CSS grid using named grid-areas for responsive
content containment. Gutters collapse naturally on small screens via
min(--content-max-width, 100%). Anchor ReportPicker to its trigger button
using CSS anchor positioning instead of fixed viewport offsets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 14:08:41 +01:00
102 changed files with 11770 additions and 1784 deletions
-54
View File
@@ -1,54 +0,0 @@
# Notr
Simple Go application for organizing and referencing notes. Loosely based on Obsidian.
**Implementation:** See `jade-depo/` directory for the CLI tool.
## Workflow
I take notes in two primary ways:
### Phone
- Quick notes, on the go.
- View and search notes.
### Workstation
- Using NeoVim for notetaking
### Other infrastructure
- I host a VPS with a Nextcloud and Gitea instances.
## What I want
- A Obsidian Vault like structure. A folder where notes live.
- A file is a note
- Can also store attachments, such as images. These files can then be referenced in the relevant notes.
- Directories is the main organization method, although tags and links can seam-lesly cross directories boundries.
- Markdown syntax (this can be handled by NeoVim and a markdown editor on other devices.)
- Tags: Syntax +tag
- Note links for referencing other notes or any other vault files. Syntax uncertain. Obsidian uses [[]]?
- See reports about the vault. Tag report
- At some point I would like to have a web-app and host it on my server. This would integrate with my authentik service for auth, and would be a live view of a users vault
- OCR would be great.
## Implementation
I have a tendency to scope creep and never actually getting a usable product, so an important goal here is practicing getting a usable app up and running. This should not have to be the biggest project, so I'll try to predict the process:
### Version 0.1
Here I use other tools for the note-taking and accept that any searching is on a directory basis only.
- [ ] I create a directory in Nextcloud. This I will start using immediately.
- [ ] Find a good Markdown editor for android.
- [ ] Adopt any crutial Obsidian notes
### Version 1.0 ✓
This is where I can use Notr to find and search notes on my workstation. CLI implementation complete!
- [x] Process notes. Metadata and diffs
- [x] Search and Filter by tags
- [x] Search and Filter by content
- [x] Add, edit, delete notes
- [x] List all notes and tags
### Version 2.0
Here I can do the same on my phone.
Also:
- [ ] OCR
## Metadata approach
Multiple approaches possible.
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 |
File diff suppressed because it is too large Load Diff
+533
View File
@@ -0,0 +1,533 @@
# Web CLI Parity — Requirements Spec
**Status:** Draft
**Last updated:** 2026-02-19
**Related:** [`cli-ux-improvements.md`](cli-ux-improvements.md) — CLI UX
improvements being developed in parallel. Features marked with **(CLI dep)**
depend on or benefit from CLI-side work landing first.
This document covers the features the CLI exposes that the web frontend does
not. Each section maps a CLI capability to a proposed web feature, with
acceptance criteria and priority.
---
## Problem Statement
The opal CLI is the primary interface and offers rich task management: in-place
editing, start/stop timers, detailed task info, project/tag browsing, bulk
operations, and full sync controls. The web frontend currently only supports
create, complete, delete, and report-based listing. Users who switch between
CLI and web hit a wall — most tasks that go beyond "add" and "done" require
falling back to the terminal.
---
## Tier 1 — Core Gaps (the web feels broken without these)
### 1.1 Task Detail View (`info` equivalent)
**CLI:** `opal info 2` shows every field on a task — UUID, status, description,
urgency, priority, project, all timestamps (created, modified, started, ended,
due, scheduled, wait, until), recurrence pattern, parent UUID, and tags.
**Web gap:** Tapping a task does nothing. There is no way to see a task's full
state.
**User story:** As a user, I want to tap a task to see all of its fields so
that I can understand its full context without switching to the CLI.
**Acceptance criteria:**
- Given a task in the list, when I tap it, then a detail view opens showing
every non-null field on the task
- Given a task with a recurrence pattern, when I view its detail, then the
recurrence interval and parent template link are visible
- Given a task with scheduled/wait/until dates, when I view its detail, then
those dates are displayed with labels
- Given a task detail view, when I tap outside or press a close/back control,
then the detail view closes and the list is restored
**Interaction:** Bottom sheet — slides up from the bottom of the screen.
Natural on mobile, avoids a route change, and leaves the task list partially
visible behind it. Light-dismiss (tap scrim to close).
**Priority:** MUST
---
### 1.2 Task Editing (`modify` / `edit` equivalent)
**CLI:** `opal 2 modify priority:H due:friday` changes attributes inline.
`opal 2 edit` opens `$EDITOR` with all fields in a structured format.
**Web gap:** There is no way to edit a task after creation. Users must delete
and recreate to fix a typo or change a due date.
**User story:** As a user, I want to edit any field on an existing task so that
I can adjust it as my plans change.
**Acceptance criteria:**
- Given a task detail view, when I tap an editable field, then I can modify its
value
- Given I change a field and confirm, when the update succeeds, then the task
list reflects the change
- Given I change a field and confirm, when the update fails, then the error is
shown and the original value is preserved
- Editable fields: description, project, priority, due, scheduled, wait, until,
tags, recurrence (on templates)
- Read-only fields (displayed but not editable): UUID, created, modified, end,
parent UUID, status
**Implementation notes:**
- Uses `PUT /tasks/:uuid` — already exists in the API
- For tags, uses `POST /tasks/:uuid/tags` and
`DELETE /tasks/:uuid/tags/:tag` — already exist
- Field editing could be inline (tap field to edit) or form-based (edit mode
that makes all fields editable). Inline feels lighter for single-field tweaks.
**Priority:** MUST
---
### 1.3 Start / Stop Timer (`start` / `stop` equivalent)
**CLI:** `opal 1 start` marks a task as actively being worked on (sets `start`
timestamp). `opal 1 stop` clears it. The `active` report lists started tasks.
**Web gap:** The API endpoints (`POST /tasks/:uuid/start`,
`POST /tasks/:uuid/stop`) are wired in `endpoints.js` but there are no UI
controls. The `active` report works in the report picker but users can't
actually start tasks.
**User story:** As a user, I want to mark a task as "in progress" so that I can
track what I'm actively working on and see it in the Active report.
**Acceptance criteria:**
- Given a pending task, when I tap a start control, then the task's start time
is set and it appears in the Active report
- Given a started task, when I tap a stop control, then the start time is
cleared
- Given a started task, when I view it in the list, then there is a visual
indicator that it is active (distinguishable from non-started tasks)
- The start/stop action should be accessible from both the task row and the
detail view
**Interaction:** Swipe left to toggle start/stop — mirrors swipe right for
complete. Also accessible from the detail view (1.1). No visible button on the
task row.
**Priority:** MUST
---
### 1.4 Uncomplete / Revert (`modify status:pending` equivalent)
**(CLI dep)** — CLI is getting a dedicated `uncomplete` command and a generic
`undo` (see [IMP-1](cli-ux-improvements.md#imp-1-undo--uncomplete)). The web
feature should align with however the CLI exposes this so the mental model is
consistent.
**CLI:** `opal <id> modify status:pending` reverts a completed task. IMP-1
proposes `opal <id> uncomplete` and `opal undo` as dedicated commands.
**Web gap:** Once a task is completed it disappears from the pending list. The
only recovery is the `completed` report + CLI. This is also called out in
`BUGS.md` as a missing feature.
**User story:** As a user, I want to undo an accidental completion so that the
task reappears in my pending list.
**Acceptance criteria:**
- Given a completed task (visible in the Completed report), when I tap an
uncomplete action, then the task's status reverts to pending and it
reappears in the pending list
- Given I just completed a task, when I tap undo within a brief window (e.g.
toast with undo button), then the task is reverted without navigating to the
Completed report
**Implementation notes:**
- Uses `PUT /tasks/:uuid` with `{"status": "pending"}` — works today
- The undo toast after completion is a UX nicety but not strictly required for
parity
- If CLI IMP-1 lands an undo log table, the web could use the same mechanism
for a more robust undo (revert any action, not just completion)
**Priority:** MUST
---
### 1.5 Delete Confirmation
**(CLI dep)** — CLI is improving confirmation prompts to show matched tasks
before confirming (see [IMP-3](cli-ux-improvements.md#imp-3-show-matched-tasks-in-confirmations)).
The web confirmation should follow the same pattern.
**CLI:** `opal delete` always prompts `Proceed? (y/N)` before deleting. IMP-3
proposes showing the affected task(s) in the confirmation.
**Web gap:** Delete is instant with no confirmation. There is no undo.
**User story:** As a user, I want a confirmation before deleting a task so that
I don't lose work by accident.
**Acceptance criteria:**
- Given I trigger a delete action, when the confirmation appears, then I must
explicitly confirm before the delete proceeds
- Given I dismiss the confirmation, then the task is not deleted
- The confirmation should show the task description (matching CLI IMP-3's
approach of showing what will be affected)
**Priority:** MUST
---
## Tier 2 — CLI Parity (brings the web up to feature-complete)
### ~~2.1 Projects View~~ — DEFERRED
### ~~2.2 Tags View~~ — DEFERRED
Projects and tags browsing is deferred. The existing filter syntax
(`project:foo`, `+tag`) covers this adequately for now. When filter
autocomplete is added later, discoverability will improve without needing
dedicated views.
---
### 2.3 Display All Date Fields on Task Items
**CLI:** `opal info` and the table display show scheduled, wait, until, and
start dates when present. The `waiting` report makes sense because you can see
*when* the wait expires.
**Web gap:** Only the due date is shown on task rows. Scheduled, wait, until,
and start dates are invisible. Users can set them via CLI syntax in the input
bar but can't see them afterward.
**User story:** As a user, I want to see all relevant dates on a task so that I
understand when it's scheduled, when it becomes visible, and when it expires.
**Acceptance criteria:**
- Given a task with a `scheduled` date, then a "Scheduled: <date>" indicator
appears on the task row
- Given a task with a `wait` date, then a "Wait: <date>" indicator appears
- Given a task with an `until` date, then an "Until: <date>" indicator appears
- Given a task with a `start` time set, then an "Active since <time>" or
similar indicator appears
- Date fields that are null are not shown (no empty labels)
- The detail view (1.1) shows all dates; the task row shows them in a
compact/abbreviated form
**Priority:** SHOULD
---
### 2.4 Recurrence Display and Management
**(CLI dep)** — CLI is improving recurring task feedback on completion (see
[IMP-7](cli-ux-improvements.md#imp-7-recurring-task-feedback)). The web should
show equivalent feedback when completing a recurring task.
**CLI:** `opal add "standup" due:mon recur:1w` creates a recurring template +
first instance. `opal template` and `opal recurring` list them. Completing an
instance spawns the next one. `opal edit <id>` on an instance can update the
template's recurrence pattern.
**Web gap:** Recurring tasks can be created via CLI syntax in the input bar, and
the `recurring`/`template` reports work, but:
- There is no visual indicator that a task is a recurring instance
- There is no way to see or navigate to the parent template
- There is no way to edit the recurrence pattern
- Completing a recurring task gives no feedback about the next instance
**User story:** As a user, I want to see which tasks are recurring, view their
schedule, and manage the recurrence pattern so that I can adjust repeating
commitments.
**Acceptance criteria:**
- Given a recurring instance, when I view it in the list, then a recurrence
icon or badge is visible
- Given a recurring instance, when I open its detail view, then the recurrence
interval and parent template are shown
- Given a recurring template, when I open its detail view, then I can edit the
recurrence interval
- Given I complete a recurring instance, then the next instance is
automatically created (this already works server-side; just verify the list
refreshes to show it)
- Given I complete a recurring instance, then a toast or inline message shows
the next instance's due date (mirrors CLI IMP-7)
**Priority:** SHOULD
---
### ~~2.5 Sync Controls~~ — DEFERRED
Sync is deprioritized. The web has no client-side task parsing, so offline
functionality is non-functioning. The web fetches directly from the server on
every action — sync is only relevant if we move to an offline-first model,
which is not planned.
---
### 2.6 Bulk Operations (`opal 1 2 3 modify ...` / `opal +tag done`)
**CLI:** Filters and numeric IDs can target multiple tasks.
`opal +urgent done` completes all tasks tagged `urgent`.
`opal 1 2 3 modify project:sprint-2` moves three tasks at once.
The CLI prompts for confirmation when multiple tasks are affected.
**Web gap:** All actions are single-task only. No multi-select, no batch
complete, no batch modify.
**User story:** As a user, I want to select multiple tasks and perform an
action on all of them so that I can manage tasks efficiently.
**Acceptance criteria:**
- Given I enter selection mode (e.g., long-press a task), then I can tap
additional tasks to add them to the selection
- Given I have tasks selected, then I can batch-complete, batch-delete, or
batch-modify (at minimum: change project, add/remove tag, change priority)
- Given a batch action targets 2+ tasks, then a confirmation prompt appears
before executing
- Given I tap outside the selection or press a cancel control, then selection
mode is exited
**Priority:** SHOULD
---
## Tier 3 — Power User & Polish
### 3.1 Keyboard Shortcuts
**CLI:** The CLI is entirely keyboard-driven by nature.
**Web gap:** No keyboard shortcuts exist. Desktop users must use the mouse for
everything.
**User story:** As a desktop user, I want keyboard shortcuts so that I can
manage tasks without reaching for the mouse.
**Suggested bindings:**
| Key | Action |
|-----|--------|
| `n` | Focus the input bar (new task) |
| `j` / `k` | Move selection down / up in task list |
| `x` | Complete selected task |
| `e` | Open detail/edit for selected task |
| `d` | Delete selected task (with confirmation) |
| `s` | Start/stop selected task |
| `Escape` | Close detail view / deselect / blur input |
| `/` | Open filter modal |
**Acceptance criteria:**
- Given I press `n` when not focused on an input, then the input bar is focused
- Given I press `j`/`k`, then the visual selection moves through the task list
- Given I press `x` with a task selected, then that task is completed
- Shortcuts are disabled when an input or textarea is focused (except Escape)
**Priority:** COULD
---
### 3.2 Description Search
**CLI:** Filtering by tags, project, priority, and status covers structured
attributes, but there's no full-text description search in the CLI either.
**Web opportunity:** The web could add a search capability that the CLI lacks.
**User story:** As a user, I want to search tasks by description text so that I
can find a specific task without remembering its tags or project.
**Acceptance criteria:**
- Given I type in a search field, then the task list filters to tasks whose
description contains the search text (case-insensitive)
- Search can be client-side (filter the loaded report) or server-side (new
query param)
**Interaction:** Extends the existing filter syntax. Bare text in the filter
input is interpreted as a description search. The filter modal becomes
filter/search — no separate search bar.
**Priority:** COULD
---
### ~~3.3 Display IDs~~ — DROPPED
Not needed. The web uses touch/tap interaction, not keyboard-driven ID
selection. Display IDs are a CLI affordance that doesn't translate to the web
interaction model.
---
### ~~3.4 API Key Management~~ — ON HOLD
Blocked on user management investigation. Open questions: Are API keys
user-scoped? Should we move to per-user databases? This feature should wait
until the auth/user model is settled.
---
### 3.5 Task Annotations (`annotate` equivalent)
**(CLI dep)** — The CLI is adding `opal <id> annotate "<text>"` (see
[IMP-11](cli-ux-improvements.md#imp-11-task-annotations)). This requires a
backend schema change (annotations storage). Once that lands, the web should
surface annotations.
**CLI (proposed):** `opal 3 annotate "Traced to token expiry"` adds a
timestamped note. `opal 3 info` shows annotations. IMP-11 notes potential
integration with jade-depo (the gems note management system).
**Web gap:** No concept of annotations exists. Tasks have only a description
field for text.
**User story:** As a user, I want to add and view notes on a task so that I can
record progress and context over time.
**Acceptance criteria:**
- Given a task detail view (1.1), then any existing annotations are shown as a
timestamped list below the task fields
- Given a task detail view, when I tap an "Add note" control, then I can enter
annotation text and it is saved with a timestamp
- Annotations are ordered newest-first or oldest-first (decide which)
- Annotations are read-only after creation (no inline editing — `denotate`
removes the latest)
**Decisions:**
- Annotations are visible only in the detail view (1.1), not on the task row.
- jade-depo integration is not relevant to the web UI at this time.
**Priority:** COULD (blocked on CLI IMP-11 landing the backend schema)
---
### 3.6 Task History (`log` equivalent)
**(CLI dep)** — The CLI is adding `opal <id> log` (see
[IMP-12](cli-ux-improvements.md#imp-12-task-history)). This reads the existing
`change_log` table which already exists for sync.
**CLI (proposed):** `opal 3 log` shows timestamped change history (created,
modified, completed, etc.).
**Web gap:** No way to see what happened to a task over time.
**User story:** As a user, I want to see a task's change history so that I can
understand when and how it was modified.
**Acceptance criteria:**
- Given a task detail view (1.1), then a "History" section or tab shows the
change log for that task
- Each entry shows: timestamp, change type, and what changed (e.g.,
"priority: default -> high")
- History is read-only
**Implementation notes:**
- The `change_log` table already exists for sync. This likely needs a new API
endpoint (`GET /tasks/:uuid/log` or similar) to expose it.
- Alternatively, the change log could be included in the task detail response.
**Priority:** COULD (blocked on CLI IMP-12 / API endpoint)
---
### 3.7 Configurable Default Report
**CLI:** `default_report` in `opal.yml` controls what `opal` with no arguments
shows (default: `list`). `default_filter` controls the base filter.
**Web gap:** The web always starts on the `list` report. There is no user
preference for the landing view.
**User story:** As a user, I want to choose which report I see when I open the
app so that my most-used view loads first.
**Acceptance criteria:**
- Given I choose a default report in settings, then the app opens to that
report on next launch
- The preference is stored in localStorage (no backend change needed)
**Priority:** COULD
---
## Non-Functional Requirements
| # | Requirement | Priority |
|---|-------------|----------|
| NFR-1 | All new features must work on Android Chrome and desktop Chrome/Firefox (per architecture doc) | MUST |
| NFR-2 | Task detail and edit interactions must be touch-friendly (44px minimum tap targets) | MUST |
| NFR-3 | Editing a task must be optimistic — UI updates immediately, rolls back on failure | SHOULD |
| NFR-4 | Keyboard shortcuts must not conflict with browser defaults (Ctrl+T, etc.) | MUST |
| NFR-5 | New features must work with all three themes (Obsidian, Paper, Midnight) | MUST |
| NFR-6 | No new API endpoints are required for Tier 1 — all endpoints already exist | N/A |
---
## Constraints & Assumptions
**Constraints:**
- Single-screen architecture per the existing design doc — no new routes for
projects/tags (use sheets/modals/filters instead)
- Server-side parsing and sorting — the frontend stays a thin shell
- SvelteKit + Vite stack, Svelte 5 runes
**Assumptions:**
- The `PUT /tasks/:uuid` endpoint accepts partial updates (only fields present
in the request body are changed)
- The working set display IDs are not currently exposed via the API; Tier 3.3
would require an API change
- Sync transport (pull/push) works correctly; the gap is only in applying
pulled changes to the store
---
## Cross-Reference: CLI UX Improvements
The following items from [`cli-ux-improvements.md`](cli-ux-improvements.md)
directly affect web features in this spec:
| CLI IMP | CLI Feature | Web Impact |
|---------|-------------|------------|
| IMP-1 | Undo / uncomplete | Enables 1.4 (uncomplete). If undo log is stored in DB, web can use same mechanism. |
| IMP-2 | Better `add` feedback | Web already shows the created task in-list. No direct web change, but if the API response for `POST /tasks/parse` is enriched (display ID, parsed modifiers), the web could show a richer confirmation toast. |
| IMP-3 | Show matched tasks in confirmations | Pattern for 1.5 (delete confirmation) and 2.6 (bulk ops). |
| IMP-5 | Handle colons in descriptions | Affects web input bar — same parsing runs server-side via `/tasks/parse`. No web change needed, but web benefits automatically. |
| IMP-7 | Recurring task feedback | Directly feeds 2.4 (recurrence display). |
| IMP-9 | Relative dates in CLI | Web already does this. No change needed. |
| IMP-11 | Task annotations | Enables 3.5 (annotations on web). Blocked on backend schema. |
| IMP-12 | Task history | Enables 3.6 (history on web). Blocked on API endpoint. |
Items from the CLI spec with **no web impact**: IMP-4 (delete ID resolution
bug), IMP-6 (consistent error codes), IMP-8 (shell completions), IMP-10
(dry-run flag), IMP-13 (version command).
---
## Out of Scope
- Collaboration / multi-user sharing
- Notifications, reminders, or push alerts
- Custom fields or metadata
- Drag-to-reorder (ordering is report/urgency-driven)
- Offline-first / IndexedDB task storage (tasks are server-side only)
- iOS Safari support
---
## Open Questions Summary
| # | Question | Blocks | Status |
|---|----------|--------|--------|
| ~~Q1~~ | ~~Detail view format~~ | ~~1.1~~ | **Resolved** — bottom sheet |
| ~~Q2~~ | ~~Start/stop control placement~~ | ~~1.3~~ | **Resolved** — swipe left |
| ~~Q3~~ | ~~Projects/tags view format~~ | ~~2.1, 2.2~~ | **Resolved** — deferred, use filters |
| ~~Q4~~ | ~~Description search format~~ | ~~3.2~~ | **Resolved** — extend filter syntax |
| ~~Q5~~ | ~~Display IDs~~ | ~~3.3~~ | **Resolved** — dropped |
| ~~Q6~~ | ~~Annotation visibility~~ | ~~3.5~~ | **Resolved** — detail view only |
| ~~Q7~~ | ~~jade-depo integration~~ | ~~3.5~~ | **Resolved** — not for web |
+1
View File
@@ -0,0 +1 @@
0.2.0
+32 -14
View File
@@ -63,31 +63,42 @@ func addTask(args []string) error {
return fmt.Errorf("failed to create task: %w", err) return fmt.Errorf("failed to create task: %w", err)
} }
fmt.Printf("Created task %s\n", task.UUID) engine.RecordUndo("add", task.UUID)
if len(task.Tags) > 0 {
fmt.Printf("Tags: %s\n", strings.Join(task.Tags, ", "))
}
displayID, err := engine.AppendTask(task)
if err != nil {
// Non-fatal: task was created, just can't assign display ID
fmt.Printf("Created task %s\n", task.UUID)
return nil return nil
} }
// parseAddArgs extracts description and modifiers from args fmt.Print(engine.FormatAddFeedback(task, displayID))
// Description = all non-filter/modifier words joined with spaces return nil
// 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) { func parseAddArgs(args []string) (string, []string, error) {
var descParts []string var descParts []string
var modifiers []string var modifiers []string
for _, arg := range args { for _, arg := range args {
isFilterOrModifier := strings.HasPrefix(arg, "+") || if strings.HasPrefix(arg, "+") || strings.HasPrefix(arg, "-") {
strings.HasPrefix(arg, "-") ||
strings.Contains(arg, ":")
if isFilterOrModifier {
modifiers = append(modifiers, arg) modifiers = append(modifiers, arg)
} else { continue
descParts = append(descParts, arg)
} }
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 { if len(descParts) == 0 {
@@ -104,8 +115,15 @@ func addRecurringTask(description string, mod *engine.Modifier) error {
return err 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("Created recurring task %s\n", *instance.ParentUUID)
fmt.Printf("First instance: %s\n", instance.UUID) fmt.Printf("First instance: %s\n", instance.UUID)
return nil
}
fmt.Print(engine.FormatRecurringAddFeedback(instance, displayID))
return nil 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
}
+77
View File
@@ -0,0 +1,77 @@
package cmd
import (
"fmt"
"os"
"time"
"git.jnss.me/joakim/opal/internal/engine"
"github.com/spf13/cobra"
)
var olderFlag string
var cleanCmd = &cobra.Command{
Use: "clean",
Short: "Purge soft-deleted tasks from the database",
Run: func(cmd *cobra.Command, args []string) {
if err := cleanTasks(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func init() {
cleanCmd.Flags().StringVar(&olderFlag, "older", "", "Only purge tasks deleted longer than this duration ago (e.g. 30d, 1w)")
}
func cleanTasks() error {
var olderThan *time.Duration
if olderFlag != "" {
d, err := engine.ParseRecurrencePattern(olderFlag)
if err != nil {
return fmt.Errorf("invalid duration %q: %w", olderFlag, err)
}
olderThan = &d
}
tasks, err := engine.GetDeletedTasks(olderThan)
if err != nil {
return err
}
if len(tasks) == 0 {
fmt.Println("No deleted tasks to purge.")
return nil
}
if dryRunFlag {
fmt.Printf("Would permanently remove %d deleted task(s).\n", len(tasks))
return nil
}
if len(tasks) > 1 {
fmt.Printf("Permanently remove %d deleted task(s)? This cannot be undone. (y/N): ", len(tasks))
var confirm string
fmt.Scanln(&confirm)
if confirm != "y" && confirm != "Y" {
fmt.Println("Cancelled.")
return nil
}
}
for _, task := range tasks {
if err := task.Delete(true); err != nil {
return fmt.Errorf("failed to purge task %s: %w", task.UUID, err)
}
}
fmt.Printf("Purged %d deleted task(s).\n", len(tasks))
if err := engine.CleanupChangeLog(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to clean up change log: %v\n", err)
}
return nil
}
+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)
}
},
}
+132
View File
@@ -0,0 +1,132 @@
package cmd
import (
"fmt"
"strings"
"git.jnss.me/joakim/opal/internal/engine"
"github.com/spf13/cobra"
)
// taskFilterCompletion provides dynamic completions for task filter arguments.
// Suggests +tag, project:name, and attribute value completions from the database.
func taskFilterCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// If typing a key:value, complete the value part
if idx := strings.IndexByte(toComplete, ':'); idx >= 0 {
key := toComplete[:idx]
if engine.ValidAttributeKeys[key] {
return attributeValueCompletions(key, toComplete), cobra.ShellCompDirectiveNoFileComp
}
}
// If toComplete is a prefix of an attribute key, return only key:
// completions with NoSpace so the cursor stays after the colon.
if toComplete != "" && !strings.HasPrefix(toComplete, "+") && !strings.HasPrefix(toComplete, "-") {
var keyCompletions []string
for key := range engine.ValidAttributeKeys {
if strings.HasPrefix(key, toComplete) {
keyCompletions = append(keyCompletions, fmt.Sprintf("%s:", key))
}
}
if len(keyCompletions) > 0 {
return keyCompletions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
}
}
var completions []string
tags, err := engine.GetAllTags()
if err == nil {
for _, tag := range tags {
completions = append(completions, fmt.Sprintf("+%s", tag))
}
}
projects, err := engine.GetAllProjects()
if err == nil {
for _, proj := range projects {
completions = append(completions, fmt.Sprintf("project:%s", proj))
}
}
// Add known attribute keys
for key := range engine.ValidAttributeKeys {
completions = append(completions, fmt.Sprintf("%s:", key))
}
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
}
// attributeValueCompletions returns key:value completions for a known attribute key.
// Cobra filters by prefix automatically, so we return all values prefixed with "key:".
func attributeValueCompletions(key, toComplete string) []string {
var values []string
switch key {
case "status":
values = []string{"pending", "completed", "deleted", "recurring"}
case "priority":
values = []string{"H", "M", "L"}
case "project":
projects, err := engine.GetAllProjects()
if err == nil {
values = projects
}
case "due", "wait", "scheduled", "until":
values = []string{
"today", "tomorrow", "yesterday", "now",
"eod", "sow", "eow", "som", "eom",
"mon", "tue", "wed", "thu", "fri", "sat", "sun",
}
case "recur":
values = []string{"daily", "weekly", "monthly", "yearly", "1d", "1w", "2w", "1m", "1y"}
}
completions := make([]string, 0, len(values))
for _, v := range values {
completions = append(completions, fmt.Sprintf("%s:%s", key, v))
}
return completions
}
// rootValidArgsFunction provides completions for root-level arguments,
// enabling flexible syntax like "opal 1 de<TAB>" to complete to "delete".
func rootValidArgsFunction(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Delegate to taskFilterCompletion first — if toComplete is a partial
// attribute key, it returns early with NoSpace and we should honour that.
filterCompletions, directive := taskFilterCompletion(cmd, args, toComplete)
var completions []string
// Suggest command names
for _, name := range commandNames {
completions = append(completions, name)
}
// Suggest report names
for _, name := range reportNames {
completions = append(completions, name)
}
completions = append(completions, filterCompletions...)
return completions, directive
}
func init() {
// Root command completions for flexible syntax (e.g., "opal 1 de<TAB>")
rootCmd.ValidArgsFunction = rootValidArgsFunction
// Register dynamic completions for commands that accept filters
addCmd.ValidArgsFunction = taskFilterCompletion
doneCmd.ValidArgsFunction = taskFilterCompletion
deleteCmd.ValidArgsFunction = taskFilterCompletion
modifyCmd.ValidArgsFunction = taskFilterCompletion
startCmd.ValidArgsFunction = taskFilterCompletion
stopCmd.ValidArgsFunction = taskFilterCompletion
editCmd.ValidArgsFunction = taskFilterCompletion
infoCmd.ValidArgsFunction = taskFilterCompletion
annotateCmd.ValidArgsFunction = taskFilterCompletion
denotateCmd.ValidArgsFunction = taskFilterCompletion
logCmd.ValidArgsFunction = taskFilterCompletion
}
+60 -7
View File
@@ -8,45 +8,98 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var hardDeleteFlag bool
var deleteCmd = &cobra.Command{ var deleteCmd = &cobra.Command{
Use: "delete [filter...]", Use: "delete [filter...]",
Short: "Delete tasks", Short: "Delete tasks",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
parsed := getParsedArgs(cmd) parsed := getParsedArgs(cmd)
if err := deleteTasks(parsed.Filters); err != nil { if err := deleteTasks(parsed.Filters, hardDeleteFlag); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
}, },
} }
func deleteTasks(args []string) error { func init() {
deleteCmd.Flags().BoolVar(&hardDeleteFlag, "hard", false, "Permanently remove task from database")
}
func deleteTasks(args []string, hard bool) error {
filter, err := engine.ParseFilter(args) filter, err := engine.ParseFilter(args)
if err != nil { if err != nil {
return err 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 { if err != nil {
return err return err
} }
tasks = append(tasks, task)
}
} else {
tasks, err = engine.GetTasks(filter)
if err != nil {
return err
}
}
if len(tasks) == 0 { if len(tasks) == 0 {
fmt.Println("No tasks matched.") return fmt.Errorf("no tasks matched filter")
}
action := "delete"
if hard {
action = "permanently delete"
}
if dryRunFlag {
fmt.Print(engine.FormatTaskConfirmList(action, tasks, ws))
fmt.Println("Dry run — no changes made.")
return nil return nil
} }
fmt.Printf("Delete %d task(s)? (y/N): ", len(tasks)) if len(tasks) > 1 || hard {
fmt.Print(engine.FormatTaskConfirmList(action, tasks, ws))
if hard {
fmt.Printf("This cannot be undone. Proceed? (y/N): ")
} else {
fmt.Printf("Proceed? (y/N): ")
}
var confirm string var confirm string
fmt.Scanln(&confirm) fmt.Scanln(&confirm)
if confirm != "y" && confirm != "Y" { if confirm != "y" && confirm != "Y" {
fmt.Println("Cancelled.")
return nil return nil
} }
}
for _, task := range tasks { for _, task := range tasks {
task.Delete(false) // Soft delete task.Delete(hard)
if !hard {
engine.RecordUndo("delete", task.UUID)
}
} }
fmt.Printf("Deleted %d task(s).\n", len(tasks)) verb := "Deleted"
if hard {
verb = "Permanently deleted"
}
if len(tasks) == 1 {
fmt.Printf("%s task %s\n", verb, engine.FormatTaskSummary(tasks[0], ws))
} else {
fmt.Printf("%s %d task(s).\n", verb, len(tasks))
}
return nil 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 { 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 return nil
} }
// Confirm if multiple tasks
if len(tasks) > 1 { 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 var confirm string
fmt.Scanln(&confirm) fmt.Scanln(&confirm)
if confirm != "y" && confirm != "Y" { if confirm != "y" && confirm != "Y" {
@@ -81,14 +86,19 @@ func completeTasks(args []string) error {
// Complete tasks // Complete tasks
completed := 0 completed := 0
for _, task := range tasks { 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) fmt.Fprintf(os.Stderr, "Warning: failed to complete task %s: %v\n", task.UUID, err)
} else { } else {
engine.RecordUndo("done", task.UUID)
completed++ 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) fmt.Printf("Completed %d task(s).\n", completed)
}
return nil return nil
} }
+39 -1
View File
@@ -198,6 +198,17 @@ func generateEditableContent(task *engine.Task) string {
sb.WriteString("tags: \n") 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() return sb.String()
} }
@@ -240,7 +251,8 @@ func applyEditedFields(task *engine.Task, fields map[string]string) error {
return err return err
} }
// Then complete (which saves automatically) // Then complete (which saves automatically)
return task.Complete() _, err := task.Complete()
return err
} }
// If changing to deleted, use Delete() method // 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 // Tags - replace all tags
if tagsStr, ok := fields["tags"]; ok { if tagsStr, ok := fields["tags"]; ok {
// Remove all existing tags // 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 { 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 // Confirm if multiple tasks or no filters specified
if len(tasks) > 1 || len(filterArgs) == 0 { 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 var confirm string
fmt.Scanln(&confirm) fmt.Scanln(&confirm)
if confirm != "y" && confirm != "Y" { if confirm != "y" && confirm != "Y" {
@@ -107,6 +114,7 @@ func modifyTasks(filterArgs, modifierArgs []string) error {
if err := mod.Apply(task); err != nil { if err := mod.Apply(task); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to modify task %s: %v\n", task.UUID, err) fmt.Fprintf(os.Stderr, "Warning: failed to modify task %s: %v\n", task.UUID, err)
} else { } else {
engine.RecordUndo("modify", task.UUID)
modified++ modified++
} }
} }
+137 -32
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"strings"
"git.jnss.me/joakim/opal/internal/engine" "git.jnss.me/joakim/opal/internal/engine"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -14,6 +15,7 @@ type ParsedArgs struct {
Command string Command string
Filters []string Filters []string
Modifiers []string Modifiers []string
CmdArgIndex int // position of command in os.Args[1:], -1 if not found
} }
// Context key for parsed args // Context key for parsed args
@@ -25,13 +27,15 @@ const parsedArgsKey contextKey = "parsedArgs"
var ( var (
configDirFlag string configDirFlag string
dataDirFlag string dataDirFlag string
dryRunFlag bool
) )
// Command classification // Command classification
var commandNames = []string{ var commandNames = []string{
"add", "done", "modify", "delete", "add", "done", "modify", "delete", "clean",
"start", "stop", "count", "projects", "tags", "start", "stop", "count", "projects", "tags",
"info", "edit", "server", "sync", "reports", "setup", "info", "edit", "server", "sync", "reports", "setup",
"version", "annotate", "denotate", "undo", "uncomplete", "log", "completion",
} }
// Report names (dynamically populated) // Report names (dynamically populated)
@@ -44,13 +48,15 @@ var reportNames = []string{
var commandsWithModifiers = map[string]bool{ var commandsWithModifiers = map[string]bool{
"add": true, "add": true,
"modify": true, "modify": true,
"annotate": true,
} }
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "opal", Use: "opal [filter] [command|report] [modifiers]",
Short: "Opal task manager - taskwarrior-inspired CLI task management", 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.`, It supports filtering, tags, priorities, projects, and recurring tasks.`,
Args: cobra.ArbitraryArgs,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// Default behavior: run configured default report (defaults to "list") // Default behavior: run configured default report (defaults to "list")
parsed := getParsedArgs(cmd) parsed := getParsedArgs(cmd)
@@ -80,28 +86,34 @@ func Execute() error {
if len(os.Args) > 1 { if len(os.Args) > 1 {
firstArg := os.Args[1] firstArg := os.Args[1]
if firstArg == "-h" || firstArg == "--help" || firstArg == "help" { if firstArg == "-h" || firstArg == "--help" || firstArg == "help" {
// Let Cobra handle help - skip preprocessing return rootCmd.Execute()
}
// Let Cobra's built-in completion machinery handle shell completions
// directly, bypassing preprocessing that would create tasks.
if firstArg == "__complete" || firstArg == "__completeNoDesc" {
return rootCmd.Execute() return rootCmd.Execute()
} }
} }
// Preprocess arguments BEFORE Cobra routing // Preprocess arguments (read-only scan — os.Args is never mutated)
if len(os.Args) > 1 { if len(os.Args) > 1 {
parsed := preprocessArgs(os.Args[1:]) parsed := preprocessArgs(os.Args[1:])
// Store in context for commands to use
ctx := context.WithValue(context.Background(), parsedArgsKey, parsed) ctx := context.WithValue(context.Background(), parsedArgsKey, parsed)
rootCmd.SetContext(ctx) rootCmd.SetContext(ctx)
// Rewrite os.Args for Cobra based on parsed command // Build clean args for Cobra via SetArgs (os.Args stays untouched).
// This allows Cobra to route to the correct command if parsed.CmdArgIndex >= 0 {
if parsed.Command != "list" || len(parsed.Filters) > 0 || len(parsed.Modifiers) > 0 { i := parsed.CmdArgIndex + 1 // offset for binary name in os.Args
// Reconstruct args: [command, ...filters, ...modifiers] cmdAndAfter := os.Args[i:] // command + subcommands + their flags
newArgs := []string{os.Args[0], parsed.Command} preCmdFlags := collectFlags(os.Args[1:i]) // persistent flags before command
newArgs = append(newArgs, parsed.Filters...)
newArgs = append(newArgs, parsed.Modifiers...) cobraArgs := make([]string, 0, len(cmdAndAfter)+len(preCmdFlags))
os.Args = newArgs cobraArgs = append(cobraArgs, cmdAndAfter...)
cobraArgs = append(cobraArgs, preCmdFlags...)
rootCmd.SetArgs(cobraArgs)
} }
// CmdArgIndex == -1: no command found, don't call SetArgs.
// Cobra processes os.Args naturally → root command → default report.
} }
return rootCmd.Execute() return rootCmd.Execute()
@@ -119,20 +131,25 @@ func getParsedArgs(cmd *cobra.Command) *ParsedArgs {
// preprocessArgs parses command-line arguments before Cobra routing // preprocessArgs parses command-line arguments before Cobra routing
// Returns: command name, filters, modifiers // Returns: command name, filters, modifiers
// Flags (--foo) are stripped from filters/modifiers; Cobra handles them from os.Args.
func preprocessArgs(args []string) *ParsedArgs { func preprocessArgs(args []string) *ParsedArgs {
if len(args) == 0 { if len(args) == 0 {
return &ParsedArgs{ return &ParsedArgs{
Command: "list", // Default command Command: "list", // Default command
Filters: []string{}, Filters: []string{},
Modifiers: []string{}, Modifiers: []string{},
CmdArgIndex: -1,
} }
} }
// Find command position (check both regular commands and reports) // Find command position, skipping flag-like args
cmdIdx := -1 cmdIdx := -1
cmdName := "" cmdName := ""
for i, arg := range args { for i, arg := range args {
if strings.HasPrefix(arg, "-") {
continue // Skip flags — Cobra handles them
}
// Check regular commands // Check regular commands
for _, name := range commandNames { for _, name := range commandNames {
if arg == name { if arg == name {
@@ -160,68 +177,152 @@ func preprocessArgs(args []string) *ParsedArgs {
if cmdIdx == -1 { if cmdIdx == -1 {
return &ParsedArgs{ return &ParsedArgs{
Command: "list", Command: "list",
Filters: args, Filters: stripFlags(args),
Modifiers: []string{}, Modifiers: []string{},
CmdArgIndex: -1,
} }
} }
// Split arguments around command // Split arguments around command
leftArgs := args[:cmdIdx] // Everything before command leftArgs := stripFlags(args[:cmdIdx])
rightArgs := []string{} rightArgs := []string{}
if cmdIdx+1 < len(args) { if cmdIdx+1 < len(args) {
rightArgs = args[cmdIdx+1:] // Everything after command rightArgs = stripFlags(args[cmdIdx+1:])
} }
// Determine how to interpret right args // Determine how to interpret right args
if commandsWithModifiers[cmdName] { if commandsWithModifiers[cmdName] {
// Command accepts modifiers
// Left = filters, Right = modifiers
return &ParsedArgs{ return &ParsedArgs{
Command: cmdName, Command: cmdName,
Filters: leftArgs, Filters: leftArgs,
Modifiers: rightArgs, Modifiers: rightArgs,
CmdArgIndex: cmdIdx,
} }
} else { } else {
// Command doesn't accept modifiers
// Both left and right are filters
allFilters := append(leftArgs, rightArgs...) allFilters := append(leftArgs, rightArgs...)
return &ParsedArgs{ return &ParsedArgs{
Command: cmdName, Command: cmdName,
Filters: allFilters, Filters: allFilters,
Modifiers: []string{}, Modifiers: []string{},
CmdArgIndex: cmdIdx,
} }
} }
} }
// stripFlags removes flag-like args (starting with -) from a slice
func stripFlags(args []string) []string {
var result []string
for _, arg := range args {
if !strings.HasPrefix(arg, "-") {
result = append(result, arg)
}
}
return result
}
// collectFlags extracts flag arguments (with their values) from a slice.
// Uses Cobra's persistent flag registry to determine if a flag takes a value.
func collectFlags(args []string) []string {
var flags []string
for i := 0; i < len(args); i++ {
if !strings.HasPrefix(args[i], "-") {
continue
}
flags = append(flags, args[i])
// If flag uses = syntax, value is already included
if strings.Contains(args[i], "=") {
continue
}
// Check if this flag takes a value argument
if i+1 < len(args) {
name := strings.TrimLeft(args[i], "-")
f := rootCmd.PersistentFlags().Lookup(name)
if f == nil && len(name) == 1 {
f = rootCmd.PersistentFlags().ShorthandLookup(name)
}
if f != nil && f.Value.Type() != "bool" {
i++
flags = append(flags, args[i])
}
}
}
return flags
}
func init() { func init() {
// Add persistent flags for directory overrides // Add persistent flags for directory overrides
rootCmd.PersistentFlags().StringVar(&configDirFlag, "config-dir", "", rootCmd.PersistentFlags().StringVar(&configDirFlag, "config-dir", "",
"Config directory (default: $XDG_CONFIG_HOME/opal or ~/.config/opal)") "Config directory (default: $XDG_CONFIG_HOME/opal or ~/.config/opal)")
rootCmd.PersistentFlags().StringVar(&dataDirFlag, "data-dir", "", rootCmd.PersistentFlags().StringVar(&dataDirFlag, "data-dir", "",
"Data directory (default: $XDG_DATA_HOME/opal or ~/.local/share/opal)") "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) // Use PersistentPreRun for initialization (runs for all subcommands unless overridden)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
initializeApp() initializeApp()
} }
// Add regular subcommands // Command groups for organized help output
rootCmd.AddGroup(
&cobra.Group{ID: "task", Title: "Task Commands:"},
&cobra.Group{ID: "report", Title: "Reports:"},
&cobra.Group{ID: "other", Title: "Other:"},
)
// Task commands
addCmd.GroupID = "task"
doneCmd.GroupID = "task"
modifyCmd.GroupID = "task"
deleteCmd.GroupID = "task"
startCmd.GroupID = "task"
stopCmd.GroupID = "task"
editCmd.GroupID = "task"
infoCmd.GroupID = "task"
annotateCmd.GroupID = "task"
denotateCmd.GroupID = "task"
undoCmd.GroupID = "task"
uncompleteCmd.GroupID = "task"
logCmd.GroupID = "task"
cleanCmd.GroupID = "task"
rootCmd.AddCommand(addCmd) rootCmd.AddCommand(addCmd)
rootCmd.AddCommand(doneCmd) rootCmd.AddCommand(doneCmd)
rootCmd.AddCommand(modifyCmd) rootCmd.AddCommand(modifyCmd)
rootCmd.AddCommand(deleteCmd) rootCmd.AddCommand(deleteCmd)
rootCmd.AddCommand(startCmd) rootCmd.AddCommand(startCmd)
rootCmd.AddCommand(stopCmd) rootCmd.AddCommand(stopCmd)
rootCmd.AddCommand(infoCmd)
rootCmd.AddCommand(editCmd)
rootCmd.AddCommand(annotateCmd)
rootCmd.AddCommand(denotateCmd)
rootCmd.AddCommand(undoCmd)
rootCmd.AddCommand(uncompleteCmd)
rootCmd.AddCommand(logCmd)
rootCmd.AddCommand(cleanCmd)
// Other commands
countCmd.GroupID = "other"
projectsCmd.GroupID = "other"
tagsCmd.GroupID = "other"
reportsCmd.GroupID = "other"
versionCmd.GroupID = "other"
completionCmd.GroupID = "other"
rootCmd.AddCommand(countCmd) rootCmd.AddCommand(countCmd)
rootCmd.AddCommand(projectsCmd) rootCmd.AddCommand(projectsCmd)
rootCmd.AddCommand(tagsCmd) rootCmd.AddCommand(tagsCmd)
rootCmd.AddCommand(infoCmd)
rootCmd.AddCommand(editCmd)
rootCmd.AddCommand(reportsCmd) rootCmd.AddCommand(reportsCmd)
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(completionCmd)
// Enable --version flag on root command
rootCmd.Version = Version
// Add report commands dynamically // Add report commands dynamically
reportCommands := CreateReportCommands() reportCommands := CreateReportCommands()
for _, cmd := range reportCommands { for _, cmd := range reportCommands {
cmd.GroupID = "report"
rootCmd.AddCommand(cmd) rootCmd.AddCommand(cmd)
} }
} }
@@ -244,16 +345,20 @@ func initializeApp() {
os.Exit(1) os.Exit(1)
} }
// Load config // On first run, create the config file with defaults
if isFirstRun {
if err := engine.InitConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Error creating config: %v\n", err)
os.Exit(1)
}
showFirstRunMessage()
}
// Load config (reads file if present, otherwise uses defaults)
if _, err := engine.LoadConfig(); err != nil { if _, err := engine.LoadConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Show first-run message after config is created
if isFirstRun {
showFirstRunMessage()
}
} }
func showFirstRunMessage() { func showFirstRunMessage() {
+20 -2
View File
@@ -97,21 +97,37 @@ var serverStartCmd = &cobra.Command{
Examples: Examples:
opal server start opal server start
opal server start --addr :8080 opal server start --addr :8080
opal server start --dev
opal server start --db /var/lib/opal/opal.db`, opal server start --db /var/lib/opal/opal.db`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
addr, _ := cmd.Flags().GetString("addr") addr, _ := cmd.Flags().GetString("addr")
dbPath, _ := cmd.Flags().GetString("db") dbPath, _ := cmd.Flags().GetString("db")
devMode, _ := cmd.Flags().GetBool("dev")
// Override DB path if specified // Override DB path if specified
if dbPath != "" { if dbPath != "" {
os.Setenv("OPAL_DB_PATH", dbPath) os.Setenv("OPAL_DB_PATH", dbPath)
} }
// Validate server configuration // In dev mode, skip OAuth config validation
if devMode {
fmt.Println("┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓")
fmt.Println("┃ ⚠ DEV MODE ENABLED ⚠ ┃")
fmt.Println("┃ Auth disabled — all requests use uid 1 ┃")
fmt.Println("┃ Do NOT use in production! ┃")
fmt.Println("┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛")
} else {
if err := validateServerConfig(); err != nil { if err := validateServerConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Server configuration validation failed:\n%v\n", err) fmt.Fprintf(os.Stderr, "Server configuration validation failed:\n%v\n", err)
os.Exit(1) os.Exit(1)
} }
}
// Load config (read-only — uses defaults if no opal.yml exists)
if _, err := engine.LoadConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
// Initialize database // Initialize database
if err := engine.InitDB(); err != nil { if err := engine.InitDB(); err != nil {
@@ -121,7 +137,7 @@ Examples:
defer engine.CloseDB() defer engine.CloseDB()
// Create and start server // Create and start server
server := api.NewServer(addr) server := api.NewServer(addr, devMode)
if err := server.Start(); err != nil { if err := server.Start(); err != nil {
fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err) fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err)
os.Exit(1) os.Exit(1)
@@ -182,12 +198,14 @@ Examples:
} }
func init() { func init() {
serverCmd.GroupID = "other"
rootCmd.AddCommand(serverCmd) rootCmd.AddCommand(serverCmd)
serverCmd.AddCommand(serverStartCmd) serverCmd.AddCommand(serverStartCmd)
serverCmd.AddCommand(keygenCmd) serverCmd.AddCommand(keygenCmd)
serverStartCmd.Flags().StringP("addr", "a", ":8080", "Server address") serverStartCmd.Flags().StringP("addr", "a", ":8080", "Server address")
serverStartCmd.Flags().StringP("db", "d", "", "Database path (default: config directory)") serverStartCmd.Flags().StringP("db", "d", "", "Database path (default: config directory)")
serverStartCmd.Flags().Bool("dev", false, "Enable dev mode (no auth, no OAuth env vars required)")
keygenCmd.Flags().StringP("name", "n", "", "Name for this API key (e.g., device name)") keygenCmd.Flags().StringP("name", "n", "", "Name for this API key (e.g., device name)")
keygenCmd.Flags().StringP("db", "d", "", "Database path (default: config directory)") keygenCmd.Flags().StringP("db", "d", "", "Database path (default: config directory)")
+12 -57
View File
@@ -49,6 +49,7 @@ Examples:
} }
func init() { func init() {
setupCmd.GroupID = "other"
rootCmd.AddCommand(setupCmd) rootCmd.AddCommand(setupCmd)
setupCmd.Flags().BoolVar(&showSystemdFlag, "show-systemd", false, "Show systemd service template") setupCmd.Flags().BoolVar(&showSystemdFlag, "show-systemd", false, "Show systemd service template")
@@ -121,33 +122,6 @@ func runQuickSetup() {
os.Exit(1) os.Exit(1)
} }
// Create config
cfg := engine.Config{
DefaultFilter: "status:pending",
DefaultSort: "due,priority",
DefaultReport: "list",
ColorOutput: true,
WeekStartDay: "monday",
DefaultDueTime: "",
NextLimit: 5,
SyncEnabled: false,
SyncStrategy: "last-write-wins",
SyncQueueOffline: true,
UrgencyDue: 12.0,
UrgencyPriorityH: 6.0,
UrgencyPriorityM: 3.9,
UrgencyPriorityD: 1.8,
UrgencyPriorityL: 0.0,
UrgencyActive: 4.0,
UrgencyAge: 2.0,
UrgencyAgeMax: 365,
UrgencyTags: 1.0,
UrgencyProject: 1.0,
UrgencyWaiting: -3.0,
UrgencyUrgentTag: "next",
UrgencyUrgentCoeff: 15.0,
}
// Create directories // Create directories
if err := os.MkdirAll(configDir, 0755); err != nil { if err := os.MkdirAll(configDir, 0755); err != nil {
wizard.PrintError(fmt.Sprintf("Failed to create config directory: %v", err)) wizard.PrintError(fmt.Sprintf("Failed to create config directory: %v", err))
@@ -158,8 +132,8 @@ func runQuickSetup() {
os.Exit(1) os.Exit(1)
} }
// Save config // Save default config
if err := engine.SaveConfig(&cfg); err != nil { if err := engine.SaveConfig(engine.DefaultConfig()); err != nil {
wizard.PrintError(fmt.Sprintf("Failed to save config: %v", err)) wizard.PrintError(fmt.Sprintf("Failed to save config: %v", err))
os.Exit(1) os.Exit(1)
} }
@@ -304,34 +278,15 @@ func runInteractiveSetup() {
return return
} }
// Create configuration // Create configuration from defaults, then apply user choices
cfg := &engine.Config{ cfg := engine.DefaultConfig()
DefaultFilter: defaultFilter, cfg.DefaultFilter = defaultFilter
DefaultSort: "due,priority", cfg.DefaultReport = reportNames[defaultReport]
DefaultReport: reportNames[defaultReport], cfg.ColorOutput = colorOutput
ColorOutput: colorOutput, cfg.WeekStartDay = weekStartDay
WeekStartDay: weekStartDay, cfg.SyncEnabled = syncEnabled
DefaultDueTime: "", cfg.SyncURL = syncURL
NextLimit: 5, cfg.SyncAPIKey = syncAPIKey
SyncEnabled: syncEnabled,
SyncURL: syncURL,
SyncAPIKey: syncAPIKey,
SyncStrategy: "last-write-wins",
SyncQueueOffline: true,
UrgencyDue: 12.0,
UrgencyPriorityH: 6.0,
UrgencyPriorityM: 3.9,
UrgencyPriorityD: 1.8,
UrgencyPriorityL: 0.0,
UrgencyActive: 4.0,
UrgencyAge: 2.0,
UrgencyAgeMax: 365,
UrgencyTags: 1.0,
UrgencyProject: 1.0,
UrgencyWaiting: -3.0,
UrgencyUrgentTag: "next",
UrgencyUrgentCoeff: 15.0,
}
// Set directory overrides // Set directory overrides
engine.SetConfigDirOverride(configDir) engine.SetConfigDirOverride(configDir)
+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 { for _, task := range tasks {
task.StartTask() task.StartTask()
engine.RecordUndo("start", task.UUID)
fmt.Printf("Started task: %s\n", task.Description) 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 { for _, task := range tasks {
task.StopTask() task.StopTask()
engine.RecordUndo("stop", task.UUID)
fmt.Printf("Stopped task: %s\n", task.Description) fmt.Printf("Stopped task: %s\n", task.Description)
} }
+82 -62
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"strings" "strings"
"time"
"git.jnss.me/joakim/opal/internal/engine" "git.jnss.me/joakim/opal/internal/engine"
"git.jnss.me/joakim/opal/internal/sync" "git.jnss.me/joakim/opal/internal/sync"
@@ -223,8 +224,8 @@ var syncUpCmd = &cobra.Command{
client := sync.NewClient(cfg.SyncURL, cfg.SyncAPIKey, cfg.SyncClientID) client := sync.NewClient(cfg.SyncURL, cfg.SyncAPIKey, cfg.SyncClientID)
// Get local changes // Get local changes
lastSync := getLastSyncTime(cfg.SyncClientID) lastSync := sync.GetLastSyncTime(cfg.SyncClientID)
localChanges, err := getLocalChanges(lastSync) localChanges, err := sync.GetLocalChanges(lastSync)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error getting local changes: %v\n", err) fmt.Fprintf(os.Stderr, "Error getting local changes: %v\n", err)
os.Exit(1) os.Exit(1)
@@ -263,7 +264,7 @@ var syncDownCmd = &cobra.Command{
} }
client := sync.NewClient(cfg.SyncURL, cfg.SyncAPIKey, cfg.SyncClientID) client := sync.NewClient(cfg.SyncURL, cfg.SyncAPIKey, cfg.SyncClientID)
lastSync := getLastSyncTime(cfg.SyncClientID) lastSync := sync.GetLastSyncTime(cfg.SyncClientID)
changes, err := client.PullChanges(lastSync) changes, err := client.PullChanges(lastSync)
if err != nil { if err != nil {
@@ -276,7 +277,55 @@ var syncDownCmd = &cobra.Command{
return return
} }
fmt.Printf("✓ Pulled %d changes from server\n", len(changes)) // Parse changes into tasks
tasks, err := client.ParseChanges(changes)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing changes: %v\n", err)
os.Exit(1)
}
// Apply each task locally
var applied int
for _, task := range tasks {
if err := task.Save(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to save task %s: %v\n", task.UUID, err)
continue
}
// Mark as sync-originated to prevent feedback loop
_ = engine.MarkChangeLogAsSync(task.UUID.String())
// Sync tags
savedTask, err := engine.GetTask(task.UUID)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to reload task %s: %v\n", task.UUID, err)
continue
}
currentTags, _ := savedTask.GetTags()
currentSet := make(map[string]bool)
for _, tag := range currentTags {
currentSet[tag] = true
}
desiredSet := make(map[string]bool)
for _, tag := range task.Tags {
desiredSet[tag] = true
}
for tag := range currentSet {
if !desiredSet[tag] {
savedTask.RemoveTag(tag)
}
}
for tag := range desiredSet {
if !currentSet[tag] {
savedTask.AddTag(tag)
}
}
applied++
}
fmt.Printf("✓ Pulled %d changes, applied %d tasks from server\n", len(changes), applied)
}, },
} }
@@ -391,6 +440,7 @@ Examples:
} }
func init() { func init() {
syncCmd.GroupID = "other"
rootCmd.AddCommand(syncCmd) rootCmd.AddCommand(syncCmd)
syncCmd.AddCommand(syncInitCmd) syncCmd.AddCommand(syncInitCmd)
@@ -412,63 +462,33 @@ func init() {
syncCmd.PersistentFlags().BoolVarP(&quietFlag, "quiet", "q", false, "Suppress progress output") syncCmd.PersistentFlags().BoolVarP(&quietFlag, "quiet", "q", false, "Suppress progress output")
} }
// Helper functions
func getLastSyncTime(clientID string) int64 {
db := engine.GetDB()
if db == nil {
return 0
}
var lastSync int64
err := db.QueryRow("SELECT last_sync FROM sync_state WHERE client_id = ?", clientID).Scan(&lastSync)
if err != nil {
return 0
}
return lastSync
}
func getLocalChanges(since int64) ([]*engine.Task, error) {
db := engine.GetDB()
if db == nil {
return nil, fmt.Errorf("database not initialized")
}
rows, err := db.Query(`
SELECT DISTINCT task_uuid
FROM change_log
WHERE changed_at > ?
ORDER BY changed_at ASC
`, since)
if err != nil {
return nil, err
}
defer rows.Close()
var tasks []*engine.Task
for rows.Next() {
var uuidStr string
if err := rows.Scan(&uuidStr); err != nil {
continue
}
taskUUID, err := uuid.Parse(uuidStr)
if err != nil {
continue
}
task, err := engine.GetTask(taskUUID)
if err != nil {
continue
}
tasks = append(tasks, task)
}
return tasks, nil
}
func formatTimestamp(ts int64) string { func formatTimestamp(ts int64) string {
return fmt.Sprintf("%d", ts) // Simple for now, can enhance later t := time.Unix(ts, 0)
now := time.Now()
diff := now.Sub(t)
switch {
case diff < time.Minute:
return "just now"
case diff < time.Hour:
m := int(diff.Minutes())
if m == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", m)
case diff < 24*time.Hour:
h := int(diff.Hours())
if h == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", h)
case diff < 7*24*time.Hour:
d := int(diff.Hours() / 24)
if d == 1 {
return "yesterday"
}
return fmt.Sprintf("%d days ago", d)
default:
return t.Format("2006-01-02 15:04")
}
} }
+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)
},
}
+921
View File
@@ -0,0 +1,921 @@
# Opal-Task API Reference
REST API for the opal task manager. Built with Go and [chi](https://github.com/go-chi/chi) router, backed by SQLite.
**Base URL:** `http://localhost:8080` (default) or behind a reverse proxy at `/api`
## Table of Contents
- [Authentication](#authentication)
- [Response Format](#response-format)
- [Endpoints](#endpoints)
- [Health](#health)
- [OAuth](#oauth)
- [Tasks](#tasks)
- [Tags](#tags)
- [Projects](#projects)
- [Sync](#sync)
- [API Keys](#api-keys)
- [Data Models](#data-models)
- [Reports](#reports)
---
## Authentication
The API supports two authentication methods:
### API Key
Generate a key with the CLI, then pass it as a Bearer token:
```bash
opal server keygen --name "My Phone"
# Output: oak_aBcDeFgH... (shown once, save it)
```
```
Authorization: Bearer oak_aBcDeFgH...
```
Keys are bcrypt-hashed at rest. The `oak_` prefix identifies opal API keys.
### OAuth / JWT
When OAuth is enabled, authenticate through the [login flow](#get-authlogin) to receive a JWT:
```
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
```
JWTs are HS256-signed, issued by `opal-task`, and expire after 1 hour by default (configurable via `JWT_EXPIRY`).
### Public Endpoints
These endpoints require no authentication:
| Endpoint | Description |
|---|---|
| `GET /health` | Health check |
| `GET /auth/login` | Get OAuth login URL |
| `POST /auth/callback` | OAuth code exchange |
| `POST /auth/refresh` | Refresh access token |
| `POST /auth/logout` | Revoke refresh token |
All other endpoints require a valid `Authorization: Bearer <token>` header.
---
## Response Format
Every response follows this envelope:
### Success
```json
{
"success": true,
"data": { ... }
}
```
### Error
```json
{
"success": false,
"error": "description of what went wrong"
}
```
### Conventions
- **JSON keys** are `snake_case` throughout.
- **Timestamps** are Unix seconds (integers), not ISO 8601 strings. Nullable timestamps are `null`.
- **Durations** (e.g., `recurrence_duration`) are in seconds. A 1-week recurrence is `604800`.
- **Status** is a single-character string: `"P"`, `"C"`, `"D"`, or `"R"`.
### Status Codes
| Code | Meaning |
|---|---|
| `200` | Success |
| `201` | Resource created |
| `400` | Invalid input (bad JSON, missing required fields, invalid UUID) |
| `401` | Missing or invalid authentication |
| `404` | Resource not found |
| `500` | Server error |
| `501` | Feature disabled (e.g., OAuth not configured) |
---
## Endpoints
### Health
#### `GET /health`
Returns server status. No authentication required.
```bash
curl http://localhost:8080/health
```
```json
{
"success": true,
"data": {
"status": "ok"
}
}
```
---
### OAuth
#### `GET /auth/login`
Returns the OAuth authorization URL for redirecting the user to the identity provider.
```bash
curl http://localhost:8080/auth/login
```
```json
{
"success": true,
"data": {
"url": "https://auth.example.com/application/o/authorize/?client_id=...&state=abc123",
"state": "abc123"
}
}
```
Returns `501` if OAuth is not enabled.
---
#### `POST /auth/callback`
Exchanges an OAuth authorization code for access and refresh tokens. The `code` parameter comes from the OAuth provider's redirect.
**Query parameters:**
| Parameter | Type | Required | Description |
|---|---|---|---|
| `code` | string | yes | Authorization code from OAuth provider |
```bash
curl -X POST "http://localhost:8080/auth/callback?code=AUTH_CODE_HERE"
```
```json
{
"success": true,
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "dGhpcyBpcyBhIHJlZnJl...",
"expires_at": 1739700000,
"token_type": "Bearer",
"user": {
"id": 1,
"username": "alice",
"email": "alice@example.com"
}
}
}
```
---
#### `POST /auth/refresh`
Exchanges a valid refresh token for a new access token.
**Request body:**
```json
{
"refresh_token": "dGhpcyBpcyBhIHJlZnJl..."
}
```
```json
{
"success": true,
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"expires_at": 1739703600,
"token_type": "Bearer"
}
}
```
Returns `401` if the refresh token is invalid or expired.
---
#### `POST /auth/logout`
Revokes a refresh token, preventing further use.
**Request body:**
```json
{
"refresh_token": "dGhpcyBpcyBhIHJlZnJl..."
}
```
```json
{
"success": true,
"data": {
"message": "logged out"
}
}
```
---
### Tasks
#### `GET /tasks`
Lists tasks, either by named report or by filter parameters.
**Query parameters:**
| Parameter | Type | Required | Description |
|---|---|---|---|
| `report` | string | no | Named report (see [Reports](#reports)). Overrides filter params. |
| `status` | string | no | Filter by status: `pending`, `completed`, `deleted`, `recurring` |
| `project` | string | no | Filter by project name |
| `priority` | string | no | Filter by priority: `L`, `D`, `M`, `H` |
| `tag` | string[] | no | Filter by tags (repeat for multiple: `?tag=home&tag=urgent`) |
**With report:**
```bash
curl -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/tasks?report=overdue"
```
```json
{
"success": true,
"data": {
"report": "overdue",
"tasks": [ ... ],
"count": 3
}
}
```
**With filters:**
```bash
curl -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/tasks?status=pending&tag=home&priority=H"
```
```json
{
"success": true,
"data": [
{
"uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"id": 42,
"status": "P",
"description": "Fix leaking faucet",
"project": "house",
"priority": 3,
"created": 1739174400,
"modified": 1739545800,
"start": null,
"end": null,
"due": 1740009600,
"scheduled": null,
"wait": null,
"until": null,
"recurrence_duration": null,
"parent_uuid": null,
"tags": ["home", "urgent"],
"urgency": 12.4
}
]
}
```
---
#### `POST /tasks`
Creates a new task using structured JSON fields.
**Request body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `description` | string | yes | Task description |
| `tags` | string[] | no | Tags to attach |
| `project` | string | no | Project name |
| `priority` | string | no | `L` (low), `D` (default), `M` (medium), `H` (high) |
| `due` | int64 | no | Due date as Unix timestamp (seconds) |
| `scheduled` | int64 | no | Scheduled date as Unix timestamp |
| `wait` | int64 | no | Wait-until date as Unix timestamp |
| `until` | int64 | no | Expiration date as Unix timestamp |
| `recurrence` | string | no | Recurrence interval (e.g., `1d`, `2w`, `3m`, `1y`) |
```bash
curl -X POST http://localhost:8080/tasks \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"description": "Buy groceries",
"tags": ["personal", "errands"],
"project": "household",
"priority": "M",
"due": 1739836800
}'
```
**Response** (`201 Created`):
```json
{
"success": true,
"data": {
"uuid": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"id": 43,
"status": "P",
"description": "Buy groceries",
"project": "household",
"priority": 2,
"created": 1739750400,
"modified": 1739750400,
"due": 1739836800,
"tags": ["personal", "errands"],
"urgency": 6.1
}
}
```
---
#### `POST /tasks/parse`
Creates a task from a CLI-style input string. Supports the same syntax as the `opal add` command: words without special prefixes become the description, `+tag` adds tags, `-tag` removes tags, and `key:value` pairs set attributes.
**Request body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `input` | string | yes | CLI-style task input |
**Modifier syntax:**
| Syntax | Meaning | Example |
|---|---|---|
| `+tag` | Add tag | `+home` |
| `-tag` | Remove tag | `-garden` |
| `project:name` | Set project | `project:household` |
| `priority:X` | Set priority | `priority:H` |
| `due:value` | Set due date | `due:tomorrow`, `due:monday`, `due:2026-03-01` |
| `scheduled:value` | Set scheduled date | `scheduled:nextweek` |
| `wait:value` | Set wait date | `wait:friday` |
| `until:value` | Set expiration | `until:eom` |
| `recur:interval` | Set recurrence | `recur:1w`, `recur:2d` |
```bash
curl -X POST http://localhost:8080/tasks/parse \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"input": "Change bed sheets +home project:household due:sunday recur:1w"}'
```
**Response** (`201 Created`):
```json
{
"success": true,
"data": {
"task": {
"uuid": "c9bf9e57-1685-4c89-bafb-ff5af830be8a",
"id": 44,
"status": "P",
"description": "Change bed sheets",
"project": "household",
"priority": 1,
"created": 1739750400,
"modified": 1739750400,
"due": 1739836800,
"recurrence_duration": 604800,
"parent_uuid": "d4e5f6a7-b8c9-0123-4567-890abcdef012",
"tags": ["home"],
"urgency": 5.3
}
}
}
```
For recurring tasks (those with `recur:`), the API creates a template task (status `"R"`) and returns the first instance.
---
#### `GET /tasks/{uuid}`
Returns a single task by its UUID.
```bash
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/tasks/a1b2c3d4-e5f6-7890-abcd-ef1234567890
```
```json
{
"success": true,
"data": {
"uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"id": 42,
"status": "P",
"description": "Fix leaking faucet",
"project": "house",
"priority": 3,
"created": 1739174400,
"modified": 1739545800,
"due": 1740009600,
"tags": ["home", "urgent"],
"urgency": 12.4
}
}
```
Returns `404` if the UUID does not match any task.
---
#### `PUT /tasks/{uuid}`
Updates one or more fields on an existing task. Only include the fields you want to change.
**Request body:**
| Field | Type | Description |
|---|---|---|
| `description` | string | New description |
| `status` | string | New status: `pending`, `completed`, `deleted` |
| `priority` | string | `L`, `D`, `M`, `H` |
| `project` | string | Project name |
| `due` | int64 | Unix timestamp (seconds) |
| `scheduled` | int64 | Unix timestamp |
| `wait` | int64 | Unix timestamp |
| `until` | int64 | Unix timestamp |
| `start` | int64 | Unix timestamp |
| `recurrence` | string | Recurrence interval |
All fields are optional.
```bash
curl -X PUT http://localhost:8080/tasks/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"priority": "H", "due": 1739923200}'
```
**Response:**
```json
{
"success": true,
"data": {
"uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"id": 42,
"status": "P",
"description": "Fix leaking faucet",
"project": "house",
"priority": 3,
"created": 1739174400,
"modified": 1739750400,
"due": 1739923200,
"tags": ["home", "urgent"],
"urgency": 14.7
}
}
```
---
#### `DELETE /tasks/{uuid}`
Deletes a task. By default, sets the task status to `"D"` (soft delete). Pass `permanent=true` to remove it from the database entirely.
**Query parameters:**
| Parameter | Type | Default | Description |
|---|---|---|---|
| `permanent` | string | `false` | Set to `true` for permanent deletion |
```bash
# Soft delete
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/tasks/a1b2c3d4-e5f6-7890-abcd-ef1234567890
# Permanent delete
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/tasks/a1b2c3d4-e5f6-7890-abcd-ef1234567890?permanent=true"
```
```json
{
"success": true,
"data": {
"message": "task deleted"
}
}
```
---
#### `POST /tasks/{uuid}/complete`
Marks a task as completed. Sets the status to `"C"` and records the completion time. For recurring task instances, this may trigger creation of the next instance.
```bash
curl -X POST -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/tasks/a1b2c3d4-e5f6-7890-abcd-ef1234567890/complete
```
**Response:** The updated task object with `"status": "C"`.
---
#### `POST /tasks/{uuid}/start`
Marks a task as actively being worked on by setting its `start` timestamp to now.
```bash
curl -X POST -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/tasks/a1b2c3d4-e5f6-7890-abcd-ef1234567890/start
```
**Response:** The updated task object with `start` set to the current unix timestamp.
---
#### `POST /tasks/{uuid}/stop`
Clears the `start` timestamp, marking the task as no longer actively being worked on.
```bash
curl -X POST -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/tasks/a1b2c3d4-e5f6-7890-abcd-ef1234567890/stop
```
**Response:** The updated task object with `start` cleared to `null`.
---
### Task Tags
#### `GET /tasks/{uuid}/tags`
Returns the tag list for a specific task.
```bash
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/tasks/a1b2c3d4-e5f6-7890-abcd-ef1234567890/tags
```
```json
{
"success": true,
"data": ["home", "urgent"]
}
```
---
#### `POST /tasks/{uuid}/tags`
Adds a tag to a task.
**Request body:**
```json
{
"tag": "important"
}
```
**Response:** The updated task object.
---
#### `DELETE /tasks/{uuid}/tags/{tag}`
Removes a tag from a task.
```bash
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/tasks/a1b2c3d4-e5f6-7890-abcd-ef1234567890/tags/urgent
```
**Response:** The updated task object.
---
### Tags
#### `GET /tags`
Returns all tags used across all tasks.
```bash
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/tags
```
```json
{
"success": true,
"data": ["errands", "home", "important", "personal", "urgent", "work"]
}
```
---
### Projects
#### `GET /projects`
Returns all project names used across all tasks.
```bash
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/projects
```
```json
{
"success": true,
"data": ["household", "work", "garden"]
}
```
---
### Sync
These endpoints power the multi-device sync protocol. The CLI client uses them via `opal sync` commands.
#### `POST /sync/changes`
Returns all changes recorded since a given timestamp. Used by clients to pull updates from the server.
**Request body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `since` | int64 | yes | Unix timestamp (seconds). Return changes after this time. Use `0` for initial sync. |
| `client_id` | string | yes | Unique identifier for the syncing device |
```bash
curl -X POST http://localhost:8080/sync/changes \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"since": 1739600000, "client_id": "phone-abc123"}'
```
```json
{
"success": true,
"data": [
{
"id": 101,
"task_uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"change_type": "create",
"changed_at": 1739650000,
"data": "description:Buy groceries\nstatus:80\npriority:2"
},
{
"id": 102,
"task_uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"change_type": "update",
"changed_at": 1739660000,
"data": "status:67"
}
]
}
```
The `change_type` is one of `create`, `update`, or `delete`. The `data` field uses a `key:value` format with newline separators, recorded by database triggers on every task mutation. Note that change log data uses raw database values (integer status codes), not the API's serialized format.
---
#### `POST /sync/push`
Pushes local task changes to the server. Conflicts are resolved with **last-write-wins** based on the `modified` timestamp.
**Request body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `tasks` | Task[] | yes | Array of full task objects to push |
| `client_id` | string | yes | Unique identifier for the syncing device |
```bash
curl -X POST http://localhost:8080/sync/push \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"client_id": "phone-abc123",
"tasks": [
{
"uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "P",
"description": "Buy groceries",
"priority": 2,
"created": 1739600000,
"modified": 1739650000,
"tags": ["personal"]
}
]
}'
```
```json
{
"success": true,
"data": {
"processed": 1,
"conflicts": 0
}
}
```
- **processed** — number of tasks successfully applied
- **conflicts** — number of tasks where the server had a newer version (still applied via last-write-wins)
---
### API Keys
#### `GET /auth/keys`
Lists all API keys for the current user. The key value itself is not returned (it is only shown once at creation time).
```bash
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/auth/keys
```
```json
{
"success": true,
"data": [
{
"id": 1,
"name": "My Phone",
"user_id": 1,
"created_at": 1736935200,
"last_used": 1739558400,
"revoked": false
}
]
}
```
---
#### `DELETE /auth/keys/{id}`
Revokes an API key. The key becomes immediately unusable.
**URL parameters:**
| Parameter | Type | Description |
|---|---|---|
| `id` | int | API key ID |
```bash
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/auth/keys/1
```
```json
{
"success": true,
"data": {
"message": "API key revoked"
}
}
```
---
## Data Models
### Task
| Field | Type | Description |
|---|---|---|
| `uuid` | string | Unique identifier (UUID v4) |
| `id` | int | Auto-increment database ID |
| `status` | string | `"P"` (pending), `"C"` (completed), `"D"` (deleted), `"R"` (recurring) |
| `description` | string | Task description |
| `project` | string \| null | Project name |
| `priority` | int | `0` = Low, `1` = Default, `2` = Medium, `3` = High |
| `created` | int | Unix timestamp (seconds) |
| `modified` | int | Unix timestamp (seconds) |
| `start` | int \| null | When the task was started (actively being worked on) |
| `end` | int \| null | When the task was completed or deleted |
| `due` | int \| null | Deadline |
| `scheduled` | int \| null | Earliest date the task is actionable |
| `wait` | int \| null | Task is hidden until this date |
| `until` | int \| null | Task auto-deletes after this date |
| `recurrence_duration` | int \| null | Recurrence interval in seconds (e.g., `604800` = 1 week) |
| `parent_uuid` | string \| null | UUID of the recurring template task |
| `tags` | string[] | Attached tags |
| `urgency` | float | Computed urgency score (higher = more urgent) |
#### Status Values
| Value | Meaning |
|---|---|
| `"P"` | Pending — active, not yet completed |
| `"C"` | Completed |
| `"D"` | Deleted (soft delete) |
| `"R"` | Recurring template |
#### Priority Values
| API Input | Numeric Value | Meaning |
|---|---|---|
| `L` | `0` | Low |
| `D` | `1` | Default |
| `M` | `2` | Medium |
| `H` | `3` | High |
Use the letter codes (`L`, `D`, `M`, `H`) when creating or updating tasks. Responses return the numeric value.
---
## Reports
Named reports return pre-filtered, pre-sorted task lists. Pass the report name as `?report=<name>` on `GET /tasks`.
| Report | Description |
|---|---|
| `active` | Tasks that have been started (pending with a `start` time) |
| `all` | All tasks including recurring templates |
| `completed` | Completed tasks |
| `list` | Pending tasks (default view) |
| `minimal` | Minimal output view |
| `newest` | Pending tasks sorted newest first |
| `next` | Next task due |
| `oldest` | Pending tasks sorted oldest first |
| `overdue` | Tasks past their due date |
| `ready` | Tasks ready to work on (past scheduled date, not waiting) |
| `recurring` | Recurring template tasks |
| `template` | Alias for `recurring` |
| `waiting` | Tasks with a future `wait` date |
---
## CORS
The API allows cross-origin requests:
- **Origins:** `*`
- **Methods:** `GET`, `POST`, `PUT`, `DELETE`, `OPTIONS`
- **Headers:** `Content-Type`, `Authorization`
---
## Running the Server
```bash
# Build
go build -o opal main.go
# Generate an API key
./opal server keygen --name "My Device" --db /path/to/opal.db
# Start the server
./opal server start --addr :8080 --db /path/to/opal.db
```
### Environment Variables
| Variable | Default | Description |
|---|---|---|
| `OAUTH_ENABLED` | `false` | Enable OAuth authentication |
| `OAUTH_CLIENT_ID` | — | OAuth client ID |
| `OAUTH_CLIENT_SECRET` | — | OAuth client secret |
| `OAUTH_ISSUER` | — | OAuth issuer URL |
| `OAUTH_REDIRECT_URI` | — | OAuth redirect URI |
| `JWT_SECRET` | — | Secret for signing JWTs |
| `JWT_EXPIRY` | `3600` | JWT lifetime in seconds |
| `REFRESH_TOKEN_EXPIRY` | `604800` | Refresh token lifetime in seconds (default 7 days) |
| `OPAL_DB_PATH` | XDG data dir | Override database file path |
| `OPAL_CONFIG_DIR` | `~/.config/opal` | Config directory |
| `OPAL_DATA_DIR` | `~/.local/share/opal` | Data directory |
+16 -1
View File
@@ -39,7 +39,22 @@ func GetLoginURL(w http.ResponseWriter, r *http.Request) {
// OAuthCallback handles the OAuth callback // OAuthCallback handles the OAuth callback
func OAuthCallback(w http.ResponseWriter, r *http.Request) { func OAuthCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code") var code string
// Support both GET (direct OAuth redirect) and POST (frontend exchange)
if r.Method == http.MethodPost {
var req struct {
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorResponse(w, http.StatusBadRequest, "invalid request body")
return
}
code = req.Code
} else {
code = r.URL.Query().Get("code")
}
if code == "" { if code == "" {
errorResponse(w, http.StatusBadRequest, "missing code parameter") errorResponse(w, http.StatusBadRequest, "missing code parameter")
return return
+7 -2
View File
@@ -104,6 +104,8 @@ func PushChanges(w http.ResponseWriter, r *http.Request) {
if err := task.Save(); err != nil { if err := task.Save(); err != nil {
continue continue
} }
// Mark as sync-originated to prevent feedback loop
_ = engine.MarkChangeLogAsSync(task.UUID.String())
// Add tags // Add tags
for _, tag := range task.Tags { for _, tag := range task.Tags {
_ = task.AddTag(tag) _ = task.AddTag(tag)
@@ -114,15 +116,18 @@ func PushChanges(w http.ResponseWriter, r *http.Request) {
// Task exists - check timestamps for conflicts // Task exists - check timestamps for conflicts
if existing.Modified.Unix() > task.Modified.Unix() { if existing.Modified.Unix() > task.Modified.Unix() {
// Server version is newer - conflict (but we'll apply last-write-wins) // Server version is newer - skip this push
conflicts++ conflicts++
continue
} }
// Apply changes (last-write-wins) // Apply changes (client is newer or equal)
task.ID = existing.ID // Preserve database ID task.ID = existing.ID // Preserve database ID
if err := task.Save(); err != nil { if err := task.Save(); err != nil {
continue continue
} }
// Mark as sync-originated to prevent feedback loop
_ = engine.MarkChangeLogAsSync(task.UUID.String())
// Sync tags // Sync tags
existingTags := make(map[string]bool) existingTags := make(map[string]bool)
+36 -17
View File
@@ -49,6 +49,9 @@ func ListTasks(w http.ResponseWriter, r *http.Request) {
return return
} }
// Report sorts may already populate urgency, but ensure it for all paths
engine.PopulateUrgency(tasks...)
jsonResponse(w, http.StatusOK, map[string]interface{}{ jsonResponse(w, http.StatusOK, map[string]interface{}{
"report": reportName, "report": reportName,
"tasks": tasks, "tasks": tasks,
@@ -80,6 +83,11 @@ func ListTasks(w http.ResponseWriter, r *http.Request) {
filter.IncludeTags = tags filter.IncludeTags = tags
} }
// Exclude tag filters
if excludeTags := query["exclude_tag"]; len(excludeTags) > 0 {
filter.ExcludeTags = excludeTags
}
// Get tasks // Get tasks
tasks, err := engine.GetTasks(filter) tasks, err := engine.GetTasks(filter)
if err != nil { if err != nil {
@@ -87,6 +95,7 @@ func ListTasks(w http.ResponseWriter, r *http.Request) {
return return
} }
engine.PopulateUrgency(tasks...)
jsonResponse(w, http.StatusOK, tasks) jsonResponse(w, http.StatusOK, tasks)
} }
@@ -121,35 +130,35 @@ func CreateTask(w http.ResponseWriter, r *http.Request) {
mod.AddTags = req.Tags mod.AddTags = req.Tags
if req.Project != nil { if req.Project != nil {
mod.SetAttributes["project"] = req.Project mod.Set("project", req.Project)
} }
if req.Priority != nil { if req.Priority != nil {
mod.SetAttributes["priority"] = req.Priority mod.Set("priority", req.Priority)
} }
if req.Due != nil { if req.Due != nil {
dueStr := fmt.Sprintf("%d", *req.Due) dueStr := fmt.Sprintf("%d", *req.Due)
mod.SetAttributes["due"] = &dueStr mod.Set("due", &dueStr)
} }
if req.Scheduled != nil { if req.Scheduled != nil {
scheduledStr := fmt.Sprintf("%d", *req.Scheduled) scheduledStr := fmt.Sprintf("%d", *req.Scheduled)
mod.SetAttributes["scheduled"] = &scheduledStr mod.Set("scheduled", &scheduledStr)
} }
if req.Wait != nil { if req.Wait != nil {
waitStr := fmt.Sprintf("%d", *req.Wait) waitStr := fmt.Sprintf("%d", *req.Wait)
mod.SetAttributes["wait"] = &waitStr mod.Set("wait", &waitStr)
} }
if req.Until != nil { if req.Until != nil {
untilStr := fmt.Sprintf("%d", *req.Until) untilStr := fmt.Sprintf("%d", *req.Until)
mod.SetAttributes["until"] = &untilStr mod.Set("until", &untilStr)
} }
if req.Recurrence != nil { if req.Recurrence != nil {
mod.SetAttributes["recurrence"] = req.Recurrence mod.Set("recur", req.Recurrence)
} }
// Create task // Create task
@@ -159,6 +168,7 @@ func CreateTask(w http.ResponseWriter, r *http.Request) {
return return
} }
engine.PopulateUrgency(task)
jsonResponse(w, http.StatusCreated, task) jsonResponse(w, http.StatusCreated, task)
} }
@@ -178,6 +188,7 @@ func GetTask(w http.ResponseWriter, r *http.Request) {
return return
} }
engine.PopulateUrgency(task)
jsonResponse(w, http.StatusOK, task) jsonResponse(w, http.StatusOK, task)
} }
@@ -221,39 +232,39 @@ func UpdateTask(w http.ResponseWriter, r *http.Request) {
mod := engine.NewModifier() mod := engine.NewModifier()
if req.Description != nil { if req.Description != nil {
mod.SetAttributes["description"] = req.Description mod.Set("description", req.Description)
} }
if req.Status != nil { if req.Status != nil {
mod.SetAttributes["status"] = req.Status mod.Set("status", req.Status)
} }
if req.Priority != nil { if req.Priority != nil {
mod.SetAttributes["priority"] = req.Priority mod.Set("priority", req.Priority)
} }
if req.Project != nil { if req.Project != nil {
mod.SetAttributes["project"] = req.Project mod.Set("project", req.Project)
} }
if req.Due != nil { if req.Due != nil {
dueStr := fmt.Sprintf("%d", *req.Due) dueStr := fmt.Sprintf("%d", *req.Due)
mod.SetAttributes["due"] = &dueStr mod.Set("due", &dueStr)
} }
if req.Scheduled != nil { if req.Scheduled != nil {
scheduledStr := fmt.Sprintf("%d", *req.Scheduled) scheduledStr := fmt.Sprintf("%d", *req.Scheduled)
mod.SetAttributes["scheduled"] = &scheduledStr mod.Set("scheduled", &scheduledStr)
} }
if req.Wait != nil { if req.Wait != nil {
waitStr := fmt.Sprintf("%d", *req.Wait) waitStr := fmt.Sprintf("%d", *req.Wait)
mod.SetAttributes["wait"] = &waitStr mod.Set("wait", &waitStr)
} }
if req.Until != nil { if req.Until != nil {
untilStr := fmt.Sprintf("%d", *req.Until) untilStr := fmt.Sprintf("%d", *req.Until)
mod.SetAttributes["until"] = &untilStr mod.Set("until", &untilStr)
} }
if req.Start != nil { if req.Start != nil {
@@ -262,7 +273,7 @@ func UpdateTask(w http.ResponseWriter, r *http.Request) {
} }
if req.Recurrence != nil { if req.Recurrence != nil {
mod.SetAttributes["recurrence"] = req.Recurrence mod.Set("recur", req.Recurrence)
} }
// Apply modifier // Apply modifier
@@ -271,6 +282,7 @@ func UpdateTask(w http.ResponseWriter, r *http.Request) {
return return
} }
engine.PopulateUrgency(task)
jsonResponse(w, http.StatusOK, task) jsonResponse(w, http.StatusOK, task)
} }
@@ -317,11 +329,12 @@ func CompleteTask(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := task.Complete(); err != nil { if _, err := task.Complete(); err != nil {
errorResponse(w, http.StatusInternalServerError, err.Error()) errorResponse(w, http.StatusInternalServerError, err.Error())
return return
} }
engine.PopulateUrgency(task)
jsonResponse(w, http.StatusOK, task) jsonResponse(w, http.StatusOK, task)
} }
@@ -346,6 +359,7 @@ func StartTask(w http.ResponseWriter, r *http.Request) {
return return
} }
engine.PopulateUrgency(task)
jsonResponse(w, http.StatusOK, task) jsonResponse(w, http.StatusOK, task)
} }
@@ -370,6 +384,7 @@ func StopTask(w http.ResponseWriter, r *http.Request) {
return return
} }
engine.PopulateUrgency(task)
jsonResponse(w, http.StatusOK, task) jsonResponse(w, http.StatusOK, task)
} }
@@ -429,6 +444,7 @@ func AddTaskTag(w http.ResponseWriter, r *http.Request) {
return return
} }
engine.PopulateUrgency(task)
jsonResponse(w, http.StatusOK, task) jsonResponse(w, http.StatusOK, task)
} }
@@ -489,6 +505,7 @@ func ParseTask(w http.ResponseWriter, r *http.Request) {
errorResponse(w, http.StatusBadRequest, err.Error()) errorResponse(w, http.StatusBadRequest, err.Error())
return return
} }
engine.PopulateUrgency(instance)
jsonResponse(w, http.StatusCreated, map[string]interface{}{"task": instance}) jsonResponse(w, http.StatusCreated, map[string]interface{}{"task": instance})
return return
} }
@@ -500,6 +517,7 @@ func ParseTask(w http.ResponseWriter, r *http.Request) {
return return
} }
engine.PopulateUrgency(task)
jsonResponse(w, http.StatusCreated, map[string]interface{}{"task": task}) jsonResponse(w, http.StatusCreated, map[string]interface{}{"task": task})
} }
@@ -525,5 +543,6 @@ func RemoveTaskTag(w http.ResponseWriter, r *http.Request) {
return return
} }
engine.PopulateUrgency(task)
jsonResponse(w, http.StatusOK, task) jsonResponse(w, http.StatusOK, task)
} }
+15 -9
View File
@@ -57,8 +57,14 @@ func TestParseTask_DescriptionOnly(t *testing.T) {
if !ok { if !ok {
t.Fatal("expected task in data") t.Fatal("expected task in data")
} }
if task["Description"] != "buy groceries" { if task["description"] != "buy groceries" {
t.Errorf("expected description 'buy groceries', got %v", task["Description"]) t.Errorf("expected description 'buy groceries', got %v", task["description"])
}
if _, ok := task["urgency"]; !ok {
t.Error("expected urgency field in response")
}
if _, ok := task["urgency"].(float64); !ok {
t.Error("expected urgency to be a number")
} }
} }
@@ -81,11 +87,11 @@ func TestParseTask_WithModifiers(t *testing.T) {
data := resp["data"].(map[string]interface{}) data := resp["data"].(map[string]interface{})
task := data["task"].(map[string]interface{}) task := data["task"].(map[string]interface{})
if task["Description"] != "review PR" { if task["description"] != "review PR" {
t.Errorf("expected description 'review PR', got %v", task["Description"]) t.Errorf("expected description 'review PR', got %v", task["description"])
} }
if task["Project"] != "backend" { if task["project"] != "backend" {
t.Errorf("expected project 'backend', got %v", task["Project"]) t.Errorf("expected project 'backend', got %v", task["project"])
} }
} }
@@ -109,9 +115,9 @@ func TestParseTask_WithRecurrence(t *testing.T) {
data := resp["data"].(map[string]interface{}) data := resp["data"].(map[string]interface{})
task := data["task"].(map[string]interface{}) task := data["task"].(map[string]interface{})
// The returned task should be the first instance (pending, with ParentUUID) // The returned task should be the first instance (pending, with parent_uuid)
if task["ParentUUID"] == nil { if task["parent_uuid"] == nil {
t.Error("expected ParentUUID to be set for recurring instance") t.Error("expected parent_uuid to be set for recurring instance")
} }
} }
+10
View File
@@ -73,6 +73,16 @@ func GetUserID(r *http.Request) int {
return userID return userID
} }
// DevAuthMiddleware always injects userID=1 into context — for local dev only
func DevAuthMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), userIDKey, 1)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// CORSMiddleware adds CORS headers for future web frontend // CORSMiddleware adds CORS headers for future web frontend
func CORSMiddleware() func(http.Handler) http.Handler { func CORSMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
+25 -1
View File
@@ -13,13 +13,15 @@ import (
type Server struct { type Server struct {
router chi.Router router chi.Router
addr string addr string
devMode bool
} }
// NewServer creates a new API server // NewServer creates a new API server
func NewServer(addr string) *Server { func NewServer(addr string, devMode bool) *Server {
s := &Server{ s := &Server{
router: chi.NewRouter(), router: chi.NewRouter(),
addr: addr, addr: addr,
devMode: devMode,
} }
s.setupRoutes() s.setupRoutes()
return s return s
@@ -39,15 +41,37 @@ func (s *Server) setupRoutes() {
JSON(w, http.StatusOK, map[string]string{"status": "ok"}) JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}) })
if s.devMode {
// Dev mode: fake session endpoint so the frontend can "log in"
r.Get("/auth/dev-session", func(w http.ResponseWriter, r *http.Request) {
JSON(w, http.StatusOK, map[string]interface{}{
"access_token": "dev-token",
"refresh_token": "",
"expires_at": 9999999999,
"token_type": "bearer",
"user": map[string]interface{}{
"id": 1,
"username": "dev",
"email": "dev@localhost",
},
})
})
} else {
// OAuth endpoints (no auth required) // OAuth endpoints (no auth required)
r.Get("/auth/login", handlers.GetLoginURL) r.Get("/auth/login", handlers.GetLoginURL)
r.Get("/auth/callback", handlers.OAuthCallback)
r.Post("/auth/callback", handlers.OAuthCallback) r.Post("/auth/callback", handlers.OAuthCallback)
r.Post("/auth/refresh", handlers.RefreshToken) r.Post("/auth/refresh", handlers.RefreshToken)
r.Post("/auth/logout", handlers.Logout) r.Post("/auth/logout", handlers.Logout)
}
// Protected routes // Protected routes
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
if s.devMode {
r.Use(DevAuthMiddleware())
} else {
r.Use(AuthMiddleware()) r.Use(AuthMiddleware())
}
// Tasks // Tasks
r.Route("/tasks", func(r chi.Router) { r.Route("/tasks", func(r chi.Router) {
+16 -3
View File
@@ -6,10 +6,23 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"path"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
// issuerBase resolves ".." to get the base OAuth path from the issuer URL.
// e.g. "https://auth.example.com/application/o/app/" -> "https://auth.example.com/application/o/"
func issuerBase(issuer string) string {
u, err := url.Parse(issuer)
if err != nil {
return issuer
}
u.Path = path.Dir(path.Clean(u.Path)) + "/"
return u.String()
}
type OAuthClient struct { type OAuthClient struct {
config *oauth2.Config config *oauth2.Config
cfg *Config cfg *Config
@@ -22,8 +35,8 @@ func NewOAuthClient(cfg *Config) *OAuthClient {
ClientSecret: cfg.OAuthClientSecret, ClientSecret: cfg.OAuthClientSecret,
RedirectURL: cfg.OAuthRedirectURI, RedirectURL: cfg.OAuthRedirectURI,
Endpoint: oauth2.Endpoint{ Endpoint: oauth2.Endpoint{
AuthURL: cfg.OAuthIssuer + "../authorize/", AuthURL: issuerBase(cfg.OAuthIssuer) + "authorize/",
TokenURL: cfg.OAuthIssuer + "../token/", TokenURL: issuerBase(cfg.OAuthIssuer) + "token/",
}, },
Scopes: []string{"openid", "profile", "email"}, Scopes: []string{"openid", "profile", "email"},
}, },
@@ -47,7 +60,7 @@ type UserInfo struct {
} }
func (c *OAuthClient) GetUserInfo(ctx context.Context, accessToken string) (*UserInfo, error) { func (c *OAuthClient) GetUserInfo(ctx context.Context, accessToken string) (*UserInfo, error) {
req, err := http.NewRequestWithContext(ctx, "GET", c.cfg.OAuthIssuer+"../userinfo/", nil) req, err := http.NewRequestWithContext(ctx, "GET", issuerBase(c.cfg.OAuthIssuer)+"userinfo/", nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+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")
}
}
+64 -6
View File
@@ -3,6 +3,7 @@ package engine
import ( import (
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"encoding/json"
"fmt" "fmt"
"time" "time"
@@ -11,12 +12,69 @@ import (
// APIKey represents an API key in the database // APIKey represents an API key in the database
type APIKey struct { type APIKey struct {
ID int ID int `json:"id"`
Name string Name string `json:"name"`
UserID int UserID int `json:"user_id"`
CreatedAt time.Time CreatedAt time.Time `json:"created_at"`
LastUsed *time.Time LastUsed *time.Time `json:"last_used,omitempty"`
Revoked bool Revoked bool `json:"revoked"`
}
// MarshalJSON emits APIKey with unix timestamps.
func (k APIKey) MarshalJSON() ([]byte, error) {
type keyJSON struct {
ID int `json:"id"`
Name string `json:"name"`
UserID int `json:"user_id"`
CreatedAt int64 `json:"created_at"`
LastUsed *int64 `json:"last_used,omitempty"`
Revoked bool `json:"revoked"`
}
var lastUsed *int64
if k.LastUsed != nil {
v := k.LastUsed.Unix()
lastUsed = &v
}
return json.Marshal(keyJSON{
ID: k.ID,
Name: k.Name,
UserID: k.UserID,
CreatedAt: k.CreatedAt.Unix(),
LastUsed: lastUsed,
Revoked: k.Revoked,
})
}
// UnmarshalJSON parses APIKey from JSON with unix timestamps.
func (k *APIKey) UnmarshalJSON(data []byte) error {
type keyJSON struct {
ID int `json:"id"`
Name string `json:"name"`
UserID int `json:"user_id"`
CreatedAt int64 `json:"created_at"`
LastUsed *int64 `json:"last_used,omitempty"`
Revoked bool `json:"revoked"`
}
var raw keyJSON
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
k.ID = raw.ID
k.Name = raw.Name
k.UserID = raw.UserID
k.CreatedAt = time.Unix(raw.CreatedAt, 0)
k.Revoked = raw.Revoked
if raw.LastUsed != nil {
t := time.Unix(*raw.LastUsed, 0)
k.LastUsed = &t
}
return nil
} }
// GenerateAPIKey creates a new API key for the given name // GenerateAPIKey creates a new API key for the given name
+141 -122
View File
@@ -8,39 +8,40 @@ import (
"time" "time"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.yaml.in/yaml/v3"
) )
type Config struct { type Config struct {
DefaultFilter string `mapstructure:"default_filter"` DefaultFilter string `mapstructure:"default_filter" yaml:"default_filter"`
DefaultSort string `mapstructure:"default_sort"` DefaultSort string `mapstructure:"default_sort" yaml:"default_sort"`
DefaultReport string `mapstructure:"default_report"` DefaultReport string `mapstructure:"default_report" yaml:"default_report"`
ColorOutput bool `mapstructure:"color_output"` ColorOutput bool `mapstructure:"color_output" yaml:"color_output"`
WeekStartDay string `mapstructure:"week_start_day"` WeekStartDay string `mapstructure:"week_start_day" yaml:"week_start_day"`
DefaultDueTime string `mapstructure:"default_due_time"` DefaultDueTime string `mapstructure:"default_due_time" yaml:"default_due_time"`
// Urgency coefficients // Urgency coefficients
UrgencyDue float64 `mapstructure:"urgency_due_coefficient"` UrgencyDue float64 `mapstructure:"urgency_due_coefficient" yaml:"urgency_due_coefficient"`
UrgencyPriorityH float64 `mapstructure:"urgency_priority_h_coefficient"` UrgencyPriorityH float64 `mapstructure:"urgency_priority_h_coefficient" yaml:"urgency_priority_h_coefficient"`
UrgencyPriorityM float64 `mapstructure:"urgency_priority_m_coefficient"` UrgencyPriorityM float64 `mapstructure:"urgency_priority_m_coefficient" yaml:"urgency_priority_m_coefficient"`
UrgencyPriorityD float64 `mapstructure:"urgency_priority_d_coefficient"` UrgencyPriorityD float64 `mapstructure:"urgency_priority_d_coefficient" yaml:"urgency_priority_d_coefficient"`
UrgencyPriorityL float64 `mapstructure:"urgency_priority_l_coefficient"` UrgencyPriorityL float64 `mapstructure:"urgency_priority_l_coefficient" yaml:"urgency_priority_l_coefficient"`
UrgencyActive float64 `mapstructure:"urgency_active_coefficient"` UrgencyActive float64 `mapstructure:"urgency_active_coefficient" yaml:"urgency_active_coefficient"`
UrgencyAge float64 `mapstructure:"urgency_age_coefficient"` UrgencyAge float64 `mapstructure:"urgency_age_coefficient" yaml:"urgency_age_coefficient"`
UrgencyAgeMax int `mapstructure:"urgency_age_max"` UrgencyAgeMax int `mapstructure:"urgency_age_max" yaml:"urgency_age_max"`
UrgencyTags float64 `mapstructure:"urgency_tags_coefficient"` UrgencyTags float64 `mapstructure:"urgency_tags_coefficient" yaml:"urgency_tags_coefficient"`
UrgencyProject float64 `mapstructure:"urgency_project_coefficient"` UrgencyProject float64 `mapstructure:"urgency_project_coefficient" yaml:"urgency_project_coefficient"`
UrgencyWaiting float64 `mapstructure:"urgency_waiting_coefficient"` UrgencyWaiting float64 `mapstructure:"urgency_waiting_coefficient" yaml:"urgency_waiting_coefficient"`
UrgencyUrgentTag string `mapstructure:"urgency_urgent_tag"` UrgencyUrgentTag string `mapstructure:"urgency_urgent_tag" yaml:"urgency_urgent_tag"`
UrgencyUrgentCoeff float64 `mapstructure:"urgency_urgent_coefficient"` UrgencyUrgentCoeff float64 `mapstructure:"urgency_urgent_coefficient" yaml:"urgency_urgent_coefficient"`
NextLimit int `mapstructure:"next_limit"` NextLimit int `mapstructure:"next_limit" yaml:"next_limit"`
// Sync settings // Sync settings
SyncEnabled bool `mapstructure:"sync_enabled"` SyncEnabled bool `mapstructure:"sync_enabled" yaml:"sync_enabled"`
SyncURL string `mapstructure:"sync_url"` SyncURL string `mapstructure:"sync_url" yaml:"sync_url"`
SyncAPIKey string `mapstructure:"sync_api_key"` SyncAPIKey string `mapstructure:"sync_api_key" yaml:"sync_api_key"`
SyncClientID string `mapstructure:"sync_client_id"` SyncClientID string `mapstructure:"sync_client_id" yaml:"sync_client_id"`
SyncStrategy string `mapstructure:"sync_strategy"` SyncStrategy string `mapstructure:"sync_strategy" yaml:"sync_strategy"`
SyncQueueOffline bool `mapstructure:"sync_queue_offline"` SyncQueueOffline bool `mapstructure:"sync_queue_offline" yaml:"sync_queue_offline"`
} }
var globalConfig *Config var globalConfig *Config
@@ -234,77 +235,105 @@ func IsFirstRun() bool {
return !ConfigExists() return !ConfigExists()
} }
// LoadConfig loads the configuration from file or creates default // DefaultConfig returns a Config populated with all default values.
// This is the single source of truth for defaults.
func DefaultConfig() *Config {
return &Config{
DefaultFilter: "status:pending",
DefaultSort: "due,priority",
DefaultReport: "list",
ColorOutput: true,
WeekStartDay: "monday",
DefaultDueTime: "",
UrgencyDue: 12.0,
UrgencyPriorityH: 6.0,
UrgencyPriorityM: 3.9,
UrgencyPriorityD: 1.8,
UrgencyPriorityL: 0.0,
UrgencyActive: 4.0,
UrgencyAge: 2.0,
UrgencyAgeMax: 365,
UrgencyTags: 1.0,
UrgencyProject: 1.0,
UrgencyWaiting: -3.0,
UrgencyUrgentTag: "next",
UrgencyUrgentCoeff: 15.0,
NextLimit: 5,
SyncEnabled: false,
SyncURL: "",
SyncAPIKey: "",
SyncClientID: "",
SyncStrategy: "last-write-wins",
SyncQueueOffline: true,
}
}
// setViperDefaults registers all default values with a Viper instance.
func setViperDefaults(v *viper.Viper) {
d := DefaultConfig()
v.SetDefault("default_filter", d.DefaultFilter)
v.SetDefault("default_sort", d.DefaultSort)
v.SetDefault("default_report", d.DefaultReport)
v.SetDefault("color_output", d.ColorOutput)
v.SetDefault("week_start_day", d.WeekStartDay)
v.SetDefault("default_due_time", d.DefaultDueTime)
v.SetDefault("urgency_due_coefficient", d.UrgencyDue)
v.SetDefault("urgency_priority_h_coefficient", d.UrgencyPriorityH)
v.SetDefault("urgency_priority_m_coefficient", d.UrgencyPriorityM)
v.SetDefault("urgency_priority_d_coefficient", d.UrgencyPriorityD)
v.SetDefault("urgency_priority_l_coefficient", d.UrgencyPriorityL)
v.SetDefault("urgency_active_coefficient", d.UrgencyActive)
v.SetDefault("urgency_age_coefficient", d.UrgencyAge)
v.SetDefault("urgency_age_max", d.UrgencyAgeMax)
v.SetDefault("urgency_tags_coefficient", d.UrgencyTags)
v.SetDefault("urgency_project_coefficient", d.UrgencyProject)
v.SetDefault("urgency_waiting_coefficient", d.UrgencyWaiting)
v.SetDefault("urgency_urgent_tag", d.UrgencyUrgentTag)
v.SetDefault("urgency_urgent_coefficient", d.UrgencyUrgentCoeff)
v.SetDefault("next_limit", d.NextLimit)
v.SetDefault("sync_enabled", d.SyncEnabled)
v.SetDefault("sync_url", d.SyncURL)
v.SetDefault("sync_api_key", d.SyncAPIKey)
v.SetDefault("sync_client_id", d.SyncClientID)
v.SetDefault("sync_strategy", d.SyncStrategy)
v.SetDefault("sync_queue_offline", d.SyncQueueOffline)
}
// LoadConfig loads configuration using layered sources:
// 1. Hardcoded defaults (always present)
// 2. YAML config file (optional, read-only — never created as a side effect)
// 3. Environment variables with OPAL_ prefix (override everything)
//
// Returns a valid *Config even if no config file exists. Never returns nil.
// Returns an error only if the config file exists but is malformed.
func LoadConfig() (*Config, error) { func LoadConfig() (*Config, error) {
if globalConfig != nil { if globalConfig != nil {
return globalConfig, nil return globalConfig, nil
} }
configDir, err := GetConfigDir()
if err != nil {
return nil, err
}
// Ensure config directory exists
if err := os.MkdirAll(configDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create config directory: %w", err)
}
configPath, err := GetConfigPath()
if err != nil {
return nil, err
}
v := viper.New() v := viper.New()
v.SetConfigFile(configPath)
v.SetConfigType("yaml") v.SetConfigType("yaml")
// Set defaults // Layer 1: Hardcoded defaults
v.SetDefault("default_filter", "status:pending") setViperDefaults(v)
v.SetDefault("default_sort", "due,priority")
v.SetDefault("default_report", "list")
v.SetDefault("color_output", true)
v.SetDefault("week_start_day", "monday")
v.SetDefault("default_due_time", "")
// Urgency defaults (adjusted for Opal's simpler model) // Layer 2: YAML config file (optional, read-only)
v.SetDefault("urgency_due_coefficient", 12.0) if configPath, err := GetConfigPath(); err == nil {
v.SetDefault("urgency_priority_h_coefficient", 6.0) v.SetConfigFile(configPath)
v.SetDefault("urgency_priority_m_coefficient", 3.9)
v.SetDefault("urgency_priority_d_coefficient", 1.8)
v.SetDefault("urgency_priority_l_coefficient", 0.0)
v.SetDefault("urgency_active_coefficient", 4.0)
v.SetDefault("urgency_age_coefficient", 2.0)
v.SetDefault("urgency_age_max", 365)
v.SetDefault("urgency_tags_coefficient", 1.0)
v.SetDefault("urgency_project_coefficient", 1.0)
v.SetDefault("urgency_waiting_coefficient", -3.0)
v.SetDefault("urgency_urgent_tag", "next")
v.SetDefault("urgency_urgent_coefficient", 15.0)
v.SetDefault("next_limit", 5)
// Sync defaults
v.SetDefault("sync_enabled", false)
v.SetDefault("sync_url", "")
v.SetDefault("sync_api_key", "")
v.SetDefault("sync_client_id", "")
v.SetDefault("sync_strategy", "last-write-wins")
v.SetDefault("sync_queue_offline", true)
// Try to read existing config
err = v.ReadInConfig()
if err != nil {
// Config doesn't exist, create it with defaults
// Write config with defaults (ignoring the error type check for now)
if err := v.WriteConfigAs(configPath); err != nil {
return nil, fmt.Errorf("failed to create config file: %w", err)
}
// Try reading again
if err := v.ReadInConfig(); err != nil { if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read newly created config: %w", err) // Distinguish "file doesn't exist" from "file is malformed"
if _, statErr := os.Stat(configPath); statErr == nil {
// File exists but couldn't be parsed
return nil, fmt.Errorf("config file %s is invalid: %w", configPath, err)
}
// File doesn't exist — that's fine, defaults apply
} }
} }
// Layer 3: Environment variable overrides (OPAL_DEFAULT_FILTER, etc.)
v.SetEnvPrefix("OPAL")
v.AutomaticEnv()
cfg := &Config{} cfg := &Config{}
if err := v.Unmarshal(cfg); err != nil { if err := v.Unmarshal(cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err) return nil, fmt.Errorf("failed to unmarshal config: %w", err)
@@ -314,52 +343,42 @@ func LoadConfig() (*Config, error) {
return cfg, nil return cfg, nil
} }
// SaveConfig saves the configuration to file // InitConfig creates the config directory and writes a default opal.yml.
// This should be called explicitly during CLI first-run or setup, never as a
// side effect of loading config.
func InitConfig() error {
configDir, err := GetConfigDir()
if err != nil {
return err
}
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
return SaveConfig(DefaultConfig())
}
// SaveConfig writes the configuration to the opal.yml file.
func SaveConfig(cfg *Config) error { func SaveConfig(cfg *Config) error {
configPath, err := GetConfigPath() configPath, err := GetConfigPath()
if err != nil { if err != nil {
return err return err
} }
v := viper.New() data, err := yaml.Marshal(cfg)
v.SetConfigFile(configPath) if err != nil {
v.SetConfigType("yaml") return fmt.Errorf("failed to marshal config: %w", err)
}
v.Set("default_filter", cfg.DefaultFilter) if err := os.WriteFile(configPath, data, 0644); err != nil {
v.Set("default_sort", cfg.DefaultSort) return fmt.Errorf("failed to write config file: %w", err)
v.Set("default_report", cfg.DefaultReport)
v.Set("color_output", cfg.ColorOutput)
v.Set("week_start_day", cfg.WeekStartDay)
v.Set("default_due_time", cfg.DefaultDueTime)
// Urgency settings
v.Set("urgency_due_coefficient", cfg.UrgencyDue)
v.Set("urgency_priority_h_coefficient", cfg.UrgencyPriorityH)
v.Set("urgency_priority_m_coefficient", cfg.UrgencyPriorityM)
v.Set("urgency_priority_d_coefficient", cfg.UrgencyPriorityD)
v.Set("urgency_priority_l_coefficient", cfg.UrgencyPriorityL)
v.Set("urgency_active_coefficient", cfg.UrgencyActive)
v.Set("urgency_age_coefficient", cfg.UrgencyAge)
v.Set("urgency_age_max", cfg.UrgencyAgeMax)
v.Set("urgency_tags_coefficient", cfg.UrgencyTags)
v.Set("urgency_project_coefficient", cfg.UrgencyProject)
v.Set("urgency_waiting_coefficient", cfg.UrgencyWaiting)
v.Set("urgency_urgent_tag", cfg.UrgencyUrgentTag)
v.Set("urgency_urgent_coefficient", cfg.UrgencyUrgentCoeff)
v.Set("next_limit", cfg.NextLimit)
// Sync settings
v.Set("sync_enabled", cfg.SyncEnabled)
v.Set("sync_url", cfg.SyncURL)
v.Set("sync_api_key", cfg.SyncAPIKey)
v.Set("sync_client_id", cfg.SyncClientID)
v.Set("sync_strategy", cfg.SyncStrategy)
v.Set("sync_queue_offline", cfg.SyncQueueOffline)
return v.WriteConfig()
} }
// GetConfig returns the loaded config or loads it if not already loaded // Update the cached singleton
globalConfig = cfg
return nil
}
// GetConfig returns the loaded config or loads it if not already loaded.
// Never returns nil — falls back to defaults if no config file exists.
func GetConfig() (*Config, error) { func GetConfig() (*Config, error) {
if globalConfig != nil { if globalConfig != nil {
return globalConfig, nil return globalConfig, nil
+42 -7
View File
@@ -112,6 +112,7 @@ func runMigrations() error {
recurrence_duration INTEGER, recurrence_duration INTEGER,
parent_uuid TEXT, parent_uuid TEXT,
annotations TEXT DEFAULT NULL,
FOREIGN KEY (parent_uuid) REFERENCES tasks(uuid) ON DELETE CASCADE 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_hash ON refresh_tokens(token_hash);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id); 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 -- Triggers to populate change_log
CREATE TRIGGER track_task_create AFTER INSERT ON tasks CREATE TRIGGER track_task_create AFTER INSERT ON tasks
BEGIN 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.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.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.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 (SELECT CASE WHEN COUNT(*) > 0
THEN 'tags: ' || GROUP_CONCAT(tag, ',') || CHAR(10) THEN 'tags: ' || GROUP_CONCAT(tag, ',') || CHAR(10)
ELSE '' 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.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.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.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 (SELECT CASE WHEN COUNT(*) > 0
THEN 'tags: ' || GROUP_CONCAT(tag, ',') || CHAR(10) THEN 'tags: ' || GROUP_CONCAT(tag, ',') || CHAR(10)
ELSE '' ELSE ''
@@ -295,6 +307,13 @@ func runMigrations() error {
END; END;
`, `,
}, },
{
version: 2,
sql: `
ALTER TABLE change_log ADD COLUMN source TEXT NOT NULL DEFAULT 'local';
CREATE INDEX idx_change_log_source ON change_log(source);
`,
},
} }
// Apply pending migrations // Apply pending migrations
@@ -315,7 +334,7 @@ func runMigrations() error {
if _, err := tx.Exec( if _, err := tx.Exec(
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)", "INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
migration.version, migration.version,
getCurrentTimestamp(), GetCurrentTimestamp(),
); err != nil { ); err != nil {
tx.Rollback() tx.Rollback()
return fmt.Errorf("failed to record migration %d: %w", migration.version, err) return fmt.Errorf("failed to record migration %d: %w", migration.version, err)
@@ -330,14 +349,9 @@ func runMigrations() error {
return nil return nil
} }
// getCurrentTimestamp returns the current Unix timestamp
func getCurrentTimestamp() int64 {
return timeNow().Unix()
}
// GetCurrentTimestamp returns the current Unix timestamp (exported for API use) // GetCurrentTimestamp returns the current Unix timestamp (exported for API use)
func GetCurrentTimestamp() int64 { func GetCurrentTimestamp() int64 {
return getCurrentTimestamp() return timeNow().Unix()
} }
// CleanupChangeLog removes old change log entries based on retention policy // CleanupChangeLog removes old change log entries based on retention policy
@@ -397,3 +411,24 @@ func SetChangeLogRetentionDays(days int) error {
_, err := db.Exec("INSERT OR REPLACE INTO sync_config (key, value) VALUES ('change_log_retention_days', ?)", days) _, err := db.Exec("INSERT OR REPLACE INTO sync_config (key, value) VALUES ('change_log_retention_days', ?)", days)
return err return err
} }
// MarkChangeLogAsSync marks the most recent change_log entry for a task UUID
// as originating from sync (not local), preventing the feedback loop where
// synced changes get re-pushed as local changes.
func MarkChangeLogAsSync(taskUUID string) error {
db := GetDB()
if db == nil {
return fmt.Errorf("database not initialized")
}
_, err := db.Exec(`
UPDATE change_log SET source = 'sync'
WHERE id = (
SELECT id FROM change_log
WHERE task_uuid = ?
ORDER BY id DESC
LIMIT 1
)
`, taskUUID)
return err
}
+18 -33
View File
@@ -7,6 +7,21 @@ import (
"time" "time"
) )
var monthNames = map[string]time.Month{
"jan": time.January, "january": time.January,
"feb": time.February, "february": time.February,
"mar": time.March, "march": time.March,
"apr": time.April, "april": time.April,
"may": time.May,
"jun": time.June, "june": time.June,
"jul": time.July, "july": time.July,
"aug": time.August, "august": time.August,
"sep": time.September, "september": time.September,
"oct": time.October, "october": time.October,
"nov": time.November, "november": time.November,
"dec": time.December, "december": time.December,
}
// DateParser handles all date/time/duration parsing with configurable options // DateParser handles all date/time/duration parsing with configurable options
type DateParser struct { type DateParser struct {
base time.Time base time.Time
@@ -54,7 +69,7 @@ func (p *DateParser) parseDateOnly(s string) (time.Time, error) {
} }
// Try ISO format first // 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 return t, nil
} }
@@ -238,22 +253,7 @@ func (p *DateParser) parseWeekday(s string) (time.Time, bool) {
// parseMonthName handles month names (jan, january, feb, february, etc.) // parseMonthName handles month names (jan, january, feb, february, etc.)
func (p *DateParser) parseMonthName(s string) (time.Time, bool) { func (p *DateParser) parseMonthName(s string) (time.Time, bool) {
months := map[string]time.Month{ month, ok := monthNames[s]
"jan": time.January, "january": time.January,
"feb": time.February, "february": time.February,
"mar": time.March, "march": time.March,
"apr": time.April, "april": time.April,
"may": time.May,
"jun": time.June, "june": time.June,
"jul": time.July, "july": time.July,
"aug": time.August, "august": time.August,
"sep": time.September, "september": time.September,
"oct": time.October, "october": time.October,
"nov": time.November, "november": time.November,
"dec": time.December, "december": time.December,
}
month, ok := months[s]
if !ok { if !ok {
return time.Time{}, false return time.Time{}, false
} }
@@ -316,22 +316,7 @@ func (p *DateParser) parseDayAndMonth(dayStr, monthStr string) (int, time.Month,
return 0, 0, false return 0, 0, false
} }
months := map[string]time.Month{ month, ok := monthNames[monthStr]
"jan": time.January, "january": time.January,
"feb": time.February, "february": time.February,
"mar": time.March, "march": time.March,
"apr": time.April, "april": time.April,
"may": time.May,
"jun": time.June, "june": time.June,
"jul": time.July, "july": time.July,
"aug": time.August, "august": time.August,
"sep": time.September, "september": time.September,
"oct": time.October, "october": time.October,
"nov": time.November, "november": time.November,
"dec": time.December, "december": time.December,
}
month, ok := months[monthStr]
if !ok { if !ok {
return 0, 0, false return 0, 0, false
} }
+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) { func TestExpandedDurationFormats(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+55 -38
View File
@@ -2,6 +2,7 @@ package engine
import ( import (
"fmt" "fmt"
"strings"
"time" "time"
"github.com/fatih/color" "github.com/fatih/color"
@@ -28,15 +29,9 @@ func FormatTaskListWithFormat(tasks []*Task, ws *WorkingSet, format string) stri
if format == "minimal" { if format == "minimal" {
result := "" result := ""
for i, task := range tasks { for i, task := range tasks {
displayID := i + 1 displayID := resolveDisplayID(task, ws)
if ws != nil { if displayID == 0 {
// Use working set display ID if available displayID = i + 1
for id, uuid := range ws.byID {
if uuid == task.UUID {
displayID = id
break
}
}
} }
urgency := task.CalculateUrgency(coeffs) urgency := task.CalculateUrgency(coeffs)
urgencyColor := getUrgencyColor(urgency) urgencyColor := getUrgencyColor(urgency)
@@ -70,15 +65,9 @@ func FormatTaskListWithFormat(tasks []*Task, ws *WorkingSet, format string) stri
// Add rows // Add rows
for i, task := range tasks { for i, task := range tasks {
displayID := i + 1 displayID := resolveDisplayID(task, ws)
if ws != nil { if displayID == 0 {
// Use working set display ID if available displayID = i + 1
for id, uuid := range ws.byID {
if uuid == task.UUID {
displayID = id
break
}
}
} }
urgency := task.CalculateUrgency(coeffs) urgency := task.CalculateUrgency(coeffs)
@@ -143,19 +132,23 @@ func FormatTaskDetail(task *Task) string {
} }
if task.Due != nil { 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 { if task.Scheduled != nil {
t.AppendRow(table.Row{"Scheduled", formatTime(*task.Scheduled)}) t.AppendRow(table.Row{"Scheduled", FormatDateWithRelative(*task.Scheduled)})
} }
if task.Wait != nil { if task.Wait != nil {
t.AppendRow(table.Row{"Wait", formatTime(*task.Wait)}) t.AppendRow(table.Row{"Wait", FormatDateWithRelative(*task.Wait)})
} }
if task.Until != nil { if task.Until != nil {
t.AppendRow(table.Row{"Until", formatTime(*task.Until)}) t.AppendRow(table.Row{"Until", FormatDateWithRelative(*task.Until)})
} }
if task.RecurrenceDuration != nil { if task.RecurrenceDuration != nil {
@@ -171,6 +164,37 @@ func FormatTaskDetail(task *Task) string {
t.AppendRow(table.Row{"Tags", formatTags(task.Tags)}) 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() return t.Render()
} }
@@ -234,8 +258,6 @@ func FormatTagCounts(tagCounts map[string]int) string {
return t.Render() return t.Render()
} }
// Helper functions
func formatStatus(status Status) string { func formatStatus(status Status) string {
switch status { switch status {
case StatusPending: case StatusPending:
@@ -286,7 +308,6 @@ func formatUrgency(urgency float64) string {
} }
func getUrgencyColor(urgency float64) *color.Color { func getUrgencyColor(urgency float64) *color.Color {
// Returns color for minimal format
if urgency >= 10.0 { if urgency >= 10.0 {
return color.New(color.FgHiRed, color.Bold) return color.New(color.FgHiRed, color.Bold)
} else if urgency >= 5.0 { } else if urgency >= 5.0 {
@@ -310,24 +331,20 @@ func formatDue(due *time.Time) string {
return "" return ""
} }
now := time.Now() rel := FormatRelativeDate(*due)
now := timeNow()
if due.Before(now) { if due.Before(now) {
return color.RedString(due.Format("2006-01-02")) return color.RedString(rel)
} }
if due.Before(now.Add(24 * time.Hour)) { today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
return color.YellowString(due.Format("2006-01-02")) 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 {
now := time.Now()
if t.Before(now) {
return color.RedString(t.Format("2006-01-02 15:04"))
}
return t.Format("2006-01-02 15:04")
} }
func formatTime(t time.Time) string { func formatTime(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 -12
View File
@@ -42,17 +42,18 @@ func ParseFilter(args []string) (*Filter, error) {
} else if strings.HasPrefix(arg, "-") && !strings.Contains(arg, ":") { } else if strings.HasPrefix(arg, "-") && !strings.Contains(arg, ":") {
// Exclude tag (but not negative modifiers like priority:-) // Exclude tag (but not negative modifiers like priority:-)
f.ExcludeTags = append(f.ExcludeTags, strings.TrimPrefix(arg, "-")) f.ExcludeTags = append(f.ExcludeTags, strings.TrimPrefix(arg, "-"))
} else if strings.Contains(arg, ":") { } else if idx := strings.Index(arg, ":"); idx > 0 {
// Attribute filter key := arg[:idx]
parts := strings.SplitN(arg, ":", 2) value := arg[idx+1:]
key := parts[0]
value := parts[1]
if key == "uuid" { if FilterOnlyKeys[key] {
// Filter-only keys (e.g., uuid)
f.UUIDs = append(f.UUIDs, value) f.UUIDs = append(f.UUIDs, value)
} else { } else if ValidAttributeKeys[key] {
// Known attribute filter
f.Attributes[key] = value f.Attributes[key] = value
} }
// Unrecognized key:value tokens are silently ignored
} else { } else {
// Try parsing as numeric ID // Try parsing as numeric ID
id, err := strconv.Atoi(arg) id, err := strconv.Atoi(arg)
@@ -70,10 +71,8 @@ func (f *Filter) ToSQL() (string, []interface{}) {
conditions := []string{} conditions := []string{}
args := []interface{}{} args := []interface{}{}
// Track if we have an explicit status filter
hasStatusFilter := false hasStatusFilter := false
// Status filter
if status, ok := f.Attributes["status"]; ok { if status, ok := f.Attributes["status"]; ok {
hasStatusFilter = true hasStatusFilter = true
@@ -103,13 +102,11 @@ func (f *Filter) ToSQL() (string, []interface{}) {
} }
} }
// Project filter
if project, ok := f.Attributes["project"]; ok { if project, ok := f.Attributes["project"]; ok {
conditions = append(conditions, "project = ?") conditions = append(conditions, "project = ?")
args = append(args, project) args = append(args, project)
} }
// Priority filter
if priority, ok := f.Attributes["priority"]; ok { if priority, ok := f.Attributes["priority"]; ok {
priorityInt := priorityStringToInt(priority) priorityInt := priorityStringToInt(priority)
conditions = append(conditions, "priority = ?") conditions = append(conditions, "priority = ?")
@@ -137,7 +134,6 @@ func (f *Filter) ToSQL() (string, []interface{}) {
args = append(args, tag) args = append(args, tag)
} }
// UUID filter
if len(f.UUIDs) > 0 { if len(f.UUIDs) > 0 {
placeholders := strings.Repeat("?,", len(f.UUIDs)) placeholders := strings.Repeat("?,", len(f.UUIDs))
placeholders = placeholders[:len(placeholders)-1] placeholders = placeholders[:len(placeholders)-1]
+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)
}
})
}
}
+31
View File
@@ -0,0 +1,31 @@
package engine
// ValidAttributeKeys are the recognized key:value attribute names.
// Used by parseAddArgs (cmd/add.go), ParseFilter, and ParseModifier
// to distinguish modifiers from description text.
var ValidAttributeKeys = map[string]bool{
"description": true,
"due": true,
"priority": true,
"project": true,
"recur": true,
"status": true,
"wait": true,
"scheduled": true,
"until": true,
}
// DateKeys is the subset of ValidAttributeKeys that hold date values.
// Used by Modifier.Apply and Modifier.ApplyToNew for date parsing.
var DateKeys = map[string]bool{
"due": true,
"scheduled": true,
"wait": true,
"until": true,
}
// FilterOnlyKeys are additional keys valid in filter context but not as
// modifiers (e.g., uuid is a filter target, not something you set via modify).
var FilterOnlyKeys = map[string]bool{
"uuid": true,
}
+56 -35
View File
@@ -23,7 +23,16 @@ func NewModifier() *Modifier {
} }
} }
// ParseModifier parses command-line args into Modifier // Set adds an attribute to the modifier, maintaining the SetAttributes and
// AttributeOrder invariant. Pass nil to clear the attribute.
func (m *Modifier) Set(key string, value *string) {
m.SetAttributes[key] = value
m.AttributeOrder = append(m.AttributeOrder, key)
}
// ParseModifier parses command-line args into Modifier.
// Only recognized attribute keys (ValidAttributeKeys) are accepted;
// unrecognized key:value tokens produce an error.
func ParseModifier(args []string) (*Modifier, error) { func ParseModifier(args []string) (*Modifier, error) {
m := NewModifier() m := NewModifier()
@@ -34,11 +43,13 @@ func ParseModifier(args []string) (*Modifier, error) {
} else if strings.HasPrefix(arg, "-") && !strings.Contains(arg, ":") { } else if strings.HasPrefix(arg, "-") && !strings.Contains(arg, ":") {
// Remove tag // Remove tag
m.RemoveTags = append(m.RemoveTags, strings.TrimPrefix(arg, "-")) m.RemoveTags = append(m.RemoveTags, strings.TrimPrefix(arg, "-"))
} else if strings.Contains(arg, ":") { } else if idx := strings.Index(arg, ":"); idx > 0 {
// Attribute modification key := arg[:idx]
parts := strings.SplitN(arg, ":", 2) value := arg[idx+1:]
key := parts[0]
value := parts[1] if !ValidAttributeKeys[key] {
return nil, fmt.Errorf("unknown modifier: %q (known: due, priority, project, recur, status, wait, scheduled, until)", key)
}
if value == "" { if value == "" {
// Clear attribute (priority: with no value) // Clear attribute (priority: with no value)
@@ -82,13 +93,20 @@ func (m *Modifier) Apply(task *Task) error {
resolvedDates["created"] = task.Created resolvedDates["created"] = task.Created
resolvedDates["modified"] = task.Modified resolvedDates["modified"] = task.Modified
// Safety net: if SetAttributes were populated without AttributeOrder,
// reconstruct order from map keys so updates aren't silently dropped.
if len(m.AttributeOrder) == 0 && len(m.SetAttributes) > 0 {
for key := range m.SetAttributes {
m.AttributeOrder = append(m.AttributeOrder, key)
}
}
// Apply attributes in the order they were specified (important for relative references) // 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 { for _, key := range m.AttributeOrder {
valuePtr := m.SetAttributes[key] valuePtr := m.SetAttributes[key]
// Handle date attributes with relative expression support
if dateKeys[key] { if dateKeys[key] {
if err := applyDateAttribute(key, valuePtr, task, resolvedDates); err != nil { if err := applyDateAttribute(key, valuePtr, task, resolvedDates); err != nil {
return err return err
@@ -96,30 +114,11 @@ func (m *Modifier) Apply(task *Task) error {
continue continue
} }
// Handle non-date attributes if err := applyNonDateAttribute(key, valuePtr, task); err != nil {
switch key { return err
case "priority":
if valuePtr == nil {
task.Priority = PriorityDefault
} else {
task.Priority = Priority(priorityStringToInt(*valuePtr))
}
case "project":
task.Project = valuePtr
case "recur":
if valuePtr == nil {
task.RecurrenceDuration = nil
} else {
duration, err := ParseRecurrencePattern(*valuePtr)
if err != nil {
return fmt.Errorf("invalid recurrence: %w", err)
}
task.RecurrenceDuration = &duration
}
} }
} }
// Apply tag changes
for _, tag := range m.AddTags { for _, tag := range m.AddTags {
if err := task.AddTag(tag); err != nil { if err := task.AddTag(tag); err != nil {
return err return err
@@ -150,13 +149,20 @@ func (m *Modifier) ApplyToNew(task *Task) error {
resolvedDates["created"] = task.Created resolvedDates["created"] = task.Created
} }
// Safety net: if SetAttributes were populated without AttributeOrder,
// reconstruct order from map keys so updates aren't silently dropped.
if len(m.AttributeOrder) == 0 && len(m.SetAttributes) > 0 {
for key := range m.SetAttributes {
m.AttributeOrder = append(m.AttributeOrder, key)
}
}
// Apply attributes in the order they were specified (important for relative references) // 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 { for _, key := range m.AttributeOrder {
valuePtr := m.SetAttributes[key] valuePtr := m.SetAttributes[key]
// Handle date attributes with relative expression support
if dateKeys[key] { if dateKeys[key] {
if err := applyDateAttribute(key, valuePtr, task, resolvedDates); err != nil { if err := applyDateAttribute(key, valuePtr, task, resolvedDates); err != nil {
return err return err
@@ -164,8 +170,26 @@ func (m *Modifier) ApplyToNew(task *Task) error {
continue continue
} }
// Handle non-date attributes if err := applyNonDateAttribute(key, valuePtr, task); err != nil {
return err
}
}
// Note: Tags are added after task is saved (in CreateTask function)
return nil
}
// applyNonDateAttribute applies a non-date attribute to a task.
func applyNonDateAttribute(key string, valuePtr *string, task *Task) error {
switch key { switch key {
case "description":
if valuePtr != nil {
task.Description = *valuePtr
}
case "status":
if valuePtr != nil && len(*valuePtr) > 0 {
task.Status = Status((*valuePtr)[0])
}
case "priority": case "priority":
if valuePtr == nil { if valuePtr == nil {
task.Priority = PriorityDefault task.Priority = PriorityDefault
@@ -185,9 +209,6 @@ func (m *Modifier) ApplyToNew(task *Task) error {
task.RecurrenceDuration = &duration task.RecurrenceDuration = &duration
} }
} }
}
// Note: Tags are added after task is saved (in CreateTask function)
return nil return nil
} }
-4
View File
@@ -14,20 +14,16 @@ func ParseKeyValueFormat(data string, skipComments bool) (map[string]string, err
lines := strings.Split(data, "\n") lines := strings.Split(data, "\n")
for i, line := range lines { for i, line := range lines {
// Trim whitespace
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
// Skip empty lines
if line == "" { if line == "" {
continue continue
} }
// Skip comments if requested
if skipComments && strings.HasPrefix(line, "#") { if skipComments && strings.HasPrefix(line, "#") {
continue continue
} }
// Split on first ':'
parts := strings.SplitN(line, ":", 2) parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 { if len(parts) != 2 {
return nil, fmt.Errorf("line %d: invalid format (expected 'key:value')", i+1) return nil, fmt.Errorf("line %d: invalid format (expected 'key:value')", i+1)
+12 -11
View File
@@ -188,20 +188,21 @@ func CreateRecurringTask(description string, mod *Modifier) (*Task, error) {
return instance, nil return instance, nil
} }
// SpawnNextInstance creates a new task instance from completed recurring task // SpawnNextInstance creates a new task instance from completed recurring task.
func SpawnNextInstance(completedInstance *Task) error { // Returns the newly created instance, or nil if recurrence has expired.
func SpawnNextInstance(completedInstance *Task) (*Task, error) {
if completedInstance.ParentUUID == nil { 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 // Load template
template, err := GetTask(*completedInstance.ParentUUID) template, err := GetTask(*completedInstance.ParentUUID)
if err != nil { 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 { 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 // Calculate next due date
@@ -212,7 +213,7 @@ func SpawnNextInstance(completedInstance *Task) error {
} else if completedInstance.Due != nil { } else if completedInstance.Due != nil {
baseDate = *completedInstance.Due baseDate = *completedInstance.Due
} else { } 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) next := CalculateNextDue(baseDate, *template.RecurrenceDuration)
@@ -221,7 +222,7 @@ func SpawnNextInstance(completedInstance *Task) error {
// Check if we're past 'until' date // Check if we're past 'until' date
if template.Until != nil && nextDue != nil && nextDue.After(*template.Until) { if template.Until != nil && nextDue != nil && nextDue.After(*template.Until) {
// Don't spawn, recurrence has expired // Don't spawn, recurrence has expired
return nil return nil, nil
} }
// Create new instance // Create new instance
@@ -243,20 +244,20 @@ func SpawnNextInstance(completedInstance *Task) error {
} }
if err := newInstance.Save(); err != nil { 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 // Copy tags from template
templateTags, err := template.GetTags() templateTags, err := template.GetTags()
if err != nil { 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 { for _, tag := range templateTags {
if err := newInstance.AddTag(tag); err != nil { 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) // 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) 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) // 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) 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)")
}
}
+27 -37
View File
@@ -2,6 +2,7 @@ package engine
import ( import (
"fmt" "fmt"
"sort"
) )
// DisplayFormat defines how tasks should be displayed // DisplayFormat defines how tasks should be displayed
@@ -20,6 +21,7 @@ type Report struct {
DisplayFormat DisplayFormat // How to display results DisplayFormat DisplayFormat // How to display results
SortFunc func([]*Task) []*Task SortFunc func([]*Task) []*Task
LimitFunc func([]*Task) []*Task LimitFunc func([]*Task) []*Task
ShowWaiting bool // If false (default), tasks with future wait dates are hidden
} }
// AllReports returns all predefined reports // AllReports returns all predefined reports
@@ -76,6 +78,7 @@ func AllReport() *Report {
Description: "All tasks", Description: "All tasks",
BaseFilter: filter, BaseFilter: filter,
DisplayFormat: DisplayFormatTable, DisplayFormat: DisplayFormatTable,
ShowWaiting: true,
} }
} }
@@ -131,16 +134,11 @@ func NewestReport() *Report {
BaseFilter: filter, BaseFilter: filter,
DisplayFormat: DisplayFormatTable, DisplayFormat: DisplayFormatTable,
SortFunc: func(tasks []*Task) []*Task { SortFunc: func(tasks []*Task) []*Task {
// Sort by created descending
sorted := make([]*Task, len(tasks)) sorted := make([]*Task, len(tasks))
copy(sorted, tasks) copy(sorted, tasks)
for i := 0; i < len(sorted)-1; i++ { sort.Slice(sorted, func(i, j int) bool {
for j := i + 1; j < len(sorted); j++ { return sorted[i].Created.After(sorted[j].Created)
if sorted[i].Created.Before(sorted[j].Created) { })
sorted[i], sorted[j] = sorted[j], sorted[i]
}
}
}
return sorted return sorted
}, },
LimitFunc: func(tasks []*Task) []*Task { LimitFunc: func(tasks []*Task) []*Task {
@@ -164,23 +162,14 @@ func NextReport() *Report {
BaseFilter: filter, BaseFilter: filter,
DisplayFormat: DisplayFormatTable, DisplayFormat: DisplayFormatTable,
SortFunc: func(tasks []*Task) []*Task { SortFunc: func(tasks []*Task) []*Task {
// Sort by urgency descending
cfg, _ := GetConfig() cfg, _ := GetConfig()
coeffs := BuildUrgencyCoefficients(cfg) coeffs := BuildUrgencyCoefficients(cfg)
sorted := make([]*Task, len(tasks)) sorted := make([]*Task, len(tasks))
copy(sorted, tasks) copy(sorted, tasks)
sort.Slice(sorted, func(i, j int) bool {
for i := 0; i < len(sorted)-1; i++ { return sorted[i].CalculateUrgency(coeffs) > sorted[j].CalculateUrgency(coeffs)
for j := i + 1; j < len(sorted); j++ { })
urgI := sorted[i].CalculateUrgency(coeffs)
urgJ := sorted[j].CalculateUrgency(coeffs)
if urgI < urgJ {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
}
}
return sorted return sorted
}, },
LimitFunc: func(tasks []*Task) []*Task { LimitFunc: func(tasks []*Task) []*Task {
@@ -208,16 +197,11 @@ func OldestReport() *Report {
BaseFilter: filter, BaseFilter: filter,
DisplayFormat: DisplayFormatTable, DisplayFormat: DisplayFormatTable,
SortFunc: func(tasks []*Task) []*Task { SortFunc: func(tasks []*Task) []*Task {
// Sort by created ascending (already default, but explicit)
sorted := make([]*Task, len(tasks)) sorted := make([]*Task, len(tasks))
copy(sorted, tasks) copy(sorted, tasks)
for i := 0; i < len(sorted)-1; i++ { sort.Slice(sorted, func(i, j int) bool {
for j := i + 1; j < len(sorted); j++ { return sorted[i].Created.Before(sorted[j].Created)
if sorted[i].Created.After(sorted[j].Created) { })
sorted[i], sorted[j] = sorted[j], sorted[i]
}
}
}
return sorted return sorted
}, },
} }
@@ -291,6 +275,7 @@ func WaitingReport() *Report {
Description: "Hidden/waiting tasks", Description: "Hidden/waiting tasks",
BaseFilter: filter, BaseFilter: filter,
DisplayFormat: DisplayFormatTable, DisplayFormat: DisplayFormatTable,
ShowWaiting: true,
} }
} }
@@ -340,6 +325,12 @@ func (r *Report) applyPostFilters(tasks []*Task) []*Task {
for _, task := range tasks { for _, task := range tasks {
include := true include := true
// By default, hide tasks with a future wait date (like taskwarrior).
// Reports that need to show waiting tasks set ShowWaiting = true.
if !r.ShowWaiting && task.Wait != nil && task.Wait.After(now) {
include = false
}
// Check for _started marker // Check for _started marker
if r.BaseFilter.Attributes["_started"] == "true" { if r.BaseFilter.Attributes["_started"] == "true" {
if task.Start == nil { if task.Start == nil {
@@ -420,7 +411,8 @@ func mergeFilters(base, user *Filter) *Filter {
return merged return merged
} }
// sortByUrgency is a helper function to sort tasks by urgency (descending) // sortByUrgency is a helper function to sort tasks by urgency (descending).
// It also populates the Urgency field on each task so the score is available in responses.
func sortByUrgency(tasks []*Task) []*Task { func sortByUrgency(tasks []*Task) []*Task {
cfg, _ := GetConfig() cfg, _ := GetConfig()
coeffs := BuildUrgencyCoefficients(cfg) coeffs := BuildUrgencyCoefficients(cfg)
@@ -428,15 +420,13 @@ func sortByUrgency(tasks []*Task) []*Task {
sorted := make([]*Task, len(tasks)) sorted := make([]*Task, len(tasks))
copy(sorted, tasks) copy(sorted, tasks)
for i := 0; i < len(sorted)-1; i++ { for _, t := range sorted {
for j := i + 1; j < len(sorted); j++ { t.Urgency = t.CalculateUrgency(coeffs)
urgI := sorted[i].CalculateUrgency(coeffs)
urgJ := sorted[j].CalculateUrgency(coeffs)
if urgI < urgJ {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
}
} }
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].Urgency > sorted[j].Urgency
})
return sorted return sorted
} }
+276 -112
View File
@@ -1,6 +1,7 @@
package engine package engine
import ( import (
"encoding/json"
"fmt" "fmt"
"time" "time"
@@ -16,6 +17,24 @@ const (
StatusRecurring Status = 'R' StatusRecurring Status = 'R'
) )
// MarshalJSON encodes Status as a single-character string (e.g. "P", "C").
func (s Status) MarshalJSON() ([]byte, error) {
return json.Marshal(string(s))
}
// UnmarshalJSON decodes a single-character string into a Status.
func (s *Status) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
if len(str) != 1 {
return fmt.Errorf("invalid status: %q", str)
}
*s = Status(str[0])
return nil
}
type Priority int type Priority int
const ( const (
@@ -25,33 +44,147 @@ const (
PriorityHigh Priority = 3 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 { type Task struct {
// Identity // Identity
UUID uuid.UUID UUID uuid.UUID `json:"uuid"`
ID int ID int `json:"id"`
// Core fields // Core fields
Status Status Status Status `json:"status"`
Description string Description string `json:"description"`
Project *string Project *string `json:"project"`
Priority Priority Priority Priority `json:"priority"`
// Timestamps // Timestamps
Created time.Time Created time.Time `json:"created"`
Modified time.Time Modified time.Time `json:"modified"`
Start *time.Time Start *time.Time `json:"start,omitempty"`
End *time.Time End *time.Time `json:"end,omitempty"`
Due *time.Time Due *time.Time `json:"due,omitempty"`
Scheduled *time.Time Scheduled *time.Time `json:"scheduled,omitempty"`
Wait *time.Time Wait *time.Time `json:"wait,omitempty"`
Until *time.Time Until *time.Time `json:"until,omitempty"`
// Recurrence (parent-child approach) // Recurrence (parent-child approach)
RecurrenceDuration *time.Duration RecurrenceDuration *time.Duration `json:"recurrence_duration,omitempty"`
ParentUUID *uuid.UUID ParentUUID *uuid.UUID `json:"parent_uuid,omitempty"`
// Annotations (stored as JSON in DB)
Annotations []Annotation `json:"annotations,omitempty"`
// Derived fields (not stored in DB) // Derived fields (not stored in DB)
Tags []string Tags []string `json:"tags"`
Urgency float64 `json:"urgency"`
}
// taskJSON is the wire format for Task, using unix timestamps instead of time.Time.
type taskJSON struct {
UUID uuid.UUID `json:"uuid"`
ID int `json:"id"`
Status Status `json:"status"`
Description string `json:"description"`
Project *string `json:"project"`
Priority Priority `json:"priority"`
Created int64 `json:"created"`
Modified int64 `json:"modified"`
Start *int64 `json:"start,omitempty"`
End *int64 `json:"end,omitempty"`
Due *int64 `json:"due,omitempty"`
Scheduled *int64 `json:"scheduled,omitempty"`
Wait *int64 `json:"wait,omitempty"`
Until *int64 `json:"until,omitempty"`
RecurrenceDuration *int64 `json:"recurrence_duration,omitempty"`
ParentUUID *uuid.UUID `json:"parent_uuid,omitempty"`
Annotations []Annotation `json:"annotations,omitempty"`
Tags []string `json:"tags"`
Urgency float64 `json:"urgency"`
}
// MarshalJSON emits Task with unix timestamps (int64) instead of RFC3339 strings.
func (t Task) MarshalJSON() ([]byte, error) {
toUnix := func(tp *time.Time) *int64 {
if tp == nil {
return nil
}
v := tp.Unix()
return &v
}
var recurDur *int64
if t.RecurrenceDuration != nil {
v := int64(*t.RecurrenceDuration / time.Second)
recurDur = &v
}
return json.Marshal(taskJSON{
UUID: t.UUID,
ID: t.ID,
Status: t.Status,
Description: t.Description,
Project: t.Project,
Priority: t.Priority,
Created: t.Created.Unix(),
Modified: t.Modified.Unix(),
Start: toUnix(t.Start),
End: toUnix(t.End),
Due: toUnix(t.Due),
Scheduled: toUnix(t.Scheduled),
Wait: toUnix(t.Wait),
Until: toUnix(t.Until),
RecurrenceDuration: recurDur,
ParentUUID: t.ParentUUID,
Annotations: t.Annotations,
Tags: t.Tags,
Urgency: t.Urgency,
})
}
// UnmarshalJSON parses Task from JSON with unix timestamps (int64) and duration in seconds.
func (t *Task) UnmarshalJSON(data []byte) error {
var raw taskJSON
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
fromUnix := func(v *int64) *time.Time {
if v == nil {
return nil
}
t := time.Unix(*v, 0)
return &t
}
t.UUID = raw.UUID
t.ID = raw.ID
t.Status = raw.Status
t.Description = raw.Description
t.Project = raw.Project
t.Priority = raw.Priority
t.Created = time.Unix(raw.Created, 0)
t.Modified = time.Unix(raw.Modified, 0)
t.Start = fromUnix(raw.Start)
t.End = fromUnix(raw.End)
t.Due = fromUnix(raw.Due)
t.Scheduled = fromUnix(raw.Scheduled)
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
if raw.RecurrenceDuration != nil {
d := time.Duration(*raw.RecurrenceDuration) * time.Second
t.RecurrenceDuration = &d
}
return nil
} }
// timeNow returns current time (allows mocking in tests) // timeNow returns current time (allows mocking in tests)
@@ -122,6 +255,32 @@ func uuidPtrToSQL(u *uuid.UUID) interface{} {
return u.String() 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 { func sqlToUUIDPtr(v interface{}) *uuid.UUID {
if v == nil { if v == nil {
return nil return nil
@@ -186,21 +345,13 @@ func CreateTaskWithModifier(description string, mod *Modifier) (*Task, error) {
return task, nil return task, nil
} }
// GetTask retrieves a task by UUID // scanner is satisfied by both *sql.Row and *sql.Rows.
func GetTask(taskUUID uuid.UUID) (*Task, error) { type scanner interface {
db := GetDB() Scan(dest ...interface{}) error
if db == nil {
return nil, fmt.Errorf("database not initialized")
} }
query := ` // scanTask reads a single task row from a scanner and populates all fields including tags.
SELECT id, uuid, status, description, project, priority, func scanTask(s scanner) (*Task, error) {
created, modified, start, end, due, scheduled, wait, until_date,
recurrence_duration, parent_uuid
FROM tasks
WHERE uuid = ?
`
task := &Task{} task := &Task{}
var ( var (
uuidStr string uuidStr string
@@ -215,9 +366,10 @@ func GetTask(taskUUID uuid.UUID) (*Task, error) {
until interface{} until interface{}
recurDuration interface{} recurDuration interface{}
parentUUIDStr interface{} parentUUIDStr interface{}
annotationsStr interface{}
) )
err := db.QueryRow(query, taskUUID.String()).Scan( err := s.Scan(
&task.ID, &task.ID,
&uuidStr, &uuidStr,
&task.Status, &task.Status,
@@ -234,23 +386,20 @@ func GetTask(taskUUID uuid.UUID) (*Task, error) {
&until, &until,
&recurDuration, &recurDuration,
&parentUUIDStr, &parentUUIDStr,
&annotationsStr,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get task: %w", err) return nil, err
} }
// Parse UUID
task.UUID, err = uuid.Parse(uuidStr) task.UUID, err = uuid.Parse(uuidStr)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse UUID: %w", err) return nil, fmt.Errorf("failed to parse UUID: %w", err)
} }
// Convert timestamps
task.Created = time.Unix(created, 0) task.Created = time.Unix(created, 0)
task.Modified = time.Unix(modified, 0) task.Modified = time.Unix(modified, 0)
// Convert nullable fields
task.Project = sqlToStringPtr(project) task.Project = sqlToStringPtr(project)
task.Start = sqlToTime(start) task.Start = sqlToTime(start)
task.End = sqlToTime(end) task.End = sqlToTime(end)
@@ -260,8 +409,8 @@ func GetTask(taskUUID uuid.UUID) (*Task, error) {
task.Until = sqlToTime(until) task.Until = sqlToTime(until)
task.RecurrenceDuration = sqlToDuration(recurDuration) task.RecurrenceDuration = sqlToDuration(recurDuration)
task.ParentUUID = sqlToUUIDPtr(parentUUIDStr) task.ParentUUID = sqlToUUIDPtr(parentUUIDStr)
task.Annotations = sqlToAnnotations(annotationsStr)
// Load tags
tags, err := task.GetTags() tags, err := task.GetTags()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to load tags: %w", err) return nil, fmt.Errorf("failed to load tags: %w", err)
@@ -271,6 +420,29 @@ func GetTask(taskUUID uuid.UUID) (*Task, error) {
return task, nil return task, nil
} }
// GetTask retrieves a task by UUID
func GetTask(taskUUID uuid.UUID) (*Task, error) {
db := GetDB()
if db == nil {
return nil, fmt.Errorf("database not initialized")
}
query := `
SELECT id, uuid, status, description, project, priority,
created, modified, start, end, due, scheduled, wait, until_date,
recurrence_duration, parent_uuid, annotations
FROM tasks
WHERE uuid = ?
`
task, err := scanTask(db.QueryRow(query, taskUUID.String()))
if err != nil {
return nil, fmt.Errorf("failed to get task: %w", err)
}
return task, nil
}
// GetTasks retrieves all tasks with optional filtering // GetTasks retrieves all tasks with optional filtering
func GetTasks(filter *Filter) ([]*Task, error) { func GetTasks(filter *Filter) ([]*Task, error) {
db := GetDB() db := GetDB()
@@ -288,7 +460,7 @@ func GetTasks(filter *Filter) ([]*Task, error) {
query := fmt.Sprintf(` query := fmt.Sprintf(`
SELECT id, uuid, status, description, project, priority, SELECT id, uuid, status, description, project, priority,
created, modified, start, end, due, scheduled, wait, until_date, created, modified, start, end, due, scheduled, wait, until_date,
recurrence_duration, parent_uuid recurrence_duration, parent_uuid, annotations
FROM tasks FROM tasks
WHERE %s WHERE %s
ORDER BY ORDER BY
@@ -306,73 +478,10 @@ func GetTasks(filter *Filter) ([]*Task, error) {
tasks := []*Task{} tasks := []*Task{}
for rows.Next() { for rows.Next() {
task := &Task{} task, err := scanTask(rows)
var (
uuidStr string
project interface{}
created int64
modified int64
start interface{}
end interface{}
due interface{}
scheduled interface{}
wait interface{}
until interface{}
recurDuration interface{}
parentUUIDStr interface{}
)
err := rows.Scan(
&task.ID,
&uuidStr,
&task.Status,
&task.Description,
&project,
&task.Priority,
&created,
&modified,
&start,
&end,
&due,
&scheduled,
&wait,
&until,
&recurDuration,
&parentUUIDStr,
)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to scan task: %w", err) return nil, fmt.Errorf("failed to scan task: %w", err)
} }
// Parse UUID
task.UUID, err = uuid.Parse(uuidStr)
if err != nil {
return nil, fmt.Errorf("failed to parse UUID: %w", err)
}
// Convert timestamps
task.Created = time.Unix(created, 0)
task.Modified = time.Unix(modified, 0)
// Convert nullable fields
task.Project = sqlToStringPtr(project)
task.Start = sqlToTime(start)
task.End = sqlToTime(end)
task.Due = sqlToTime(due)
task.Scheduled = sqlToTime(scheduled)
task.Wait = sqlToTime(wait)
task.Until = sqlToTime(until)
task.RecurrenceDuration = sqlToDuration(recurDuration)
task.ParentUUID = sqlToUUIDPtr(parentUUIDStr)
// Load tags
tags, err := task.GetTags()
if err != nil {
return nil, fmt.Errorf("failed to load tags: %w", err)
}
task.Tags = tags
tasks = append(tasks, task) tasks = append(tasks, task)
} }
@@ -402,7 +511,7 @@ func (t *Task) Save() error {
status = ?, description = ?, project = ?, priority = ?, status = ?, description = ?, project = ?, priority = ?,
modified = ?, start = ?, end = ?, due = ?, modified = ?, start = ?, end = ?, due = ?,
scheduled = ?, wait = ?, until_date = ?, scheduled = ?, wait = ?, until_date = ?,
recurrence_duration = ?, parent_uuid = ? recurrence_duration = ?, parent_uuid = ?, annotations = ?
WHERE uuid = ? WHERE uuid = ?
` `
@@ -420,6 +529,7 @@ func (t *Task) Save() error {
timeToSQL(t.Until), timeToSQL(t.Until),
durationToSQL(t.RecurrenceDuration), durationToSQL(t.RecurrenceDuration),
uuidPtrToSQL(t.ParentUUID), uuidPtrToSQL(t.ParentUUID),
annotationsToSQL(t.Annotations),
t.UUID.String(), t.UUID.String(),
) )
@@ -432,8 +542,8 @@ func (t *Task) Save() error {
INSERT INTO tasks ( INSERT INTO tasks (
uuid, status, description, project, priority, uuid, status, description, project, priority,
created, modified, start, end, due, scheduled, wait, until_date, created, modified, start, end, due, scheduled, wait, until_date,
recurrence_duration, parent_uuid recurrence_duration, parent_uuid, annotations
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
` `
result, err := db.Exec(query, result, err := db.Exec(query,
@@ -452,6 +562,7 @@ func (t *Task) Save() error {
timeToSQL(t.Until), timeToSQL(t.Until),
durationToSQL(t.RecurrenceDuration), durationToSQL(t.RecurrenceDuration),
uuidPtrToSQL(t.ParentUUID), uuidPtrToSQL(t.ParentUUID),
annotationsToSQL(t.Annotations),
) )
if err != nil { if err != nil {
@@ -553,25 +664,27 @@ func (t *Task) GetTags() ([]string, error) {
return tags, nil return tags, nil
} }
// Complete marks a task as completed // Complete marks a task as completed.
func (t *Task) Complete() error { // Returns the next recurring instance if one was spawned, or nil.
func (t *Task) Complete() (*Task, error) {
t.Status = StatusCompleted t.Status = StatusCompleted
now := timeNow() now := timeNow()
t.End = &now t.End = &now
if err := t.Save(); err != nil { if err := t.Save(); err != nil {
return err return nil, err
} }
// If this is a recurring instance, spawn next instance // If this is a recurring instance, spawn next instance
if t.ParentUUID != nil { if t.ParentUUID != nil {
if err := SpawnNextInstance(t); err != nil { next, err := SpawnNextInstance(t)
// Log error but don't fail the completion if err != nil {
return fmt.Errorf("completed task but failed to spawn next instance: %w", err) 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) // Delete marks a task as deleted (soft delete)
@@ -622,3 +735,54 @@ func (t *Task) IsRecurringTemplate() bool {
func (t *Task) IsRecurringInstance() bool { func (t *Task) IsRecurringInstance() bool {
return t.ParentUUID != nil return t.ParentUUID != nil
} }
// GetDeletedTasks retrieves soft-deleted tasks, optionally filtered by age.
// If olderThan is non-nil, only returns tasks deleted more than that duration ago.
func GetDeletedTasks(olderThan *time.Duration) ([]*Task, error) {
db := GetDB()
if db == nil {
return nil, fmt.Errorf("database not initialized")
}
query := `
SELECT id, uuid, status, description, project, priority,
created, modified, start, end, due, scheduled, wait, until_date,
recurrence_duration, parent_uuid, annotations
FROM tasks
WHERE status = ?`
args := []interface{}{byte(StatusDeleted)}
if olderThan != nil {
cutoff := timeNow().Add(-*olderThan).Unix()
query += ` AND end < ?`
args = append(args, cutoff)
}
query += ` ORDER BY end ASC`
rows, err := db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query deleted tasks: %w", err)
}
defer rows.Close()
var tasks []*Task
for rows.Next() {
task, err := scanTask(rows)
if err != nil {
return nil, fmt.Errorf("failed to scan task: %w", err)
}
tasks = append(tasks, task)
}
return tasks, nil
}
// PopulateUrgency computes and sets the Urgency field on the given tasks.
func PopulateUrgency(tasks ...*Task) {
cfg, _ := GetConfig()
coeffs := BuildUrgencyCoefficients(cfg)
for _, t := range tasks {
t.Urgency = t.CalculateUrgency(coeffs)
}
}
+1 -1
View File
@@ -165,7 +165,7 @@ func TestTaskComplete(t *testing.T) {
t.Fatalf("Failed to create task: %v", err) 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) 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)
}
}
+5 -1
View File
@@ -170,8 +170,12 @@ func (t *Task) urgencyProject(coeff float64) float64 {
return 0.0 return 0.0
} }
// BuildUrgencyCoefficients creates UrgencyCoefficients from config // BuildUrgencyCoefficients creates UrgencyCoefficients from config.
// If cfg is nil, uses DefaultConfig() to prevent nil-pointer panics.
func BuildUrgencyCoefficients(cfg *Config) *UrgencyCoefficients { func BuildUrgencyCoefficients(cfg *Config) *UrgencyCoefficients {
if cfg == nil {
cfg = DefaultConfig()
}
return &UrgencyCoefficients{ return &UrgencyCoefficients{
Due: cfg.UrgencyDue, Due: cfg.UrgencyDue,
PriorityH: cfg.UrgencyPriorityH, PriorityH: cfg.UrgencyPriorityH,
+29
View File
@@ -142,3 +142,32 @@ func (ws *WorkingSet) GetTasks() []*Task {
func (ws *WorkingSet) Size() int { func (ws *WorkingSet) Size() int {
return len(ws.byID) 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
}
+58 -17
View File
@@ -102,14 +102,20 @@ func (c *Client) PullChanges(since int64) ([]ChangeLogEntry, error) {
func (c *Client) PushChanges(tasks []*engine.Task) error { func (c *Client) PushChanges(tasks []*engine.Task) error {
// Convert tasks to JSON // Convert tasks to JSON
var taskData []json.RawMessage var taskData []json.RawMessage
var marshalErrors []string
for _, task := range tasks { for _, task := range tasks {
data, err := json.Marshal(task) data, err := json.Marshal(task)
if err != nil { if err != nil {
marshalErrors = append(marshalErrors, fmt.Sprintf("task %s: %v", task.UUID, err))
continue continue
} }
taskData = append(taskData, data) taskData = append(taskData, data)
} }
if len(taskData) == 0 && len(marshalErrors) > 0 {
return fmt.Errorf("all tasks failed to marshal: %s", strings.Join(marshalErrors, "; "))
}
reqBody := map[string]interface{}{ reqBody := map[string]interface{}{
"tasks": taskData, "tasks": taskData,
"client_id": c.clientID, "client_id": c.clientID,
@@ -139,6 +145,11 @@ func (c *Client) PushChanges(tasks []*engine.Task) error {
return fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body)) return fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body))
} }
if len(marshalErrors) > 0 {
return fmt.Errorf("pushed %d tasks but %d failed to marshal: %s",
len(taskData), len(marshalErrors), strings.Join(marshalErrors, "; "))
}
return nil return nil
} }
@@ -219,7 +230,7 @@ func (c *Client) Sync(strategy ConflictResolution, reporter ProgressReporter) (*
} }
// Convert changes to tasks // Convert changes to tasks
remoteTasks, err := c.parseChanges(changes) remoteTasks, err := c.ParseChanges(changes)
if err != nil { if err != nil {
if len(changes) > 0 { if len(changes) > 0 {
reporter.CompletePhase() reporter.CompletePhase()
@@ -283,6 +294,11 @@ func (c *Client) Sync(strategy ConflictResolution, reporter ProgressReporter) (*
continue continue
} }
// Mark change_log entry as sync-originated to prevent feedback loop
if err := engine.MarkChangeLogAsSync(task.UUID.String()); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("failed to mark change as sync for %s: %v", task.UUID, err))
}
// Reload task to ensure we have the database ID // Reload task to ensure we have the database ID
savedTask, err := engine.GetTask(task.UUID) savedTask, err := engine.GetTask(task.UUID)
if err != nil { if err != nil {
@@ -347,18 +363,7 @@ func (c *Client) Sync(strategy ConflictResolution, reporter ProgressReporter) (*
// getLastSyncTime retrieves the last sync timestamp from database // getLastSyncTime retrieves the last sync timestamp from database
func (c *Client) getLastSyncTime() int64 { func (c *Client) getLastSyncTime() int64 {
db := engine.GetDB() return GetLastSyncTime(c.clientID)
if db == nil {
return 0
}
var lastSync int64
err := db.QueryRow("SELECT last_sync FROM sync_state WHERE client_id = ?", c.clientID).Scan(&lastSync)
if err != nil {
return 0
}
return lastSync
} }
// updateLastSyncTime updates the last sync timestamp // updateLastSyncTime updates the last sync timestamp
@@ -376,6 +381,27 @@ func (c *Client) updateLastSyncTime(timestamp int64) {
// getLocalChanges retrieves local changes since a timestamp // getLocalChanges retrieves local changes since a timestamp
func (c *Client) getLocalChanges(since int64) ([]*engine.Task, error) { func (c *Client) getLocalChanges(since int64) ([]*engine.Task, error) {
return GetLocalChanges(since)
}
// GetLastSyncTime retrieves the last sync timestamp for a client ID from the database.
func GetLastSyncTime(clientID string) int64 {
db := engine.GetDB()
if db == nil {
return 0
}
var lastSync int64
err := db.QueryRow("SELECT last_sync FROM sync_state WHERE client_id = ?", clientID).Scan(&lastSync)
if err != nil {
return 0
}
return lastSync
}
// GetLocalChanges retrieves local (non-sync-originated) changes since a timestamp.
func GetLocalChanges(since int64) ([]*engine.Task, error) {
db := engine.GetDB() db := engine.GetDB()
if db == nil { if db == nil {
return nil, fmt.Errorf("database not initialized") return nil, fmt.Errorf("database not initialized")
@@ -384,7 +410,7 @@ func (c *Client) getLocalChanges(since int64) ([]*engine.Task, error) {
rows, err := db.Query(` rows, err := db.Query(`
SELECT DISTINCT task_uuid SELECT DISTINCT task_uuid
FROM change_log FROM change_log
WHERE changed_at > ? WHERE changed_at > ? AND source = 'local'
ORDER BY changed_at ASC ORDER BY changed_at ASC
`, since) `, since)
if err != nil { if err != nil {
@@ -415,8 +441,8 @@ func (c *Client) getLocalChanges(since int64) ([]*engine.Task, error) {
return tasks, nil return tasks, nil
} }
// parseChanges converts change log entries to tasks // ParseChanges converts change log entries to tasks
func (c *Client) parseChanges(changes []ChangeLogEntry) ([]*engine.Task, error) { func (c *Client) ParseChanges(changes []ChangeLogEntry) ([]*engine.Task, error) {
// Sort changes by timestamp (primary) and ID (secondary) to ensure correct order // Sort changes by timestamp (primary) and ID (secondary) to ensure correct order
// This handles same-second updates (e.g., CREATE followed by UPDATE with tags) // This handles same-second updates (e.g., CREATE followed by UPDATE with tags)
sort.Slice(changes, func(i, j int) bool { sort.Slice(changes, func(i, j int) bool {
@@ -666,16 +692,31 @@ func parseTagsFromChangeLog(s string) []string {
// pushQueuedChanges sends queued changes to server // pushQueuedChanges sends queued changes to server
func (c *Client) pushQueuedChanges(changes []QueuedChange) error { func (c *Client) pushQueuedChanges(changes []QueuedChange) error {
var tasks []*engine.Task var tasks []*engine.Task
var unmarshalErrors []string
for _, change := range changes { for _, change := range changes {
var task engine.Task var task engine.Task
if err := json.Unmarshal(change.Data, &task); err != nil { if err := json.Unmarshal(change.Data, &task); err != nil {
unmarshalErrors = append(unmarshalErrors, fmt.Sprintf("queued change: %v", err))
continue continue
} }
tasks = append(tasks, &task) tasks = append(tasks, &task)
} }
return c.PushChanges(tasks) if len(tasks) == 0 && len(unmarshalErrors) > 0 {
return fmt.Errorf("all queued changes failed to unmarshal: %s", strings.Join(unmarshalErrors, "; "))
}
if err := c.PushChanges(tasks); err != nil {
return err
}
if len(unmarshalErrors) > 0 {
return fmt.Errorf("pushed %d tasks but %d queued changes failed to unmarshal: %s",
len(tasks), len(unmarshalErrors), strings.Join(unmarshalErrors, "; "))
}
return nil
} }
// SyncResult represents the result of a sync operation // SyncResult represents the result of a sync operation
+6 -7
View File
@@ -57,7 +57,11 @@ func MergeTasks(local, remote []*engine.Task, strategy ConflictResolution) ([]*e
if DetectConflict(task, remoteTask) { if DetectConflict(task, remoteTask) {
conflicts++ conflicts++
winner := resolveConflict(task, remoteTask, strategy) winner := resolveConflict(task, remoteTask, strategy)
logConflict(task, remoteTask, winner) winnerLabel := "local"
if winner == remoteTask {
winnerLabel = "remote"
}
logConflict(task, remoteTask, winnerLabel)
result = append(result, winner) result = append(result, winner)
} else { } else {
// No conflict - use either (same content) // No conflict - use either (same content)
@@ -110,17 +114,12 @@ func resolveConflict(local, remote *engine.Task, strategy ConflictResolution) *e
} }
// logConflict writes conflict information to log file // logConflict writes conflict information to log file
func logConflict(local, remote *engine.Task, winner *engine.Task) { func logConflict(local, remote *engine.Task, winnerLabel string) {
logPath, err := engine.GetSyncConflictLogPath() logPath, err := engine.GetSyncConflictLogPath()
if err != nil { if err != nil {
return return
} }
winnerLabel := "local"
if winner.UUID == remote.UUID && winner.Modified.Equal(remote.Modified) {
winnerLabel = "remote"
}
entry := fmt.Sprintf( entry := fmt.Sprintf(
"[%s] Conflict on task %s\n"+ "[%s] Conflict on task %s\n"+
" Local: modified %s - %s\n"+ " Local: modified %s - %s\n"+
+1 -2
View File
@@ -2,5 +2,4 @@
VITE_API_URL=https://opal.example.com/api VITE_API_URL=https://opal.example.com/api
VITE_AUTH_URL=https://auth.example.com VITE_AUTH_URL=https://auth.example.com
# OAuth # OAuth (not needed for local dev — Vite's DEV mode auto-skips auth)
VITE_OAUTH_ENABLED=true
+40
View File
@@ -0,0 +1,40 @@
# Wait/Scheduled not working correctly
`Buy milk due:8d wait:5d` still showing up
# Missing uncomplete feat
Undo / uncomplete fails in web-ui. task still has checked box and strikethrough.
# Cycling priority - Web
Trying to edit a task priority results in following console error:
GQLaRcBw.js:1 PUT https://opal.jnss.me/api/tasks/8814798c-97af-4134-9786-47e027d164c8 400 (Bad Request)
window.fetch @ GQLaRcBw.js:1
n @ nlPNz7OE.js:1
update @ nlPNz7OE.js:1
updateTask @ 2.qiXA3Mmu.js:1
X @ 2.qiXA3Mmu.js:3
Q @ 2.qiXA3Mmu.js:1
(anonymous) @ idxpmzXF.js:1
Qe @ jWcw5lls.js:1
n @ idxpmzXF.js:1
nlPNz7OE.js:1 API Error [/tasks/8814798c-97af-4134-9786-47e027d164c8]: Error: HTTP 400:
at n (nlPNz7OE.js:1:1556)
at async Object.updateTask (2.qiXA3Mmu.js:1:4502)
at async X (2.qiXA3Mmu.js:3:3519)
at async HTMLDivElement.Q (2.qiXA3Mmu.js:1:58579)
n @ nlPNz7OE.js:1
await in n
update @ nlPNz7OE.js:1
updateTask @ 2.qiXA3Mmu.js:1
X @ 2.qiXA3Mmu.js:3
Q @ 2.qiXA3Mmu.js:1
(anonymous) @ idxpmzXF.js:1
Qe @ jWcw5lls.js:1
n @ idxpmzXF.js:1
2.qiXA3Mmu.js:3 Failed to update task: Error: HTTP 400:
at n (nlPNz7OE.js:1:1556)
at async Object.updateTask (2.qiXA3Mmu.js:1:4502)
at async X (2.qiXA3Mmu.js:3:3519)
at async HTMLDivElement.Q (2.qiXA3Mmu.js:1:58579)
# Ambiguity complete and details tap.
Pressing the task description completes the task. only the checkbox click should complete task, otherwise open details view. to complete swipe left
@@ -0,0 +1,255 @@
# API Serialization Fix & Urgency Field
## Problem
Two issues block showing urgency scores in the web frontend:
1. **The Task struct has no JSON tags.** Go defaults to PascalCase field names
(`Description`, `ParentUUID`, `RecurrenceDuration`), but the frontend expects
snake_case (`description`, `parent_uuid`, `recurrence_duration`). There is no
transformation layer on either side — the frontend code works today only
because single-word fields like `description`/`Description` are
case-insensitive in JavaScript property access... actually they're not.
**This is a latent bug** — any field with multiple words
(`RecurrenceDuration`, `ParentUUID`) is broken in production.
2. **Urgency is computed but never exposed.** The report engine calculates
urgency internally for sorting (`sortByUrgency` in `report.go`) but discards
the score before serialization. The `Task` struct has no urgency field.
### Secondary issue: `time.Time` serialization
Go's `time.Time` marshals as RFC3339 strings (`"2026-02-15T10:30:00Z"`), but the
frontend expects unix timestamps (numbers). The `Status` type (`byte`) marshals
as an integer (80 for `'P'`), but the frontend expects a string character
(`"P"`). These need explicit handling.
---
## Scope
### Backend (`opal-task`)
#### 1. Add json tags to `engine.Task`
File: `internal/engine/task.go`
```go
type Task struct {
UUID uuid.UUID `json:"uuid"`
ID int `json:"id"`
Status Status `json:"status"`
Description string `json:"description"`
Project *string `json:"project"`
Priority Priority `json:"priority"`
Created time.Time `json:"created"`
Modified time.Time `json:"modified"`
Start *time.Time `json:"start,omitempty"`
End *time.Time `json:"end,omitempty"`
Due *time.Time `json:"due,omitempty"`
Scheduled *time.Time `json:"scheduled,omitempty"`
Wait *time.Time `json:"wait,omitempty"`
Until *time.Time `json:"until,omitempty"`
RecurrenceDuration *time.Duration `json:"recurrence_duration,omitempty"`
ParentUUID *uuid.UUID `json:"parent_uuid,omitempty"`
Tags []string `json:"tags"`
Urgency float64 `json:"urgency"`
}
```
#### 2. Custom JSON marshaling for `Status` and timestamps
`Status` is a `byte` — it will serialize as `80` not `"P"`. Add a
`MarshalJSON`/`UnmarshalJSON` pair on `Status` to emit a single-character
string.
For `time.Time`, the cleanest approach is a custom `MarshalJSON` on `Task` that
emits unix timestamps for all time fields. This matches the existing frontend
expectation and avoids date-parsing complexity in the browser.
```go
// On Status type
func (s Status) MarshalJSON() ([]byte, error) {
return json.Marshal(string(s))
}
func (s *Status) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
if len(str) != 1 {
return fmt.Errorf("invalid status: %q", str)
}
*s = Status(str[0])
return nil
}
```
For timestamps, implement `MarshalJSON` and `UnmarshalJSON` on `Task` that
convert between `time.Time` and unix seconds (int64). Nullable time fields
become `null` or the unix value. `RecurrenceDuration` (a `time.Duration`, stored
internally as nanoseconds) must also be converted to seconds for consistency —
the API uses seconds as its universal time unit. The symmetric pair ensures
`json.Unmarshal` into a `Task` works correctly (for sync, tests,
client-to-client), not just the handler request structs.
#### 3. Populate urgency before returning
**Where:** Centralize in a helper that handlers call before responding.
```go
// In a new file or in task.go
func PopulateUrgency(tasks ...*Task) {
cfg, _ := GetConfig()
coeffs := BuildUrgencyCoefficients(cfg)
for _, t := range tasks {
t.Urgency = t.CalculateUrgency(coeffs)
}
}
```
**Call sites** — every handler in `handlers/tasks.go` that returns task(s):
| Handler | Returns |
|----------------|-----------|
| `ListTasks` | `[]*Task` |
| `CreateTask` | `*Task` |
| `GetTask` | `*Task` |
| `UpdateTask` | `*Task` |
| `CompleteTask` | `*Task` |
| `StartTask` | `*Task` |
| `StopTask` | `*Task` |
| `AddTaskTag` | `*Task` |
| `RemoveTaskTag`| `*Task` |
| `ParseTask` | `*Task` |
The report path (`ListTasks` with `?report=`) already sorts by urgency via
`sortByUrgency` — it should also populate the field so the score is in the
response. Currently urgency is calculated, used for sorting, then thrown away.
#### 4. Update tests
`handlers/tasks_test.go` currently asserts PascalCase keys
(`task["Description"]`). Update to snake_case (`task["description"]`). Add
assertions for `urgency` field presence and that it's a number.
---
### Frontend (`opal-web`)
#### 5. Add `urgency` to Task type
File: `src/lib/api/types.js`
```javascript
/**
* @typedef {Object} Task
* ...existing fields...
* @property {number} urgency
*/
```
#### 6. Display urgency in TaskItem
File: `src/lib/components/TaskItem.svelte`
Show the urgency score as a small numeric badge in the task metadata row.
Render it as a one-decimal float (e.g. `8.2`) with color coding:
| Range | Meaning | Color |
|----------|-------------|--------------------------------|
| >= 10 | Critical | `--color-priority-high-text` |
| >= 5 | High | `--color-priority-medium-text` |
| > 0 | Normal | `--text-secondary` |
| 0 | None | Don't render |
Position: rightmost item in the meta row, right-aligned. Use a monospace or
tabular-nums font variant so scores don't cause layout shift.
#### 7. Update mock data
File: `src/lib/mock/tasks.js`
Add `urgency` field to mock tasks with representative values so mock mode
continues to work.
---
## Technical Decisions
### ADR-7: JSON tags with snake_case convention
**Context:** The Go Task struct has no json tags. PascalCase default breaks
multi-word fields in the frontend.
**Decision:** Add explicit `json:"snake_case"` tags to all Task fields.
**Alternatives:**
- Frontend transformation layer (rejected — masks the real problem, adds runtime
overhead, easy to forget when adding new fields)
- Middleware that converts all response keys (rejected — fragile, doesn't handle
nested types, hides the contract)
**Consequences:** Breaking change to API response shape for any existing
consumers. Since the web frontend is the only consumer and its types already
expect snake_case, this is actually a **fix** not a break.
### ADR-8: Custom MarshalJSON for unix timestamps
**Context:** `time.Time` marshals as RFC3339 strings, frontend expects unix ints.
**Decision:** Implement `MarshalJSON` on `Task` that emits unix seconds for all
time fields and string for Status.
**Alternatives:**
- Use a custom `UnixTime` type wrapper (rejected — too invasive, changes every
function that touches time fields)
- Parse RFC3339 in the frontend (rejected — adds complexity to every consumer,
breaks existing date math that assumes unix seconds)
**Consequences:** Time values in API responses are plain integers (seconds).
Nullable times are `null`. `recurrence_duration` is also seconds (not
nanoseconds) — a 1-week recurrence is `604800`, not `604800000000000`. Simple to
consume in any language.
### ADR-9: Urgency as a derived field on Task struct
**Context:** Urgency is computed from task attributes + config coefficients. It's
used for sorting in reports but never exposed to consumers.
**Decision:** Add `Urgency float64` to the Task struct alongside `Tags` (both
are derived/computed, not stored in DB). Populate via a `PopulateUrgency` helper
called in handlers before serialization.
**Alternatives:**
- Separate response wrapper struct (rejected — duplicates the entire type for one
field, adds mapping boilerplate in every handler)
- Compute client-side (rejected — requires shipping coefficient config to the
frontend, duplicates complex calculation logic)
**Consequences:** Every API response that includes tasks will have urgency
scores. The score is a snapshot at response time — it may drift slightly from
what the sort order used if computed at different moments, but this is negligible.
---
## File Change Summary
```
opal-task/
internal/engine/
task.go .............. ADD json tags, ADD Urgency field, ADD MarshalJSON
ADD PopulateUrgency helper
urgency.go ........... No changes
report.go ............ Populate urgency after sort (in sortByUrgency or Execute)
internal/api/handlers/
tasks.go ............. Call PopulateUrgency before jsonResponse in all handlers
tasks_test.go ........ Update key assertions to snake_case, add urgency checks
opal-web/
src/lib/api/types.js ... ADD urgency field to Task typedef
src/lib/components/
TaskItem.svelte ...... ADD urgency badge to meta row
src/lib/mock/tasks.js .. ADD urgency values to mock data
```
+6
View File
@@ -68,6 +68,8 @@
--color-overdue-text: #f85149; --color-overdue-text: #f85149;
--color-tag-bg: rgba(139, 148, 158, 0.1); --color-tag-bg: rgba(139, 148, 158, 0.1);
--color-tag-text: #8b949e; --color-tag-text: #8b949e;
--color-active-bg: rgba(57, 208, 186, 0.15);
--color-active-text: #39d0ba;
color-scheme: dark; color-scheme: dark;
} }
@@ -118,6 +120,8 @@
--color-overdue-text: #be123c; --color-overdue-text: #be123c;
--color-tag-bg: #f5f5f4; --color-tag-bg: #f5f5f4;
--color-tag-text: #78716c; --color-tag-text: #78716c;
--color-active-bg: rgba(99, 102, 241, 0.12);
--color-active-text: #4f46e5;
color-scheme: light; color-scheme: light;
} }
@@ -168,6 +172,8 @@
--color-overdue-text: #ef4444; --color-overdue-text: #ef4444;
--color-tag-bg: rgba(148, 163, 184, 0.1); --color-tag-bg: rgba(148, 163, 184, 0.1);
--color-tag-text: #94a3b8; --color-tag-text: #94a3b8;
--color-active-bg: rgba(139, 92, 246, 0.15);
--color-active-text: #8b5cf6;
color-scheme: dark; color-scheme: dark;
} }
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <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="description" content="Mobile-first task management with offline support" />
<meta name="theme-color" content="#4f46e5" /> <meta name="theme-color" content="#4f46e5" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
+3 -6
View File
@@ -6,7 +6,7 @@ import { get } from 'svelte/store';
* @typedef {import('./types.js').AuthTokens} AuthTokens * @typedef {import('./types.js').AuthTokens} AuthTokens
*/ */
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080'; export const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080';
/** /**
* Make authenticated API request * Make authenticated API request
@@ -18,12 +18,12 @@ const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080';
export async function apiRequest(endpoint, options = {}) { export async function apiRequest(endpoint, options = {}) {
const auth = get(authStore); const auth = get(authStore);
/** @type {Record<string, string>} */
const headers = { const headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options.headers .../** @type {Record<string, string>} */ (options.headers)
}; };
// Add auth token if available
if (auth.accessToken) { if (auth.accessToken) {
headers['Authorization'] = `Bearer ${auth.accessToken}`; headers['Authorization'] = `Bearer ${auth.accessToken}`;
} }
@@ -34,11 +34,9 @@ export async function apiRequest(endpoint, options = {}) {
headers headers
}); });
// Token expired - try refresh
if (response.status === 401 && auth.refreshToken) { if (response.status === 401 && auth.refreshToken) {
const refreshed = await refreshAccessToken(auth.refreshToken); const refreshed = await refreshAccessToken(auth.refreshToken);
if (refreshed) { if (refreshed) {
// Retry with new token
headers['Authorization'] = `Bearer ${refreshed.access_token}`; headers['Authorization'] = `Bearer ${refreshed.access_token}`;
return apiRequest(endpoint, { ...options, headers }); return apiRequest(endpoint, { ...options, headers });
} }
@@ -78,7 +76,6 @@ async function refreshAccessToken(refreshToken) {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
// Update auth store
authStore.setTokens(result.data); authStore.setTokens(result.data);
return result.data; return result.data;
} }
+17 -70
View File
@@ -1,4 +1,4 @@
import { apiRequest } from './client.js'; import { apiRequest, API_BASE } from './client.js';
/** /**
* @typedef {import('./types.js').Task} Task * @typedef {import('./types.js').Task} Task
@@ -8,12 +8,8 @@ import { apiRequest } from './client.js';
* @typedef {import('./types.js').User} User * @typedef {import('./types.js').User} User
*/ */
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080';
// Tasks API
export const tasks = { export const tasks = {
/** /**
* List all tasks with optional filters
* @param {TaskFilters} [filters] * @param {TaskFilters} [filters]
* @returns {Promise<Task[]>} * @returns {Promise<Task[]>}
*/ */
@@ -25,25 +21,20 @@ export const tasks = {
if (filters.tags) { if (filters.tags) {
filters.tags.forEach(tag => params.append('tag', tag)); filters.tags.forEach(tag => params.append('tag', tag));
} }
if (filters.excludeTags) {
filters.excludeTags.forEach(tag => params.append('exclude_tag', tag));
}
const query = params.toString(); const query = params.toString();
return apiRequest(`/tasks${query ? `?${query}` : ''}`); return apiRequest(`/tasks${query ? `?${query}` : ''}`);
}, },
/** /** @param {string} uuid @returns {Promise<Task>} */
* Get single task by UUID
* @param {string} uuid
* @returns {Promise<Task>}
*/
async get(uuid) { async get(uuid) {
return apiRequest(`/tasks/${uuid}`); return apiRequest(`/tasks/${uuid}`);
}, },
/** /** @param {Partial<Task>} task @returns {Promise<Task>} */
* Create new task
* @param {Partial<Task>} task
* @returns {Promise<Task>}
*/
async create(task) { async create(task) {
return apiRequest('/tasks', { return apiRequest('/tasks', {
method: 'POST', method: 'POST',
@@ -52,7 +43,6 @@ export const tasks = {
}, },
/** /**
* Update existing task
* @param {string} uuid * @param {string} uuid
* @param {Partial<Task>} updates * @param {Partial<Task>} updates
* @returns {Promise<Task>} * @returns {Promise<Task>}
@@ -64,46 +54,29 @@ export const tasks = {
}); });
}, },
/** /** @param {string} uuid @returns {Promise<void>} */
* Delete task
* @param {string} uuid
* @returns {Promise<void>}
*/
async delete(uuid) { async delete(uuid) {
return apiRequest(`/tasks/${uuid}`, { method: 'DELETE' }); return apiRequest(`/tasks/${uuid}`, { method: 'DELETE' });
}, },
/** /** @param {string} uuid @returns {Promise<Task>} */
* Complete task
* @param {string} uuid
* @returns {Promise<Task>}
*/
async complete(uuid) { async complete(uuid) {
return apiRequest(`/tasks/${uuid}/complete`, { method: 'POST' }); return apiRequest(`/tasks/${uuid}/complete`, { method: 'POST' });
}, },
/** /** @param {string} uuid @returns {Promise<Task>} */
* Start task timer
* @param {string} uuid
* @returns {Promise<Task>}
*/
async start(uuid) { async start(uuid) {
return apiRequest(`/tasks/${uuid}/start`, { method: 'POST' }); return apiRequest(`/tasks/${uuid}/start`, { method: 'POST' });
}, },
/** /** @param {string} uuid @returns {Promise<Task>} */
* Stop task timer
* @param {string} uuid
* @returns {Promise<Task>}
*/
async stop(uuid) { async stop(uuid) {
return apiRequest(`/tasks/${uuid}/stop`, { method: 'POST' }); return apiRequest(`/tasks/${uuid}/stop`, { method: 'POST' });
}, },
/** /**
* Parse CLI input and create task
* @param {string} input - Raw opal CLI syntax * @param {string} input - Raw opal CLI syntax
* @returns {Promise<Task>} * @returns {Promise<{task?: Task} & Task>}
*/ */
async parse(input) { async parse(input) {
return apiRequest('/tasks/parse', { return apiRequest('/tasks/parse', {
@@ -112,29 +85,20 @@ export const tasks = {
}); });
}, },
/** /** @param {string} reportName @returns {Promise<Task[]>} */
* List tasks by report name
* @param {string} reportName
* @returns {Promise<Task[]>}
*/
async listByReport(reportName) { async listByReport(reportName) {
const result = await apiRequest(`/tasks?report=${encodeURIComponent(reportName)}`); const result = await apiRequest(`/tasks?report=${encodeURIComponent(reportName)}`);
return result.tasks ?? result; return result.tasks ?? result;
} }
}; };
// Tags API
export const tags = { export const tags = {
/** /** @returns {Promise<string[]>} */
* List all unique tags
* @returns {Promise<string[]>}
*/
async list() { async list() {
return apiRequest('/tags'); return apiRequest('/tags');
}, },
/** /**
* Add tag to task
* @param {string} uuid * @param {string} uuid
* @param {string} tag * @param {string} tag
* @returns {Promise<void>} * @returns {Promise<void>}
@@ -147,7 +111,6 @@ export const tags = {
}, },
/** /**
* Remove tag from task
* @param {string} uuid * @param {string} uuid
* @param {string} tag * @param {string} tag
* @returns {Promise<void>} * @returns {Promise<void>}
@@ -159,21 +122,15 @@ export const tags = {
} }
}; };
// Projects API
export const projects = { export const projects = {
/** /** @returns {Promise<string[]>} */
* List all projects
* @returns {Promise<string[]>}
*/
async list() { async list() {
return apiRequest('/projects'); return apiRequest('/projects');
} }
}; };
// Sync API
export const sync = { export const sync = {
/** /**
* Get changes since timestamp
* @param {number} since - Unix timestamp * @param {number} since - Unix timestamp
* @param {string} clientId * @param {string} clientId
* @returns {Promise<any[]>} * @returns {Promise<any[]>}
@@ -186,8 +143,7 @@ export const sync = {
}, },
/** /**
* Push local changes to server * @param {Partial<Task>[]} tasks
* @param {Task[]} tasks
* @param {string} clientId * @param {string} clientId
* @returns {Promise<{processed: number, conflicts: number}>} * @returns {Promise<{processed: number, conflicts: number}>}
*/ */
@@ -199,12 +155,8 @@ export const sync = {
} }
}; };
// Auth API
export const auth = { export const auth = {
/** /** @returns {Promise<{url: string, state: string}>} */
* Get OAuth login URL
* @returns {Promise<{url: string, state: string}>}
*/
async getLoginUrl() { async getLoginUrl() {
const response = await fetch(`${API_BASE}/auth/login`); const response = await fetch(`${API_BASE}/auth/login`);
const result = await response.json(); const result = await response.json();
@@ -215,7 +167,6 @@ export const auth = {
}, },
/** /**
* Exchange OAuth code for tokens
* @param {string} code * @param {string} code
* @returns {Promise<{access_token: string, refresh_token: string, expires_at: number, token_type: string, user: User}>} * @returns {Promise<{access_token: string, refresh_token: string, expires_at: number, token_type: string, user: User}>}
*/ */
@@ -233,11 +184,7 @@ export const auth = {
return result.data; return result.data;
}, },
/** /** @param {string} refreshToken @returns {Promise<void>} */
* Logout (revoke refresh token)
* @param {string} refreshToken
* @returns {Promise<void>}
*/
async logout(refreshToken) { async logout(refreshToken) {
return apiRequest('/auth/logout', { return apiRequest('/auth/logout', {
method: 'POST', method: 'POST',
+2
View File
@@ -27,6 +27,7 @@
* @property {number|null} recurrence_duration * @property {number|null} recurrence_duration
* @property {string|null} parent_uuid * @property {string|null} parent_uuid
* @property {string[]} tags * @property {string[]} tags
* @property {number} urgency
*/ */
/** /**
@@ -76,6 +77,7 @@
* @property {string} [project] * @property {string} [project]
* @property {string} [priority] * @property {string} [priority]
* @property {string[]} [tags] * @property {string[]} [tags]
* @property {string[]} [excludeTags]
*/ */
export {}; export {};
@@ -0,0 +1,249 @@
<script>
import { onMount, afterUpdate } from 'svelte';
/** @type {boolean} */
export let open = false;
/** @type {() => void} */
export let onClose;
let dragOffset = 0;
let dragging = false;
let locked = false;
let mounted = false;
/** @type {number|null} */
let startY = null;
/** @type {number|null} */
let startX = null;
/** @type {number|null} */
let lastY = null;
/** @type {number} */
let lastTime = 0;
/** @type {number} */
let velocity = 0;
const DISMISS_THRESHOLD = 150;
const VELOCITY_THRESHOLD = 0.5;
/** @type {HTMLDivElement|null} */
let sheetEl = null;
afterUpdate(() => {
if (!mounted) return;
if (open) {
document.documentElement.style.overflow = 'hidden';
} else {
document.documentElement.style.overflow = '';
dragOffset = 0;
dragging = false;
locked = false;
}
});
/**
* @param {KeyboardEvent} e
*/
function handleKeydown(e) {
if (!open) return;
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
if (e.key === 'Tab' && sheetEl) {
const focusable = sheetEl.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = /** @type {HTMLElement} */ (focusable[0]);
const last = /** @type {HTMLElement} */ (focusable[focusable.length - 1]);
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
onMount(() => {
mounted = true;
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
document.documentElement.style.overflow = '';
};
});
/**
* @param {TouchEvent} e
*/
function handleDragStart(e) {
const touch = e.touches[0];
startY = touch.clientY;
startX = touch.clientX;
lastY = touch.clientY;
lastTime = Date.now();
locked = false;
dragging = false;
velocity = 0;
}
/**
* @param {TouchEvent} e
*/
function handleDragMove(e) {
if (startY === null || startX === null) return;
const touch = e.touches[0];
const deltaY = touch.clientY - startY;
const deltaX = touch.clientX - startX;
if (!locked && !dragging) {
if (Math.abs(deltaY) > 10 && Math.abs(deltaY) > Math.abs(deltaX) * 2) {
dragging = true;
locked = true;
} else if (Math.abs(deltaX) > 10) {
startY = null;
startX = null;
return;
}
}
if (dragging) {
if (e.cancelable) e.preventDefault();
dragOffset = Math.max(0, deltaY);
const now = Date.now();
const dt = now - lastTime;
if (dt > 0 && lastY !== null) {
velocity = (touch.clientY - lastY) / dt;
}
lastY = touch.clientY;
lastTime = now;
}
}
function handleDragEnd() {
if (!dragging) {
startY = null;
startX = null;
return;
}
if (dragOffset > DISMISS_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
onClose();
}
dragOffset = 0;
dragging = false;
startY = null;
startX = null;
}
function handleScrimClick() {
onClose();
}
$: transitioning = !dragging && dragOffset === 0;
</script>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="sheet-scrim" class:open on:click={handleScrimClick}>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions a11y-interactive-supports-focus -->
<div
bind:this={sheetEl}
class="sheet"
class:open
class:transitioning
style:transform={open
? `translateY(${dragOffset}px)`
: undefined}
on:click|stopPropagation
role="dialog"
aria-modal="true"
>
<div
class="sheet-handle"
on:touchstart={handleDragStart}
on:touchmove={handleDragMove}
on:touchend={handleDragEnd}
>
<div class="handle-bar"></div>
</div>
<div class="sheet-content">
<slot />
</div>
</div>
</div>
<style>
.sheet-scrim {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.sheet-scrim.open {
opacity: 1;
pointer-events: auto;
}
.sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
max-height: 85dvh;
background: var(--bg-primary);
border-radius: 1rem 1rem 0 0;
box-shadow: var(--shadow-lg);
transform: translateY(100%);
display: flex;
flex-direction: column;
z-index: 51;
}
.sheet.transitioning {
transition: transform 0.3s ease-out;
}
.sheet.open {
transform: translateY(0);
}
.sheet-handle {
display: flex;
justify-content: center;
padding: var(--spacing-sm) 0;
cursor: grab;
touch-action: none;
flex-shrink: 0;
}
.handle-bar {
width: 2.5rem;
height: 0.25rem;
background: var(--text-tertiary);
border-radius: 9999px;
}
.sheet-content {
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0 var(--spacing-md) var(--spacing-lg);
}
@media (min-width: 769px) {
.sheet {
max-width: 480px;
left: 50%;
margin-left: -240px;
}
}
</style>
@@ -0,0 +1,178 @@
<script>
/** @type {string} */
export let title;
/** @type {string} */
export let message;
/** @type {string|undefined} */
export let detail = undefined;
/** @type {string} */
export let confirmLabel;
/** @type {'danger'|'primary'} */
export let confirmVariant = 'danger';
/** @type {() => void} */
export let onConfirm;
/** @type {() => void} */
export let onCancel;
/** @type {HTMLDialogElement|null} */
let dialogEl = null;
/** @type {HTMLButtonElement|null} */
let cancelBtn = null;
export function open() {
dialogEl?.showModal();
requestAnimationFrame(() => cancelBtn?.focus());
}
function handleConfirm() {
dialogEl?.close();
onConfirm();
}
function handleCancel() {
dialogEl?.close();
onCancel();
}
/**
* @param {MouseEvent} e
*/
function handleBackdropClick(e) {
if (e.target === dialogEl) {
handleCancel();
}
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
<dialog
bind:this={dialogEl}
class="confirm-dialog"
on:click={handleBackdropClick}
on:cancel|preventDefault={handleCancel}
>
<div class="dialog-content">
<h3 class="dialog-title">{title}</h3>
<p class="dialog-message">"{message}"</p>
{#if detail}
<p class="dialog-detail">{detail}</p>
{/if}
<div class="dialog-actions">
<button
bind:this={cancelBtn}
class="btn btn-ghost"
on:click={handleCancel}
type="button"
>
Cancel
</button>
<button
class="btn btn-{confirmVariant}"
on:click={handleConfirm}
type="button"
>
{confirmLabel}
</button>
</div>
</div>
</dialog>
<style>
.confirm-dialog {
border: none;
border-radius: var(--border-radius);
background: transparent;
padding: 0;
margin: auto;
max-width: min(360px, calc(100vw - 2rem));
width: 100%;
}
.confirm-dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
}
.dialog-content {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
box-shadow: var(--shadow-lg);
padding: var(--spacing-lg);
}
.dialog-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
margin: 0 0 var(--spacing-md) 0;
}
.dialog-message {
font-size: var(--font-size-base);
color: var(--text-primary);
margin: 0 0 var(--spacing-xs) 0;
word-break: break-word;
}
.dialog-detail {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin: 0 0 var(--spacing-lg) 0;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
}
.btn {
padding: var(--spacing-sm) var(--spacing-md);
border: none;
border-radius: var(--border-radius);
font-size: var(--font-size-sm);
font-family: inherit;
font-weight: 500;
cursor: pointer;
min-height: 40px;
min-width: unset;
}
.btn-ghost {
background: none;
color: var(--text-secondary);
}
.btn-ghost:hover {
background-color: var(--bg-secondary);
color: var(--text-primary);
}
.btn-danger {
background-color: var(--color-danger);
color: white;
}
.btn-danger:hover {
opacity: 0.9;
}
.btn-primary {
background-color: var(--color-primary);
color: white;
}
.btn-primary:hover {
opacity: 0.9;
}
</style>
@@ -0,0 +1,303 @@
<script>
import { recentFilters, setFilter, removeRecent } from '$lib/stores/filters.js';
/** @type {() => void} */
export let onClose;
let filterInput = '';
/** @type {HTMLDialogElement|null} */
let dialogEl = null;
/** @type {HTMLInputElement|null} */
let inputEl = null;
export function open() {
filterInput = '';
dialogEl?.showModal();
// Focus after dialog animation
requestAnimationFrame(() => inputEl?.focus());
}
function apply() {
const trimmed = filterInput.trim();
if (trimmed) {
setFilter(trimmed);
}
close();
}
function close() {
dialogEl?.close();
onClose();
}
/**
* @param {string} filter
*/
function applyRecent(filter) {
setFilter(filter);
close();
}
/**
* @param {Event} e
* @param {string} filter
*/
function handleRemoveRecent(e, filter) {
e.stopPropagation();
removeRecent(filter);
}
/**
* @param {KeyboardEvent} e
*/
function handleKeydown(e) {
if (e.key === 'Enter') {
e.preventDefault();
apply();
}
}
/**
* @param {MouseEvent} e
*/
function handleBackdropClick(e) {
if (e.target === dialogEl) {
close();
}
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
<dialog
bind:this={dialogEl}
class="filter-modal"
on:click={handleBackdropClick}
>
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Filter</h3>
<button class="close-btn" on:click={close} type="button" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" width="20" height="20">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="modal-body">
<div class="input-wrapper">
<input
bind:this={inputEl}
bind:value={filterInput}
on:keydown={handleKeydown}
type="text"
placeholder="e.g. +grocer project:home"
class="filter-input"
/>
<button
class="apply-btn"
on:click={apply}
disabled={!filterInput.trim()}
type="button"
>
Apply
</button>
</div>
{#if $recentFilters.length > 0}
<div class="recents">
<div class="recents-label">Recent</div>
{#each $recentFilters as recent}
<button
class="recent-row"
on:click={() => applyRecent(recent)}
type="button"
>
<span class="recent-text">{recent}</span>
<span
class="recent-remove"
role="button"
tabindex="0"
on:click={(e) => handleRemoveRecent(e, recent)}
on:keydown={(e) => e.key === 'Enter' && handleRemoveRecent(e, recent)}
aria-label="Remove {recent} from recents"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" width="14" height="14">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</span>
</button>
{/each}
</div>
{/if}
</div>
</div>
</dialog>
<style>
.filter-modal {
border: none;
border-radius: var(--border-radius);
background: transparent;
padding: 0;
margin: auto;
max-width: min(400px, calc(100vw - 2rem));
width: 100%;
}
.filter-modal::backdrop {
background: rgba(0, 0, 0, 0.5);
}
.modal-content {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid var(--border-color);
}
.modal-title {
font-size: var(--font-size-base);
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
min-width: 32px;
min-height: 32px;
background: none;
border: none;
border-radius: 0.25rem;
color: var(--text-secondary);
cursor: pointer;
}
.close-btn:hover {
background-color: var(--bg-secondary);
}
.modal-body {
padding: var(--spacing-md);
}
.input-wrapper {
display: flex;
gap: var(--spacing-sm);
}
.filter-input {
flex: 1;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
font-size: var(--font-size-base);
font-family: inherit;
background-color: var(--bg-secondary);
color: var(--text-primary);
min-width: 0;
}
.filter-input::placeholder {
color: var(--text-tertiary);
}
.filter-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--focus-ring);
}
.apply-btn {
padding: var(--spacing-sm) var(--spacing-md);
background-color: var(--color-primary);
color: white;
border: none;
border-radius: var(--border-radius);
font-size: var(--font-size-sm);
font-family: inherit;
font-weight: 500;
cursor: pointer;
min-width: unset;
white-space: nowrap;
}
.apply-btn:hover:not(:disabled) {
background-color: var(--color-primary-dark);
}
.apply-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.recents {
margin-top: var(--spacing-md);
}
.recents-label {
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
padding-bottom: var(--spacing-xs);
}
.recent-row {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--spacing-sm);
background: none;
border: none;
border-radius: 0.25rem;
font-family: inherit;
color: var(--text-primary);
cursor: pointer;
text-align: left;
min-height: 40px;
min-width: unset;
}
.recent-row:hover {
background-color: var(--bg-secondary);
}
.recent-text {
font-size: var(--font-size-sm);
font-family: var(--font-mono);
}
.recent-remove {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 0.25rem;
color: var(--text-tertiary);
flex-shrink: 0;
}
.recent-remove:hover {
color: var(--color-danger);
background-color: var(--bg-tertiary);
}
</style>
@@ -0,0 +1,108 @@
<script>
import { activeFilter, setFilter } from '$lib/stores/filters.js';
/** @param {string} token */
function removeToken(token) {
if (!$activeFilter) return;
const tokens = $activeFilter.trim().split(/\s+/);
const remaining = tokens.filter(t => t !== token);
if (remaining.length === 0) {
activeFilter.set(null);
} else {
setFilter(remaining.join(' '));
}
}
/** @param {string} token */
function tokenType(token) {
if (token.startsWith('+')) return 'tag';
if (token.startsWith('-')) return 'exclude';
if (token.includes(':')) return 'attr';
return 'unknown';
}
$: tokens = $activeFilter ? $activeFilter.trim().split(/\s+/) : [];
</script>
{#if tokens.length > 0}
<div class="filter-pills">
{#each tokens as token}
<button
class="filter-pill {tokenType(token)}"
type="button"
on:mousedown|preventDefault={() => removeToken(token)}
title="Remove filter: {token}"
>
<span class="pill-text">{token}</span>
<svg class="pill-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" width="12" height="12">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/each}
</div>
{/if}
<style>
.filter-pills {
display: flex;
flex-wrap: nowrap;
gap: 1px;
align-items: stretch;
align-self: stretch;
flex-shrink: 0;
}
.filter-pill {
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0 0.5rem;
margin: 0;
border-radius: 0;
font-size: var(--font-size-xs);
font-family: var(--font-mono);
border: none;
cursor: pointer;
min-height: unset;
min-width: unset;
line-height: 1.3;
transition: opacity 0.15s;
}
.filter-pill:hover {
opacity: 0.7;
}
.filter-pill.tag {
background-color: var(--color-tag-bg);
color: var(--color-tag-text);
}
.filter-pill.exclude {
background-color: var(--color-priority-high-bg);
color: var(--color-priority-high-text);
}
.filter-pill.attr {
background-color: var(--color-project-bg);
color: var(--color-project-text);
}
.filter-pill.unknown {
background-color: var(--bg-tertiary);
color: var(--text-secondary);
}
.pill-text {
white-space: nowrap;
}
.pill-x {
flex-shrink: 0;
opacity: 0.6;
}
.filter-pill:hover .pill-x {
opacity: 1;
}
</style>
+71 -5
View File
@@ -1,6 +1,7 @@
<script> <script>
import ReportPicker from './ReportPicker.svelte'; import ReportPicker from './ReportPicker.svelte';
import ThemeSwitcher from './ThemeSwitcher.svelte'; import FilterModal from './FilterModal.svelte';
import { activeFilter } from '$lib/stores/filters.js';
/** /**
* @type {string} * @type {string}
@@ -15,7 +16,9 @@
/** @type {ReportPicker} */ /** @type {ReportPicker} */
let picker; let picker;
/** Map backend report names to display labels */ /** @type {FilterModal} */
let filterModal;
const reportLabels = /** @type {Record<string, string>} */ ({ const reportLabels = /** @type {Record<string, string>} */ ({
list: 'Pending', list: 'Pending',
next: 'Next', next: 'Next',
@@ -31,9 +34,11 @@
}); });
$: displayLabel = reportLabels[activeReport] || activeReport; $: displayLabel = reportLabels[activeReport] || activeReport;
$: hasActiveFilter = !!$activeFilter;
</script> </script>
<header class="header"> <header class="header">
<div class="header-left">
<button <button
class="report-btn" class="report-btn"
on:click={() => picker.toggle()} on:click={() => picker.toggle()}
@@ -44,8 +49,22 @@
</svg> </svg>
</button> </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"> <div class="header-actions">
<ThemeSwitcher mode="cycle" />
<a href="/settings" class="settings-btn" aria-label="Settings"> <a href="/settings" class="settings-btn" aria-label="Settings">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
@@ -61,18 +80,31 @@
onSelect={onReportChange} onSelect={onReportChange}
/> />
<FilterModal
bind:this={filterModal}
onClose={() => {}}
/>
<style> <style>
.header { .header {
grid-area: header;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-sm) var(--spacing-md);
background-color: var(--bg-primary); background-color: var(--bg-primary);
border-bottom: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
flex-shrink: 0; 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 { .report-btn {
anchor-name: --report-btn;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--spacing-xs); gap: var(--spacing-xs);
@@ -101,6 +133,40 @@
color: var(--text-secondary); 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 { .header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
+47 -20
View File
@@ -1,5 +1,8 @@
<script> <script>
import PropertyPills from './PropertyPills.svelte'; 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>} * @type {(input: string) => Promise<void>}
@@ -21,8 +24,11 @@
async function handleSubmit() { async function handleSubmit() {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed || loading) return; if (!trimmed || loading) return;
const merged = mergeInputWithFilter(trimmed, $activeFilter || '');
try { try {
await onSubmit(trimmed); await onSubmit(merged);
value = ''; value = '';
} catch { } catch {
// Value preserved for retry // Value preserved for retry
@@ -54,22 +60,17 @@
}, 150); }, 150);
} }
/** /** @param {string} text */
* Insert text at cursor position
* @param {string} text
*/
function insertAtCursor(text) { function insertAtCursor(text) {
if (!inputEl) return; if (!inputEl) return;
const start = inputEl.selectionStart ?? value.length; const start = inputEl.selectionStart ?? value.length;
const end = inputEl.selectionEnd ?? value.length; const end = inputEl.selectionEnd ?? value.length;
// Add leading space if cursor isn't at start and prev char isn't a space
const needsSpace = start > 0 && value[start - 1] !== ' '; const needsSpace = start > 0 && value[start - 1] !== ' ';
const insert = (needsSpace ? ' ' : '') + text; const insert = (needsSpace ? ' ' : '') + text;
value = value.slice(0, start) + insert + value.slice(end); value = value.slice(0, start) + insert + value.slice(end);
// Restore focus and cursor position after the inserted text
const newPos = start + insert.length; const newPos = start + insert.length;
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (inputEl) { if (inputEl) {
@@ -78,16 +79,30 @@
} }
}); });
} }
/** @returns {string} */
export function getInputValue() {
return value;
}
/** @param {string} newValue */
export function setInputValue(newValue) {
value = newValue;
}
</script> </script>
<div class="input-bar"> <div class="input-bar">
<PropertyPills visible={focused} onInsert={insertAtCursor} /> <PropertyPills visible={focused} onInsert={insertAtCursor} inputValue={value} onInputChange={(v) => { value = v; }} />
{#if error} {#if error}
<div class="error">{error}</div> <div class="error">{error}</div>
{/if} {/if}
<div class="input-row"> <div class="input-row" class:focused>
{#if $activeFilter}
<FilterPills />
<div class="separator"></div>
{/if}
<input <input
bind:this={inputEl} bind:this={inputEl}
bind:value bind:value
@@ -95,7 +110,7 @@
on:focus={handleFocus} on:focus={handleFocus}
on:blur={handleBlur} on:blur={handleBlur}
type="text" 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} disabled={loading}
class="input" class="input"
/> />
@@ -119,27 +134,41 @@
<style> <style>
.input-bar { .input-bar {
flex-shrink: 0; grid-area: input;
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-sm) var(--spacing-md);
padding-bottom: calc(var(--spacing-sm) + env(safe-area-inset-bottom, 0px));
background-color: var(--bg-primary); background-color: var(--bg-primary);
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
} }
.input-row { .input-row {
display: flex; display: flex;
gap: var(--spacing-sm); align-items: stretch;
align-items: center; 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 { .input {
flex: 1; flex: 1;
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border-color); border: none;
border-radius: var(--border-radius); border-radius: 0;
font-size: var(--font-size-base); font-size: var(--font-size-base);
font-family: inherit; font-family: inherit;
background-color: var(--bg-secondary); background: transparent;
color: var(--text-primary); color: var(--text-primary);
min-width: 0; min-width: 0;
} }
@@ -150,8 +179,6 @@
.input:focus { .input:focus {
outline: none; outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--focus-ring);
} }
.submit-btn { .submit-btn {
@@ -163,7 +190,7 @@
background-color: var(--color-primary); background-color: var(--color-primary);
color: white; color: white;
border: none; border: none;
border-radius: var(--border-radius); border-radius: 0 calc(var(--border-radius) - 1px) calc(var(--border-radius) - 1px) 0;
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
min-width: 44px; min-width: 44px;
@@ -1,21 +1,43 @@
<script> <script>
import { removeTokenByPrefix } from '$lib/utils/filters.js';
/** /**
* @type {(text: string) => void} * @type {(text: string) => void}
*/ */
export let onInsert; export let onInsert;
export let inputValue = '';
export let onInputChange = /** @type {(value: string) => void} */ (() => {});
export let visible = false; export let visible = false;
const pills = [ const pills = [
{ label: 'Due', text: 'due:' }, { label: "Due", text: "due:", isTag: false },
{ label: 'Pri', text: 'priority:' }, { label: "Pri", text: "priority:", isTag: false },
{ label: 'Project', text: 'project:' }, { label: "Project", text: "project:", isTag: false },
{ label: 'Tag', text: '+' }, { label: "Tag", text: "+", isTag: true },
{ label: 'Recur', text: 'recur:' }, { label: "Recur", text: "recur:", isTag: false },
{ label: 'Scheduled', text: 'scheduled:' }, { label: "Scheduled", text: "scheduled:", isTag: false },
{ label: 'Wait', text: 'wait:' }, { label: "Wait", text: "wait:", isTag: false },
{ label: 'Until', text: 'until:' } { label: "Until", text: "until:", isTag: false },
]; ];
/**
* @param {{ text: string, isTag: boolean }} pill
*/
function handleInsert(pill) {
if (!pill.isTag && inputValue) {
const prefix = pill.text; // e.g. "due:"
const cleaned = removeTokenByPrefix(inputValue, prefix);
if (cleaned !== inputValue) {
onInputChange(cleaned);
// Let the DOM update, then insert
requestAnimationFrame(() => onInsert(pill.text));
return;
}
}
onInsert(pill.text);
}
</script> </script>
{#if visible} {#if visible}
@@ -24,7 +46,7 @@
<button <button
class="pill" class="pill"
type="button" type="button"
on:mousedown|preventDefault={() => onInsert(pill.text)} on:mousedown|preventDefault={() => handleInsert(pill)}
> >
{pill.label} {pill.label}
</button> </button>
@@ -41,11 +63,11 @@
} }
.pill { .pill {
padding: 0.25rem 0.625rem; padding: 0.375rem 0.75rem;
background-color: var(--bg-tertiary); background-color: var(--bg-tertiary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 1rem; border-radius: 1rem;
font-size: var(--font-size-xs); font-size: var(--font-size-s);
font-family: inherit; font-family: inherit;
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
@@ -83,9 +83,8 @@
<style> <style>
.report-picker { .report-picker {
position: fixed; position: fixed;
top: 48px; position-anchor: --report-btn;
left: var(--spacing-md); position-area: bottom span-right;
right: auto;
margin: 0; margin: 0;
padding: var(--spacing-sm); padding: var(--spacing-sm);
background-color: var(--bg-primary); background-color: var(--bg-primary);
+70 -18
View File
@@ -2,12 +2,23 @@
/** /**
* @type {() => void} * @type {() => void}
*/ */
export let onSwipe; export let onSwipeRight;
/**
* @type {() => void}
*/
export let onSwipeLeft;
/**
* @type {'start' | 'stop'}
*/
export let leftIcon;
let offsetX = 0; let offsetX = 0;
let swiping = false; let swiping = false;
let locked = false; let locked = false;
let completed = false; let completed = false;
let triggered = false;
/** @type {number|null} */ /** @type {number|null} */
let startX = null; let startX = null;
@@ -38,12 +49,10 @@
const deltaY = touch.clientY - startY; const deltaY = touch.clientY - startY;
if (!locked && !swiping) { if (!locked && !swiping) {
// Angle-based lock-in: horizontal must dominate
if (Math.abs(deltaX) > 10 && Math.abs(deltaX) > Math.abs(deltaY) * 2) { if (Math.abs(deltaX) > 10 && Math.abs(deltaX) > Math.abs(deltaY) * 2) {
swiping = true; swiping = true;
locked = true; locked = true;
} else if (Math.abs(deltaY) > 10) { } else if (Math.abs(deltaY) > 10) {
// Vertical scroll — abort
startX = null; startX = null;
startY = null; startY = null;
return; return;
@@ -51,9 +60,8 @@
} }
if (swiping) { if (swiping) {
e.preventDefault(); if (e.cancelable) e.preventDefault();
// Only allow right swipe offsetX = deltaX;
offsetX = Math.max(0, deltaX);
} }
} }
@@ -65,10 +73,17 @@
if (offsetX >= THRESHOLD) { if (offsetX >= THRESHOLD) {
completed = true; completed = true;
// Animate to full width before firing callback
offsetX = window.innerWidth; offsetX = window.innerWidth;
setTimeout(() => { setTimeout(() => {
onSwipe(); onSwipeRight();
}, 200);
} else if (offsetX <= -THRESHOLD) {
triggered = true;
offsetX = -window.innerWidth;
setTimeout(() => {
onSwipeLeft();
offsetX = 0;
triggered = false;
}, 200); }, 200);
} else { } else {
offsetX = 0; offsetX = 0;
@@ -86,7 +101,8 @@
startY = null; 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; $: transitioning = !swiping && offsetX !== 0;
</script> </script>
@@ -97,15 +113,28 @@
on:touchend={handleTouchEnd} on:touchend={handleTouchEnd}
on:touchcancel={resetState} on:touchcancel={resetState}
> >
<div <!-- Left background (revealed on RIGHT swipe — complete) -->
class="swipe-background" <div class="swipe-bg swipe-bg-right"
style:opacity={progress} style:opacity={rightProgress}>
> <svg class="swipe-icon check-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<svg class="check-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg> </svg>
</div> </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 <div
class="swipe-content" class="swipe-content"
class:transitioning class:transitioning
@@ -122,21 +151,44 @@
touch-action: pan-y; touch-action: pan-y;
} }
.swipe-background { .swipe-bg {
position: absolute; position: absolute;
inset: 0; inset: 0;
background-color: var(--color-success);
display: flex; display: flex;
align-items: center; 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; width: 1.5rem;
height: 1.5rem; height: 1.5rem;
color: white; 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 { .swipe-content {
position: relative; position: relative;
background-color: var(--bg-primary); background-color: var(--bg-primary);
File diff suppressed because it is too large Load Diff
+61 -3
View File
@@ -14,10 +14,21 @@
*/ */
export let onComplete; 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; let completing = false;
$: overdue = task.due && isOverdue(task.due); $: overdue = task.due && isOverdue(task.due);
$: dueToday = task.due && isTodayFn(new Date(task.due * 1000)); $: dueToday = task.due && isTodayFn(new Date(task.due * 1000));
$: active = task.start !== null && task.start !== undefined;
function handleCheckbox() { function handleCheckbox() {
if (completing) return; if (completing) return;
@@ -34,13 +45,18 @@
} }
</script> </script>
<SwipeAction onSwipe={() => onComplete(task.uuid)}> <SwipeAction
<div class="task-item" class:completing on:transitionend={handleTransitionEnd}> 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"> <button class="task-checkbox" on:click|stopPropagation={handleCheckbox} type="button" aria-label="Complete task">
<Checkbox checked={task.status === 'C'} /> <Checkbox checked={task.status === 'C'} />
</button> </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"> <div class="task-header">
<span class="task-description" class:completed={task.status === 'C'}> <span class="task-description" class:completed={task.status === 'C'}>
{task.description} {task.description}
@@ -48,6 +64,10 @@
</div> </div>
<div class="task-meta"> <div class="task-meta">
{#if active}
<span class="meta-item active-pill">Active</span>
{/if}
{#if task.project} {#if task.project}
<span class="meta-item project">{task.project}</span> <span class="meta-item project">{task.project}</span>
{/if} {/if}
@@ -73,6 +93,16 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{#if task.urgency > 0}
<span class="meta-item urgency"
class:urgency-critical={task.urgency >= 10}
class:urgency-high={task.urgency >= 5 && task.urgency < 10}
class:urgency-normal={task.urgency > 0 && task.urgency < 5}
>
{task.urgency.toFixed(1)}
</span>
{/if}
</div> </div>
</div> </div>
</div> </div>
@@ -99,6 +129,11 @@
overflow: hidden; overflow: hidden;
} }
.task-item.active {
border-left: 3px solid var(--color-primary);
padding-left: calc(var(--spacing-md) - 3px);
}
.task-checkbox { .task-checkbox {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
@@ -117,6 +152,7 @@
.task-content { .task-content {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
cursor: pointer;
} }
.task-header { .task-header {
@@ -148,6 +184,11 @@
font-weight: 500; font-weight: 500;
} }
.active-pill {
background-color: var(--color-active-bg);
color: var(--color-active-text);
}
.project { .project {
background-color: var(--color-project-bg); background-color: var(--color-project-bg);
color: var(--color-project-text); color: var(--color-project-text);
@@ -196,4 +237,21 @@
color: var(--color-tag-text); color: var(--color-tag-text);
border-radius: 0.25rem; border-radius: 0.25rem;
} }
.urgency {
margin-left: auto;
font-variant-numeric: tabular-nums;
}
.urgency-critical {
color: var(--color-priority-high-text);
}
.urgency-high {
color: var(--color-priority-medium-text);
}
.urgency-normal {
color: var(--text-secondary);
}
</style> </style>
+14 -1
View File
@@ -11,6 +11,16 @@
*/ */
export let onComplete; 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 loading = false;
export let activeReport = 'list'; export let activeReport = 'list';
@@ -50,6 +60,8 @@
<TaskItem <TaskItem
{task} {task}
{onComplete} {onComplete}
{onTap}
{onStartStop}
/> />
{/each} {/each}
{/if} {/if}
@@ -57,10 +69,11 @@
<style> <style>
.task-list { .task-list {
flex: 1; grid-area: content;
overflow-y: auto; overflow-y: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
background-color: var(--bg-primary); background-color: var(--bg-primary);
min-height: 0;
} }
.loading-container { .loading-container {
+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>
@@ -1,73 +0,0 @@
<script>
/**
* @type {Array<{value: string, label: string}>}
*/
export let options = [];
export let value = '';
export let label = '';
export let placeholder = 'Select...';
export let disabled = false;
export let id = '';
</script>
<div class="select-group">
{#if label}
<label for={id} class="label">{label}</label>
{/if}
<select
{id}
bind:value
{disabled}
class="select"
on:change
>
<option value="" disabled selected>{placeholder}</option>
{#each options as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<style>
.select-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 100%;
}
.label {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--text-primary);
}
.select {
width: 100%;
padding: 0.75rem;
font-size: var(--font-size-base);
font-family: inherit;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background-color: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%236b7280'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
background-size: 1.5rem;
padding-right: 2.5rem;
}
.select:focus {
outline: none;
border-color: var(--color-primary);
}
.select:disabled {
background-color: var(--bg-tertiary);
cursor: not-allowed;
}
</style>
-1
View File
@@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.
-300
View File
@@ -1,300 +0,0 @@
/**
* Mock task data for local development / design review.
* Covers a realistic spread of projects, priorities, tags, due dates, and statuses.
*/
const now = Math.floor(Date.now() / 1000);
const HOUR = 3600;
const DAY = 86400;
/** @type {import('$lib/api/types.js').Task[]} */
export const mockTasks = [
// ── Pending tasks ────────────────────────────────────────────
{
uuid: '11111111-1111-4111-a111-111111111101',
id: 1,
status: 'P',
description: 'Set up Caddy reverse proxy for opal-web',
project: 'Infrastructure',
priority: 3,
created: now - 7 * DAY,
modified: now - 1 * DAY,
start: now - 2 * HOUR,
end: null,
due: now + 2 * DAY,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['devops', 'selfhosted']
},
{
uuid: '11111111-1111-4111-a111-111111111102',
id: 2,
status: 'P',
description: 'Write unit tests for task filter parsing',
project: 'Opal',
priority: 2,
created: now - 5 * DAY,
modified: now - 3 * DAY,
start: null,
end: null,
due: now + 5 * DAY,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['testing', 'backend']
},
{
uuid: '11111111-1111-4111-a111-111111111103',
id: 3,
status: 'P',
description: 'Fix tag extraction for nested wiki-links',
project: 'Jade',
priority: 2,
created: now - 3 * DAY,
modified: now - 3 * DAY,
start: null,
end: null,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['bug']
},
{
uuid: '11111111-1111-4111-a111-111111111104',
id: 4,
status: 'P',
description: 'Grocery run - farmers market',
project: null,
priority: 1,
created: now - 1 * DAY,
modified: now - 1 * DAY,
start: null,
end: null,
due: now + 1 * DAY,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['errand']
},
{
uuid: '11111111-1111-4111-a111-111111111105',
id: 5,
status: 'P',
description: 'Design task detail page for opal-web',
project: 'Opal',
priority: 3,
created: now - 2 * DAY,
modified: now - 2 * DAY,
start: null,
end: null,
due: now - 1 * DAY, // overdue
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['frontend', 'design']
},
{
uuid: '11111111-1111-4111-a111-111111111106',
id: 6,
status: 'P',
description: 'Renew domain registration for jnss.me',
project: 'Infrastructure',
priority: 1,
created: now - 14 * DAY,
modified: now - 14 * DAY,
start: null,
end: null,
due: now + 30 * DAY,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['admin']
},
{
uuid: '11111111-1111-4111-a111-111111111107',
id: 7,
status: 'P',
description: 'Add recurrence UI to task creation form',
project: 'Opal',
priority: 1,
created: now - 4 * DAY,
modified: now - 4 * DAY,
start: null,
end: null,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['frontend']
},
{
uuid: '11111111-1111-4111-a111-111111111108',
id: 8,
status: 'P',
description: 'Migrate Nextcloud to latest stable',
project: 'Infrastructure',
priority: 0,
created: now - 10 * DAY,
modified: now - 10 * DAY,
start: null,
end: null,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['selfhosted', 'maintenance']
},
{
uuid: '11111111-1111-4111-a111-111111111109',
id: 9,
status: 'P',
description: 'Read "Designing Data-Intensive Applications" ch. 7',
project: null,
priority: 0,
created: now - 6 * DAY,
modified: now - 6 * DAY,
start: null,
end: null,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['reading', 'learning']
},
{
uuid: '11111111-1111-4111-a111-111111111110',
id: 10,
status: 'P',
description: 'Review PR: sync conflict resolution strategy',
project: 'Opal',
priority: 2,
created: now - 1 * DAY,
modified: now - 1 * DAY,
start: null,
end: null,
due: now, // due today
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['review', 'backend']
},
// ── Completed tasks ──────────────────────────────────────────
{
uuid: '22222222-2222-4222-a222-222222222201',
id: 11,
status: 'C',
description: 'Implement XDG directory support',
project: 'Opal',
priority: 2,
created: now - 14 * DAY,
modified: now - 7 * DAY,
start: null,
end: now - 7 * DAY,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['backend', 'refactor']
},
{
uuid: '22222222-2222-4222-a222-222222222202',
id: 12,
status: 'C',
description: 'Set up Authentik OAuth provider',
project: 'Infrastructure',
priority: 3,
created: now - 21 * DAY,
modified: now - 10 * DAY,
start: null,
end: now - 10 * DAY,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['auth', 'selfhosted']
},
{
uuid: '22222222-2222-4222-a222-222222222203',
id: 13,
status: 'C',
description: 'Build setup wizard for first-run',
project: 'Opal',
priority: 2,
created: now - 10 * DAY,
modified: now - 5 * DAY,
start: null,
end: now - 5 * DAY,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['ux', 'backend']
},
{
uuid: '22222222-2222-4222-a222-222222222204',
id: 14,
status: 'C',
description: 'Fix PersistentPreRun initialization order',
project: 'Opal',
priority: 3,
created: now - 8 * DAY,
modified: now - 6 * DAY,
start: null,
end: now - 6 * DAY,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['bug', 'backend']
},
{
uuid: '22222222-2222-4222-a222-222222222205',
id: 15,
status: 'C',
description: 'Write deployment guide with Caddy config',
project: 'Opal',
priority: 1,
created: now - 9 * DAY,
modified: now - 6 * DAY,
start: null,
end: now - 6 * DAY,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['docs']
}
];
+30 -67
View File
@@ -17,17 +17,24 @@ import { getItem, setItem, removeItem } from '$lib/utils/storage.js';
*/ */
const STORAGE_KEY = 'opal_auth'; const STORAGE_KEY = 'opal_auth';
const MOCK_MODE = import.meta.env.VITE_MOCK_MODE === 'true'; const DEV_MODE = import.meta.env.DEV;
/** /** @type {AuthState} */
* Load auth state from localStorage const EMPTY_STATE = {
* @returns {AuthState} accessToken: null,
*/ refreshToken: null,
expiresAt: null,
user: null,
isAuthenticated: false
};
/** @returns {AuthState} */
function loadAuth() { function loadAuth() {
// In mock mode, always return authenticated // In dev mode, auto-authenticate with a dev user.
if (MOCK_MODE) { // API requests still go to the real backend (which runs with auth disabled).
if (DEV_MODE) {
return { return {
accessToken: 'mock-token', accessToken: 'dev-token',
refreshToken: '', refreshToken: '',
expiresAt: 9999999999, expiresAt: 9999999999,
user: { id: 1, username: 'dev', email: 'dev@localhost' }, user: { id: 1, username: 'dev', email: 'dev@localhost' },
@@ -35,21 +42,11 @@ function loadAuth() {
}; };
} }
if (!browser) { if (!browser) return EMPTY_STATE;
return {
accessToken: null,
refreshToken: null,
expiresAt: null,
user: null,
isAuthenticated: false
};
}
const stored = getItem(STORAGE_KEY); const stored = getItem(STORAGE_KEY);
if (stored) { if (stored) {
// Check if token expired
if (stored.expiresAt && stored.expiresAt < Date.now() / 1000) { if (stored.expiresAt && stored.expiresAt < Date.now() / 1000) {
// Token expired - clear
removeItem(STORAGE_KEY); removeItem(STORAGE_KEY);
return loadAuth(); return loadAuth();
} }
@@ -59,28 +56,21 @@ function loadAuth() {
}; };
} }
return { return EMPTY_STATE;
accessToken: null,
refreshToken: null,
expiresAt: null,
user: null,
isAuthenticated: false
};
} }
/**
* Create auth store
*/
function createAuthStore() { function createAuthStore() {
const { subscribe, set, update } = writable(loadAuth()); const { subscribe, set, update } = writable(loadAuth());
/** Persist state to localStorage */
function persist(/** @type {AuthState} */ state) {
if (browser) setItem(STORAGE_KEY, state);
}
return { return {
subscribe, subscribe,
/** /** @param {AuthTokens} tokens */
* Set authentication tokens
* @param {AuthTokens} tokens
*/
setTokens(tokens) { setTokens(tokens) {
update(state => { update(state => {
const newState = { const newState = {
@@ -90,33 +80,21 @@ function createAuthStore() {
expiresAt: tokens.expires_at, expiresAt: tokens.expires_at,
isAuthenticated: true isAuthenticated: true
}; };
persist(newState);
if (browser) {
setItem(STORAGE_KEY, newState);
}
return newState; return newState;
}); });
}, },
/** /** @param {User} user */
* Set user info
* @param {User} user
*/
setUser(user) { setUser(user) {
update(state => { update(state => {
const newState = { ...state, user }; const newState = { ...state, user };
if (browser) { persist(newState);
setItem(STORAGE_KEY, newState);
}
return newState; return newState;
}); });
}, },
/** /** @param {AuthTokens & {user: User}} data */
* Set full auth data (tokens + user)
* @param {AuthTokens & {user: User}} data
*/
setAuth(data) { setAuth(data) {
const newState = { const newState = {
accessToken: data.access_token, accessToken: data.access_token,
@@ -125,28 +103,13 @@ function createAuthStore() {
user: data.user, user: data.user,
isAuthenticated: true isAuthenticated: true
}; };
persist(newState);
if (browser) {
setItem(STORAGE_KEY, newState);
}
set(newState); set(newState);
}, },
/**
* Clear auth (logout)
*/
clear() { clear() {
if (browser) { if (browser) removeItem(STORAGE_KEY);
removeItem(STORAGE_KEY); set(EMPTY_STATE);
}
set({
accessToken: null,
refreshToken: null,
expiresAt: null,
user: null,
isAuthenticated: false
});
} }
}; };
} }
+45
View File
@@ -0,0 +1,45 @@
import { writable } from 'svelte/store';
import { getItem, setItem } from '$lib/utils/storage.js';
const ACTIVE_KEY = 'opal_active_filter';
const RECENT_KEY = 'opal_recent_filters';
const MAX_RECENT = 8;
/**
* @template T
* @param {string} key
* @param {T} fallback
*/
function persisted(key, fallback) {
const initial = getItem(key) ?? fallback;
const store = writable(/** @type {T} */ (initial));
store.subscribe(value => setItem(key, value));
return store;
}
export const activeFilter = persisted(ACTIVE_KEY, /** @type {string|null} */ (null));
export const recentFilters = persisted(RECENT_KEY, /** @type {string[]} */ ([]));
/** @param {string} str */
export function setFilter(str) {
const trimmed = str.trim();
if (!trimmed) {
clearFilter();
return;
}
activeFilter.set(trimmed);
recentFilters.update(recents => {
const deduped = recents.filter(r => r !== trimmed);
return [trimmed, ...deduped].slice(0, MAX_RECENT);
});
}
export function clearFilter() {
activeFilter.set(null);
}
/** @param {string} str */
export function removeRecent(str) {
recentFilters.update(recents => recents.filter(r => r !== str));
}
+8 -25
View File
@@ -20,10 +20,7 @@ import { generateUUID } from '$lib/utils/uuid.js';
const SYNC_STATE_KEY = 'opal_sync_state'; const SYNC_STATE_KEY = 'opal_sync_state';
const CLIENT_ID_KEY = 'opal_client_id'; const CLIENT_ID_KEY = 'opal_client_id';
/** /** @returns {string} */
* Get or create client ID
* @returns {string}
*/
function getClientId() { function getClientId() {
let clientId = getItem(CLIENT_ID_KEY); let clientId = getItem(CLIENT_ID_KEY);
if (!clientId) { if (!clientId) {
@@ -33,10 +30,7 @@ function getClientId() {
return clientId; return clientId;
} }
/** /** @returns {SyncState} */
* Load sync state
* @returns {SyncState}
*/
function loadSyncState() { function loadSyncState() {
const stored = getItem(SYNC_STATE_KEY); const stored = getItem(SYNC_STATE_KEY);
return { return {
@@ -48,19 +42,13 @@ function loadSyncState() {
}; };
} }
/**
* Create sync store
*/
function createSyncStore() { function createSyncStore() {
const { subscribe, set, update } = writable(loadSyncState()); const { subscribe, set, update } = writable(loadSyncState());
return { return {
subscribe, subscribe,
/** /** @returns {Promise<SyncResult>} */
* Perform sync
* @returns {Promise<SyncResult>}
*/
async sync() { async sync() {
update(state => ({ ...state, status: 'syncing', error: null })); update(state => ({ ...state, status: 'syncing', error: null }));
@@ -68,7 +56,8 @@ function createSyncStore() {
const state = loadSyncState(); const state = loadSyncState();
const queue = getQueue(); const queue = getQueue();
let result = { /** @type {SyncResult} */
const result = {
pulled: 0, pulled: 0,
pushed: 0, pushed: 0,
conflicts_resolved: 0, conflicts_resolved: 0,
@@ -76,28 +65,25 @@ function createSyncStore() {
errors: [] errors: []
}; };
// Push queued changes
if (queue.length > 0) { if (queue.length > 0) {
const tasks = queue.map(q => q.data); const tasks = queue.map(q => q.data);
try { try {
await syncAPI.push(tasks, state.clientId); await syncAPI.push(tasks, state.clientId);
clearQueue(); clearQueue();
result.pushed = queue.length; result.pushed = queue.length;
} catch (error) { } catch (/** @type {any} */ error) {
result.errors.push(`Failed to push queue: ${error.message}`); result.errors.push(`Failed to push queue: ${error.message}`);
} }
} }
// Pull changes from server
try { try {
const changes = await syncAPI.getChanges(state.lastSync, state.clientId); const changes = await syncAPI.getChanges(state.lastSync, state.clientId);
result.pulled = changes.length; result.pulled = changes.length;
// TODO: Apply changes to local state // TODO: Apply changes to local state
} catch (error) { } catch (/** @type {any} */ error) {
result.errors.push(`Failed to pull changes: ${error.message}`); result.errors.push(`Failed to pull changes: ${error.message}`);
} }
// Update sync state
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
setItem(SYNC_STATE_KEY, { lastSync: now }); setItem(SYNC_STATE_KEY, { lastSync: now });
@@ -110,7 +96,7 @@ function createSyncStore() {
})); }));
return result; return result;
} catch (error) { } catch (/** @type {any} */ error) {
update(state => ({ update(state => ({
...state, ...state,
status: 'error', status: 'error',
@@ -120,9 +106,6 @@ function createSyncStore() {
} }
}, },
/**
* Update queue size
*/
updateQueueSize() { updateQueueSize() {
update(state => ({ update(state => ({
...state, ...state,
+53 -175
View File
@@ -1,55 +1,31 @@
import { writable, derived } from 'svelte/store'; import { writable, derived } from 'svelte/store';
import { tasks as tasksAPI } from '$lib/api/endpoints.js'; import { tasks as tasksAPI } from '$lib/api/endpoints.js';
import { queueChange } from '$lib/utils/sync-queue.js'; import { queueChange } from '$lib/utils/sync-queue.js';
import { generateUUID } from '$lib/utils/uuid.js';
/** /**
* @typedef {import('$lib/api/types.js').Task} Task * @typedef {import('$lib/api/types.js').Task} Task
* @typedef {import('$lib/api/types.js').TaskFilters} TaskFilters * @typedef {import('$lib/api/types.js').TaskFilters} TaskFilters
*/ */
const MOCK_MODE = import.meta.env.VITE_MOCK_MODE === 'true';
/** Report names that map to pending tasks in mock mode */
const PENDING_REPORTS = new Set(['list', 'next', 'active', 'ready', 'overdue', 'waiting', 'newest', 'oldest']);
/**
* Create tasks store
*/
function createTasksStore() { function createTasksStore() {
const { subscribe, set, update } = writable(/** @type {Task[]} */ ([])); const { subscribe, set, update } = writable(/** @type {Task[]} */ ([]));
/** @type {Task[]} */ /**
let mockData = []; * Replace a single task in the array by UUID.
* @param {string} uuid
/** Ensure mock data is loaded */ * @param {(task: Task) => Task} fn
async function ensureMockData() { */
if (mockData.length === 0) { function updateByUuid(uuid, fn) {
const { mockTasks } = await import('$lib/mock/tasks.js'); update(tasks => tasks.map(t => t.uuid === uuid ? fn(t) : t));
mockData = [...mockTasks];
}
} }
return { return {
subscribe, subscribe,
/** /**
* Load tasks by report name
* @param {string} reportName - Backend report name (e.g. 'list', 'next', 'completed') * @param {string} reportName - Backend report name (e.g. 'list', 'next', 'completed')
*/ */
async loadReport(reportName) { async loadReport(reportName) {
if (MOCK_MODE) {
await ensureMockData();
if (reportName === 'completed') {
set(mockData.filter(t => t.status === 'C'));
} else if (PENDING_REPORTS.has(reportName)) {
set(mockData.filter(t => t.status === 'P'));
} else {
set(mockData.filter(t => t.status === 'P'));
}
return;
}
try { try {
const tasks = await tasksAPI.listByReport(reportName); const tasks = await tasksAPI.listByReport(reportName);
set(tasks); set(tasks);
@@ -60,60 +36,13 @@ function createTasksStore() {
}, },
/** /**
* Parse CLI input and create a task
* @param {string} input - Raw opal CLI syntax * @param {string} input - Raw opal CLI syntax
* @returns {Promise<Task>} * @returns {Promise<Task>}
*/ */
async parseAndCreate(input) { async parseAndCreate(input) {
if (MOCK_MODE) {
await ensureMockData();
// Naive parse: non-modifier words become description
const words = input.split(/\s+/);
const descWords = [];
const task = /** @type {Task} */ ({
uuid: generateUUID(),
id: mockData.length + 1,
status: 'P',
description: '',
project: null,
priority: 1,
created: Date.now() / 1000,
modified: Date.now() / 1000,
start: null,
end: null,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: []
});
for (let i = 0; i < words.length; i++) {
const w = words[i];
if (w.startsWith('project:')) {
task.project = w.slice(8);
} else if (w.startsWith('priority:') || w.startsWith('pri:')) {
const val = w.includes(':') ? w.split(':')[1] : '1';
/** @type {Record<string, number>} */
const map = { H: 3, M: 2, L: 0, h: 3, m: 2, l: 0 };
task.priority = /** @type {import('$lib/api/types.js').TaskPriority} */ ((map[val] ?? parseInt(val, 10)) || 1);
} else if (w.startsWith('+')) {
task.tags = [...(task.tags || []), w.slice(1)];
} else {
descWords.push(w);
}
}
task.description = descWords.join(' ') || 'New task';
mockData = [task, ...mockData];
update(tasks => [task, ...tasks]);
return task;
}
try { try {
const created = await tasksAPI.parse(input); const result = await tasksAPI.parse(input);
const created = /** @type {Task} */ (result.task ?? result);
update(tasks => [created, ...tasks]); update(tasks => [created, ...tasks]);
return created; return created;
} catch (error) { } catch (error) {
@@ -122,21 +51,8 @@ function createTasksStore() {
} }
}, },
/** /** @param {TaskFilters} [filters] */
* Load all tasks from API (or mock data in dev)
* @param {TaskFilters} [filters]
*/
async load(filters = {}) { async load(filters = {}) {
if (MOCK_MODE) {
await ensureMockData();
let filtered = mockData;
if (filters.status) {
filtered = filtered.filter(t => t.status === filters.status);
}
set(filtered);
return;
}
try { try {
const tasks = await tasksAPI.list(filters); const tasks = await tasksAPI.list(filters);
set(tasks); set(tasks);
@@ -147,123 +63,85 @@ function createTasksStore() {
}, },
/** /**
* Add new task (optimistic update) * Optimistic create queues offline on failure.
* @param {Partial<Task>} task * @param {Task} task
*/ */
async add(task) { async add(task) {
if (MOCK_MODE) {
const fullTask = /** @type {Task} */ ({ ...task, id: mockData.length + 1 });
mockData = [...mockData, fullTask];
update(tasks => [...tasks, fullTask]);
return fullTask;
}
try { try {
const created = await tasksAPI.create(task); const created = await tasksAPI.create(task);
update(tasks => [...tasks, created]); update(tasks => [...tasks, created]);
return created; return created;
} catch (error) { } catch (error) {
// Queue for offline sync queueChange({ type: 'create', task_uuid: task.uuid, data: task });
queueChange({
type: 'create',
task_uuid: task.uuid,
data: task
});
// Still update UI optimistically
update(tasks => [...tasks, task]); update(tasks => [...tasks, task]);
throw error; throw error;
} }
}, },
/** /**
* Update task (optimistic update) * Optimistic update queues offline on failure.
* @param {string} uuid * @param {string} uuid
* @param {Partial<Task>} updates * @param {Partial<Task>} updates
*/ */
async updateTask(uuid, updates) { async updateTask(uuid, updates) {
// Optimistic update updateByUuid(uuid, t => ({ ...t, ...updates, modified: Date.now() / 1000 }));
update(tasks => {
const index = tasks.findIndex(t => t.uuid === uuid);
if (index >= 0) {
tasks[index] = { ...tasks[index], ...updates, modified: Date.now() / 1000 };
}
return tasks;
});
if (MOCK_MODE) {
const index = mockData.findIndex(t => t.uuid === uuid);
if (index >= 0) {
mockData[index] = { ...mockData[index], ...updates, modified: Date.now() / 1000 };
}
return;
}
try { try {
const updated = await tasksAPI.update(uuid, updates); const updated = await tasksAPI.update(uuid, updates);
// Sync with server response updateByUuid(uuid, () => updated);
update(tasks => {
const index = tasks.findIndex(t => t.uuid === uuid);
if (index >= 0) {
tasks[index] = updated;
}
return tasks;
});
} catch (error) { } catch (error) {
// Queue for offline sync queueChange({ type: 'update', task_uuid: uuid, data: updates });
queueChange({
type: 'update',
task_uuid: uuid,
data: updates
});
throw error; throw error;
} }
}, },
/** /** @param {string} uuid */
* Delete task
* @param {string} uuid
*/
async deleteTask(uuid) { async deleteTask(uuid) {
// Optimistic removal
update(tasks => tasks.filter(t => t.uuid !== uuid)); update(tasks => tasks.filter(t => t.uuid !== uuid));
if (MOCK_MODE) {
mockData = mockData.filter(t => t.uuid !== uuid);
return;
}
try { try {
await tasksAPI.delete(uuid); await tasksAPI.delete(uuid);
} catch (error) { } catch (error) {
queueChange({ queueChange({ type: 'delete', task_uuid: uuid, data: {} });
type: 'delete',
task_uuid: uuid,
data: {}
});
throw error; throw error;
} }
}, },
/** /** @param {string} uuid */
* Complete task async startTask(uuid) {
* @param {string} uuid const now = Math.floor(Date.now() / 1000);
*/ updateByUuid(uuid, t => ({ ...t, start: now }));
async complete(uuid) { try {
if (MOCK_MODE) { const updated = await tasksAPI.start(uuid);
const mi = mockData.findIndex(t => t.uuid === uuid); updateByUuid(uuid, () => updated);
if (mi >= 0) { } catch (error) {
mockData[mi] = { updateByUuid(uuid, t => ({ ...t, start: null }));
...mockData[mi], throw error;
status: /** @type {'P'|'C'} */ ('C'),
end: Date.now() / 1000,
modified: Date.now() / 1000
};
}
update(tasks => tasks.filter(t => t.uuid !== uuid));
return;
} }
},
/** @param {string} uuid */
async stopTask(uuid) {
/** @type {number|null} */
let prevStart = null;
update(tasks => tasks.map(t => {
if (t.uuid === uuid) {
prevStart = t.start;
return { ...t, start: null };
}
return t;
}));
try {
const updated = await tasksAPI.stop(uuid);
updateByUuid(uuid, () => updated);
} catch (error) {
updateByUuid(uuid, t => ({ ...t, start: prevStart }));
throw error;
}
},
/** @param {string} uuid */
async complete(uuid) {
try { try {
await tasksAPI.complete(uuid); await tasksAPI.complete(uuid);
update(tasks => tasks.filter(t => t.uuid !== uuid)); update(tasks => tasks.filter(t => t.uuid !== uuid));
@@ -281,7 +159,6 @@ function createTasksStore() {
export const tasksStore = createTasksStore(); export const tasksStore = createTasksStore();
// Derived stores for filtered views
export const pendingTasks = derived( export const pendingTasks = derived(
tasksStore, tasksStore,
$tasks => $tasks.filter(t => t.status === 'P') $tasks => $tasks.filter(t => t.status === 'P')
@@ -295,6 +172,7 @@ export const completedTasks = derived(
export const tasksByProject = derived( export const tasksByProject = derived(
tasksStore, tasksStore,
$tasks => { $tasks => {
/** @type {Record<string, Task[]>} */
const grouped = {}; const grouped = {};
$tasks.forEach(task => { $tasks.forEach(task => {
const project = task.project || 'No Project'; const project = task.project || 'No Project';
+1 -7
View File
@@ -11,10 +11,7 @@ const DEFAULT_THEME = 'obsidian';
/** @type {ThemeName[]} */ /** @type {ThemeName[]} */
export const THEMES = ['obsidian', 'paper', 'midnight']; export const THEMES = ['obsidian', 'paper', 'midnight'];
/** /** @returns {ThemeName} */
* Read stored theme, falling back to default
* @returns {ThemeName}
*/
function getInitial() { function getInitial() {
if (!browser) return DEFAULT_THEME; if (!browser) return DEFAULT_THEME;
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
@@ -27,7 +24,6 @@ function getInitial() {
function createThemeStore() { function createThemeStore() {
const { subscribe, set, update } = writable(getInitial()); const { subscribe, set, update } = writable(getInitial());
/** Apply theme to the document */
function apply(/** @type {ThemeName} */ theme) { function apply(/** @type {ThemeName} */ theme) {
if (browser) { if (browser) {
document.documentElement.dataset.theme = theme; document.documentElement.dataset.theme = theme;
@@ -35,7 +31,6 @@ function createThemeStore() {
} }
} }
// Apply on every change
subscribe(apply); subscribe(apply);
return { return {
@@ -44,7 +39,6 @@ function createThemeStore() {
set(theme) { set(theme) {
set(theme); set(theme);
}, },
/** Cycle to the next theme */
cycle() { cycle() {
update(current => { update(current => {
const idx = THEMES.indexOf(current); const idx = THEMES.indexOf(current);
+4 -15
View File
@@ -1,7 +1,6 @@
import { format, formatDistance, isToday, isTomorrow, isPast } from 'date-fns'; import { format, formatDistance, isToday, isTomorrow, isPast } from 'date-fns';
/** /**
* Format Unix timestamp to readable date
* @param {number|null} timestamp - Unix timestamp (seconds) * @param {number|null} timestamp - Unix timestamp (seconds)
* @param {string} formatStr - date-fns format string * @param {string} formatStr - date-fns format string
* @returns {string} * @returns {string}
@@ -12,8 +11,7 @@ export function formatDate(timestamp, formatStr = 'MMM d, yyyy') {
} }
/** /**
* Format Unix timestamp to relative time * @param {number|null} timestamp - Unix timestamp (seconds)
* @param {number|null} timestamp
* @returns {string} * @returns {string}
*/ */
export function formatRelative(timestamp) { export function formatRelative(timestamp) {
@@ -27,8 +25,7 @@ export function formatRelative(timestamp) {
} }
/** /**
* Check if timestamp is overdue * @param {number|null} timestamp - Unix timestamp (seconds)
* @param {number|null} timestamp
* @returns {boolean} * @returns {boolean}
*/ */
export function isOverdue(timestamp) { export function isOverdue(timestamp) {
@@ -36,20 +33,12 @@ export function isOverdue(timestamp) {
return isPast(new Date(timestamp * 1000)); return isPast(new Date(timestamp * 1000));
} }
/** /** @param {Date} date @returns {number} */
* Convert Date object to Unix timestamp
* @param {Date} date
* @returns {number}
*/
export function toUnix(date) { export function toUnix(date) {
return Math.floor(date.getTime() / 1000); return Math.floor(date.getTime() / 1000);
} }
/** /** @param {number} timestamp @returns {Date} */
* Convert Unix timestamp to Date object
* @param {number} timestamp
* @returns {Date}
*/
export function fromUnix(timestamp) { export function fromUnix(timestamp) {
return new Date(timestamp * 1000); return new Date(timestamp * 1000);
} }
+84
View File
@@ -0,0 +1,84 @@
/** Valid filter attribute keys that work as query filters in the engine */
const FILTER_ATTRS = new Set(['status', 'project', 'priority']);
/**
* @typedef {Object} ParsedFilter
* @property {string[]} tags
* @property {string[]} excludeTags
* @property {Record<string, string>} attributes
*/
/**
* Parse a filter string into structured tokens.
* Recognizes +tag, -tag, and key:value for supported filter attributes.
* @param {string} str
* @returns {ParsedFilter}
*/
export function parseFilterString(str) {
/** @type {ParsedFilter} */
const result = { tags: [], excludeTags: [], attributes: {} };
if (!str || !str.trim()) return result;
const tokens = str.trim().split(/\s+/);
for (const token of tokens) {
if (token.startsWith('+') && token.length > 1) {
result.tags.push(token.slice(1));
} else if (token.startsWith('-') && token.length > 1 && !token.includes(':')) {
result.excludeTags.push(token.slice(1));
} else if (token.includes(':')) {
const idx = token.indexOf(':');
const key = token.slice(0, idx).toLowerCase();
const value = token.slice(idx + 1);
if (FILTER_ATTRS.has(key) && value) {
result.attributes[key] = value;
}
}
}
return result;
}
/**
* @param {ParsedFilter} parsed
* @returns {import('$lib/api/types.js').TaskFilters}
*/
export function filterToParams(parsed) {
/** @type {import('$lib/api/types.js').TaskFilters} */
const params = {};
if (parsed.tags.length > 0) params.tags = parsed.tags;
if (parsed.excludeTags.length > 0) params.excludeTags = parsed.excludeTags;
if (parsed.attributes.status) params.status = /** @type {any} */ (parsed.attributes.status);
if (parsed.attributes.project) params.project = parsed.attributes.project;
if (parsed.attributes.priority) params.priority = parsed.attributes.priority;
return params;
}
/**
* Remove a token matching a given prefix from a string.
* e.g. removeTokenByPrefix("buy milk due:tomorrow", "due:") "buy milk"
* @param {string} input
* @param {string} prefix
* @returns {string}
*/
export function removeTokenByPrefix(input, prefix) {
const tokens = input.split(/\s+/);
const filtered = tokens.filter(t => !t.toLowerCase().startsWith(prefix.toLowerCase()));
return filtered.join(' ');
}
/**
* Deduplicate filter tokens from user input that are already in the active filter.
* @param {string} userInput
* @param {string} filterStr
* @returns {string}
*/
export function mergeInputWithFilter(userInput, filterStr) {
if (!filterStr || !filterStr.trim()) return userInput;
if (!userInput || !userInput.trim()) return filterStr;
const filterTokens = new Set(filterStr.trim().split(/\s+/).map(t => t.toLowerCase()));
const inputTokens = userInput.trim().split(/\s+/);
const deduped = inputTokens.filter(t => !filterTokens.has(t.toLowerCase()));
const combined = [...deduped, filterStr.trim()].filter(Boolean).join(' ');
return combined;
}
+1 -9
View File
@@ -1,7 +1,6 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
/** /**
* Get item from localStorage
* @param {string} key * @param {string} key
* @returns {any} * @returns {any}
*/ */
@@ -18,7 +17,6 @@ export function getItem(key) {
} }
/** /**
* Set item in localStorage
* @param {string} key * @param {string} key
* @param {any} value * @param {any} value
*/ */
@@ -32,10 +30,7 @@ export function setItem(key, value) {
} }
} }
/** /** @param {string} key */
* Remove item from localStorage
* @param {string} key
*/
export function removeItem(key) { export function removeItem(key) {
if (!browser) return; if (!browser) return;
@@ -46,9 +41,6 @@ export function removeItem(key) {
} }
} }
/**
* Clear all items
*/
export function clear() { export function clear() {
if (!browser) return; if (!browser) return;
+4 -26
View File
@@ -1,16 +1,11 @@
import { getItem, setItem } from './storage.js'; import { getItem, setItem } from './storage.js';
import { generateUUID } from './uuid.js'; import { generateUUID } from './uuid.js';
/** /** @typedef {import('$lib/api/types.js').QueuedChange} QueuedChange */
* @typedef {import('$lib/api/types.js').QueuedChange} QueuedChange
*/
const QUEUE_KEY = 'opal_sync_queue'; const QUEUE_KEY = 'opal_sync_queue';
/** /** @param {Omit<QueuedChange, 'id'|'timestamp'>} change */
* Add change to sync queue
* @param {Omit<QueuedChange, 'id'|'timestamp'>} change
*/
export function queueChange(change) { export function queueChange(change) {
const queue = getQueue(); const queue = getQueue();
@@ -23,33 +18,16 @@ export function queueChange(change) {
setItem(QUEUE_KEY, queue); setItem(QUEUE_KEY, queue);
} }
/** /** @returns {QueuedChange[]} */
* Get all queued changes
* @returns {QueuedChange[]}
*/
export function getQueue() { export function getQueue() {
return getItem(QUEUE_KEY) || []; return getItem(QUEUE_KEY) || [];
} }
/**
* Clear sync queue
*/
export function clearQueue() { export function clearQueue() {
setItem(QUEUE_KEY, []); setItem(QUEUE_KEY, []);
} }
/** /** @param {string} id */
* Get queue size
* @returns {number}
*/
export function getQueueSize() {
return getQueue().length;
}
/**
* Remove specific change from queue
* @param {string} id
*/
export function removeFromQueue(id) { export function removeFromQueue(id) {
const queue = getQueue().filter((change) => change.id !== id); const queue = getQueue().filter((change) => change.id !== id);
setItem(QUEUE_KEY, queue); setItem(QUEUE_KEY, queue);
+4 -4
View File
@@ -1,8 +1,8 @@
/** /** @returns {string} */
* Generate UUID v4
* @returns {string}
*/
export function generateUUID() { export function generateUUID() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0; const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8; const v = c === 'x' ? r : (r & 0x3) | 0x8;
+14 -2
View File
@@ -27,8 +27,20 @@
<style> <style>
.app { .app {
height: 100dvh; height: 100dvh;
display: flex; display: grid;
flex-direction: column; grid-template-columns: 1fr min(var(--content-max-width), 100%) 1fr;
grid-template-rows: 1fr auto auto;
grid-template-areas:
". content ."
". input ."
". header .";
overflow: hidden; overflow: hidden;
background: linear-gradient(
to right,
var(--bg-secondary),
var(--bg-primary) calc(50% - var(--content-max-width) / 2 + 60px),
var(--bg-primary) calc(50% + var(--content-max-width) / 2 - 60px),
var(--bg-secondary)
);
} }
</style> </style>
+196
View File
@@ -3,9 +3,15 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.js'; import { authStore } from '$lib/stores/auth.js';
import { tasksStore } from '$lib/stores/tasks.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 Header from '$lib/components/Header.svelte';
import TaskList from '$lib/components/TaskList.svelte'; import TaskList from '$lib/components/TaskList.svelte';
import InputBar from '$lib/components/InputBar.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'; let activeReport = 'list';
/** @type {import('$lib/api/types.js').Task[]} */ /** @type {import('$lib/api/types.js').Task[]} */
@@ -13,9 +19,29 @@
let loading = true; let loading = true;
let inputError = ''; 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 // Subscribe to store
const unsubscribe = tasksStore.subscribe(value => { const unsubscribe = tasksStore.subscribe(value => {
tasks = value; tasks = value;
// Keep selectedTask in sync with store changes
if (selectedTask) {
const updated = value.find(t => t.uuid === selectedTask.uuid);
if (updated) selectedTask = updated;
}
}); });
onMount(() => { onMount(() => {
@@ -24,11 +50,61 @@
return; return;
} }
// Load with existing active filter if any
if ($activeFilter) {
loadWithFilter($activeFilter);
} else {
loadReport(activeReport); loadReport(activeReport);
}
return unsubscribe; 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 * @param {string} reportName
*/ */
@@ -49,6 +125,10 @@
*/ */
function handleReportChange(reportName) { function handleReportChange(reportName) {
activeReport = reportName; activeReport = reportName;
// Changing report clears any active filter
if ($activeFilter) {
clearFilter();
}
loadReport(reportName); loadReport(reportName);
} }
@@ -68,12 +148,93 @@
* @param {string} uuid * @param {string} uuid
*/ */
async function handleComplete(uuid) { async function handleComplete(uuid) {
const task = tasks.find(t => t.uuid === uuid);
if (!task) return;
try { try {
await tasksStore.complete(uuid); await tasksStore.complete(uuid);
undoToast = { uuid: task.uuid, description: task.description };
} catch (error) { } catch (error) {
console.error('Failed to complete task:', error); console.error('Failed to complete task:', error);
} }
} }
async function handleUndo() {
if (!undoToast) return;
const { uuid } = undoToast;
undoToast = null;
try {
await tasksStore.updateTask(uuid, { status: 'P', end: null });
await loadReport(activeReport);
} catch (error) {
console.error('Failed to undo completion:', error);
}
}
/**
* @param {string} uuid
*/
async function handleStartStop(uuid) {
const task = tasks.find(t => t.uuid === uuid);
if (!task) return;
try {
if (task.start) {
await tasksStore.stopTask(uuid);
} else {
await tasksStore.startTask(uuid);
}
// Update selectedTask if it's the same task
if (selectedTask?.uuid === uuid) {
const updated = tasks.find(t => t.uuid === uuid);
if (updated) selectedTask = updated;
}
} catch (error) {
console.error('Failed to start/stop task:', error);
}
}
/**
* @param {string} uuid
* @param {Partial<import('$lib/api/types.js').Task>} updates
*/
async function handleUpdate(uuid, updates) {
try {
await tasksStore.updateTask(uuid, updates);
} catch (error) {
console.error('Failed to update task:', error);
}
}
/**
* @param {string} uuid
*/
function handleDeleteRequest(uuid) {
const task = tasks.find(t => t.uuid === uuid)
?? (selectedTask?.uuid === uuid ? selectedTask : null);
if (!task) return;
deleteTarget = { uuid: task.uuid, description: task.description };
confirmDialog.open();
}
async function handleDeleteConfirm() {
if (!deleteTarget) return;
const { uuid } = deleteTarget;
deleteTarget = null;
selectedTask = null;
try {
await tasksStore.deleteTask(uuid);
} catch (error) {
console.error('Failed to delete task:', error);
}
}
function handleDeleteCancel() {
deleteTarget = null;
}
</script> </script>
<Header {activeReport} onReportChange={handleReportChange} /> <Header {activeReport} onReportChange={handleReportChange} />
@@ -83,9 +244,44 @@
{loading} {loading}
{activeReport} {activeReport}
onComplete={handleComplete} onComplete={handleComplete}
onTap={(task) => selectedTask = task}
onStartStop={handleStartStop}
/> />
<InputBar <InputBar
onSubmit={handleSubmit} onSubmit={handleSubmit}
error={inputError} 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}
/>
@@ -45,10 +45,11 @@
<style> <style>
.callback-page { .callback-page {
grid-column: 1 / -1;
grid-row: 1 / -1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh;
} }
.callback-card { .callback-card {
+2 -1
View File
@@ -53,10 +53,11 @@
<style> <style>
.login-page { .login-page {
grid-column: 1 / -1;
grid-row: 1 / -1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh;
} }
.login-card { .login-card {

Some files were not shown because too many files have changed in this diff Show More