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 && (