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 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 }
} }
} }