Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}
},
"postCreateCommand": "npm run setup",
"postAttachCommand": "npm run dev",
"forwardPorts": [
3000,
5432
Expand Down
33 changes: 31 additions & 2 deletions app/(dashboard)/tasks/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ export async function getAllTasks() {
try {
const tasks = await prisma.task.findMany({
include: {
assignee: { select: { id: true, name: true, email: true, password: true } },
creator: { select: { id: true, name: true, email: true, password: true } },
assignee: { select: { id: true, name: true, email: true } },
creator: { select: { id: true, name: true, email: true } },
},
orderBy: [
{ createdAt: "desc" },
Expand All @@ -60,6 +60,35 @@ export async function getAllTasks() {
}
}

// Search tasks by title or description (case-insensitive)
export async function searchTasks(query: string) {
try {
if (!query || query.length < 3) {
return await getAllTasks();
}

const tasks = await prisma.task.findMany({
where: {
OR: [
{ name: { contains: query, mode: 'insensitive' } },
{ description: { contains: query, mode: 'insensitive' } },
],
},
include: {
assignee: { select: { id: true, name: true, email: true } },
creator: { select: { id: true, name: true, email: true } },
},
orderBy: [
{ createdAt: "desc" },
{ id: "desc" }
],
});
return { tasks, error: null };
} catch (e) {
return { tasks: [], error: "Failed to search tasks." };
}
}

// Delete a task by ID
export async function deleteTask(taskId: number) {
try {
Expand Down
7 changes: 3 additions & 4 deletions app/(dashboard)/tasks/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { Suspense } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Plus, Search } from "lucide-react"
import { Plus } from "lucide-react"
import Link from "next/link"
import { TaskList } from "@/components/task-list"
import { TasksPageClient } from "@/components/tasks-page-client"
import { poppins } from "@/lib/fonts"

import { getAllTasks } from "@/app/(dashboard)/tasks/actions"
Expand Down Expand Up @@ -31,7 +30,7 @@ export default async function TasksPage() {
</div>

<Suspense fallback={<div>Loading tasks...</div>}>
<TaskList initialTasks={tasks || []} />
<TasksPageClient initialTasks={tasks || []} />
</Suspense>
</div>
)
Expand Down
15 changes: 15 additions & 0 deletions components/empty-state.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Search } from "lucide-react"

export function EmptyState() {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="rounded-full bg-muted p-6 mb-4">
<Search className="h-12 w-12 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold mb-2">No tasks found</h3>
<p className="text-muted-foreground max-w-md">
No tasks match your current search and filter criteria. Try adjusting your search terms or filter settings.
</p>
</div>
)
}
12 changes: 9 additions & 3 deletions components/task-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useOptimistic, useTransition, useState, useEffect } from "react"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Checkbox } from "@/components/ui/checkbox"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
Expand All @@ -20,7 +20,7 @@ type TaskWithProfile = PrismaTask & {
assignee?: Pick<User, "name"> | null;
};

export function TaskList({ initialTasks }: { initialTasks: TaskWithProfile[]; }) {
export function TaskList({ initialTasks, onTaskStatusChange }: { initialTasks: TaskWithProfile[]; onTaskStatusChange?: (taskId: number, status: TaskWithProfile["status"]) => void }) {
const [tasks, setTasks] = useState(initialTasks)
const [optimisticTasks, setOptimisticTasks] = useOptimistic(
tasks,
Expand Down Expand Up @@ -53,7 +53,13 @@ export function TaskList({ initialTasks }: { initialTasks: TaskWithProfile[]; })
const handleToggle = async (task: TaskWithProfile) => {
startTransition(async () => {
setOptimisticTasks({ action: "toggle", task })
await updateTaskStatus(task.id, task.status === "done" ? "todo" : "done")
const nextStatus = task.status === "done" ? "todo" : "done"
const result = await updateTaskStatus(task.id, nextStatus)
if (result?.error) {
setOptimisticTasks({ action: "toggle", task })
return
}
if (onTaskStatusChange) onTaskStatusChange(task.id, nextStatus)
})
}

Expand Down
192 changes: 192 additions & 0 deletions components/tasks-page-client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
"use client"

import React from "react"
import { useState, useEffect, useTransition, useCallback, useRef } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Search, X, Filter, Loader2 } from "lucide-react"
import { TaskList } from "@/components/task-list"
import { EmptyState } from "@/components/empty-state"
import { searchTasks } from "@/app/(dashboard)/tasks/actions"

import type { Task as PrismaTask, User } from "@/app/generated/prisma/client"

type TaskWithProfile = PrismaTask & {
assignee?: Pick<User, "name"> | null
}

function mergeTaskUpdates(current: TaskWithProfile[], updates: TaskWithProfile[]): TaskWithProfile[] {
if (updates.length === 0) return current

const updateMap = new Map(updates.map((task) => [task.id, task]))
const seen = new Set<number>()
const merged: TaskWithProfile[] = current.map((task) => {
const updated = updateMap.get(task.id)
if (updated) {
seen.add(task.id)
return { ...task, ...updated }
}
return task
})

updates.forEach((task) => {
if (!seen.has(task.id)) {
merged.push(task)
}
})

return merged
}

interface TasksPageClientProps {
initialTasks: TaskWithProfile[]
}

export function TasksPageClient({ initialTasks }: TasksPageClientProps) {
const [searchQuery, setSearchQuery] = useState("")
const [allTasks, setAllTasks] = useState<TaskWithProfile[]>(initialTasks)
const [tasks, setTasks] = useState<TaskWithProfile[]>(initialTasks)
const [isPending, startTransition] = useTransition()
const allTasksRef = useRef(allTasks)

useEffect(() => {
allTasksRef.current = allTasks
}, [allTasks])

// Optimize state synchronization to avoid redundant updates
useEffect(() => {
if (JSON.stringify(allTasksRef.current) !== JSON.stringify(initialTasks)) {
setAllTasks(initialTasks)
setTasks(initialTasks)
}
}, [initialTasks])

// Initialize search query from URL on mount
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const query = params.get("search") || ""
setSearchQuery(query)

if (query && query.length >= 3) {
startTransition(async () => {
const { tasks: searchResults } = await searchTasks(query)
setTasks(searchResults || [])
if (searchResults) {
setAllTasks((prev) => mergeTaskUpdates(prev, searchResults))
}
})
}
}, [])

// Optimize debounce logic to minimize state updates
useEffect(() => {
const timer = setTimeout(() => {
if (searchQuery.length >= 3) {
startTransition(async () => {
const { tasks: searchResults } = await searchTasks(searchQuery)
if (searchResults) {
setTasks(searchResults)
setAllTasks((prev) => mergeTaskUpdates(prev, searchResults))
}
})
} else if (searchQuery.length === 0) {
setTasks(allTasksRef.current)
}
}, 300)

return () => clearTimeout(timer)
}, [searchQuery, allTasksRef])

// Handle browser back/forward
useEffect(() => {
const handlePopState = () => {
const params = new URLSearchParams(window.location.search)
const query = params.get("search") || ""
setSearchQuery(query)

if (query && query.length >= 3) {
startTransition(async () => {
const { tasks: searchResults } = await searchTasks(query)
setTasks(searchResults || [])
if (searchResults) {
setAllTasks((prev) => mergeTaskUpdates(prev, searchResults))
}
})
} else {
setTasks(allTasksRef.current)
}
}

window.addEventListener("popstate", handlePopState)
return () => window.removeEventListener("popstate", handlePopState)
}, [])

const handleClear = useCallback(() => {
setSearchQuery("")
setTasks(allTasks)

// Remove search param from URL
const url = new URL(window.location.href)
url.searchParams.delete("search")
window.history.pushState({}, "", url.toString())
}, [allTasks])

const showEmptyState = searchQuery.length >= 3 && tasks.length === 0

// Keep local state in sync after a status change
const handleTaskStatusChange = useCallback((taskId: number, status: TaskWithProfile["status"]) => {
setTasks((current) => current.map((task) => (task.id === taskId ? { ...task, status } : task)))
setAllTasks((current) => current.map((task) => (task.id === taskId ? { ...task, status } : task)))
}, [])

// Wrap TaskList with React.memo to prevent unnecessary re-renders
const MemoizedTaskList = React.memo(TaskList)

return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search tasks..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-9"
data-testid="search-input"
/>
{isPending && !searchQuery && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" data-testid="search-spinner" />
</div>
)}
{searchQuery && !isPending && (
<button
onClick={handleClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
data-testid="clear-search"
>
<X className="h-4 w-4" />
</button>
)}
{searchQuery && isPending && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" data-testid="search-spinner" />
<button
onClick={handleClear}
className="text-muted-foreground hover:text-foreground"
data-testid="clear-search"
>
<X className="h-4 w-4" />
</button>
</div>
)}
</div>
<Button variant="outline" size="icon" data-testid="filter-button">
<Filter className="h-4 w-4" />
</Button>
</div>

{showEmptyState ? <EmptyState /> : <MemoizedTaskList initialTasks={tasks} onTaskStatusChange={handleTaskStatusChange} />}
</div>
)
}
15 changes: 15 additions & 0 deletions jest.setup.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,17 @@
// Jest setup: configure testing-library or global mocks here
import '@testing-library/jest-dom';
import { TextEncoder, TextDecoder } from 'util';

// Polyfill TextEncoder/TextDecoder for Node environment
global.TextEncoder = TextEncoder as any;
(global as any).TextDecoder = TextDecoder;

// Mock next/font/google
jest.mock('next/font/google', () => ({
Poppins: () => ({ className: 'mocked-poppins' }),
}));

// Mock next/cache
jest.mock('next/cache', () => ({
revalidatePath: jest.fn(),
}));
Loading