From f98f8492b5b5cb4e5457f2c285a59f4b0e93ce30 Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Tue, 14 Apr 2026 06:01:19 -0700 Subject: [PATCH] Group pending tasks by due date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add grouping of pending tasks into Overdue/Today/Tomorrow/This Week/Later/No Date buckets when the active list has group_by_due_date enabled. This provides a grouped view in TasksScreen (section headers, no drag-and-drop) and a new derived store groupedPendingTasks exported from the app store to supply the UI. Dated groups are sorted by due_date while the No Date group preserves the custom order. Few changes: lets make the date chip red when in the overdue section. In all other sections, lets hide the date chip for individual tasks. Then, for the Later section, lets break that up into individual days. - Replaced single "Later" group with per-day groups (e.g. "Mon, Apr 20"), each sorted by due_date - Added `dateChipStyle` prop to TaskItem ("normal" | "overdue" | "hidden"), default "normal" - Date chip is red (border-red-400 text-red-400) in "overdue" style, hidden in "hidden" style - TasksScreen passes dateChipStyle="overdue" for Overdue group, "hidden" for all others Fix date chips and include year in future group labels Replace dynamic ternary class strings with explicit {#if} blocks for the date chip so Tailwind v4's scanner picks up the classes and theme tokens are respected. Use text-danger and border-danger for overdue chips and proper border/token classes for non-overdue chips to fix the overdue chip appearance and prevent chips from showing in sections that should be hidden. Also include the year in per-day group labels when the date is not in the current year (e.g. "Mon, Apr 20, 2027") by passing the appropriate Intl.DateTimeFormat options, so tasks scheduled in future years display an unambiguous header. yes (remove This Week, extend per-day grouping to everything beyond tomorrow) - Removed "This Week" group entirely - Everything beyond tomorrow now gets a per-day group label (same as what "Later" was doing), giving a consistent Overdue → Today → Tomorrow → [per-day] → No Date structure Respect manual order within same-day groups Ensure tasks that fall on the same day keep the user's manual ordering by using a task_order index as a tiebreaker when sorting. Also add drag-and-drop group tracking so drags and drops are confined to their originating group and wire full DnD handlers into the grouped view. - Use a taskOrderIndex map in sortByDue to break ties when due dates are equal so same-day tasks respect manual order. - Add dragGroup state and record it on handleDragStart; ignore dragOver/drop events when the event group differs from the originating dragGroup to prevent cross-group drops. - Reset dragGroup on drag end. - Wrap grouped TaskItem entries with draggable divs and hook up dragstart/dragover/dragend/drop to match the flat view's DnD behavior. Update drag-and-drop to set task due dates Support dragging tasks between date-grouped columns by updating the task's due_date to the target group's date (or clearing it when dropped into No Date). Preserve the task's time-of-day when has_time is set, block dropping into the computed Overdue group, and allow dragging out of Overdue. Also add TaskGroup.date to the grouping data and expose a showSubtaskCount prop and optional subtask rendering in the task list UI. fix null taskId error when reordering after cross-group drag - Capture dragId and dragGroup into local variables at the top of handleDrop before any awaits — prevents the race where dragend fires during updateTask and nulls out dragId before reorderTask is called Allow dragging tasks to update date groups Add date handling to task groups so dragging a task into another group updates its due_date. Introduce date: Date | null for TaskGroup (null represents Overdue/No Date), update cross-group drops to set or clear a task's due_date based on the target group while preserving time-of-day for tasks with has_time, and keep within-group drops as reorders. Also ensure Overdue remains a computed-only target (cannot drop into it) while allowing dragging out. Additionally, add GoogleTasks workspace support fields (WorkspaceMode::GoogleTasks and an optional google_account) and include the new field in WorkspaceConfig::new to initialize google_account to None. --- apps/tauri/src/lib/components/TaskItem.svelte | 16 ++- apps/tauri/src/lib/screens/TasksScreen.svelte | 119 +++++++++++++----- apps/tauri/src/lib/stores/app.svelte.ts | 63 ++++++++++ crates/onyx-core/src/config.rs | 6 +- 4 files changed, 168 insertions(+), 36 deletions(-) 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 } } }