From 9453f88df4b0a8555729eb616b91808cfce8cf03 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 21 Feb 2026 16:32:15 -0600 Subject: [PATCH] feat: task search and save feedback fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add task search with debounce (title + description filtering) - Add sonner toast library for notifications - Visual save feedback: spinner → green 'Saved!' → toast confirmation - Error handling with descriptive toast messages - Works across Kanban and Backlog views --- package-lock.json | 13 +++++- package.json | 3 +- src/app/layout.tsx | 11 ++++++ src/app/page.tsx | 70 +++++++++++++++++++++++++++++++-- src/app/tasks/[taskId]/page.tsx | 64 +++++++++++++++++++++++++----- src/components/BacklogView.tsx | 21 ++++++++-- src/hooks/useDebounce.ts | 17 ++++++++ src/stores/useTaskStore.ts | 20 ++++++---- 8 files changed, 193 insertions(+), 26 deletions(-) create mode 100644 src/hooks/useDebounce.ts diff --git a/package-lock.json b/package-lock.json index 22c6a64..5c70910 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,8 @@ "better-sqlite3": "^12.6.2", "dotenv": "^16.6.1", "firebase": "^12.9.0", - "resend": "^6.9.2" + "resend": "^6.9.2", + "sonner": "^2.0.7" }, "devDependencies": { "@radix-ui/react-slot": "^1.2.4", @@ -9328,6 +9329,16 @@ "simple-concat": "^1.0.0" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index e4ea7bf..0a463f2 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "better-sqlite3": "^12.6.2", "dotenv": "^16.6.1", "firebase": "^12.9.0", - "resend": "^6.9.2" + "resend": "^6.9.2", + "sonner": "^2.0.7" }, "devDependencies": { "@radix-ui/react-slot": "^1.2.4", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index be7ff0f..283b6a4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next' import { JetBrains_Mono, Lexend, Source_Sans_3 } from 'next/font/google' +import { Toaster } from 'sonner' import './globals.css' const headingFont = Lexend({ @@ -31,6 +32,16 @@ export default function RootLayout({ {children} + ) diff --git a/src/app/page.tsx b/src/app/page.tsx index 137809a..b803cd7 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,7 @@ "use client" import { useState, useEffect, useMemo, type ChangeEvent, type ReactNode } from "react" +import { useDebounce } from "@/hooks/useDebounce" import { DndContext, DragEndEvent, @@ -34,7 +35,7 @@ import { } from "@/lib/attachments" import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment, type CommentAuthor } from "@/stores/useTaskStore" import { BacklogView } from "@/components/BacklogView" -import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download } from "lucide-react" +import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download, Search } from "lucide-react" interface AssignableUser { id: string @@ -379,6 +380,8 @@ export default function Home() { const [authReady, setAuthReady] = useState(false) const [initialSyncComplete, setInitialSyncComplete] = useState(false) const [users, setUsers] = useState([]) + const [searchQuery, setSearchQuery] = useState("") + const debouncedSearchQuery = useDebounce(searchQuery, 300) const getTags = (taskLike: { tags?: unknown }) => { if (!Array.isArray(taskLike.tags)) return [] as string[] @@ -589,7 +592,17 @@ export default function Home() { // Filter tasks to only show current sprint tasks in Kanban (from ALL projects) const sprintTasks = currentSprint - ? tasks.filter((t) => t.sprintId === currentSprint.id) + ? tasks.filter((t) => { + if (t.sprintId !== currentSprint.id) return false + // Apply search filter + if (debouncedSearchQuery.trim()) { + const query = debouncedSearchQuery.toLowerCase() + const matchesTitle = t.title.toLowerCase().includes(query) + const matchesDescription = t.description?.toLowerCase().includes(query) ?? false + return matchesTitle || matchesDescription + } + return true + }) : [] const activeKanbanTask = activeKanbanTaskId ? sprintTasks.find((task) => task.id === activeKanbanTaskId) @@ -893,6 +906,25 @@ export default function Home() {

+ {/* Search Input */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search tasks..." + className="w-48 lg:w-64 pl-9 pr-8 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition-colors" + /> + {searchQuery && ( + + )} +
{isLoading && ( @@ -937,7 +969,15 @@ export default function Home() { {currentSprint ? currentSprint.name : "Work Board"}

- {sprintTasks.length} tasks · {sprintTasks.filter((t) => t.status === "done").length} done + {debouncedSearchQuery.trim() ? ( + <> + {sprintTasks.length} of {currentSprint ? tasks.filter((t) => t.sprintId === currentSprint.id).length : 0} tasks match "{debouncedSearchQuery}" + + ) : ( + <> + {sprintTasks.length} tasks · {sprintTasks.filter((t) => t.status === "done").length} done + + )}

@@ -973,9 +1013,31 @@ export default function Home() {
+ {/* Mobile Search - shown only on small screens */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search tasks..." + className="w-full pl-9 pr-8 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500" + /> + {searchQuery && ( + + )} +
+
+ {/* View Content */} {viewMode === 'backlog' ? ( - + ) : ( <> {/* Current Sprint Header */} diff --git a/src/app/tasks/[taskId]/page.tsx b/src/app/tasks/[taskId]/page.tsx index 57a2c02..a1bdb81 100644 --- a/src/app/tasks/[taskId]/page.tsx +++ b/src/app/tasks/[taskId]/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState, type ChangeEvent } from "react" import { useParams, useRouter } from "next/navigation" -import { ArrowLeft, Download, MessageSquare, Paperclip, Trash2, X } from "lucide-react" +import { ArrowLeft, Check, Download, Loader2, MessageSquare, Paperclip, Save, Trash2, X } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Label } from "@/components/ui/label" @@ -28,6 +28,7 @@ import { type TaskType, type UserProfile, } from "@/stores/useTaskStore" +import { toast } from "sonner" interface AssignableUser { id: string @@ -239,6 +240,7 @@ export default function TaskDetailPage() { const [newComment, setNewComment] = useState("") const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("") const [isSaving, setIsSaving] = useState(false) + const [saveSuccess, setSaveSuccess] = useState(false) const [replyDrafts, setReplyDrafts] = useState>({}) const [openReplyEditors, setOpenReplyEditors] = useState>({}) const [authReady, setAuthReady] = useState(false) @@ -448,14 +450,39 @@ export default function TaskDetailPage() { }) } - const handleSave = () => { + const handleSave = async () => { if (!editedTask) return setIsSaving(true) - updateTask(editedTask.id, { - ...editedTask, - comments: getComments(editedTask.comments), - }) - setIsSaving(false) + setSaveSuccess(false) + + try { + const success = await updateTask(editedTask.id, { + ...editedTask, + comments: getComments(editedTask.comments), + }) + + if (success) { + setSaveSuccess(true) + toast.success("Task saved successfully", { + description: "Your changes have been saved to the server.", + duration: 3000, + }) + // Reset success state after 2 seconds + setTimeout(() => setSaveSuccess(false), 2000) + } else { + toast.error("Failed to save task", { + description: "Changes were saved locally but could not sync to the server. Please try again.", + duration: 5000, + }) + } + } catch (error) { + toast.error("Error saving task", { + description: error instanceof Error ? error.message : "An unexpected error occurred.", + duration: 5000, + }) + } finally { + setIsSaving(false) + } } const openAttachment = async (attachment: TaskAttachment) => { @@ -954,8 +981,27 @@ export default function TaskDetailPage() { - diff --git a/src/components/BacklogView.tsx b/src/components/BacklogView.tsx index 5f633c5..31635f8 100644 --- a/src/components/BacklogView.tsx +++ b/src/components/BacklogView.tsx @@ -228,7 +228,11 @@ function TaskSection({ ) } -export function BacklogView() { +interface BacklogViewProps { + searchQuery?: string +} + +export function BacklogView({ searchQuery = "" }: BacklogViewProps) { const router = useRouter() const [assignableUsers, setAssignableUsers] = useState([]) const { @@ -270,6 +274,15 @@ export function BacklogView() { return assignableUsers.find((user) => user.id === task.assigneeId)?.avatarUrl || task.assigneeAvatarUrl } + // Filter tasks by search query + const matchesSearch = (task: Task): boolean => { + if (!searchQuery.trim()) return true + const query = searchQuery.toLowerCase() + const matchesTitle = task.title.toLowerCase().includes(query) + const matchesDescription = task.description?.toLowerCase().includes(query) ?? false + return matchesTitle || matchesDescription + } + const [activeId, setActiveId] = useState(null) const [openSections, setOpenSections] = useState>({ current: true, @@ -306,10 +319,10 @@ export function BacklogView() { // Get tasks by section const currentSprintTasks = currentSprint - ? tasks.filter((t) => t.sprintId === currentSprint.id) + ? tasks.filter((t) => t.sprintId === currentSprint.id && matchesSearch(t)) : [] - const backlogTasks = tasks.filter((t) => !t.sprintId) + const backlogTasks = tasks.filter((t) => !t.sprintId && matchesSearch(t)) // Get active task for drag overlay const activeTask = activeId ? tasks.find((t) => t.id === activeId) : null @@ -400,7 +413,7 @@ export function BacklogView() { {otherSprints .sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()) .map((sprint) => { - const sprintTasks = tasks.filter((t) => t.sprintId === sprint.id) + const sprintTasks = tasks.filter((t) => t.sprintId === sprint.id && matchesSearch(t)) console.log(`Sprint ${sprint.name}: ${sprintTasks.length} tasks`, sprintTasks.map(t => t.title)) return ( diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..0a45ef2 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react'; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/stores/useTaskStore.ts b/src/stores/useTaskStore.ts index 5e201dd..03415e3 100644 --- a/src/stores/useTaskStore.ts +++ b/src/stores/useTaskStore.ts @@ -108,7 +108,7 @@ interface TaskStore { // Task actions addTask: (task: Omit) => void - updateTask: (id: string, updates: Partial) => void + updateTask: (id: string, updates: Partial) => Promise deleteTask: (id: string) => void selectTask: (id: string | null) => void @@ -753,8 +753,11 @@ export const useTaskStore = create()( }) }, - updateTask: (id, updates) => { + updateTask: async (id, updates) => { console.log('updateTask called:', id, updates) + let syncSuccess = false + let updatedTask: Task | undefined + set((state) => { const actor = profileToCommentAuthor(state.currentUser) const newTasks = state.tasks.map((t) => @@ -771,14 +774,17 @@ export const useTaskStore = create()( } as Task) : t ) - const updatedTask = newTasks.find(t => t.id === id) + updatedTask = newTasks.find(t => t.id === id) console.log('updateTask: updated task:', updatedTask) - // Sync individual task to server (lightweight) - if (updatedTask) { - syncTaskToServer(updatedTask) - } return { tasks: newTasks } }) + + // Sync individual task to server (lightweight) + if (updatedTask) { + syncSuccess = await syncTaskToServer(updatedTask) + } + + return syncSuccess }, deleteTask: (id) => {