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 && (
@@ -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() {
-