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}