Group pending tasks by due date

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.
This commit is contained in:
Tristan Michael 2026-04-14 06:01:19 -07:00
parent 105d46775c
commit f98f8492b5
4 changed files with 168 additions and 36 deletions

View file

@ -6,7 +6,7 @@
import type { Task } from "../types"; import type { Task } from "../types";
import { app } from "../stores/app.svelte"; 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 subtasks = $derived(app.getSubtasks(task.id));
let subtaskCount = $derived(subtasks.length); let subtaskCount = $derived(subtasks.length);
@ -150,10 +150,16 @@
{#if task.description} {#if task.description}
<p class="mt-0.5 text-xs opacity-40 line-clamp-1">{task.description}</p> <p class="mt-0.5 text-xs opacity-40 line-clamp-1">{task.description}</p>
{/if} {/if}
{#if task.due_date} {#if task.due_date && dateChipStyle !== "hidden"}
<span class="mt-1 inline-block rounded-full border border-border-light px-2 py-0.5 text-xs opacity-50 dark:border-border-dark"> {#if dateChipStyle === "overdue"}
{formatDate(task.due_date)} <span class="mt-1 inline-block rounded-full border border-danger px-2 py-0.5 text-xs text-danger opacity-80">
</span> {formatDate(task.due_date)}
</span>
{:else}
<span class="mt-1 inline-block rounded-full border border-border-light px-2 py-0.5 text-xs opacity-50 dark:border-border-dark">
{formatDate(task.due_date)}
</span>
{/if}
{/if} {/if}
{#if subtaskCount > 0} {#if subtaskCount > 0}
<span class="mt-1 inline-flex items-center gap-1 text-xs opacity-40" aria-label="{subtasks.filter(s => s.status === 'completed').length} of {subtaskCount} subtasks completed"> <span class="mt-1 inline-flex items-center gap-1 text-xs opacity-40" aria-label="{subtasks.filter(s => s.status === 'completed').length} of {subtaskCount} subtasks completed">

View file

@ -62,11 +62,13 @@
let renameValue = $state(""); let renameValue = $state("");
let showListMenu = $state(false); let showListMenu = $state(false);
let listMenuEl = $state<HTMLDivElement | null>(null); let listMenuEl = $state<HTMLDivElement | null>(null);
let showSubtasks = $state(false);
let confirmDeleteList = $state(false); let confirmDeleteList = $state(false);
let confirmDeleteCompleted = $state(false); let confirmDeleteCompleted = $state(false);
let confirmRemoveWorkspace = $state<string | null>(null); let confirmRemoveWorkspace = $state<string | null>(null);
let dragId = $state<string | null>(null); let dragId = $state<string | null>(null);
let dragOverId = $state<string | null>(null); let dragOverId = $state<string | null>(null);
let dragGroup = $state<string | null>(null);
let resizing = $state(false); let resizing = $state(false);
let resizeTimer: ReturnType<typeof setTimeout>; let resizeTimer: ReturnType<typeof setTimeout>;
@ -145,8 +147,9 @@
if (showWorkspacePicker) { showWorkspacePicker = false; return; } if (showWorkspacePicker) { showWorkspacePicker = false; return; }
} }
function handleDragStart(e: DragEvent, taskId: string) { function handleDragStart(e: DragEvent, taskId: string, group?: string) {
dragId = taskId; dragId = taskId;
dragGroup = group ?? null;
if (e.dataTransfer) { if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move"; e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", taskId); 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(); e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
dragOverId = taskId; dragOverId = taskId;
@ -182,13 +186,35 @@
function handleDragEnd() { function handleDragEnd() {
dragId = null; dragId = null;
dragOverId = null; dragOverId = null;
dragGroup = null;
} }
async function handleDrop(e: DragEvent, targetId: string) { async function handleDrop(e: DragEvent, targetId: string, group?: string) {
e.preventDefault(); 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); 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(); handleDragEnd();
} }
@ -535,15 +561,17 @@
Delete completed Delete completed
</button> </button>
{/if} {/if}
<button {#if !app.isGoogleTasks}
onclick={promptDeleteList} <button
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-danger hover:bg-black/5 dark:hover:bg-white/10" onclick={promptDeleteList}
> class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-danger hover:bg-black/5 dark:hover:bg-white/10"
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> >
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" /> <svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
</svg> <path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
Delete list </svg>
</button> Delete list
</button>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
@ -575,22 +603,53 @@
Select a list Select a list
</div> </div>
{:else} {:else}
{#each app.pendingTasks as task (task.id)} {#if app.groupedPendingTasks}
<!-- svelte-ignore a11y_no_static_element_interactions --> {#each app.groupedPendingTasks as group (group.label)}
<div <div class="px-4 pb-1 pt-4 text-xs font-semibold uppercase tracking-wider opacity-40">{group.label}</div>
draggable="true" {#each group.tasks as task (task.id)}
ondragstart={(e) => handleDragStart(e, task.id)} <!-- svelte-ignore a11y_no_static_element_interactions -->
ondragover={(e) => handleDragOver(e, task.id)} <div
ondragend={handleDragEnd} draggable="true"
ondrop={(e) => handleDrop(e, task.id)} ondragstart={(e) => handleDragStart(e, task.id, group.label)}
class="{dragId === task.id ? 'opacity-30' : ''} {dragOverId === task.id && dragId !== task.id ? 'border-t-2 border-t-primary' : ''}" ondragover={(e) => handleDragOver(e, task.id, group.label)}
> ondragend={handleDragEnd}
<TaskItem {task} onopen={openTask} /> ondrop={(e) => handleDrop(e, task.id, group.label)}
</div> class="{dragId === task.id ? 'opacity-30' : ''} {dragOverId === task.id && dragId !== task.id ? 'border-t-2 border-t-primary' : ''}"
{/each} >
<TaskItem {task} onopen={openTask} dateChipStyle={group.label === "Overdue" ? "overdue" : "hidden"} showSubtaskCount={!showSubtasks} />
{#if app.pendingTasks.length === 0} </div>
<div class="p-8 text-center text-sm opacity-40">No tasks. Add one below.</div> {#if showSubtasks}
{#each app.getSubtasks(task.id).filter(s => s.status === "backlog") as subtask (subtask.id)}
<TaskItem task={subtask} onopen={openTask} depth={1} dateChipStyle="hidden" />
{/each}
{/if}
{/each}
{/each}
{#if app.pendingTasks.length === 0}
<div class="p-8 text-center text-sm opacity-40">No tasks. Add one below.</div>
{/if}
{:else}
{#each app.pendingTasks as task (task.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
draggable="true"
ondragstart={(e) => 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' : ''}"
>
<TaskItem {task} onopen={openTask} showSubtaskCount={!showSubtasks} />
</div>
{#if showSubtasks}
{#each app.getSubtasks(task.id).filter(s => s.status === "backlog") as subtask (subtask.id)}
<TaskItem task={subtask} onopen={openTask} depth={1} />
{/each}
{/if}
{/each}
{#if app.pendingTasks.length === 0}
<div class="p-8 text-center text-sm opacity-40">No tasks. Add one below.</div>
{/if}
{/if} {/if}
{#if app.completedTasks.length > 0} {#if app.completedTasks.length > 0}

View file

@ -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 pendingTasks = $derived(tasks.filter((t) => t.status === "backlog" && !t.parent_id));
let completedTasks = $derived(tasks.filter((t) => t.status === "completed" && !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<string, { date: Date; tasks: Task[] }>();
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 // Build a map of parent_id -> children for subtask hierarchy
let childrenMap = $derived.by(() => { let childrenMap = $derived.by(() => {
const map = new Map<string, Task[]>(); const map = new Map<string, Task[]>();
@ -533,6 +593,9 @@ export const app = {
get pendingTasks() { get pendingTasks() {
return pendingTasks; return pendingTasks;
}, },
get groupedPendingTasks() {
return groupedPendingTasks;
},
get completedTasks() { get completedTasks() {
return completedTasks; return completedTasks;
}, },

View file

@ -9,6 +9,7 @@ use crate::error::{Error, Result};
pub enum WorkspaceMode { pub enum WorkspaceMode {
Local, Local,
Webdav, Webdav,
GoogleTasks,
} }
impl Default for WorkspaceMode { impl Default for WorkspaceMode {
@ -27,6 +28,9 @@ pub struct WorkspaceConfig {
pub webdav_url: Option<String>, pub webdav_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)] #[serde(skip_serializing_if = "Option::is_none", default)]
pub webdav_path: Option<String>, pub webdav_path: Option<String>,
/// Display name / email of the connected Google account (GoogleTasks workspaces only).
#[serde(skip_serializing_if = "Option::is_none", default)]
pub google_account: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)] #[serde(skip_serializing_if = "Option::is_none", default)]
pub last_sync: Option<chrono::DateTime<chrono::Utc>>, pub last_sync: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Option::is_none", default)] #[serde(skip_serializing_if = "Option::is_none", default)]
@ -39,7 +43,7 @@ pub struct WorkspaceConfig {
impl WorkspaceConfig { impl WorkspaceConfig {
pub fn new(name: String, path: PathBuf) -> Self { 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 }
} }
} }