Files
joakim 78881e1b07 feat: add parse endpoint, refactor recurring tasks, and improve web task completion
Extract CreateRecurringTask into engine package for reuse by both CLI
and API. Add POST /tasks/parse endpoint for CLI-style input parsing.
Remove FK constraint on change_log to preserve history after task
deletion. Update web frontend to filter completed tasks from view and
add mock mode support for development.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:49:20 +01:00

23 KiB

Opal Web PWA — Architecture Design

Target environment: Internal product. Android Chrome and desktop Chrome/Firefox. Browser versions are controlled. iOS is not in scope.

CSS philosophy: Prefer modern HTML/CSS over JavaScript wherever possible. Use dvh units, has(), container queries, flex-wrap, popover, @starting-style, and other modern features freely — no legacy browser concerns.

1. Architecture Overview

The redesign transforms opal-web from a multi-page form-based app into a single-screen CLI-passthrough interface. The frontend becomes a thin shell over the opal command language, with all parsing handled server-side.

graph TD
    subgraph "Frontend (SvelteKit Static PWA)"
        Layout["+layout.svelte"]
        Home["+page.svelte — Single Screen"]
        Settings["/settings"]

        Home --> Header
        Home --> TaskList
        Home --> InputBar

        Header --> ReportPicker["ReportPicker dropdown"]
        Header --> GearIcon["⚙ → /settings"]

        InputBar --> PropertyPills
        InputBar --> TextInput["CLI text input"]

        TaskList --> TaskItem["TaskItem (swipe + checkbox)"]
    end

    subgraph "Backend (Go + chi)"
        API["REST API"]
        Parser["Modifier Parser"]
        Reports["Report Engine"]
        DB["SQLite"]

        API --> Parser
        API --> Reports
        Parser --> DB
        Reports --> DB
    end

    TextInput -- "POST /tasks/parse\n{input: raw string}" --> API
    TaskList -- "GET /tasks?report=pending" --> API
    TaskItem -- "POST /tasks/:uuid/complete" --> API

Page Structure

/ ........................ Single-screen task view (auth-gated)
/settings ................ Settings page (gear icon)
/auth/login .............. OAuth login
/auth/callback ........... OAuth callback

All other routes (/tasks/new, /tasks/[uuid], /projects, /tags) are removed. The bottom navigation bar is removed. The input bar replaces it.


2. Component Design

2.1 Root Layout (+layout.svelte)

Responsibility: App shell — auth gating, global styles, routing.

Changes from current:

  • Remove BottomNav component entirely
  • No conditional nav rendering — the layout is just a slot

2.2 Home Page (+page.svelte)

Responsibility: The single-screen orchestrator. Owns the three vertical zones: header, task list, input bar.

Interface (reactive state):

// Active report selection
let activeReport: string = 'pending'

// Task data
let tasks: Task[] = []
let loading: boolean = true

// Input bar
let inputValue: string = ''
let inputFocused: boolean = false
let inputError: string | null = null

Behavior:

  • On mount: redirect to /auth/login if not authenticated, otherwise load tasks for default report (pending)
  • On report change: reload tasks with GET /tasks?report={name}
  • On input submit: POST /tasks/parse with raw string. On success, the response contains the created task — insert it into the list at the top (rather than re-fetching the entire list). The next report load will sort it into the correct position
  • On task complete (swipe or checkbox): POST /tasks/:uuid/complete, remove from list with animation

2.3 Header

Responsibility: Shows current report name, report picker trigger, settings link.

Interface:

interface HeaderProps {
  activeReport: string
  onReportChange: (report: string) => void
}

Layout: [Report Name ▾] left-aligned, [⚙] right-aligned. Tapping the report name or chevron opens the ReportPicker.

2.4 ReportPicker

Responsibility: Dropdown overlay listing all available reports.

Interface:

interface ReportPickerProps {
  activeReport: string
  open: boolean
  onSelect: (report: string) => void
  onClose: () => void
}

Reports list:

Group Reports
Common Pending, Next, Active, Ready
Time-based Overdue, Waiting, Newest, Oldest
Recurring Recurring, Template
Archive Completed

The dropdown is grouped with subtle dividers to manage the 11-item density on mobile. The active report has a visual indicator (checkmark or highlight).

Implementation: Use the native Popover API (popover attribute + popovertarget). This gives us light-dismiss (tap outside to close), top-layer rendering, and ::backdrop styling for free — no JS for open/close state or click-outside detection. Entry/exit animations via @starting-style + transition-behavior: allow-discrete.

Dismiss: Tapping outside (popover light-dismiss), selecting a report, or pressing Escape.

2.5 TaskList

Responsibility: Scrollable list of tasks between header and input bar.

Interface:

interface TaskListProps {
  tasks: Task[]
  loading: boolean
  activeReport: string
  onComplete: (uuid: string) => void
}

Empty states:

  • Loading: skeleton or spinner
  • No tasks at all (first use): "Type a task below to get started"
  • No tasks for report: "No {report} tasks" (e.g., "No overdue tasks")

Scroll region: Uses flex: 1 with overflow-y: auto between the fixed-position header and input bar. Must account for keyboard open state.

2.6 TaskItem

Responsibility: Single task row with completion interactions.

Interface:

interface TaskItemProps {
  task: Task
  onComplete: (uuid: string) => void
}

Visual layout:

[○] Buy groceries                    due:Feb 15
    +errand  project:Home  pri:H
  • Left: completion circle/checkbox
  • Center: description on first line, metadata pills on second line (only if metadata exists)
  • Metadata: project badge, priority indicator, due date, tags — all inline with subtle color coding

Completion interactions:

  1. Checkbox tap: Calls onComplete immediately
  2. Swipe right: See section 2.7

Priority colors:

  • High (3): red/warm
  • Medium (2): amber
  • Low (0): muted gray
  • Default (1): no indicator

Due date colors:

  • Overdue: red
  • Due today: amber
  • Due within 7 days: default
  • Future/none: muted

2.7 SwipeAction

Responsibility: Wraps TaskItem to provide swipe-to-complete gesture.

Interface:

interface SwipeActionProps {
  onSwipe: () => void
  threshold?: number  // pixels, default 100
  children: Snippet   // TaskItem content
}

Gesture model (addresses Design Note on swipe/scroll conflict):

  1. Touch starts — record startX, startY
  2. On move, compute deltaX and deltaY
  3. Lock-in rule: If |deltaX| > 10px AND |deltaX| > |deltaY| * 2 (angle < ~27°), lock to horizontal swipe and call preventDefault() on the touch event to suppress scrolling
  4. If vertical movement dominates first, do nothing (allow scroll)
  5. During swipe: translate the task row, reveal green background with checkmark icon underneath
  6. Threshold: If deltaX > 100px on release → trigger completion
  7. Cancel: If released before threshold → CSS transition back to translate(0) (set transition: transform 0.2s ease on release, remove it on touch start)
  8. Completion animation: On threshold met, add a completing class that triggers a CSS transition (opacity: 0; height: 0; margin: 0; padding: 0; overflow: hidden; transition: all 0.3s ease). The transitionend event fires the API call and removes the DOM element
  9. The swipe tracking (touch start/move/end, deltaX/deltaY, translateX) requires JS. But all animations (snap-back, completion fade-out) are CSS transitions

2.8 InputBar

Responsibility: Fixed-to-bottom CLI input with property pills.

Interface:

interface InputBarProps {
  value: string
  error: string | null
  loading: boolean
  onSubmit: (input: string) => void
  onInput: (value: string) => void
  onFocusChange: (focused: boolean) => void
}

Layout (bottom-up):

┌─────────────────────────────────────┐
│ [Due] [Pri] [Project] [Tag] ...     │  ← Pills (visible when focused)
├─────────────────────────────────────┤
│ [  Add a task...              ] [→] │  ← Input + submit button
├─────────────────────────────────────┤
│ (error message if any)              │  ← Inline error
└─────────────────────────────────────┘

Keyboard handling (addresses Design Note):

  • The entire page layout uses height: 100dvh (dvh = dynamic viewport height, which accounts for the virtual keyboard on Android Chrome/Firefox)
  • The layout is a CSS grid or flexbox column: header | task-list (flex:1) | input-bar. When the keyboard opens, 100dvh shrinks, the flex container shrinks, and the input bar stays at the bottom — no JS needed
  • This is a controlled-browser internal product (Android Chrome / desktop Firefox+Chrome); dvh is fully supported

Error display:

  • Errors appear as a small red text line between the input and the pills
  • Input does NOT clear on error — user can fix and retry
  • Error clears on next input change

2.9 PropertyPills

Responsibility: Horizontal row of tappable shortcut pills.

Interface:

interface PropertyPillsProps {
  visible: boolean
  onInsert: (text: string) => void
}

Pills (ordered per requirements):

Label Inserts
Due due:
Pri priority:
Project project:
Tag +
Recur recur:
Scheduled scheduled:
Wait wait:
Until until:

Behavior:

  • Tapping a pill calls onInsert(text) — the parent InputBar handles cursor positioning via selectionStart/selectionEnd on the input element
  • Pills wrap over multiple lines using display: flex; flex-wrap: wrap; gap
  • Appears with a brief slide-up animation when input focuses
  • Disappears when input blurs (with short delay to allow pill tap to register before blur fires)

3. Data Model

No changes to the task entity. The existing Task type from src/lib/api/types.js is sufficient.

New derived type for API responses:

// Report metadata returned alongside task list
interface ReportResponse {
  report: string        // Report name
  tasks: Task[]         // Filtered/sorted tasks
  count: number         // Total count
}

// Parse endpoint response
interface ParseResponse {
  task: Task            // The created task
}

// Parse endpoint error
interface ParseError {
  error: string         // Human-readable error message
  field?: string        // Which modifier failed, if applicable
}

4. API Design

4.1 New Endpoint: Parse and Create Task

Resolves Q6 from requirements.

POST /tasks/parse
Content-Type: application/json
Authorization: Bearer <token>

Request:
{
  "input": "Buy groceries due:tomorrow +errand priority:H"
}

Success Response (201 Created):
{
  "success": true,
  "data": {
    "task": { ...Task object... }
  }
}

Error Response (400 Bad Request):
{
  "success": false,
  "error": "invalid date value for 'due': neverday"
}

Backend implementation: The handler inlines the trivial arg classification from cmd/add.go:parseAddArgs() (split on whitespace, args with +/- prefix or containing : are modifiers, the rest is description). The real parsing is done by engine.ParseModifier() and engine.CreateTaskWithModifier(), which are already in the engine package.

Handler steps:

  1. Split input string on whitespace
  2. Classify args: modifier if starts with +/- or contains :, otherwise description word
  3. Call engine.ParseModifier(modifierArgs)
  4. Call engine.CreateTaskWithModifier(description, modifier)
  5. Handle recurrence if recur is set (same logic as cmd/add.go:addRecurringTask)
  6. Return the created task or a parse error

4.2 Updated Endpoint: Report-Based Task Listing

Resolves Q7 from requirements.

GET /tasks?report=pending
Authorization: Bearer <token>

Response (200 OK):
{
  "success": true,
  "data": {
    "report": "pending",
    "tasks": [ ...Task objects... ],
    "count": 42
  }
}

Query parameters:

  • report — Report name (default: pending). One of: pending, next, active, ready, overdue, completed, waiting, recurring, template, newest, oldest
  • Existing filter params (project, tag, etc.) are merged with the report's base filter, allowing further narrowing

Backend implementation: The report engine already exists in internal/engine/report.go. The handler:

  1. Looks up the report by name (map in report.go already has all reports)
  2. Applies the report's base filter + any additional query params
  3. Executes the query
  4. Applies post-filters, sort, and limit functions
  5. Returns tasks in the report's sort order

Mapping of frontend report names to backend:

Frontend Backend report name
Pending list
Next next
Active active
Ready ready
Overdue overdue
Completed completed
Waiting waiting
Recurring recurring
Template template
Newest newest
Oldest oldest

4.3 Existing Endpoints (No Changes)

These are already sufficient:

  • POST /tasks/{uuid}/complete — Used by swipe and checkbox
  • GET /health — Health check
  • All auth endpoints — Unchanged

4.4 Error Response Format

All endpoints already use the standard envelope:

{ success: boolean, data?: any, error?: string }

No change needed. The parse endpoint follows the same pattern, with error containing a human-readable message suitable for display below the input bar.


5. Technical Decisions

ADR-1: Single-screen layout replaces multi-page routing

Context: The current app has bottom nav with routes for tasks, projects, tags, and settings. Requirements specify a single-screen design.

Decision: Remove bottom nav and all routes except /, /settings, /auth/*. The home page owns the full screen layout.

Alternatives: Keep routes but hide nav (rejected — adds routing complexity for no benefit since there's only one functional view).

Consequences: Simpler mental model. Projects/tags pages (stubs) are removed. Task detail route is removed (out of scope per requirements). tasks/new is removed (replaced by input bar).

ADR-2: Server-side parsing via new /tasks/parse endpoint

Context: The frontend needs to accept raw CLI syntax. Parsing is complex (dates, recurrence, relative expressions).

Decision: New POST /tasks/parse endpoint. Frontend sends the raw string; backend splits and parses using existing parseAddArgs() + ParseModifier().

Alternatives:

  • Client-side parsing (rejected — would duplicate 500+ lines of Go date/modifier logic in JS, creating a maintenance burden)
  • Sending pre-split args array (rejected — adds no value; splitting on whitespace is trivial)

Consequences: Clean separation. Frontend is truly a thin shell. Parsing logic stays in one place. Trade-off: requires network round-trip for every add, but this matches the "CLI-passthrough" philosophy.

ADR-3: Report-based queries via ?report= query parameter

Context: The frontend needs to show 11 different task views. Reports involve complex server-side logic (urgency calculation, date filtering, sorting).

Decision: Extend the existing GET /tasks endpoint with a report query parameter. When present, it uses the report engine instead of raw filter queries.

Alternatives:

  • Dedicated endpoints per report (GET /reports/next) — rejected: adds 11 routes with boilerplate
  • Client-side filtering/sorting — rejected: urgency calculation is complex and should stay server-side

Consequences: Single endpoint serves both raw filter queries (backward compatible) and report-based queries. The report engine already exists and is well-tested.

ADR-4: Swipe gesture with angle-based lock-in

Context: Horizontal swipe on a vertical scroll list has ambiguity. Must not accidentally complete tasks during normal scrolling.

Decision: Use angle detection. If initial movement angle is < ~27° from horizontal (|deltaX| > |deltaY| * 2) after 10px of horizontal movement, lock into swipe mode. Otherwise, allow scroll. Threshold of 100px to trigger completion.

Alternatives:

  • Time-based delay (rejected — feels sluggish)
  • Long-press then swipe (rejected — adds friction)
  • Only allow checkbox completion (rejected — swipe is a MUST requirement)

Consequences: Natural gesture feel. Small risk of mis-detection near the boundary angle, mitigated by the 100px release threshold (mid-swipe cancel is always safe).

ADR-5: Pure CSS keyboard-aware layout with dvh units

Context: Mobile keyboards push fixed elements off-screen. The input bar must stay visible above the keyboard.

Decision: Use height: 100dvh on the app shell with a flexbox column layout (header | task-list flex:1 | input-bar). The dvh (dynamic viewport height) unit automatically accounts for the virtual keyboard — when the keyboard opens, 100dvh shrinks, the flex container reflows, and the input bar stays anchored at the bottom. No JavaScript needed.

Alternatives:

  • visualViewport API with JS resize listener (rejected — unnecessary complexity; dvh solves this in CSS)
  • position: fixed; bottom: 0 (rejected — doesn't reflow the scroll area)
  • interactive-widget=resizes-content meta tag (viable fallback, but dvh alone is sufficient on target browsers)

Consequences: Zero JS for keyboard handling. Depends on dvh support, which is available in all target browsers (Chrome 108+, Firefox 108+). Not an issue since this is an internal product with controlled browser versions.

ADR-6: Blur-delay for property pill interaction

Context: Tapping a pill above the input causes the input to blur (since the pill is a separate element), which would hide the pills before the tap registers.

Decision: Add a ~150ms delay before hiding pills on blur. If a pill tap occurs during that window, cancel the hide and re-focus the input.

Alternatives:

  • Use mousedown/touchstart with preventDefault on pills to prevent blur (simpler, but may have side effects on mobile)
  • Keep pills always visible (rejected — requirement F-23 says pills disappear on blur)

Consequences: Slight visual delay before pills disappear, but the interaction is reliable. The preventDefault approach could be used as a refinement if the delay feels wrong.


6. File & Module Structure

New / Modified Files

src/
├── lib/
│   ├── api/
│   │   └── endpoints.js          # MODIFY: add tasks.parse(), update tasks.list()
│   ├── components/
│   │   ├── Header.svelte         # NEW: report name + picker trigger + gear
│   │   ├── ReportPicker.svelte   # NEW: dropdown with grouped reports
│   │   ├── InputBar.svelte       # NEW: CLI input + submit + error display
│   │   ├── PropertyPills.svelte  # NEW: horizontal pill row
│   │   ├── SwipeAction.svelte    # NEW: swipe gesture wrapper
│   │   ├── TaskList.svelte       # MODIFY: empty states, remove loading card
│   │   ├── TaskItem.svelte       # MODIFY: layout tweaks, remove click nav
│   │   └── ui/                   # KEEP: existing UI primitives
│   ├── stores/
│   │   └── tasks.js              # MODIFY: add loadReport(), parseAndCreate()
│   └── mock/
│       └── tasks.js              # KEEP: existing mock data
├── routes/
│   ├── +layout.svelte            # MODIFY: remove BottomNav
│   ├── +page.svelte              # REWRITE: single-screen orchestrator
│   ├── settings/+page.svelte     # KEEP: unchanged
│   ├── auth/
│   │   ├── login/+page.svelte    # KEEP: unchanged
│   │   └── callback/+page.svelte # KEEP: unchanged
│   ├── tasks/                    # DELETE: entire directory
│   ├── projects/+page.svelte     # DELETE
│   └── tags/+page.svelte         # DELETE
└── app.css                       # MODIFY: add swipe/pill/input-bar styles

Deleted Files

  • src/routes/tasks/new/+page.svelte — replaced by InputBar
  • src/routes/tasks/[uuid]/+page.svelte — out of scope
  • src/routes/projects/+page.svelte — out of scope
  • src/routes/tags/+page.svelte — out of scope
  • src/lib/components/BottomNav.svelte — replaced by InputBar

Backend Changes

opal-task/
└── internal/
    └── api/
        ├── server.go             # MODIFY: add POST /tasks/parse route
        ├── handlers.go           # MODIFY: add HandleParseTask(),
        │                         #         update HandleListTasks() for ?report=
        └── (no new files needed)

7. Integration Points

Frontend → Backend API

Action Endpoint When
Load tasks GET /tasks?report={name} On mount, report change
Create task POST /tasks/parse Input bar submit
Complete task POST /tasks/{uuid}/complete Swipe or checkbox
Auth (existing) GET /auth/login, etc. Login flow (unchanged)
Sync (existing) POST /sync/* Background sync (unchanged)

Mock Mode

Mock mode (VITE_MOCK_MODE=true) continues to work:

  • tasks.loadReport(name) returns mock data filtered client-side by status (good enough for development)
  • tasks.parseAndCreate(input) does a naive client-side split (description = non-modifier words, ignore modifiers) and adds to mock array
  • No backend needed for development

PWA / Service Worker

No changes to PWA config. The new endpoint (/tasks/parse) is covered by the existing Workbox runtime cache pattern (/api/* with NetworkFirst strategy).


8. Deferred Items

Items explicitly not included in this design, to be addressed in future iterations:

Req ID Description Priority Notes
F-39 Pull-to-refresh to reload task list COULD Low priority; report switching already reloads. Can be added later with overscroll-behavior + a small JS handler.