feat(ui): add reactive trash count using SWR to navbar and dashboard trash link

This commit is contained in:
Chneemann 2026-08-12 09:11:51 +02:00
parent 23f898d763
commit b386c2b4c5
No known key found for this signature in database
9 changed files with 142 additions and 49 deletions

View file

@ -1,6 +1,6 @@
/**
* @file dashboard/Board.tsx
* @description Client component wrapping the columns grid, tracking individual task update/deletion states, and handling asynchronous mutations via API.
* @description Client component wrapping the columns grid, tracking individual task update/deletion states, and handling asynchronous mutations via API with cache revalidation.
*/
"use client";
@ -10,23 +10,17 @@ import { useRouter } from "next/navigation";
import Column from "./column/Column";
import { COLUMNS, Task, TaskStatus } from "@/types/tasks";
import Header from "./header/Header";
import { mutate } from "swr";
/**
* Renders the responsive grid container of columns, coordinating state tracking
* for active task updates/deletions, trash counts, and triggering mutation API requests.
* for active task updates/deletions, and triggering mutation API requests.
*
* @param {Object} props - The component props.
* @param {Task[]} props.tasks - The array of task items displayed across the board.
* @param {number} props.trashCount - The count of items currently in the trash bin.
* @returns {JSX.Element} The rendered board component.
*/
export default function Board({
tasks,
trashCount,
}: {
tasks: Task[];
trashCount: number;
}) {
export default function Board({ tasks }: { tasks: Task[] }) {
const router = useRouter();
const [, startTransition] = useTransition();
const [updatingTaskIds, setUpdatingTaskIds] = useState<Set<string>>(
@ -35,7 +29,7 @@ export default function Board({
/**
* Updates the status of a specific task by sending a PATCH request to the API,
* managing loading states, and refreshing the router upon success.
* managing loading states, triggering SWR cache mutations for the trash count, and refreshing the router upon success.
*
* @param {string} taskId - The unique identifier of the task to update.
* @param {TaskStatus} targetStatus - The new target status for the task.
@ -54,7 +48,7 @@ export default function Board({
if (!response.ok) {
throw new Error("Failed to update task status");
}
mutate("/api/trash/count");
await router.refresh();
} catch (error) {
console.error("Error during task status update:", error);
@ -70,7 +64,7 @@ export default function Board({
/**
* Deletes a specific task by sending a DELETE request to the API,
* managing loading states, and refreshing the router upon success.
* managing loading states, triggering SWR cache mutations for the trash count, and refreshing the router upon success.
*
* @param {string} taskId - The unique identifier of the task to delete.
*/
@ -84,6 +78,7 @@ export default function Board({
});
if (!response.ok) throw new Error("Failed to delete task");
mutate("/api/trash/count");
await router.refresh();
} catch (error) {
console.error("Error during task deletion:", error);
@ -100,7 +95,7 @@ export default function Board({
return (
<div className="space-y-8">
{/* Workspace Header */}
<Header onTaskDelete={deleteTask} trashCount={trashCount} />
<Header onTaskDelete={deleteTask} />
{/* Columns Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 items-start">

View file

@ -19,10 +19,8 @@ import NewTaskButton from "./NewTaskButton";
*/
export default function Header({
onTaskDelete,
trashCount,
}: {
onTaskDelete: (taskId: string) => void;
trashCount: number;
}) {
return (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
@ -41,7 +39,7 @@ export default function Header({
<DeleteDropZone onTaskDelete={onTaskDelete} />
<div className="flex items-center gap-3 peer-not-empty:hidden">
<NewTaskButton />
<TrashLink count={trashCount} />
<TrashLink />
</div>
</div>
</div>

View file

@ -1,21 +1,36 @@
/**
* @file dasboard/header/TrashLink.tsx
* @description Client component rendering a navigation link button to the trash view with a dynamic item counter badge.
* @file dashboard/header/TrashLink.tsx
* @description Client component rendering a navigation link button to the trash view with a dynamic item counter badge fetched via SWR.
*/
"use client";
import Link from "next/link";
import { Trash2 } from "lucide-react";
import useSWR from "swr";
/**
* Renders an interactive trash icon link featuring hover animations and an optional item count badge.
* Fetches data from a given URL and returns the parsed JSON response.
*
* @param {Object} props - The component props.
* @param {number} props.count - The number of items currently in the trash.
* @returns {JSX.Element} The rendered trash button component.
* @async
* @param {string} url - The target endpoint URL to fetch.
* @returns {Promise<any>} The JSON response data.
*/
export default function TrashLink({ count }: { count: number }) {
const fetcher = (url: string) => fetch(url).then((res) => res.json());
/**
* Renders an interactive trash icon link featuring hover animations and a self-fetched item count badge.
*
* @returns {JSX.Element} The rendered trash link component.
*/
export default function TrashLink() {
const { data } = useSWR("/api/trash/count", fetcher, {
revalidateOnFocus: false,
revalidateIfStale: false,
});
const count = data?.count || 0;
return (
<Link
href="/trash"

View file

@ -1,6 +1,6 @@
/**
* @file dashboard/page.tsx
* @description Server component rendering the main dashboard page, handling authentication, fetching active tasks with assignees and creators, computing trash counts, and passing data to the board container.
* @description Server component rendering the main dashboard page, handling authentication, fetching active tasks with assignees and creators, and passing data to the board container.
*/
import { db } from "@/db";
@ -23,7 +23,7 @@ import { TaskService } from "@/services/task.service";
/**
* Renders the dashboard page component with user session validation,
* optimized database queries for active tasks, team assignees, and soft-deleted trash counts.
* optimized database queries for active tasks, and team assignees
*
* @async
* @returns {Promise<JSX.Element>} The rendered dashboard page component.
@ -69,22 +69,9 @@ export default async function Dashboard() {
commentsCount: 0,
}));
// Check the trash counter
const [trashCountResult] = await db
.select({ count: count() })
.from(tasksTable)
.where(
and(
eq(tasksTable.userId, currentUserId),
isNotNull(tasksTable.deletedAt),
),
);
const trashCount = trashCountResult.count;
return (
<div className="space-y-8 max-w-7xl mx-auto pb-12">
<Board tasks={tasks} trashCount={trashCount} />
<Board tasks={tasks} />
</div>
);
}

View file

@ -8,6 +8,7 @@
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { RotateCcw, Trash2, Loader2 } from "lucide-react";
import { mutate } from "swr";
/**
* Renders an action button for either restoring or permanently deleting a task,
@ -53,7 +54,7 @@ export default function TaskActionButton({
const data = await response.json();
if (!response.ok)
throw new Error(data.error || `Failed to ${action} task`);
mutate("/api/trash/count");
router.refresh();
} catch (error) {
console.error(`Error during task ${action}:`, error);

View file

@ -0,0 +1,44 @@
/**
* @file app/api/trash/count/route.ts
* @description API route handler to retrieve the count of soft-deleted tasks for the authenticated user.
*/
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/db";
import { tasksTable } from "@/db/schema";
import { eq, and, isNotNull, count } from "drizzle-orm";
/**
* Handles GET requests to retrieve the total number of soft-deleted tasks belonging to the authenticated user.
* Verifies the user session, queries the database for tasks with a non-null deletedAt timestamp, and returns the count.
*
* @async
* @returns {Promise<NextResponse>} A JSON response containing the trash task count or an error message with the appropriate HTTP status code.
*/
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const [result] = await db
.select({ count: count() })
.from(tasksTable)
.where(
and(
eq(tasksTable.userId, session.user.id),
isNotNull(tasksTable.deletedAt),
),
);
return NextResponse.json({ count: result?.count ?? 0 }, { status: 200 });
} catch (error) {
console.error("Error fetching trash count:", error);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
}

View file

@ -1,13 +1,16 @@
/**
* @file Navbar.tsx
* @description Client component providing responsive navigation for desktop and mobile views with active route indicators.
* @description Client component providing responsive navigation for desktop and mobile views with active route indicators and trash count.
*/
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LayoutDashboard, FileText } from "lucide-react";
import { LayoutDashboard, FileText, Trash2 } from "lucide-react";
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
/**
* Array of navigation items containing their name, route path, and corresponding icon.
@ -15,21 +18,30 @@ import { LayoutDashboard, FileText } from "lucide-react";
const navItems = [
{ name: "Summary", href: "/summary", icon: FileText },
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ name: "Trash", href: "/trash", icon: Trash2 },
];
/**
* Renders the responsive navigation bar featuring desktop sidebar layout
* and mobile bottom bar layout with active state styling.
* and mobile bottom bar layout with active state styling and cached trash counter.
*
* @returns {JSX.Element} The rendered navigation component.
*/
export default function Navbar() {
const pathname = usePathname();
const { data } = useSWR("/api/trash/count", fetcher, {
revalidateOnFocus: false,
revalidateIfStale: false,
});
const trashCount = data?.count || 0;
// Shared function to render navigation links to avoid code duplication
const renderNavLinks = (isMobile = false) =>
navItems.map((item) => {
const Icon = item.icon;
const isTrash = item.href === "/trash";
const isActive =
pathname === item.href ||
(item.href !== "/" && pathname.startsWith(item.href));
@ -53,9 +65,17 @@ export default function Navbar() {
}`
}
>
<Icon
className={`${isMobile ? "w-5 h-5" : "w-4 h-4"} ${isActive ? "text-primary" : ""}`}
/>
<div className="relative inline-flex items-center">
<Icon
className={`${isMobile ? "w-5 h-5" : "w-4 h-4"} ${isActive ? "text-primary" : ""}`}
/>
{isTrash && trashCount > 0 && (
<span className="absolute -top-1.5 -right-2 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-destructive text-[9px] font-bold text-white shadow-sm ring-1 ring-background">
{trashCount}
</span>
)}
</div>
<span className={isMobile ? "text-xs font-medium" : ""}>
{item.name}
</span>

34
package-lock.json generated
View file

@ -15,7 +15,8 @@
"next-auth": "^5.0.0-beta.32",
"postgres": "^3.4.9",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"swr": "^2.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@ -3943,6 +3944,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@ -7560,6 +7570,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/swr": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.5.0.tgz",
"integrity": "sha512-W0GomadRJe9OfzIoRX0kZHhDSaRRfdiOUeH6uazgR05SibBhDNwfdTbhAxmFtObvkf59fFymo22mooKukosvNA==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/tailwindcss": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
@ -8424,6 +8447,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View file

@ -16,7 +16,8 @@
"next-auth": "^5.0.0-beta.32",
"postgres": "^3.4.9",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"swr": "^2.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",