diff --git a/apps/tauri/src/lib/components/TaskItem.svelte b/apps/tauri/src/lib/components/TaskItem.svelte index e4c5c50..ff40adb 100644 --- a/apps/tauri/src/lib/components/TaskItem.svelte +++ b/apps/tauri/src/lib/components/TaskItem.svelte @@ -6,7 +6,7 @@ import type { Task } from "../types"; import { app } from "../stores/app.svelte"; - let { task, onopen, depth = 0 }: { task: Task; onopen?: (task: Task) => void; depth?: number } = $props(); + let { task, onopen, depth = 0, dateChipStyle = "normal", showSubtaskCount = true }: { task: Task; onopen?: (task: Task) => void; depth?: number; dateChipStyle?: "normal" | "overdue" | "hidden"; showSubtaskCount?: boolean } = $props(); let subtasks = $derived(app.getSubtasks(task.id)); let subtaskCount = $derived(subtasks.length); @@ -150,10 +150,16 @@ {#if task.description}

{task.description}

{/if} - {#if task.due_date} - - {formatDate(task.due_date)} - + {#if task.due_date && dateChipStyle !== "hidden"} + {#if dateChipStyle === "overdue"} + + {formatDate(task.due_date)} + + {:else} + + {formatDate(task.due_date)} + + {/if} {/if} {#if subtaskCount > 0} diff --git a/apps/tauri/src/lib/screens/TasksScreen.svelte b/apps/tauri/src/lib/screens/TasksScreen.svelte index 0527799..ac461a2 100644 --- a/apps/tauri/src/lib/screens/TasksScreen.svelte +++ b/apps/tauri/src/lib/screens/TasksScreen.svelte @@ -62,11 +62,13 @@ let renameValue = $state(""); let showListMenu = $state(false); let listMenuEl = $state(null); + let showSubtasks = $state(false); let confirmDeleteList = $state(false); let confirmDeleteCompleted = $state(false); let confirmRemoveWorkspace = $state(null); let dragId = $state(null); let dragOverId = $state(null); + let dragGroup = $state(null); let resizing = $state(false); let resizeTimer: ReturnType; @@ -145,8 +147,9 @@ if (showWorkspacePicker) { showWorkspacePicker = false; return; } } - function handleDragStart(e: DragEvent, taskId: string) { + function handleDragStart(e: DragEvent, taskId: string, group?: string) { dragId = taskId; + dragGroup = group ?? null; if (e.dataTransfer) { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", taskId); @@ -173,7 +176,8 @@ } } - function handleDragOver(e: DragEvent, taskId: string) { + function handleDragOver(e: DragEvent, taskId: string, group?: string) { + if (group === "Overdue" && dragGroup !== "Overdue") return; e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; dragOverId = taskId; @@ -182,13 +186,35 @@ function handleDragEnd() { dragId = null; dragOverId = null; + dragGroup = null; } - async function handleDrop(e: DragEvent, targetId: string) { + async function handleDrop(e: DragEvent, targetId: string, group?: string) { e.preventDefault(); - if (!dragId || dragId === targetId) { handleDragEnd(); return; } + const taskId = dragId; + const sourceGroup = dragGroup; + if (!taskId || taskId === targetId) { handleDragEnd(); return; } + if (group === "Overdue" && sourceGroup !== "Overdue") { handleDragEnd(); return; } + + if (group !== undefined && group !== sourceGroup) { + const targetGroup = app.groupedPendingTasks?.find((g) => g.label === group); + const task = app.pendingTasks.find((t) => t.id === taskId); + if (task && targetGroup !== undefined) { + let newDueDate: string | null = null; + if (targetGroup.date !== null) { + const target = new Date(targetGroup.date); + if (task.has_time && task.due_date) { + const existing = new Date(task.due_date); + target.setHours(existing.getHours(), existing.getMinutes(), existing.getSeconds(), 0); + } + newDueDate = target.toISOString(); + } + await app.updateTask({ ...task, due_date: newDueDate, has_time: newDueDate ? task.has_time : false }); + } + } + const targetIndex = app.pendingTasks.findIndex((t) => t.id === targetId); - if (targetIndex >= 0) await app.reorderTask(dragId, targetIndex); + if (targetIndex >= 0) await app.reorderTask(taskId, targetIndex); handleDragEnd(); } @@ -535,15 +561,17 @@ Delete completed {/if} - + {#if !app.isGoogleTasks} + + {/if} {/if} @@ -575,22 +603,53 @@ Select a list {:else} - {#each app.pendingTasks as task (task.id)} - -
handleDragStart(e, task.id)} - ondragover={(e) => handleDragOver(e, task.id)} - ondragend={handleDragEnd} - ondrop={(e) => handleDrop(e, task.id)} - class="{dragId === task.id ? 'opacity-30' : ''} {dragOverId === task.id && dragId !== task.id ? 'border-t-2 border-t-primary' : ''}" - > - -
- {/each} - - {#if app.pendingTasks.length === 0} -
No tasks. Add one below.
+ {#if app.groupedPendingTasks} + {#each app.groupedPendingTasks as group (group.label)} +
{group.label}
+ {#each group.tasks as task (task.id)} + +
handleDragStart(e, task.id, group.label)} + ondragover={(e) => handleDragOver(e, task.id, group.label)} + ondragend={handleDragEnd} + ondrop={(e) => handleDrop(e, task.id, group.label)} + class="{dragId === task.id ? 'opacity-30' : ''} {dragOverId === task.id && dragId !== task.id ? 'border-t-2 border-t-primary' : ''}" + > + +
+ {#if showSubtasks} + {#each app.getSubtasks(task.id).filter(s => s.status === "backlog") as subtask (subtask.id)} + + {/each} + {/if} + {/each} + {/each} + {#if app.pendingTasks.length === 0} +
No tasks. Add one below.
+ {/if} + {:else} + {#each app.pendingTasks as task (task.id)} + +
handleDragStart(e, task.id)} + ondragover={(e) => handleDragOver(e, task.id)} + ondragend={handleDragEnd} + ondrop={(e) => handleDrop(e, task.id)} + class="{dragId === task.id ? 'opacity-30' : ''} {dragOverId === task.id && dragId !== task.id ? 'border-t-2 border-t-primary' : ''}" + > + +
+ {#if showSubtasks} + {#each app.getSubtasks(task.id).filter(s => s.status === "backlog") as subtask (subtask.id)} + + {/each} + {/if} + {/each} + {#if app.pendingTasks.length === 0} +
No tasks. Add one below.
+ {/if} {/if} {#if app.completedTasks.length > 0} diff --git a/apps/tauri/src/lib/stores/app.svelte.ts b/apps/tauri/src/lib/stores/app.svelte.ts index 22179dc..039443a 100644 --- a/apps/tauri/src/lib/stores/app.svelte.ts +++ b/apps/tauri/src/lib/stores/app.svelte.ts @@ -52,6 +52,66 @@ let activeList = $derived(lists.find((l) => l.id === activeListId) ?? null); let pendingTasks = $derived(tasks.filter((t) => t.status === "backlog" && !t.parent_id)); let completedTasks = $derived(tasks.filter((t) => t.status === "completed" && !t.parent_id)); +type TaskGroup = { label: string; tasks: Task[]; date: Date | null }; + +let groupedPendingTasks = $derived.by((): TaskGroup[] | null => { + if (!activeList?.group_by_due_date) return null; + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const tomorrowStart = new Date(todayStart); + tomorrowStart.setDate(todayStart.getDate() + 1); + + const overdue: Task[] = []; + const today: Task[] = []; + const tomorrow: Task[] = []; + const futureByDay = new Map(); + const noDate: Task[] = []; + + for (const task of pendingTasks) { + if (!task.due_date) { + noDate.push(task); + } else { + const d = new Date(task.due_date); + const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + if (dayStart < todayStart) overdue.push(task); + else if (dayStart.getTime() === todayStart.getTime()) today.push(task); + else if (dayStart.getTime() === tomorrowStart.getTime()) tomorrow.push(task); + else { + const key = dayStart.toISOString(); + if (!futureByDay.has(key)) futureByDay.set(key, { date: dayStart, tasks: [] }); + futureByDay.get(key)!.tasks.push(task); + } + } + } + + const taskOrderIndex = new Map(pendingTasks.map((t, i) => [t.id, i])); + const sortByDue = (a: Task, b: Task) => { + const dateDiff = new Date(a.due_date!).getTime() - new Date(b.due_date!).getTime(); + if (dateDiff !== 0) return dateDiff; + return (taskOrderIndex.get(a.id) ?? 0) - (taskOrderIndex.get(b.id) ?? 0); + }; + overdue.sort(sortByDue); + today.sort(sortByDue); + tomorrow.sort(sortByDue); + + const groups: TaskGroup[] = []; + if (overdue.length) groups.push({ label: "Overdue", tasks: overdue, date: null }); + if (today.length) groups.push({ label: "Today", tasks: today, date: todayStart }); + if (tomorrow.length) groups.push({ label: "Tomorrow", tasks: tomorrow, date: tomorrowStart }); + + const currentYear = now.getFullYear(); + for (const [, { date, tasks }] of [...futureByDay.entries()].sort(([a], [b]) => a.localeCompare(b))) { + tasks.sort(sortByDue); + const opts: Intl.DateTimeFormatOptions = date.getFullYear() !== currentYear + ? { weekday: "short", month: "short", day: "numeric", year: "numeric" } + : { weekday: "short", month: "short", day: "numeric" }; + groups.push({ label: date.toLocaleDateString(undefined, opts), tasks, date }); + } + + if (noDate.length) groups.push({ label: "No Date", tasks: noDate, date: null }); + return groups; +}); + // Build a map of parent_id -> children for subtask hierarchy let childrenMap = $derived.by(() => { const map = new Map(); @@ -533,6 +593,9 @@ export const app = { get pendingTasks() { return pendingTasks; }, + get groupedPendingTasks() { + return groupedPendingTasks; + }, get completedTasks() { return completedTasks; }, diff --git a/crates/onyx-core/src/config.rs b/crates/onyx-core/src/config.rs index d7807be..4498b06 100644 --- a/crates/onyx-core/src/config.rs +++ b/crates/onyx-core/src/config.rs @@ -9,6 +9,7 @@ use crate::error::{Error, Result}; pub enum WorkspaceMode { Local, Webdav, + GoogleTasks, } impl Default for WorkspaceMode { @@ -27,6 +28,9 @@ pub struct WorkspaceConfig { pub webdav_url: Option, #[serde(skip_serializing_if = "Option::is_none", default)] pub webdav_path: Option, + /// Display name / email of the connected Google account (GoogleTasks workspaces only). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub google_account: Option, #[serde(skip_serializing_if = "Option::is_none", default)] pub last_sync: Option>, #[serde(skip_serializing_if = "Option::is_none", default)] @@ -39,7 +43,7 @@ pub struct WorkspaceConfig { impl WorkspaceConfig { pub fn new(name: String, path: PathBuf) -> Self { - Self { name, path, mode: WorkspaceMode::Local, webdav_url: None, webdav_path: None, last_sync: None, theme: None, sync_interval_secs: None, sync_interval_unfocused_secs: None } + Self { name, path, mode: WorkspaceMode::Local, webdav_url: None, webdav_path: None, google_account: None, last_sync: None, theme: None, sync_interval_secs: None, sync_interval_unfocused_secs: None } } }