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>
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
BottomNavcomponent 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/loginif not authenticated, otherwise load tasks for default report (pending) - On report change: reload tasks with
GET /tasks?report={name} - On input submit:
POST /tasks/parsewith 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:
- Checkbox tap: Calls
onCompleteimmediately - 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):
- Touch starts — record
startX,startY - On move, compute
deltaXanddeltaY - Lock-in rule: If
|deltaX| > 10pxAND|deltaX| > |deltaY| * 2(angle < ~27°), lock to horizontal swipe and callpreventDefault()on the touch event to suppress scrolling - If vertical movement dominates first, do nothing (allow scroll)
- During swipe: translate the task row, reveal green background with checkmark icon underneath
- Threshold: If
deltaX > 100pxon release → trigger completion - Cancel: If released before threshold → CSS transition back to
translate(0)(settransition: transform 0.2s easeon release, remove it on touch start) - Completion animation: On threshold met, add a
completingclass that triggers a CSS transition (opacity: 0; height: 0; margin: 0; padding: 0; overflow: hidden; transition: all 0.3s ease). Thetransitionendevent fires the API call and removes the DOM element - 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,100dvhshrinks, 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);
dvhis 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 parentInputBarhandles cursor positioning viaselectionStart/selectionEndon 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:
- Split
inputstring on whitespace - Classify args: modifier if starts with
+/-or contains:, otherwise description word - Call
engine.ParseModifier(modifierArgs) - Call
engine.CreateTaskWithModifier(description, modifier) - Handle recurrence if
recuris set (same logic ascmd/add.go:addRecurringTask) - 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:
- Looks up the report by name (map in
report.goalready has all reports) - Applies the report's base filter + any additional query params
- Executes the query
- Applies post-filters, sort, and limit functions
- 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 checkboxGET /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:
visualViewportAPI with JS resize listener (rejected — unnecessary complexity;dvhsolves this in CSS)position: fixed; bottom: 0(rejected — doesn't reflow the scroll area)interactive-widget=resizes-contentmeta tag (viable fallback, butdvhalone 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/touchstartwithpreventDefaulton 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 InputBarsrc/routes/tasks/[uuid]/+page.svelte— out of scopesrc/routes/projects/+page.svelte— out of scopesrc/routes/tags/+page.svelte— out of scopesrc/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. |