feat: task search and save feedback fixes

- 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
This commit is contained in:
Max 2026-02-21 16:32:15 -06:00
parent caa0bf1893
commit 9453f88df4
8 changed files with 193 additions and 26 deletions

13
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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({
<html lang="en">
<body className={`${headingFont.variable} ${bodyFont.variable} ${monoFont.variable} antialiased`}>
{children}
<Toaster
position="bottom-right"
toastOptions={{
style: {
background: '#1e293b',
color: '#f1f5f9',
border: '1px solid #334155',
},
}}
/>
</body>
</html>
)

View File

@ -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<AssignableUser[]>([])
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() {
</p>
</div>
<div className="flex items-center gap-3">
{/* Search Input */}
<div className="relative hidden sm:block">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
type="text"
value={searchQuery}
onChange={(e) => 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 && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 hover:bg-slate-700 rounded text-slate-400 hover:text-white"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{isLoading && (
<span className="flex items-center gap-1 text-xs text-blue-400">
<svg className="animate-spin h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@ -937,7 +969,15 @@ export default function Home() {
{currentSprint ? currentSprint.name : "Work Board"}
</h2>
<p className="text-sm text-slate-400">
{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 &quot;{debouncedSearchQuery}&quot;
</>
) : (
<>
{sprintTasks.length} tasks · {sprintTasks.filter((t) => t.status === "done").length} done
</>
)}
</p>
</div>
<div className="flex items-center gap-2">
@ -973,9 +1013,31 @@ export default function Home() {
</div>
</div>
{/* Mobile Search - shown only on small screens */}
<div className="sm:hidden mb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
type="text"
value={searchQuery}
onChange={(e) => 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 && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-slate-700 rounded text-slate-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* View Content */}
{viewMode === 'backlog' ? (
<BacklogView />
<BacklogView searchQuery={debouncedSearchQuery} />
) : (
<>
{/* Current Sprint Header */}

View File

@ -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<Record<string, string>>({})
const [openReplyEditors, setOpenReplyEditors] = useState<Record<string, boolean>>({})
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() {
<Button variant="ghost" onClick={() => router.push("/")}>
Close
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save Changes"}
<Button
onClick={handleSave}
disabled={isSaving}
className={saveSuccess ? "bg-green-600 hover:bg-green-700" : undefined}
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : saveSuccess ? (
<>
<Check className="w-4 h-4 mr-2" />
Saved!
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Save Changes
</>
)}
</Button>
</div>
</div>

View File

@ -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<AssignableUser[]>([])
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<string | null>(null)
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
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 (
<SectionDropZone key={sprint.id} id={`sprint-${sprint.id}`}>

17
src/hooks/useDebounce.ts Normal file
View File

@ -0,0 +1,17 @@
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}

View File

@ -108,7 +108,7 @@ interface TaskStore {
// Task actions
addTask: (task: Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'comments'>) => void
updateTask: (id: string, updates: Partial<Task>) => void
updateTask: (id: string, updates: Partial<Task>) => Promise<boolean>
deleteTask: (id: string) => void
selectTask: (id: string | null) => void
@ -753,8 +753,11 @@ export const useTaskStore = create<TaskStore>()(
})
},
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<TaskStore>()(
} 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) => {