From 83a9689e47a6518462e50fa41131acd7b8d042a8 Mon Sep 17 00:00:00 2001 From: Joakim Date: Sat, 14 Feb 2026 17:25:17 +0100 Subject: [PATCH] feat(web): add parse and report API endpoints with store methods Add tasks.parse() and tasks.listByReport() to the API layer, and loadReport() and parseAndCreate() to the tasks store with mock mode support for the CLI-passthrough redesign. Co-Authored-By: Claude Opus 4.6 --- opal-web/src/lib/api/endpoints.js | 22 ++++ opal-web/src/lib/stores/tasks.js | 183 ++++++++++++++++++++++++++++-- 2 files changed, 195 insertions(+), 10 deletions(-) diff --git a/opal-web/src/lib/api/endpoints.js b/opal-web/src/lib/api/endpoints.js index c79ea9d..6780afb 100644 --- a/opal-web/src/lib/api/endpoints.js +++ b/opal-web/src/lib/api/endpoints.js @@ -98,6 +98,28 @@ export const tasks = { */ async stop(uuid) { return apiRequest(`/tasks/${uuid}/stop`, { method: 'POST' }); + }, + + /** + * Parse CLI input and create task + * @param {string} input - Raw opal CLI syntax + * @returns {Promise} + */ + async parse(input) { + return apiRequest('/tasks/parse', { + method: 'POST', + body: JSON.stringify({ input }) + }); + }, + + /** + * List tasks by report name + * @param {string} reportName + * @returns {Promise} + */ + async listByReport(reportName) { + const result = await apiRequest(`/tasks?report=${encodeURIComponent(reportName)}`); + return result.tasks ?? result; } }; diff --git a/opal-web/src/lib/stores/tasks.js b/opal-web/src/lib/stores/tasks.js index 46a3bd7..322a6d7 100644 --- a/opal-web/src/lib/stores/tasks.js +++ b/opal-web/src/lib/stores/tasks.js @@ -1,26 +1,142 @@ import { writable, derived } from 'svelte/store'; import { tasks as tasksAPI } from '$lib/api/endpoints.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').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() { const { subscribe, set, update } = writable(/** @type {Task[]} */ ([])); - + + /** @type {Task[]} */ + let mockData = []; + + /** Ensure mock data is loaded */ + async function ensureMockData() { + if (mockData.length === 0) { + const { mockTasks } = await import('$lib/mock/tasks.js'); + mockData = [...mockTasks]; + } + } + return { subscribe, - + /** - * Load all tasks from API + * Load tasks by report name + * @param {string} reportName - Backend report name (e.g. 'list', 'next', 'completed') + */ + 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 { + const tasks = await tasksAPI.listByReport(reportName); + set(tasks); + } catch (error) { + console.error('Failed to load report:', error); + throw error; + } + }, + + /** + * Parse CLI input and create a task + * @param {string} input - Raw opal CLI syntax + * @returns {Promise} + */ + 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} */ + 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 { + const created = await tasksAPI.parse(input); + update(tasks => [created, ...tasks]); + return created; + } catch (error) { + console.error('Failed to parse and create task:', error); + throw error; + } + }, + + /** + * Load all tasks from API (or mock data in dev) * @param {TaskFilters} [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 { const tasks = await tasksAPI.list(filters); set(tasks); @@ -29,12 +145,19 @@ function createTasksStore() { throw error; } }, - + /** * Add new task (optimistic update) * @param {Partial} 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 { const created = await tasksAPI.create(task); update(tasks => [...tasks, created]); @@ -46,13 +169,13 @@ function createTasksStore() { task_uuid: task.uuid, data: task }); - + // Still update UI optimistically update(tasks => [...tasks, task]); throw error; } }, - + /** * Update task (optimistic update) * @param {string} uuid @@ -67,7 +190,15 @@ function createTasksStore() { } 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 { const updated = await tasksAPI.update(uuid, updates); // Sync with server response @@ -88,7 +219,7 @@ function createTasksStore() { throw error; } }, - + /** * Delete task * @param {string} uuid @@ -96,7 +227,12 @@ function createTasksStore() { async deleteTask(uuid) { // Optimistic removal update(tasks => tasks.filter(t => t.uuid !== uuid)); - + + if (MOCK_MODE) { + mockData = mockData.filter(t => t.uuid !== uuid); + return; + } + try { await tasksAPI.delete(uuid); } catch (error) { @@ -108,12 +244,39 @@ function createTasksStore() { throw error; } }, - + /** * Complete task * @param {string} uuid */ async complete(uuid) { + if (MOCK_MODE) { + update(tasks => { + const index = tasks.findIndex(t => t.uuid === uuid); + if (index >= 0) { + const newStatus = tasks[index].status === 'C' ? 'P' : 'C'; + tasks[index] = { + ...tasks[index], + status: /** @type {'P'|'C'} */ (newStatus), + end: newStatus === 'C' ? Date.now() / 1000 : null, + modified: Date.now() / 1000 + }; + } + return [...tasks]; + }); + const mi = mockData.findIndex(t => t.uuid === uuid); + if (mi >= 0) { + const newStatus = mockData[mi].status === 'C' ? 'P' : 'C'; + mockData[mi] = { + ...mockData[mi], + status: /** @type {'P'|'C'} */ (newStatus), + end: newStatus === 'C' ? Date.now() / 1000 : null, + modified: Date.now() / 1000 + }; + } + return; + } + try { const completed = await tasksAPI.complete(uuid); update(tasks => {