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", "better-sqlite3": "^12.6.2",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"firebase": "^12.9.0", "firebase": "^12.9.0",
"resend": "^6.9.2" "resend": "^6.9.2",
"sonner": "^2.0.7"
}, },
"devDependencies": { "devDependencies": {
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
@ -9328,6 +9329,16 @@
"simple-concat": "^1.0.0" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "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", "better-sqlite3": "^12.6.2",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"firebase": "^12.9.0", "firebase": "^12.9.0",
"resend": "^6.9.2" "resend": "^6.9.2",
"sonner": "^2.0.7"
}, },
"devDependencies": { "devDependencies": {
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",

View File

@ -1,5 +1,6 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { JetBrains_Mono, Lexend, Source_Sans_3 } from 'next/font/google' import { JetBrains_Mono, Lexend, Source_Sans_3 } from 'next/font/google'
import { Toaster } from 'sonner'
import './globals.css' import './globals.css'
const headingFont = Lexend({ const headingFont = Lexend({
@ -31,6 +32,16 @@ export default function RootLayout({
<html lang="en"> <html lang="en">
<body className={`${headingFont.variable} ${bodyFont.variable} ${monoFont.variable} antialiased`}> <body className={`${headingFont.variable} ${bodyFont.variable} ${monoFont.variable} antialiased`}>
{children} {children}
<Toaster
position="bottom-right"
toastOptions={{
style: {
background: '#1e293b',
color: '#f1f5f9',
border: '1px solid #334155',
},
}}
/>
</body> </body>
</html> </html>
) )

View File

@ -1,6 +1,7 @@
"use client" "use client"
import { useState, useEffect, useMemo, type ChangeEvent, type ReactNode } from "react" import { useState, useEffect, useMemo, type ChangeEvent, type ReactNode } from "react"
import { useDebounce } from "@/hooks/useDebounce"
import { import {
DndContext, DndContext,
DragEndEvent, DragEndEvent,
@ -34,7 +35,7 @@ import {
} from "@/lib/attachments" } from "@/lib/attachments"
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment, type CommentAuthor } from "@/stores/useTaskStore" import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment, type CommentAuthor } from "@/stores/useTaskStore"
import { BacklogView } from "@/components/BacklogView" 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 { interface AssignableUser {
id: string id: string
@ -379,6 +380,8 @@ export default function Home() {
const [authReady, setAuthReady] = useState(false) const [authReady, setAuthReady] = useState(false)
const [initialSyncComplete, setInitialSyncComplete] = useState(false) const [initialSyncComplete, setInitialSyncComplete] = useState(false)
const [users, setUsers] = useState<AssignableUser[]>([]) const [users, setUsers] = useState<AssignableUser[]>([])
const [searchQuery, setSearchQuery] = useState("")
const debouncedSearchQuery = useDebounce(searchQuery, 300)
const getTags = (taskLike: { tags?: unknown }) => { const getTags = (taskLike: { tags?: unknown }) => {
if (!Array.isArray(taskLike.tags)) return [] as string[] 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) // Filter tasks to only show current sprint tasks in Kanban (from ALL projects)
const sprintTasks = currentSprint 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 const activeKanbanTask = activeKanbanTaskId
? sprintTasks.find((task) => task.id === activeKanbanTaskId) ? sprintTasks.find((task) => task.id === activeKanbanTaskId)
@ -893,6 +906,25 @@ export default function Home() {
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <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 && ( {isLoading && (
<span className="flex items-center gap-1 text-xs text-blue-400"> <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"> <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"} {currentSprint ? currentSprint.name : "Work Board"}
</h2> </h2>
<p className="text-sm text-slate-400"> <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> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -973,9 +1013,31 @@ export default function Home() {
</div> </div>
</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 */} {/* View Content */}
{viewMode === 'backlog' ? ( {viewMode === 'backlog' ? (
<BacklogView /> <BacklogView searchQuery={debouncedSearchQuery} />
) : ( ) : (
<> <>
{/* Current Sprint Header */} {/* Current Sprint Header */}

View File

@ -2,7 +2,7 @@
import { useEffect, useMemo, useState, type ChangeEvent } from "react" import { useEffect, useMemo, useState, type ChangeEvent } from "react"
import { useParams, useRouter } from "next/navigation" 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 { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
@ -28,6 +28,7 @@ import {
type TaskType, type TaskType,
type UserProfile, type UserProfile,
} from "@/stores/useTaskStore" } from "@/stores/useTaskStore"
import { toast } from "sonner"
interface AssignableUser { interface AssignableUser {
id: string id: string
@ -239,6 +240,7 @@ export default function TaskDetailPage() {
const [newComment, setNewComment] = useState("") const [newComment, setNewComment] = useState("")
const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("") const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("")
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [saveSuccess, setSaveSuccess] = useState(false)
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({}) const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({})
const [openReplyEditors, setOpenReplyEditors] = useState<Record<string, boolean>>({}) const [openReplyEditors, setOpenReplyEditors] = useState<Record<string, boolean>>({})
const [authReady, setAuthReady] = useState(false) const [authReady, setAuthReady] = useState(false)
@ -448,14 +450,39 @@ export default function TaskDetailPage() {
}) })
} }
const handleSave = () => { const handleSave = async () => {
if (!editedTask) return if (!editedTask) return
setIsSaving(true) setIsSaving(true)
updateTask(editedTask.id, { setSaveSuccess(false)
...editedTask,
comments: getComments(editedTask.comments), try {
}) const success = await updateTask(editedTask.id, {
setIsSaving(false) ...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) => { const openAttachment = async (attachment: TaskAttachment) => {
@ -954,8 +981,27 @@ export default function TaskDetailPage() {
<Button variant="ghost" onClick={() => router.push("/")}> <Button variant="ghost" onClick={() => router.push("/")}>
Close Close
</Button> </Button>
<Button onClick={handleSave} disabled={isSaving}> <Button
{isSaving ? "Saving..." : "Save Changes"} 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> </Button>
</div> </div>
</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 router = useRouter()
const [assignableUsers, setAssignableUsers] = useState<AssignableUser[]>([]) const [assignableUsers, setAssignableUsers] = useState<AssignableUser[]>([])
const { const {
@ -270,6 +274,15 @@ export function BacklogView() {
return assignableUsers.find((user) => user.id === task.assigneeId)?.avatarUrl || task.assigneeAvatarUrl 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 [activeId, setActiveId] = useState<string | null>(null)
const [openSections, setOpenSections] = useState<Record<string, boolean>>({ const [openSections, setOpenSections] = useState<Record<string, boolean>>({
current: true, current: true,
@ -306,10 +319,10 @@ export function BacklogView() {
// Get tasks by section // Get tasks by section
const currentSprintTasks = currentSprint 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 // Get active task for drag overlay
const activeTask = activeId ? tasks.find((t) => t.id === activeId) : null const activeTask = activeId ? tasks.find((t) => t.id === activeId) : null
@ -400,7 +413,7 @@ export function BacklogView() {
{otherSprints {otherSprints
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()) .sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime())
.map((sprint) => { .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)) console.log(`Sprint ${sprint.name}: ${sprintTasks.length} tasks`, sprintTasks.map(t => t.title))
return ( return (
<SectionDropZone key={sprint.id} id={`sprint-${sprint.id}`}> <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 // Task actions
addTask: (task: Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'comments'>) => void 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 deleteTask: (id: string) => void
selectTask: (id: string | null) => 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) console.log('updateTask called:', id, updates)
let syncSuccess = false
let updatedTask: Task | undefined
set((state) => { set((state) => {
const actor = profileToCommentAuthor(state.currentUser) const actor = profileToCommentAuthor(state.currentUser)
const newTasks = state.tasks.map((t) => const newTasks = state.tasks.map((t) =>
@ -771,14 +774,17 @@ export const useTaskStore = create<TaskStore>()(
} as Task) } as Task)
: t : t
) )
const updatedTask = newTasks.find(t => t.id === id) updatedTask = newTasks.find(t => t.id === id)
console.log('updateTask: updated task:', updatedTask) console.log('updateTask: updated task:', updatedTask)
// Sync individual task to server (lightweight)
if (updatedTask) {
syncTaskToServer(updatedTask)
}
return { tasks: newTasks } return { tasks: newTasks }
}) })
// Sync individual task to server (lightweight)
if (updatedTask) {
syncSuccess = await syncTaskToServer(updatedTask)
}
return syncSuccess
}, },
deleteTask: (id) => { deleteTask: (id) => {