Add Vitest suite covering the smoke-test fixes
Extracts two pure helpers out of Svelte components so they can be
exercised without the reactive runtime, and adds component tests for
ConfirmDialog's Escape-handling behavior.
- apps/tauri/src/lib/grouping.ts (new): `groupTasksByDate` lifted out of
the `groupedPendingTasks` $derived in the app store.
- apps/tauri/src/lib/paths.ts (new): `workspaceNameFromPath` lifted out
of SetupScreen.handleOpen.
- apps/tauri/src/lib/grouping.test.ts: 8 cases — "No Date" placed last
(regression), full bucket ordering, empty input, within-bucket
stable sort, earlier-today stays in Today, multi-task same-day,
No Date preserves insertion order.
- apps/tauri/src/lib/paths.test.ts: 8 cases — POSIX/Windows/mixed
separators, trailing slash regression ("…/Tasks/" → "Tasks"), empty
and root-only fallback, names with spaces.
- apps/tauri/src/lib/components/ConfirmDialog.test.ts: 6 cases —
renders message/detail/custom confirm text, Cancel/Confirm fire the
right callbacks, Escape calls oncancel and does NOT reach an outer
window listener (regression), non-Escape keys are ignored, and the
module-level open-count increments/decrements correctly (including
when two dialogs are mounted at once).
Test harness: Vitest + jsdom + @testing-library/svelte. `npm test`
runs the suite; `resolve.conditions` is set to "browser" under VITEST
so Svelte resolves its client entry and mount() works.
23/23 tests pass. cargo check, cargo test -p onyx-core (162/162),
and npm run build all still green.
This commit is contained in:
parent
8a04895270
commit
67ac43e527
1197
apps/tauri/package-lock.json
generated
1197
apps/tauri/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -7,16 +7,23 @@
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@tauri-apps/cli": "^2.0.0",
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/svelte": "^5.3.1",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"jsdom": "^29.0.2",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0",
|
||||||
|
"vitest": "^4.1.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
|
|
||||||
105
apps/tauri/src/lib/components/ConfirmDialog.test.ts
Normal file
105
apps/tauri/src/lib/components/ConfirmDialog.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, cleanup } from "@testing-library/svelte";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import ConfirmDialog, { isConfirmDialogOpen } from "./ConfirmDialog.svelte";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ConfirmDialog", () => {
|
||||||
|
it("renders the message, detail and custom confirm label", () => {
|
||||||
|
render(ConfirmDialog, {
|
||||||
|
message: "Delete task?",
|
||||||
|
detail: "This cannot be undone.",
|
||||||
|
confirmText: "Delete",
|
||||||
|
onconfirm: vi.fn(),
|
||||||
|
oncancel: vi.fn(),
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Delete task?")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("This cannot be undone.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires oncancel when Cancel is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const oncancel = vi.fn();
|
||||||
|
render(ConfirmDialog, {
|
||||||
|
message: "Delete?",
|
||||||
|
onconfirm: vi.fn(),
|
||||||
|
oncancel,
|
||||||
|
});
|
||||||
|
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(oncancel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires onconfirm when Confirm is clicked and not oncancel", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onconfirm = vi.fn();
|
||||||
|
const oncancel = vi.fn();
|
||||||
|
render(ConfirmDialog, {
|
||||||
|
message: "Delete?",
|
||||||
|
confirmText: "Delete",
|
||||||
|
onconfirm,
|
||||||
|
oncancel,
|
||||||
|
});
|
||||||
|
await user.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
expect(onconfirm).toHaveBeenCalledTimes(1);
|
||||||
|
expect(oncancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels and stops propagation on Escape (regression: used to bubble and pop task detail)", async () => {
|
||||||
|
const oncancel = vi.fn();
|
||||||
|
// An outer bubble-phase listener emulates TasksScreen's svelte:window
|
||||||
|
// Escape handler. If the dialog leaks Escape, this spy fires too.
|
||||||
|
const outer = vi.fn();
|
||||||
|
window.addEventListener("keydown", outer);
|
||||||
|
try {
|
||||||
|
render(ConfirmDialog, {
|
||||||
|
message: "Delete?",
|
||||||
|
onconfirm: vi.fn(),
|
||||||
|
oncancel,
|
||||||
|
});
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
|
||||||
|
expect(oncancel).toHaveBeenCalledTimes(1);
|
||||||
|
expect(outer).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
window.removeEventListener("keydown", outer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-Escape keydowns", async () => {
|
||||||
|
const oncancel = vi.fn();
|
||||||
|
render(ConfirmDialog, {
|
||||||
|
message: "Delete?",
|
||||||
|
onconfirm: vi.fn(),
|
||||||
|
oncancel,
|
||||||
|
});
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }));
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
|
||||||
|
expect(oncancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments the open-count singleton so parent Escape handlers can defer", () => {
|
||||||
|
expect(isConfirmDialogOpen()).toBe(false);
|
||||||
|
const { unmount } = render(ConfirmDialog, {
|
||||||
|
message: "Delete?",
|
||||||
|
onconfirm: vi.fn(),
|
||||||
|
oncancel: vi.fn(),
|
||||||
|
});
|
||||||
|
expect(isConfirmDialogOpen()).toBe(true);
|
||||||
|
unmount();
|
||||||
|
expect(isConfirmDialogOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks multiple concurrently-mounted dialogs and releases on unmount", () => {
|
||||||
|
const a = render(ConfirmDialog, { message: "A?", onconfirm: vi.fn(), oncancel: vi.fn() });
|
||||||
|
const b = render(ConfirmDialog, { message: "B?", onconfirm: vi.fn(), oncancel: vi.fn() });
|
||||||
|
expect(isConfirmDialogOpen()).toBe(true);
|
||||||
|
a.unmount();
|
||||||
|
expect(isConfirmDialogOpen()).toBe(true);
|
||||||
|
b.unmount();
|
||||||
|
expect(isConfirmDialogOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
97
apps/tauri/src/lib/grouping.test.ts
Normal file
97
apps/tauri/src/lib/grouping.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { groupTasksByDate } from "./grouping";
|
||||||
|
import type { Task } from "./types";
|
||||||
|
|
||||||
|
// 2026-04-17 12:00 local time — "today" in the fixtures below.
|
||||||
|
const NOW = new Date(2026, 3, 17, 12, 0, 0);
|
||||||
|
|
||||||
|
function task(partial: Partial<Task> & { id: string }): Task {
|
||||||
|
return {
|
||||||
|
id: partial.id,
|
||||||
|
title: partial.title ?? partial.id,
|
||||||
|
description: "",
|
||||||
|
status: "backlog",
|
||||||
|
date: partial.date ?? null,
|
||||||
|
has_time: partial.has_time ?? false,
|
||||||
|
version: 1,
|
||||||
|
parent_id: null,
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("groupTasksByDate", () => {
|
||||||
|
it("returns an empty array when there are no pending tasks", () => {
|
||||||
|
expect(groupTasksByDate([], NOW)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts 'No Date' last — regression: was first, burying urgent tasks", () => {
|
||||||
|
const tasks = [
|
||||||
|
task({ id: "overdue", date: "2026-04-15T00:00:00Z" }),
|
||||||
|
task({ id: "no-date" }),
|
||||||
|
task({ id: "today", date: "2026-04-17T09:00:00Z" }),
|
||||||
|
];
|
||||||
|
const labels = groupTasksByDate(tasks, NOW).map((g) => g.label);
|
||||||
|
expect(labels).toEqual(["Overdue", "Today", "No Date"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders dated buckets: Overdue, Today, Tomorrow, future…, then No Date", () => {
|
||||||
|
const tasks = [
|
||||||
|
task({ id: "nd1" }),
|
||||||
|
task({ id: "future", date: "2026-04-20T00:00:00Z" }),
|
||||||
|
task({ id: "tomorrow", date: "2026-04-18T00:00:00Z" }),
|
||||||
|
task({ id: "today", date: "2026-04-17T09:00:00Z" }),
|
||||||
|
task({ id: "overdue", date: "2026-04-10T00:00:00Z" }),
|
||||||
|
];
|
||||||
|
const labels = groupTasksByDate(tasks, NOW).map((g) => g.label);
|
||||||
|
expect(labels[0]).toBe("Overdue");
|
||||||
|
expect(labels[1]).toBe("Today");
|
||||||
|
expect(labels[2]).toBe("Tomorrow");
|
||||||
|
// One future day label between tomorrow and No Date
|
||||||
|
expect(labels[labels.length - 1]).toBe("No Date");
|
||||||
|
expect(labels).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops empty buckets", () => {
|
||||||
|
const tasks = [task({ id: "t1", date: "2026-04-17T08:00:00Z" })];
|
||||||
|
expect(groupTasksByDate(tasks, NOW).map((g) => g.label)).toEqual(["Today"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts tasks within a bucket by due time ascending, stable on ties", () => {
|
||||||
|
const tasks = [
|
||||||
|
task({ id: "b", date: "2026-04-17T15:00:00Z", has_time: true }),
|
||||||
|
task({ id: "a", date: "2026-04-17T09:00:00Z", has_time: true }),
|
||||||
|
task({ id: "c", date: "2026-04-17T15:00:00Z", has_time: true }),
|
||||||
|
];
|
||||||
|
const today = groupTasksByDate(tasks, NOW).find((g) => g.label === "Today")!;
|
||||||
|
expect(today.tasks.map((t) => t.id)).toEqual(["a", "b", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places a task with today's date but time before 'now' in the Today bucket (not Overdue)", () => {
|
||||||
|
const tasks = [task({ id: "earlier-today", date: "2026-04-17T08:00:00Z" })];
|
||||||
|
const groups = groupTasksByDate(tasks, NOW);
|
||||||
|
expect(groups.map((g) => g.label)).toEqual(["Today"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves No Date order as given by the caller", () => {
|
||||||
|
const tasks = [
|
||||||
|
task({ id: "z" }),
|
||||||
|
task({ id: "a" }),
|
||||||
|
task({ id: "m" }),
|
||||||
|
];
|
||||||
|
const nd = groupTasksByDate(tasks, NOW).find((g) => g.label === "No Date")!;
|
||||||
|
expect(nd.tasks.map((t) => t.id)).toEqual(["z", "a", "m"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups multiple tasks on the same future day under one label", () => {
|
||||||
|
const tasks = [
|
||||||
|
task({ id: "f1", date: "2026-04-25T09:00:00Z", has_time: true }),
|
||||||
|
task({ id: "f2", date: "2026-04-25T14:00:00Z", has_time: true }),
|
||||||
|
];
|
||||||
|
const groups = groupTasksByDate(tasks, NOW);
|
||||||
|
const future = groups.find((g) => g.date?.getDate() === 25);
|
||||||
|
expect(future).toBeDefined();
|
||||||
|
expect(future!.tasks.map((t) => t.id)).toEqual(["f1", "f2"]);
|
||||||
|
// And it comes before No Date (which is absent here).
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
70
apps/tauri/src/lib/grouping.ts
Normal file
70
apps/tauri/src/lib/grouping.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import type { Task } from "./types";
|
||||||
|
|
||||||
|
export type TaskGroup = { label: string; tasks: Task[]; date: Date | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group pending tasks into date buckets for the "group by date" view.
|
||||||
|
*
|
||||||
|
* Order:
|
||||||
|
* Overdue → Today → Tomorrow → future days (chronological) → No Date
|
||||||
|
*
|
||||||
|
* Within each dated bucket tasks sort by due date+time ascending, with the
|
||||||
|
* original `pendingTasks` index as a stable tiebreaker. "No Date" preserves
|
||||||
|
* the caller-supplied order.
|
||||||
|
*/
|
||||||
|
export function groupTasksByDate(pendingTasks: Task[], now: Date = new Date()): TaskGroup[] {
|
||||||
|
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.date) {
|
||||||
|
noDate.push(task);
|
||||||
|
} else {
|
||||||
|
const d = new Date(task.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.date!).getTime() - new Date(b.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;
|
||||||
|
}
|
||||||
38
apps/tauri/src/lib/paths.test.ts
Normal file
38
apps/tauri/src/lib/paths.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { workspaceNameFromPath } from "./paths";
|
||||||
|
|
||||||
|
describe("workspaceNameFromPath", () => {
|
||||||
|
it("returns the last path component of a POSIX path", () => {
|
||||||
|
expect(workspaceNameFromPath("/home/me/Tasks")).toBe("Tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips a trailing slash (regression: used to fall back to 'workspace')", () => {
|
||||||
|
expect(workspaceNameFromPath("/home/me/Tasks/")).toBe("Tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips multiple trailing slashes", () => {
|
||||||
|
expect(workspaceNameFromPath("/home/me/Tasks///")).toBe("Tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles Windows-style backslash paths", () => {
|
||||||
|
expect(workspaceNameFromPath("C:\\Users\\me\\Tasks")).toBe("Tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips a trailing backslash on Windows paths", () => {
|
||||||
|
expect(workspaceNameFromPath("C:\\Users\\me\\Tasks\\")).toBe("Tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles mixed separators", () => {
|
||||||
|
expect(workspaceNameFromPath("C:\\Users/me\\Tasks")).toBe("Tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 'workspace' when the path has no usable tail", () => {
|
||||||
|
expect(workspaceNameFromPath("/")).toBe("workspace");
|
||||||
|
expect(workspaceNameFromPath("\\")).toBe("workspace");
|
||||||
|
expect(workspaceNameFromPath("")).toBe("workspace");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves names with spaces", () => {
|
||||||
|
expect(workspaceNameFromPath("/home/me/My Tasks/")).toBe("My Tasks");
|
||||||
|
});
|
||||||
|
});
|
||||||
9
apps/tauri/src/lib/paths.ts
Normal file
9
apps/tauri/src/lib/paths.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/**
|
||||||
|
* Derive a workspace display name from a folder path picked via the file
|
||||||
|
* dialog. Handles both `/` and `\` separators and tolerates trailing
|
||||||
|
* separators (e.g. `"/home/me/Tasks/"` → `"Tasks"`, not `"workspace"`).
|
||||||
|
*/
|
||||||
|
export function workspaceNameFromPath(folder: string): string {
|
||||||
|
const parts = folder.replace(/[\\/]+$/, "").split(/[\\/]/);
|
||||||
|
return parts[parts.length - 1] || "workspace";
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import { app } from "../stores/app.svelte";
|
import { app } from "../stores/app.svelte";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
|
import { workspaceNameFromPath } from "../paths";
|
||||||
|
|
||||||
let { cancellable = false }: { cancellable?: boolean } = $props();
|
let { cancellable = false }: { cancellable?: boolean } = $props();
|
||||||
|
|
||||||
|
|
@ -71,11 +72,7 @@
|
||||||
const selected = await open({ directory: true, multiple: false });
|
const selected = await open({ directory: true, multiple: false });
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
const folder = selected as string;
|
const folder = selected as string;
|
||||||
// Strip trailing separators before splitting so a path like "/home/me/Tasks/"
|
await app.addWorkspace(workspaceNameFromPath(folder), folder);
|
||||||
// yields "Tasks" instead of an empty tail that falls through to "workspace".
|
|
||||||
const parts = folder.replace(/[\\/]+$/, "").split(/[\\/]/);
|
|
||||||
const wsName = parts[parts.length - 1] || "workspace";
|
|
||||||
await app.addWorkspace(wsName, folder);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── WebDAV handlers ───────────────────────────────────────────────
|
// ── WebDAV handlers ───────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import type {
|
||||||
Screen,
|
Screen,
|
||||||
SyncResult,
|
SyncResult,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import { groupTasksByDate, type TaskGroup } from "../grouping";
|
||||||
|
|
||||||
// Listen for file system changes from the backend watcher.
|
// Listen for file system changes from the backend watcher.
|
||||||
listen("fs-changed", () => {
|
listen("fs-changed", () => {
|
||||||
|
|
@ -52,65 +53,9 @@ 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 => {
|
let groupedPendingTasks = $derived.by((): TaskGroup[] | null => {
|
||||||
if (!activeList?.group_by_date) return null;
|
if (!activeList?.group_by_date) return null;
|
||||||
const now = new Date();
|
return groupTasksByDate(pendingTasks);
|
||||||
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.date) {
|
|
||||||
noDate.push(task);
|
|
||||||
} else {
|
|
||||||
const d = new Date(task.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.date!).getTime() - new Date(b.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
|
||||||
|
|
|
||||||
1
apps/tauri/src/test/setup.ts
Normal file
1
apps/tauri/src/test/setup.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
/// <reference types="vitest/config" />
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
@ -14,4 +15,17 @@ export default defineConfig({
|
||||||
hmr: host ? { protocol: "ws", host, port: 1421 } : undefined,
|
hmr: host ? { protocol: "ws", host, port: 1421 } : undefined,
|
||||||
watch: { ignored: ["**/src-tauri/**"] },
|
watch: { ignored: ["**/src-tauri/**"] },
|
||||||
},
|
},
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./src/test/setup.ts"],
|
||||||
|
include: ["src/**/*.{test,spec}.{ts,svelte}"],
|
||||||
|
// Resolve Svelte's client (browser) entry under Vitest — without the
|
||||||
|
// browser condition mount() picks up Svelte's SSR export and throws
|
||||||
|
// lifecycle_function_unavailable.
|
||||||
|
server: { deps: { inline: ["@testing-library/svelte"] } },
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
conditions: process.env.VITEST ? ["browser"] : [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue