diff --git a/opal-web/src/app.css b/opal-web/src/app.css index 1eefb54..bfc2afa 100644 --- a/opal-web/src/app.css +++ b/opal-web/src/app.css @@ -68,6 +68,8 @@ --color-overdue-text: #f85149; --color-tag-bg: rgba(139, 148, 158, 0.1); --color-tag-text: #8b949e; + --color-active-bg: rgba(57, 208, 186, 0.15); + --color-active-text: #39d0ba; color-scheme: dark; } @@ -118,6 +120,8 @@ --color-overdue-text: #be123c; --color-tag-bg: #f5f5f4; --color-tag-text: #78716c; + --color-active-bg: rgba(99, 102, 241, 0.12); + --color-active-text: #4f46e5; color-scheme: light; } @@ -168,6 +172,8 @@ --color-overdue-text: #ef4444; --color-tag-bg: rgba(148, 163, 184, 0.1); --color-tag-text: #94a3b8; + --color-active-bg: rgba(139, 92, 246, 0.15); + --color-active-text: #8b5cf6; color-scheme: dark; } diff --git a/opal-web/src/lib/components/BottomSheet.svelte b/opal-web/src/lib/components/BottomSheet.svelte new file mode 100644 index 0000000..acbe6f4 --- /dev/null +++ b/opal-web/src/lib/components/BottomSheet.svelte @@ -0,0 +1,253 @@ + + + +
+ + +
+ + diff --git a/opal-web/src/lib/components/ConfirmDialog.svelte b/opal-web/src/lib/components/ConfirmDialog.svelte new file mode 100644 index 0000000..b498c53 --- /dev/null +++ b/opal-web/src/lib/components/ConfirmDialog.svelte @@ -0,0 +1,178 @@ + + + + +
+

{title}

+ +

"{message}"

+ + {#if detail} +

{detail}

+ {/if} + +
+ + +
+
+
+ + diff --git a/opal-web/src/lib/components/SwipeAction.svelte b/opal-web/src/lib/components/SwipeAction.svelte index 858d28b..cfec4ea 100644 --- a/opal-web/src/lib/components/SwipeAction.svelte +++ b/opal-web/src/lib/components/SwipeAction.svelte @@ -2,12 +2,23 @@ /** * @type {() => void} */ - export let onSwipe; + export let onSwipeRight; + + /** + * @type {() => void} + */ + export let onSwipeLeft; + + /** + * @type {'start' | 'stop'} + */ + export let leftIcon; let offsetX = 0; let swiping = false; let locked = false; let completed = false; + let triggered = false; /** @type {number|null} */ let startX = null; @@ -52,8 +63,7 @@ if (swiping) { if (e.cancelable) e.preventDefault(); - // Only allow right swipe - offsetX = Math.max(0, deltaX); + offsetX = deltaX; } } @@ -64,11 +74,20 @@ } if (offsetX >= THRESHOLD) { + // Right swipe — complete (row collapses) completed = true; - // Animate to full width before firing callback offsetX = window.innerWidth; setTimeout(() => { - onSwipe(); + onSwipeRight(); + }, 200); + } else if (offsetX <= -THRESHOLD) { + // Left swipe — start/stop (row stays) + triggered = true; + offsetX = -window.innerWidth; + setTimeout(() => { + onSwipeLeft(); + offsetX = 0; + triggered = false; }, 200); } else { offsetX = 0; @@ -86,7 +105,8 @@ startY = null; } - $: progress = Math.min(offsetX / THRESHOLD, 1); + $: rightProgress = offsetX > 0 ? Math.min(offsetX / THRESHOLD, 1) : 0; + $: leftProgress = offsetX < 0 ? Math.min(Math.abs(offsetX) / THRESHOLD, 1) : 0; $: transitioning = !swiping && offsetX !== 0; @@ -97,15 +117,28 @@ on:touchend={handleTouchEnd} on:touchcancel={resetState} > -
- + +
+
+ +
+ {#if leftIcon === 'stop'} + + + + {:else} + + + + {/if} +
+
+ import { formatDate, formatRelative, fromUnix, toUnix } from '$lib/utils/dates.js'; + import { format } from 'date-fns'; + import { tags as tagsAPI } from '$lib/api/endpoints.js'; + + /** + * @typedef {import('$lib/api/types.js').Task} Task + */ + + /** @type {Task} */ + export let task; + + /** @type {(uuid: string, updates: Partial) => Promise} */ + export let onUpdate; + + /** @type {(uuid: string) => Promise} */ + export let onStart; + + /** @type {(uuid: string) => Promise} */ + export let onStop; + + /** @type {(uuid: string) => void} */ + export let onDelete; + + /** @type {(uuid: string) => void} */ + export let onComplete; + + /** @type {() => void} */ + export let onClose; + + // Editing state — only one field at a time + /** @type {string|null} */ + let editingField = null; + + // Edit values + let editDescription = ''; + let editProject = ''; + let editTagInput = ''; + + // Recurring instance: remember user choice for this sheet session + /** @type {'instance'|'template'|null} */ + let recurringChoice = null; + + $: isRecurringInstance = task.parent_uuid !== null && task.parent_uuid !== undefined; + $: isTemplate = task.status === 'R'; + $: isCompleted = task.status === 'C'; + $: isActive = task.start !== null && task.start !== undefined; + + const statusLabels = /** @type {Record} */ ({ + P: 'Pending', + C: 'Completed', + D: 'Deleted', + R: 'Recurring' + }); + + const priorityLabels = /** @type {Record} */ ({ + 3: 'High', + 2: 'Medium', + 1: 'Default', + 0: 'Low' + }); + + const priorityCycle = /** @type {Record} */ ({ + 3: 2, + 2: 1, + 1: 0, + 0: 3 + }); + + /** + * Format a unix timestamp as yyyy-MM-dd for date input + * @param {number} ts + * @returns {string} + */ + function tsToDateValue(ts) { + return format(fromUnix(ts), 'yyyy-MM-dd'); + } + + /** + * Check if a field edit on a recurring instance needs the instance/template prompt + * @param {string} field + * @returns {boolean} + */ + function needsRecurringPrompt(field) { + if (!isRecurringInstance) return false; + if (recurringChoice) return false; + const editableOnBoth = ['description', 'priority', 'project', 'due', 'tags', 'scheduled', 'wait', 'until']; + return editableOnBoth.includes(field); + } + + /** + * @param {'instance'|'template'} choice + */ + function handleRecurringChoice(choice) { + recurringChoice = choice; + showRecurringPrompt = false; + } + + let showRecurringPrompt = false; + /** @type {string|null} */ + let pendingEditField = null; + + /** + * @param {string} field + */ + function startEdit(field) { + if (needsRecurringPrompt(field)) { + pendingEditField = field; + showRecurringPrompt = true; + return; + } + + // Save current field if editing + if (editingField) saveCurrentEdit(); + + editingField = field; + + if (field === 'description') { + editDescription = task.description; + } else if (field === 'project') { + editProject = task.project || ''; + } + } + + function saveCurrentEdit() { + if (!editingField) return; + // Each field handles its own save via its input events + editingField = null; + } + + /** + * Get the UUID to update based on recurring choice + * @returns {string} + */ + function getTargetUuid() { + if (recurringChoice === 'template' && task.parent_uuid) { + return task.parent_uuid; + } + return task.uuid; + } + + async function saveDescription() { + const trimmed = editDescription.trim(); + if (trimmed && trimmed !== task.description) { + await onUpdate(getTargetUuid(), { description: trimmed }); + } + editingField = null; + } + + async function saveProject() { + const trimmed = editProject.trim(); + const newVal = trimmed || null; + if (newVal !== task.project) { + await onUpdate(getTargetUuid(), { project: newVal }); + } + editingField = null; + } + + async function cyclePriority() { + if (needsRecurringPrompt('priority')) { + pendingEditField = 'priority-cycle'; + showRecurringPrompt = true; + return; + } + const next = priorityCycle[task.priority] ?? 1; + await onUpdate(getTargetUuid(), { priority: /** @type {import('$lib/api/types.js').TaskPriority} */ (next) }); + } + + /** + * @param {Event} e + * @param {string} field + */ + async function handleDateChange(e, field) { + const input = /** @type {HTMLInputElement} */ (e.target); + const value = input.value; + if (value) { + const date = new Date(value + 'T00:00:00'); + await onUpdate(getTargetUuid(), { [field]: toUnix(date) }); + } + editingField = null; + } + + /** + * @param {string} field + */ + async function clearDate(field) { + await onUpdate(getTargetUuid(), { [field]: null }); + editingField = null; + } + + /** + * @param {string} tag + */ + async function removeTag(tag) { + try { + await tagsAPI.remove(getTargetUuid(), tag); + // Optimistic: update local + task = { ...task, tags: task.tags.filter(t => t !== tag) }; + } catch (error) { + console.error('Failed to remove tag:', error); + } + } + + async function addTag() { + const tag = editTagInput.trim(); + if (!tag) return; + editTagInput = ''; + try { + await tagsAPI.add(getTargetUuid(), tag); + // Optimistic: update local + task = { ...task, tags: [...task.tags, tag] }; + } catch (error) { + console.error('Failed to add tag:', error); + } + } + + /** + * @param {KeyboardEvent} e + */ + function handleTagKeydown(e) { + if (e.key === 'Enter') { + e.preventDefault(); + addTag(); + } else if (e.key === 'Escape') { + editingField = null; + editTagInput = ''; + } + } + + /** + * @param {KeyboardEvent} e + */ + function handleDescriptionKeydown(e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + saveDescription(); + } else if (e.key === 'Escape') { + editingField = null; + } + } + + /** + * @param {KeyboardEvent} e + */ + function handleProjectKeydown(e) { + if (e.key === 'Enter') { + e.preventDefault(); + saveProject(); + } else if (e.key === 'Escape') { + editingField = null; + } + } + + async function copyUuid() { + try { + await navigator.clipboard.writeText(task.uuid); + } catch { + // Fallback: do nothing + } + } + + // After recurring choice is made, proceed with the pending edit + $: if (recurringChoice && pendingEditField) { + const field = pendingEditField; + pendingEditField = null; + if (field === 'priority-cycle') { + // Direct cycle + const next = priorityCycle[task.priority] ?? 1; + onUpdate(getTargetUuid(), { priority: /** @type {import('$lib/api/types.js').TaskPriority} */ (next) }); + } else { + startEdit(field); + } + } + + +
+ + {#if showRecurringPrompt} +
+ + + +
+ {/if} + + +
+ {#if editingField === 'description'} +
+ +
+ + +
+
+ {:else} + +
startEdit('description')}> +

{task.description}

+ +
+ {/if} +
+ +
+ + +
+ +
+ Status + + {statusLabels[task.status] || task.status} + +
+ + + +
+ Priority + + + {priorityLabels[task.priority] || 'Default'} + +
+ + +
+ Project + {#if editingField === 'project'} + + {:else} + + startEdit('project')}> + {task.project || 'Add...'} + + {/if} +
+ + + {#if task.due || editingField === 'due'} +
+ Due + {#if editingField === 'due'} +
+ handleDateChange(e, 'due')} + /> + +
+ {:else} + + startEdit('due')}> + {formatRelative(task.due)} ({formatDate(task.due)}) + + {/if} +
+ {:else} + +
startEdit('due')}> + Due + Set... +
+ {/if} + + + {#if task.scheduled || editingField === 'scheduled'} +
+ Scheduled + {#if editingField === 'scheduled'} +
+ handleDateChange(e, 'scheduled')} + /> + +
+ {:else} + + startEdit('scheduled')}> + {formatRelative(task.scheduled)} ({formatDate(task.scheduled)}) + + {/if} +
+ {/if} + + + {#if task.wait || editingField === 'wait'} +
+ Wait + {#if editingField === 'wait'} +
+ handleDateChange(e, 'wait')} + /> + +
+ {:else} + + startEdit('wait')}> + {formatRelative(task.wait)} ({formatDate(task.wait)}) + + {/if} +
+ {/if} + + + {#if task.until || editingField === 'until'} +
+ Until + {#if editingField === 'until'} +
+ handleDateChange(e, 'until')} + /> + +
+ {:else} + + startEdit('until')}> + {formatRelative(task.until)} ({formatDate(task.until)}) + + {/if} +
+ {/if} + + + {#if isActive} +
+ Active since + {formatRelative(task.start)} +
+ {/if} + + + {#if task.recurrence_duration} +
+ Recurrence + {task.recurrence_duration} +
+ {/if} + + + {#if isRecurringInstance} +
+ Parent + Recurring instance +
+ {/if} + + +
+ Tags +
+ {#each task.tags as tag} + + {tag} + + + {/each} + {#if editingField === 'tags'} + { addTag(); editingField = null; }} + placeholder="tag name" + /> + {:else} + + {/if} +
+
+
+ +
+ + + + +
+ + +
+ {#if isCompleted} + + {:else} + {#if isActive} + + {:else} + + {/if} + + + {/if} + + +
+
+ + diff --git a/opal-web/src/lib/components/TaskItem.svelte b/opal-web/src/lib/components/TaskItem.svelte index db62384..6ef2bcc 100644 --- a/opal-web/src/lib/components/TaskItem.svelte +++ b/opal-web/src/lib/components/TaskItem.svelte @@ -14,10 +14,21 @@ */ export let onComplete; + /** + * @type {(task: import('$lib/api/types.js').Task) => void} + */ + export let onTap; + + /** + * @type {(uuid: string) => void} + */ + export let onStartStop; + let completing = false; $: overdue = task.due && isOverdue(task.due); $: dueToday = task.due && isTodayFn(new Date(task.due * 1000)); + $: active = task.start !== null && task.start !== undefined; function handleCheckbox() { if (completing) return; @@ -34,13 +45,18 @@ } - onComplete(task.uuid)}> -
+ onComplete(task.uuid)} + onSwipeLeft={() => onStartStop(task.uuid)} + leftIcon={active ? 'stop' : 'start'} +> +
-
+ +
onTap(task)}>
{task.description} @@ -48,6 +64,10 @@
+ {#if active} + Active + {/if} + {#if task.project} {task.project} {/if} @@ -109,6 +129,11 @@ overflow: hidden; } + .task-item.active { + border-left: 3px solid var(--color-primary); + padding-left: calc(var(--spacing-md) - 3px); + } + .task-checkbox { flex-shrink: 0; display: flex; @@ -127,6 +152,7 @@ .task-content { flex: 1; min-width: 0; + cursor: pointer; } .task-header { @@ -158,6 +184,11 @@ font-weight: 500; } + .active-pill { + background-color: var(--color-active-bg); + color: var(--color-active-text); + } + .project { background-color: var(--color-project-bg); color: var(--color-project-text); diff --git a/opal-web/src/lib/components/TaskList.svelte b/opal-web/src/lib/components/TaskList.svelte index a140f36..167058b 100644 --- a/opal-web/src/lib/components/TaskList.svelte +++ b/opal-web/src/lib/components/TaskList.svelte @@ -11,6 +11,16 @@ */ export let onComplete; + /** + * @type {(task: import('$lib/api/types.js').Task) => void} + */ + export let onTap; + + /** + * @type {(uuid: string) => void} + */ + export let onStartStop; + export let loading = false; export let activeReport = 'list'; @@ -50,6 +60,8 @@ {/each} {/if} diff --git a/opal-web/src/lib/components/Toast.svelte b/opal-web/src/lib/components/Toast.svelte new file mode 100644 index 0000000..dc08145 --- /dev/null +++ b/opal-web/src/lib/components/Toast.svelte @@ -0,0 +1,102 @@ + + +
+ {message} + {#if action} + + {/if} +
+ + diff --git a/opal-web/src/lib/stores/tasks.js b/opal-web/src/lib/stores/tasks.js index 894ae3c..e26684d 100644 --- a/opal-web/src/lib/stores/tasks.js +++ b/opal-web/src/lib/stores/tasks.js @@ -140,6 +140,55 @@ function createTasksStore() { } }, + /** + * Start task timer (optimistic) + * @param {string} uuid + */ + async startTask(uuid) { + const now = Math.floor(Date.now() / 1000); + update(tasks => tasks.map(t => + t.uuid === uuid ? { ...t, start: now } : t + )); + try { + const updated = await tasksAPI.start(uuid); + update(tasks => tasks.map(t => + t.uuid === uuid ? updated : t + )); + } catch (error) { + update(tasks => tasks.map(t => + t.uuid === uuid ? { ...t, start: null } : t + )); + throw error; + } + }, + + /** + * Stop task timer (optimistic) + * @param {string} uuid + */ + async stopTask(uuid) { + /** @type {number|null} */ + let prevStart = null; + update(tasks => tasks.map(t => { + if (t.uuid === uuid) { + prevStart = t.start; + return { ...t, start: null }; + } + return t; + })); + try { + const updated = await tasksAPI.stop(uuid); + update(tasks => tasks.map(t => + t.uuid === uuid ? updated : t + )); + } catch (error) { + update(tasks => tasks.map(t => + t.uuid === uuid ? { ...t, start: prevStart } : t + )); + throw error; + } + }, + /** * Complete task * @param {string} uuid diff --git a/opal-web/src/routes/+page.svelte b/opal-web/src/routes/+page.svelte index e41ebb4..1bbb41e 100644 --- a/opal-web/src/routes/+page.svelte +++ b/opal-web/src/routes/+page.svelte @@ -8,6 +8,10 @@ import Header from '$lib/components/Header.svelte'; import TaskList from '$lib/components/TaskList.svelte'; import InputBar from '$lib/components/InputBar.svelte'; + import BottomSheet from '$lib/components/BottomSheet.svelte'; + import TaskDetail from '$lib/components/TaskDetail.svelte'; + import Toast from '$lib/components/Toast.svelte'; + import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; let activeReport = 'list'; /** @type {import('$lib/api/types.js').Task[]} */ @@ -15,6 +19,21 @@ let loading = true; let inputError = ''; + // Bottom sheet state + /** @type {import('$lib/api/types.js').Task|null} */ + let selectedTask = null; + + // Undo toast state + /** @type {{ uuid: string, description: string }|null} */ + let undoToast = null; + + // Delete confirmation state + /** @type {{ uuid: string, description: string }|null} */ + let deleteTarget = null; + + /** @type {ConfirmDialog} */ + let confirmDialog; + // Subscribe to store const unsubscribe = tasksStore.subscribe(value => { tasks = value; @@ -124,12 +143,97 @@ * @param {string} uuid */ async function handleComplete(uuid) { + const task = tasks.find(t => t.uuid === uuid); + if (!task) return; + try { await tasksStore.complete(uuid); + undoToast = { uuid: task.uuid, description: task.description }; } catch (error) { console.error('Failed to complete task:', error); } } + + async function handleUndo() { + if (!undoToast) return; + const { uuid } = undoToast; + undoToast = null; + + try { + await tasksStore.updateTask(uuid, { status: 'P', end: null }); + await loadReport(activeReport); + } catch (error) { + console.error('Failed to undo completion:', error); + } + } + + /** + * @param {string} uuid + */ + async function handleStartStop(uuid) { + const task = tasks.find(t => t.uuid === uuid); + if (!task) return; + + try { + if (task.start) { + await tasksStore.stopTask(uuid); + } else { + await tasksStore.startTask(uuid); + } + // Update selectedTask if it's the same task + if (selectedTask?.uuid === uuid) { + const updated = tasks.find(t => t.uuid === uuid); + if (updated) selectedTask = updated; + } + } catch (error) { + console.error('Failed to start/stop task:', error); + } + } + + /** + * @param {string} uuid + * @param {Partial} updates + */ + async function handleUpdate(uuid, updates) { + try { + await tasksStore.updateTask(uuid, updates); + // Keep selectedTask fresh + if (selectedTask?.uuid === uuid) { + selectedTask = { ...selectedTask, ...updates }; + } + } catch (error) { + console.error('Failed to update task:', error); + } + } + + /** + * @param {string} uuid + */ + function handleDeleteRequest(uuid) { + const task = tasks.find(t => t.uuid === uuid) + ?? (selectedTask?.uuid === uuid ? selectedTask : null); + if (!task) return; + + deleteTarget = { uuid: task.uuid, description: task.description }; + confirmDialog.open(); + } + + async function handleDeleteConfirm() { + if (!deleteTarget) return; + const { uuid } = deleteTarget; + deleteTarget = null; + selectedTask = null; + + try { + await tasksStore.deleteTask(uuid); + } catch (error) { + console.error('Failed to delete task:', error); + } + } + + function handleDeleteCancel() { + deleteTarget = null; + }
@@ -139,9 +243,44 @@ {loading} {activeReport} onComplete={handleComplete} + onTap={(task) => selectedTask = task} + onStartStop={handleStartStop} /> + + selectedTask = null}> + {#if selectedTask} + handleStartStop(uuid)} + onStop={(uuid) => handleStartStop(uuid)} + onDelete={handleDeleteRequest} + onComplete={handleComplete} + onClose={() => selectedTask = null} + /> + {/if} + + +{#if undoToast} + undoToast = null} + /> +{/if} + +