diff --git a/bun.lock b/bun.lock
index b608d7f..b8db0d9 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,6 +5,7 @@
"": {
"name": "tasgrid",
"dependencies": {
+ "@formkit/auto-animate": "^0.9.0",
"@kobalte/core": "^0.13.11",
"@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "^3.18.0",
@@ -297,6 +298,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
+ "@formkit/auto-animate": ["@formkit/auto-animate@0.9.0", "", {}, "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA=="],
+
"@internationalized/date": ["@internationalized/date@3.10.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA=="],
"@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="],
diff --git a/package.json b/package.json
index a435723..3e99c2f 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"deploy": "git pull && bun install && bun run build && (pm2 restart tasgrid || bun run start-prod)"
},
"dependencies": {
+ "@formkit/auto-animate": "^0.9.0",
"@kobalte/core": "^0.13.11",
"@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "^3.18.0",
diff --git a/src/components/TaskCard.tsx b/src/components/TaskCard.tsx
index 8669566..b69c439 100644
--- a/src/components/TaskCard.tsx
+++ b/src/components/TaskCard.tsx
@@ -9,7 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/u
import { getStatusOptionsForTask } from "@/lib/constants";
import { StatusCircle } from "./StatusCircle";
-export const TaskCard: Component<{ task: Task }> = (props) => {
+export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props) => {
const { resolvedTheme } = useTheme();
const urgencyLevel = createMemo(() => calculateUrgencyFromDate(props.task.dueDate));
@@ -58,8 +58,9 @@ export const TaskCard: Component<{ task: Task }> = (props) => {
return (
setActiveTaskId(props.task.id)}
>
diff --git a/src/hooks/useDelayedSort.ts b/src/hooks/useDelayedSort.ts
new file mode 100644
index 0000000..d88437a
--- /dev/null
+++ b/src/hooks/useDelayedSort.ts
@@ -0,0 +1,65 @@
+import { createSignal, createEffect, onCleanup, untrack } from "solid-js";
+import type { Task } from "@/store";
+
+export function useDelayedSort(
+ sourceTasks: () => Task[],
+ sortFn: (a: Task, b: Task) => number,
+ delayMs = 300
+) {
+ const initialTasks = untrack(() => [...sourceTasks()].sort(sortFn));
+ const [displayedTasks, setDisplayedTasks] = createSignal
(initialTasks);
+ const [shakingTaskIds, setShakingTaskIds] = createSignal([]);
+
+ let timer: number | undefined;
+
+ createEffect(() => {
+ const sorted = [...sourceTasks()].sort(sortFn);
+ const currentDisplayed = untrack(() => displayedTasks());
+
+ // Initial load (though now handled by initial state, kept for safety if it resets)
+ if (currentDisplayed.length === 0 && sorted.length > 0) {
+ setDisplayedTasks(sorted);
+ return;
+ }
+
+ // Check if anything fundamentally changed order
+ const newIds = sorted.map(t => t.id).join(',');
+ const oldIds = currentDisplayed.map(t => t.id).join(',');
+
+ if (newIds !== oldIds) {
+ // Find which IDs have moved or changed their presence
+ // To be safe, let's just shake all currently visible ones, or maybe just the ones moving.
+ // A simple "shake everything" works perfectly for a brief warning: "Items are shifting!"
+ // The user wanted "tasks an animation when they rearange where they shake"
+ const changedSet = new Set();
+ sorted.forEach((t: Task, i: number) => {
+ if (currentDisplayed[i]?.id !== t.id) {
+ changedSet.add(t.id);
+ if (currentDisplayed[i]) changedSet.add(currentDisplayed[i].id);
+ }
+ });
+
+ if (changedSet.size > 0) {
+ setShakingTaskIds(Array.from(changedSet));
+
+ if (timer) clearTimeout(timer);
+ timer = window.setTimeout(() => {
+ setShakingTaskIds([]);
+ setDisplayedTasks(sorted);
+ }, delayMs);
+ } else {
+ setDisplayedTasks(sorted);
+ }
+ } else {
+ // Identical order, just data updates (e.g. title changes)
+ // We want these to pass through instantly without rearranging
+ setDisplayedTasks(sorted);
+ }
+ });
+
+ onCleanup(() => {
+ if (timer) clearTimeout(timer);
+ });
+
+ return { displayedTasks, shakingTaskIds };
+}
diff --git a/src/index.css b/src/index.css
index fe1c19d..f8ab3c9 100644
--- a/src/index.css
+++ b/src/index.css
@@ -55,6 +55,40 @@
height: 0;
}
}
+
+ --animate-task-shake: task-shake 0.3s ease-in-out;
+
+ @keyframes task-shake {
+
+ 0%,
+ 100% {
+ transform: translateX(0);
+ }
+
+ 25% {
+ transform: translateX(3px);
+ }
+
+ 50% {
+ transform: translateX(-3px);
+ }
+
+ 75% {
+ transform: translateX(3px);
+ }
+ }
+
+ --animate-task-enter: task-enter 0.25s ease-out backwards;
+
+ @keyframes task-enter {
+ 0% {
+ opacity: 0;
+ }
+
+ 100% {
+ opacity: 1;
+ }
+ }
}
:root {
diff --git a/src/views/CriticalView.tsx b/src/views/CriticalView.tsx
index 8fae723..d42fe20 100644
--- a/src/views/CriticalView.tsx
+++ b/src/views/CriticalView.tsx
@@ -1,15 +1,24 @@
-import { type Component, For, createMemo } from "solid-js";
+import { type Component, For, createMemo, onMount } from "solid-js";
import { store, getCombinedScore, matchesFilter } from "@/store";
import { TaskCard } from "@/components/TaskCard";
+import { useDelayedSort } from "@/hooks/useDelayedSort";
+import autoAnimate from "@formkit/auto-animate";
export const CriticalView: Component = () => {
- const sortedTasks = createMemo(() => {
- return [...store.tasks]
- .filter(t => !t.deletedAt && matchesFilter(t))
- .sort((a, b) => {
- if (a.completed !== b.completed) return a.completed ? 1 : -1;
- return getCombinedScore(b) - getCombinedScore(a);
- });
+ const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
+
+ const { displayedTasks, shakingTaskIds } = useDelayedSort(
+ sourceTasks,
+ (a, b) => {
+ if (a.completed !== b.completed) return a.completed ? 1 : -1;
+ return getCombinedScore(b) - getCombinedScore(a);
+ },
+ 300
+ );
+
+ let listRef: HTMLDivElement | undefined;
+ onMount(() => {
+ if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
});
return (
@@ -19,13 +28,17 @@ export const CriticalView: Component = () => {
Your tasks ranked by highest urgency + priority.
-
-
- {(task) => }
+
+
+ {(task) => (
+
+
+
+ )}
- {sortedTasks().length === 0 && (
+ {displayedTasks().length === 0 && (
✨
diff --git a/src/views/DigInView.tsx b/src/views/DigInView.tsx
index c91ae15..813b864 100644
--- a/src/views/DigInView.tsx
+++ b/src/views/DigInView.tsx
@@ -1,20 +1,29 @@
-import { type Component, For, createMemo } from "solid-js";
+import { type Component, For, createMemo, onMount } from "solid-js";
import { store, getCombinedScore, matchesFilter } from "@/store";
import { TaskCard } from "@/components/TaskCard";
+import { useDelayedSort } from "@/hooks/useDelayedSort";
+import autoAnimate from "@formkit/auto-animate";
export const DigInView: Component = () => {
- const sortedTasks = createMemo(() => {
- return [...store.tasks]
- .filter(t => !t.deletedAt && matchesFilter(t))
- .sort((a, b) => {
- if (a.completed !== b.completed) return a.completed ? 1 : -1;
- // Primary: Size descending (largest first)
- const sizeA = a.size ?? 5;
- const sizeB = b.size ?? 5;
- if (sizeA !== sizeB) return sizeB - sizeA;
- // Secondary: Combined score descending (higher focus first)
- return getCombinedScore(b) - getCombinedScore(a);
- });
+ const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
+
+ const { displayedTasks, shakingTaskIds } = useDelayedSort(
+ sourceTasks,
+ (a, b) => {
+ if (a.completed !== b.completed) return a.completed ? 1 : -1;
+ // Primary: Size descending (largest first)
+ const sizeA = a.size ?? 5;
+ const sizeB = b.size ?? 5;
+ if (sizeA !== sizeB) return sizeB - sizeA;
+ // Secondary: Combined score descending (higher focus first)
+ return getCombinedScore(b) - getCombinedScore(a);
+ },
+ 300
+ );
+
+ let listRef: HTMLDivElement | undefined;
+ onMount(() => {
+ if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
});
return (
@@ -24,13 +33,17 @@ export const DigInView: Component = () => {
Largest tasks first. Tackle big projects head-on.
-
-
- {(task) => }
+
+
+ {(task) => (
+
+
+
+ )}
- {sortedTasks().length === 0 && (
+ {displayedTasks().length === 0 && (
⛏️
diff --git a/src/views/PriorityView.tsx b/src/views/PriorityView.tsx
index 4247a3f..0c7ba78 100644
--- a/src/views/PriorityView.tsx
+++ b/src/views/PriorityView.tsx
@@ -1,16 +1,24 @@
-import { type Component, For, createMemo } from "solid-js";
+import { type Component, For, createMemo, onMount } from "solid-js";
import { store, getCombinedScore, matchesFilter } from "@/store";
import { TaskCard } from "@/components/TaskCard";
+import { useDelayedSort } from "@/hooks/useDelayedSort";
+import autoAnimate from "@formkit/auto-animate";
export const PriorityView: Component = () => {
- const sortedTasks = createMemo(() => {
- return [...store.tasks]
- .filter(t => !t.deletedAt && matchesFilter(t))
- .sort((a, b) => {
- if (a.completed !== b.completed) return a.completed ? 1 : -1;
- // Use combined score to account for priority, urgency, and tags
- return getCombinedScore(b) - getCombinedScore(a);
- });
+ const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
+
+ const { displayedTasks, shakingTaskIds } = useDelayedSort(
+ sourceTasks,
+ (a, b) => {
+ if (a.completed !== b.completed) return a.completed ? 1 : -1;
+ return getCombinedScore(b) - getCombinedScore(a);
+ },
+ 300
+ );
+
+ let listRef: HTMLDivElement | undefined;
+ onMount(() => {
+ if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
});
return (
@@ -20,13 +28,17 @@ export const PriorityView: Component = () => {
Your tasks ordered by priority.
-
-
- {(task) => }
+
+
+ {(task) => (
+
+
+
+ )}
- {sortedTasks().length === 0 && (
+ {displayedTasks().length === 0 && (
🎯
diff --git a/src/views/ProgressView.tsx b/src/views/ProgressView.tsx
index 8846ec9..5ad3931 100644
--- a/src/views/ProgressView.tsx
+++ b/src/views/ProgressView.tsx
@@ -1,22 +1,31 @@
-import { type Component, For, createMemo } from "solid-js";
+import { type Component, For, createMemo, onMount } from "solid-js";
import { store, getCombinedScore, matchesFilter } from "@/store";
import { TaskCard } from "@/components/TaskCard";
+import { useDelayedSort } from "@/hooks/useDelayedSort";
+import autoAnimate from "@formkit/auto-animate";
export const ProgressView: Component = () => {
- const sortedTasks = createMemo(() => {
- return [...store.tasks]
- .filter(t => !t.deletedAt && matchesFilter(t))
- .sort((a, b) => {
- // 1. Completed tasks at the bottom
- if (a.completed !== b.completed) return a.completed ? 1 : -1;
+ const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
- // 2. Sort by Progress (Status) Descending (Highest progress first)
- // Assuming status 0-9 are active states, 10 is completed (handled above)
- if (a.status !== b.status) return b.status - a.status;
+ const { displayedTasks, shakingTaskIds } = useDelayedSort(
+ sourceTasks,
+ (a, b) => {
+ // 1. Completed tasks at the bottom
+ if (a.completed !== b.completed) return a.completed ? 1 : -1;
- // 3. Then by Focus Score (Combined Score)
- return getCombinedScore(b) - getCombinedScore(a);
- });
+ // 2. Sort by Progress (Status) Descending (Highest progress first)
+ // Assuming status 0-9 are active states, 10 is completed (handled above)
+ if (a.status !== b.status) return b.status - a.status;
+
+ // 3. Then by Focus Score (Combined Score)
+ return getCombinedScore(b) - getCombinedScore(a);
+ },
+ 300
+ );
+
+ let listRef: HTMLDivElement | undefined;
+ onMount(() => {
+ if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
});
return (
@@ -26,13 +35,17 @@ export const ProgressView: Component = () => {
Tasks sorted by progress status, then by focus score.
-
-
- {(task) => }
+
+
+ {(task) => (
+
+
+
+ )}
- {sortedTasks().length === 0 && (
+ {displayedTasks().length === 0 && (
📈
diff --git a/src/views/SnowballView.tsx b/src/views/SnowballView.tsx
index 0ee866c..4296483 100644
--- a/src/views/SnowballView.tsx
+++ b/src/views/SnowballView.tsx
@@ -1,20 +1,29 @@
-import { type Component, For, createMemo } from "solid-js";
+import { type Component, For, createMemo, onMount } from "solid-js";
import { store, getCombinedScore, matchesFilter } from "@/store";
import { TaskCard } from "@/components/TaskCard";
+import { useDelayedSort } from "@/hooks/useDelayedSort";
+import autoAnimate from "@formkit/auto-animate";
export const SnowballView: Component = () => {
- const sortedTasks = createMemo(() => {
- return [...store.tasks]
- .filter(t => !t.deletedAt && matchesFilter(t))
- .sort((a, b) => {
- if (a.completed !== b.completed) return a.completed ? 1 : -1;
- // Primary: Size ascending (smallest first)
- const sizeA = a.size ?? 5;
- const sizeB = b.size ?? 5;
- if (sizeA !== sizeB) return sizeA - sizeB;
- // Secondary: Combined score descending (higher focus first)
- return getCombinedScore(b) - getCombinedScore(a);
- });
+ const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
+
+ const { displayedTasks, shakingTaskIds } = useDelayedSort(
+ sourceTasks,
+ (a, b) => {
+ if (a.completed !== b.completed) return a.completed ? 1 : -1;
+ // Primary: Size ascending (smallest first)
+ const sizeA = a.size ?? 5;
+ const sizeB = b.size ?? 5;
+ if (sizeA !== sizeB) return sizeA - sizeB;
+ // Secondary: Combined score descending (higher focus first)
+ return getCombinedScore(b) - getCombinedScore(a);
+ },
+ 300
+ );
+
+ let listRef: HTMLDivElement | undefined;
+ onMount(() => {
+ if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
});
return (
@@ -24,13 +33,17 @@ export const SnowballView: Component = () => {
Smallest tasks first. Build momentum with quick wins.
-
-
- {(task) => }
+
+
+ {(task) => (
+
+
+
+ )}
- {sortedTasks().length === 0 && (
+ {displayedTasks().length === 0 && (
❄️
diff --git a/src/views/UrgencyView.tsx b/src/views/UrgencyView.tsx
index 33c33bd..c1ebad6 100644
--- a/src/views/UrgencyView.tsx
+++ b/src/views/UrgencyView.tsx
@@ -1,19 +1,28 @@
-import { type Component, For, createMemo } from "solid-js";
+import { type Component, For, createMemo, onMount } from "solid-js";
import { store, calculateUrgencyScore, getCombinedScore, matchesFilter } from "@/store";
import { TaskCard } from "@/components/TaskCard";
+import { useDelayedSort } from "@/hooks/useDelayedSort";
+import autoAnimate from "@formkit/auto-animate";
export const UrgencyView: Component = () => {
- const sortedTasks = createMemo(() => {
- return [...store.tasks]
- .filter(t => !t.deletedAt && matchesFilter(t))
- .sort((a, b) => {
- if (a.completed !== b.completed) return a.completed ? 1 : -1;
- const uA = calculateUrgencyScore(a.dueDate);
- const uB = calculateUrgencyScore(b.dueDate);
- if (uA !== uB) return uB - uA;
- // Tie-break with combined score (Priority + Tags)
- return getCombinedScore(b) - getCombinedScore(a);
- });
+ const sourceTasks = createMemo(() => store.tasks.filter(t => !t.deletedAt && matchesFilter(t)));
+
+ const { displayedTasks, shakingTaskIds } = useDelayedSort(
+ sourceTasks,
+ (a, b) => {
+ if (a.completed !== b.completed) return a.completed ? 1 : -1;
+ const uA = calculateUrgencyScore(a.dueDate);
+ const uB = calculateUrgencyScore(b.dueDate);
+ if (uA !== uB) return uB - uA;
+ // Tie-break with combined score (Priority + Tags)
+ return getCombinedScore(b) - getCombinedScore(a);
+ },
+ 300
+ );
+
+ let listRef: HTMLDivElement | undefined;
+ onMount(() => {
+ if (listRef) autoAnimate(listRef, { duration: 300, easing: 'ease-out' });
});
return (
@@ -23,13 +32,17 @@ export const UrgencyView: Component = () => {
Your tasks ordered by deadline.
-
-
- {(task) => }
+
+
+ {(task) => (
+
+
+
+ )}
- {sortedTasks().length === 0 && (
+ {displayedTasks().length === 0 && (