onTap(task)}>
+ {#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}
+
+