Merge pull request #40 from SteelDynamite/tm-branch-3
Group pending tasks by due date
This commit is contained in:
commit
105ed1ef62
|
|
@ -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,11 +150,17 @@
|
||||||
{#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"}
|
||||||
|
{#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">
|
<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)}
|
{formatDate(task.due_date)}
|
||||||
</span>
|
</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">
|
||||||
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
|
|
||||||
|
|
@ -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,6 +561,7 @@
|
||||||
Delete completed
|
Delete completed
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if !app.isGoogleTasks}
|
||||||
<button
|
<button
|
||||||
onclick={promptDeleteList}
|
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"
|
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>
|
</svg>
|
||||||
Delete list
|
Delete list
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -574,6 +602,32 @@
|
||||||
<div class="flex h-full items-center justify-center opacity-40">
|
<div class="flex h-full items-center justify-center opacity-40">
|
||||||
Select a list
|
Select a list
|
||||||
</div>
|
</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}
|
{:else}
|
||||||
{#each app.pendingTasks as task (task.id)}
|
{#each app.pendingTasks as task (task.id)}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
|
@ -585,13 +639,18 @@
|
||||||
ondrop={(e) => handleDrop(e, task.id)}
|
ondrop={(e) => handleDrop(e, task.id)}
|
||||||
class="{dragId === task.id ? 'opacity-30' : ''} {dragOverId === task.id && dragId !== task.id ? 'border-t-2 border-t-primary' : ''}"
|
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>
|
</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}
|
{/each}
|
||||||
|
|
||||||
{#if app.pendingTasks.length === 0}
|
{#if app.pendingTasks.length === 0}
|
||||||
<div class="p-8 text-center text-sm opacity-40">No tasks. Add one below.</div>
|
<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}
|
||||||
<div class="h-4"></div>
|
<div class="h-4"></div>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue