Merge pull request #40 from SteelDynamite/tm-branch-3

Group pending tasks by due date
This commit is contained in:
SteelDynamite 2026-04-14 15:16:27 +01:00 committed by GitHub
commit 105ed1ef62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 168 additions and 36 deletions

View file

@ -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,11 +150,17 @@
{#if task.description}
<p class="mt-0.5 text-xs opacity-40 line-clamp-1">{task.description}</p>
{/if}
{#if task.due_date}
{#if task.due_date && dateChipStyle !== "hidden"}
{#if dateChipStyle === "overdue"}
<span class="mt-1 inline-block rounded-full border border-danger px-2 py-0.5 text-xs text-danger opacity-80">
{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 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">
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">

View file

@ -62,11 +62,13 @@
let renameValue = $state("");
let showListMenu = $state(false);
let listMenuEl = $state<HTMLDivElement | null>(null);
let showSubtasks = $state(false);
let confirmDeleteList = $state(false);
let confirmDeleteCompleted = $state(false);
let confirmRemoveWorkspace = $state<string | null>(null);
let dragId = $state<string | null>(null);
let dragOverId = $state<string | null>(null);
let dragGroup = $state<string | null>(null);
let resizing = $state(false);
let resizeTimer: ReturnType<typeof setTimeout>;
@ -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,6 +561,7 @@
Delete completed
</button>
{/if}
{#if !app.isGoogleTasks}
<button
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"
@ -544,6 +571,7 @@
</svg>
Delete list
</button>
{/if}
</div>
{/if}
</div>
@ -574,6 +602,32 @@
<div class="flex h-full items-center justify-center opacity-40">
Select a list
</div>
{:else}
{#if app.groupedPendingTasks}
{#each app.groupedPendingTasks as group (group.label)}
<div class="px-4 pb-1 pt-4 text-xs font-semibold uppercase tracking-wider opacity-40">{group.label}</div>
{#each group.tasks as task (task.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
draggable="true"
ondragstart={(e) => 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' : ''}"
>
<TaskItem {task} onopen={openTask} dateChipStyle={group.label === "Overdue" ? "overdue" : "hidden"} 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} 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 -->
@ -585,13 +639,18 @@
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} />
<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 app.completedTasks.length > 0}
<div class="h-4"></div>

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 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
let childrenMap = $derived.by(() => {
const map = new Map<string, Task[]>();
@ -533,6 +593,9 @@ export const app = {
get pendingTasks() {
return pendingTasks;
},
get groupedPendingTasks() {
return groupedPendingTasks;
},
get completedTasks() {
return completedTasks;
},

View file

@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
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)]
pub last_sync: Option<chrono::DateTime<chrono::Utc>>,
#[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 }
}
}