From 4040fa6400564fb08dc560635caf2da1b5975351 Mon Sep 17 00:00:00 2001 From: ihab4real Date: Fri, 6 Jun 2025 03:46:01 +0300 Subject: [PATCH 01/13] feat(server/api): implement global search endpoint - Added search controller with cross-content search functionality - Created search routes - Updated app.js to register search routes - Added text indexes to models for optimized search performance --- server/app.js | 2 + server/controllers/searchController.js | 117 +++++++++++++++++++++++++ server/models/habitModel.js | 4 + server/models/noteModel.js | 5 ++ server/models/taskModel.js | 4 + server/routes/searchRoutes.js | 65 ++++++++++++++ 6 files changed, 197 insertions(+) create mode 100644 server/controllers/searchController.js create mode 100644 server/routes/searchRoutes.js diff --git a/server/app.js b/server/app.js index abb9107..d5ba2d0 100644 --- a/server/app.js +++ b/server/app.js @@ -13,6 +13,7 @@ import taskRoutes from "./routes/taskRoutes.js"; import noteRoutes from "./routes/noteRoutes.js"; import pomodoroRoutes from "./routes/pomodoroRoutes.js"; import habitRoutes from "./routes/habitRoutes.js"; +import searchRoutes from "./routes/searchRoutes.js"; import cookieParser from "cookie-parser"; import mongoSanitize from "express-mongo-sanitize"; import passport from "passport"; @@ -137,6 +138,7 @@ app.use("/api/tasks", taskRoutes); app.use("/api/notes", noteRoutes); app.use("/api/pomodoro", pomodoroRoutes); app.use("/api/habits", habitRoutes); +app.use("/api/search", searchRoutes); // Serve React app for all non-API routes in production if (process.env.NODE_ENV === "production") { diff --git a/server/controllers/searchController.js b/server/controllers/searchController.js new file mode 100644 index 0000000..f5a85dd --- /dev/null +++ b/server/controllers/searchController.js @@ -0,0 +1,117 @@ +import Task from "../models/taskModel.js"; +import Note from "../models/noteModel.js"; +import Habit from "../models/habitModel.js"; +import { errorTypes } from "../utils/AppError.js"; +import asyncHandler from "../utils/asyncHandler.js"; + +/** + * Global search across tasks, notes, and habits + * @route GET /api/search + * @access Private + */ +export const searchAll = asyncHandler(async (req, res, next) => { + const { q: query } = req.query; + const userId = req.user._id; + const limit = parseInt(req.query.limit) || 20; + const type = req.query.type || "all"; // 'all', 'tasks', 'notes', 'habits' + + if (!query || query.trim() === "") { + return res.status(200).json({ + tasks: [], + notes: [], + habits: [], + }); + } + + // Create text search conditions + const searchCondition = { + $or: [ + { title: { $regex: query, $options: "i" } }, + { description: { $regex: query, $options: "i" } }, + ], + user: userId, + }; + + const noteSearchCondition = { + $or: [ + { title: { $regex: query, $options: "i" } }, + { content: { $regex: query, $options: "i" } }, + ], + user: userId, + }; + + const habitSearchCondition = { + $or: [ + { name: { $regex: query, $options: "i" } }, + { description: { $regex: query, $options: "i" } }, + ], + user: userId, + }; + + // Initialize results object + const results = {}; + + // Execute searches in parallel based on requested type + const searchPromises = []; + + if (type === "all" || type === "tasks") { + searchPromises.push( + Task.find(searchCondition) + .limit(limit) + .sort({ updatedAt: -1 }) + .select("title description status priority dueDate updatedAt") + .lean() + .then((tasks) => { + results.tasks = tasks; + }) + ); + } else { + results.tasks = []; + } + + if (type === "all" || type === "notes") { + searchPromises.push( + Note.find(noteSearchCondition) + .limit(limit) + .sort({ updatedAt: -1 }) + .select("title content folder updatedAt") + .lean() + .then((notes) => { + // Process notes to extract snippets from content + results.notes = notes.map((note) => { + const plainTextContent = note.content + ? note.content.replace(/<[^>]*>?/gm, "") // Remove HTML tags + : ""; + + return { + ...note, + snippet: + plainTextContent.substring(0, 100) + + (plainTextContent.length > 100 ? "..." : ""), + }; + }); + }) + ); + } else { + results.notes = []; + } + + if (type === "all" || type === "habits") { + searchPromises.push( + Habit.find(habitSearchCondition) + .limit(limit) + .sort({ updatedAt: -1 }) + .select("name description category type targetValue unit") + .lean() + .then((habits) => { + results.habits = habits; + }) + ); + } else { + results.habits = []; + } + + await Promise.all(searchPromises); + + res.status(200).json(results); +}); diff --git a/server/models/habitModel.js b/server/models/habitModel.js index f983ac0..cc8a179 100644 --- a/server/models/habitModel.js +++ b/server/models/habitModel.js @@ -67,4 +67,8 @@ habitSchema.pre("save", function (next) { habitSchema.index({ user: 1, isActive: 1 }); habitSchema.index({ user: 1, category: 1 }); +// Add text search indexes +habitSchema.index({ name: "text", description: "text" }); +habitSchema.index({ user: 1, updatedAt: -1 }); + export default mongoose.model("Habit", habitSchema); diff --git a/server/models/noteModel.js b/server/models/noteModel.js index 501f744..9da597c 100644 --- a/server/models/noteModel.js +++ b/server/models/noteModel.js @@ -49,4 +49,9 @@ noteSchema.pre("findOneAndUpdate", function (next) { next(); }); +// Add indexes for more efficient search +noteSchema.index({ title: "text", content: "text", tags: "text" }); +noteSchema.index({ user: 1, updatedAt: -1 }); +noteSchema.index({ user: 1, folder: 1 }); + export default mongoose.model("Note", noteSchema); diff --git a/server/models/taskModel.js b/server/models/taskModel.js index 768ccd8..1ed3856 100644 --- a/server/models/taskModel.js +++ b/server/models/taskModel.js @@ -51,4 +51,8 @@ taskSchema.pre("save", function (next) { next(); }); +// Add indexes for more efficient search +taskSchema.index({ title: "text", description: "text", tags: "text" }); +taskSchema.index({ user: 1, updatedAt: -1 }); + export default mongoose.model("Task", taskSchema); diff --git a/server/routes/searchRoutes.js b/server/routes/searchRoutes.js new file mode 100644 index 0000000..ab39b50 --- /dev/null +++ b/server/routes/searchRoutes.js @@ -0,0 +1,65 @@ +import express from "express"; +import { searchAll } from "../controllers/searchController.js"; +import { protect } from "../middleware/authMiddleware.js"; + +const router = express.Router(); + +/** + * @swagger + * /api/search: + * get: + * summary: Search across all user content + * description: Search for tasks, notes, and habits matching the query + * tags: [Search] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: q + * required: true + * schema: + * type: string + * description: Search query term + * - in: query + * name: type + * required: false + * schema: + * type: string + * enum: [all, tasks, notes, habits] + * default: all + * description: Filter results by content type + * - in: query + * name: limit + * required: false + * schema: + * type: integer + * default: 20 + * description: Maximum number of results per type + * responses: + * 200: + * description: Search results from all content types + * content: + * application/json: + * schema: + * type: object + * properties: + * tasks: + * type: array + * items: + * $ref: '#/components/schemas/Task' + * notes: + * type: array + * items: + * $ref: '#/components/schemas/Note' + * habits: + * type: array + * items: + * $ref: '#/components/schemas/Habit' + * 401: + * description: Not authorized, token missing or invalid + * 500: + * description: Server error + */ +router.get("/", protect, searchAll); + +export default router; From 4ec6d9f962ffa675bcda77f0361700587818e42d Mon Sep 17 00:00:00 2001 From: ihab4real Date: Fri, 6 Jun 2025 03:47:52 +0300 Subject: [PATCH 02/13] feat(client/ui): create global search UI components - Added SearchResults dropdown component - Updated DashboardHeader to implement search functionality - Implemented keyboard navigation and filtering by category --- client/src/components/DashboardHeader.jsx | 34 ++- client/src/components/SearchResults.jsx | 291 ++++++++++++++++++++++ 2 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 client/src/components/SearchResults.jsx diff --git a/client/src/components/DashboardHeader.jsx b/client/src/components/DashboardHeader.jsx index 8fb4d2b..6c207ba 100644 --- a/client/src/components/DashboardHeader.jsx +++ b/client/src/components/DashboardHeader.jsx @@ -1,21 +1,51 @@ -import React from "react"; +import React, { useRef } from "react"; import { Search } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { useAuth } from "@/features/authentication/hooks/useAuth"; import { UserProfileMenu } from "./UserProfileMenu"; +import { SearchResults } from "./SearchResults"; +import { useSearchStore } from "@/store/useSearchStore"; export function DashboardHeader() { const { user } = useAuth(); + const { search, isOpen, navigateResults } = useSearchStore(); + const inputRef = useRef(null); + + // Handle keyboard navigation + const handleKeyDown = (e) => { + if (isOpen) { + if (e.key === "ArrowDown") { + e.preventDefault(); + navigateResults("down"); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + navigateResults("up"); + } else if (e.key === "Escape") { + e.preventDefault(); + inputRef.current?.blur(); + } else if (e.key === "Enter") { + // The enter behavior is handled by the focused item in SearchResults + } + } + }; + + const handleChange = (e) => { + search(e.target.value); + }; return (
-
+
+
diff --git a/client/src/components/SearchResults.jsx b/client/src/components/SearchResults.jsx new file mode 100644 index 0000000..c985b89 --- /dev/null +++ b/client/src/components/SearchResults.jsx @@ -0,0 +1,291 @@ +import React, { useRef, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { + FileText, + CheckSquare, + Activity, + Loader2, + AlertCircle, +} from "lucide-react"; +import { useSearchStore } from "@/store/useSearchStore"; + +export function SearchResults() { + const { + results, + isSearching, + isOpen, + selectedCategory, + focusedIndex, + closeSearch, + setSelectedCategory, + error, + } = useSearchStore(); + const navigate = useNavigate(); + const resultsRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event) { + if (resultsRef.current && !resultsRef.current.contains(event.target)) { + closeSearch(); + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [closeSearch]); + + // Scroll focused item into view + useEffect(() => { + if (focusedIndex >= 0 && resultsRef.current) { + const focusedElement = resultsRef.current.querySelector( + `[data-index="${focusedIndex}"]` + ); + if (focusedElement) { + focusedElement.scrollIntoView({ block: "nearest" }); + } + } + }, [focusedIndex]); + + if (!isOpen) return null; + + const hasResults = + results && + ((results.tasks && results.tasks.length > 0) || + (results.notes && results.notes.length > 0) || + (results.habits && results.habits.length > 0)); + + const navigateToItem = (type, id) => { + closeSearch(); + switch (type) { + case "task": + navigate(`/dashboard/taskboard?taskId=${id}`); + break; + case "note": + navigate(`/dashboard/notepanel?noteId=${id}`); + break; + case "habit": + navigate(`/dashboard/habits?habitId=${id}`); + break; + default: + break; + } + }; + + // Calculate current index for keyboard navigation + let currentIndex = -1; + + return ( +
+ {/* Category tabs */} +
+ + + + +
+ + {/* Loading state */} + {isSearching && ( +
+ + Searching... +
+ )} + + {/* Error state */} + {error && !isSearching && ( +
+ + {error} +
+ )} + + {/* No results state */} + {!isSearching && !error && !hasResults && ( +
+ No results found +
+ )} + + {/* Results */} + {!isSearching && !error && hasResults && ( +
+ {/* Task results */} + {(selectedCategory === "all" || selectedCategory === "tasks") && + results.tasks && + results.tasks.length > 0 && ( +
+

+ TASKS +

+
    + {results.tasks.map((task) => { + currentIndex++; + const isFocused = focusedIndex === currentIndex; + return ( +
  • navigateToItem("task", task._id)} + > + +
    +
    {task.title}
    + {task.description && ( +
    + {task.description} +
    + )} +
    + {task.status} • {task.priority} + {task.dueDate && + ` • Due: ${new Date(task.dueDate).toLocaleDateString()}`} +
    +
    +
  • + ); + })} +
+
+ )} + + {/* Note results */} + {(selectedCategory === "all" || selectedCategory === "notes") && + results.notes && + results.notes.length > 0 && ( +
+

+ NOTES +

+
    + {results.notes.map((note) => { + currentIndex++; + const isFocused = focusedIndex === currentIndex; + return ( +
  • navigateToItem("note", note._id)} + > + +
    +
    {note.title}
    + {note.snippet && ( +
    + {note.snippet} +
    + )} +
    + {note.folder} • Last edited:{" "} + {new Date(note.updatedAt).toLocaleDateString()} +
    +
    +
  • + ); + })} +
+
+ )} + + {/* Habit results */} + {(selectedCategory === "all" || selectedCategory === "habits") && + results.habits && + results.habits.length > 0 && ( +
+

+ HABITS +

+
    + {results.habits.map((habit) => { + currentIndex++; + const isFocused = focusedIndex === currentIndex; + return ( +
  • navigateToItem("habit", habit._id)} + > + +
    +
    {habit.name}
    + {habit.description && ( +
    + {habit.description} +
    + )} +
    + {habit.category} • {habit.type} + {habit.targetValue && + habit.unit && + ` • Target: ${habit.targetValue} ${habit.unit}`} +
    +
    +
  • + ); + })} +
+
+ )} +
+ )} +
+ ); +} From 835e49c726563c987fa3ae997430a75b8d15d150 Mon Sep 17 00:00:00 2001 From: ihab4real Date: Fri, 6 Jun 2025 03:50:11 +0300 Subject: [PATCH 03/13] feat(client/logic): implement search state management - Created Zustand store for search state - Added debounced search functionality - Implemented result filtering and error handling --- client/src/stores/useSearchStore.js | 93 +++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 client/src/stores/useSearchStore.js diff --git a/client/src/stores/useSearchStore.js b/client/src/stores/useSearchStore.js new file mode 100644 index 0000000..580aaf2 --- /dev/null +++ b/client/src/stores/useSearchStore.js @@ -0,0 +1,93 @@ +import { create } from "zustand"; +import { globalSearch } from "@/services/searchService"; +import debounce from "lodash.debounce"; + +export const useSearchStore = create((set, get) => { + // Create a debounced search function + const debouncedSearchFn = debounce(async (searchQuery, selectedCategory) => { + if (!searchQuery.trim()) { + set({ results: null, isOpen: false, isSearching: false }); + return; + } + + try { + const results = await globalSearch(searchQuery, { + type: selectedCategory !== "all" ? selectedCategory : "all", + }); + + set({ + results, + isSearching: false, + isOpen: true, + }); + } catch (error) { + console.error("Search failed:", error); + set({ + isSearching: false, + error: "Failed to perform search. Please try again.", + }); + } + }, 300); + + return { + // State + query: "", + results: null, + isSearching: false, + isOpen: false, + selectedCategory: "all", + focusedIndex: -1, + error: null, + + // Actions + search: (searchQuery) => { + set({ query: searchQuery, isSearching: true, error: null }); + + if (!searchQuery.trim()) { + set({ results: null, isOpen: false, isSearching: false }); + return; + } + + debouncedSearchFn(searchQuery, get().selectedCategory); + }, + + closeSearch: () => set({ isOpen: false }), + + setSelectedCategory: (category) => { + set({ selectedCategory: category }); + + // If we have a query and change category, rerun the search + const { query } = get(); + if (query.trim()) { + set({ isSearching: true }); + debouncedSearchFn(query, category); + } + }, + + navigateResults: (direction) => { + const { results, selectedCategory, focusedIndex } = get(); + + if (!results) return; + + const totalResults = [ + ...(selectedCategory === "all" || selectedCategory === "tasks" + ? results.tasks || [] + : []), + ...(selectedCategory === "all" || selectedCategory === "notes" + ? results.notes || [] + : []), + ...(selectedCategory === "all" || selectedCategory === "habits" + ? results.habits || [] + : []), + ].length; + + if (totalResults === 0) return; + + if (direction === "down") { + set({ focusedIndex: (focusedIndex + 1) % totalResults }); + } else if (direction === "up") { + set({ focusedIndex: (focusedIndex - 1 + totalResults) % totalResults }); + } + }, + }; +}); From 4f465ad00f068e66a6edfe9abfcc93965b90749b Mon Sep 17 00:00:00 2001 From: ihab4real Date: Fri, 6 Jun 2025 03:50:47 +0300 Subject: [PATCH 04/13] feat(client/api): add search service for API integration --- client/src/services/searchService.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 client/src/services/searchService.js diff --git a/client/src/services/searchService.js b/client/src/services/searchService.js new file mode 100644 index 0000000..4d7f749 --- /dev/null +++ b/client/src/services/searchService.js @@ -0,0 +1,28 @@ +import apiClient from "./api/apiClient"; + +/** + * Search across tasks, notes, and habits + * @param {string} query - The search term + * @param {Object} options - Search options + * @param {string} options.type - Filter by type: 'all', 'tasks', 'notes', 'habits' + * @param {number} options.limit - Number of results to return per type + * @returns {Promise<{tasks: Array, notes: Array, habits: Array}>} Search results + */ +export const globalSearch = async (query, options = {}) => { + const { type = "all", limit = 20 } = options; + + try { + const response = await apiClient.get("/api/search", { + params: { + q: query, + type, + limit, + }, + }); + + return response; + } catch (error) { + console.error("Search error:", error); + throw error; + } +}; From 129f0104199e260d4f188fd6a261da682ed20852 Mon Sep 17 00:00:00 2001 From: ihab4real Date: Fri, 6 Jun 2025 03:51:19 +0300 Subject: [PATCH 05/13] chore(client/deps): add lodash.debounce dependency for optimized search input --- client/package-lock.json | 2 +- client/package.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/client/package-lock.json b/client/package-lock.json index 3814b55..434dff4 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -54,6 +54,7 @@ "date-fns": "^4.1.0", "framer-motion": "^12.6.3", "lodash": "^4.17.21", + "lodash.debounce": "^4.0.8", "lowlight": "^3.3.0", "lucide-react": "^0.486.0", "react": "^19.0.0", @@ -10209,7 +10210,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true, "license": "MIT" }, "node_modules/lodash.merge": { diff --git a/client/package.json b/client/package.json index bdfc8f5..c2df78e 100644 --- a/client/package.json +++ b/client/package.json @@ -57,6 +57,7 @@ "date-fns": "^4.1.0", "framer-motion": "^12.6.3", "lodash": "^4.17.21", + "lodash.debounce": "^4.0.8", "lowlight": "^3.3.0", "lucide-react": "^0.486.0", "react": "^19.0.0", From e40643f593c32fd3b4f2aee654fd443ea93a13b1 Mon Sep 17 00:00:00 2001 From: ihab4real Date: Fri, 6 Jun 2025 04:09:43 +0300 Subject: [PATCH 06/13] fix(client): forgot to change import after relocating file --- client/src/components/DashboardHeader.jsx | 2 +- client/src/components/SearchResults.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/components/DashboardHeader.jsx b/client/src/components/DashboardHeader.jsx index 6c207ba..2a1ffbb 100644 --- a/client/src/components/DashboardHeader.jsx +++ b/client/src/components/DashboardHeader.jsx @@ -5,7 +5,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { useAuth } from "@/features/authentication/hooks/useAuth"; import { UserProfileMenu } from "./UserProfileMenu"; import { SearchResults } from "./SearchResults"; -import { useSearchStore } from "@/store/useSearchStore"; +import { useSearchStore } from "@/stores/useSearchStore"; export function DashboardHeader() { const { user } = useAuth(); diff --git a/client/src/components/SearchResults.jsx b/client/src/components/SearchResults.jsx index c985b89..31e7ab1 100644 --- a/client/src/components/SearchResults.jsx +++ b/client/src/components/SearchResults.jsx @@ -7,7 +7,7 @@ import { Loader2, AlertCircle, } from "lucide-react"; -import { useSearchStore } from "@/store/useSearchStore"; +import { useSearchStore } from "@/stores/useSearchStore"; export function SearchResults() { const { From fb5456e57667c6e6bf740fb6256dcbaa3c653287 Mon Sep 17 00:00:00 2001 From: ihab4real Date: Fri, 6 Jun 2025 04:13:27 +0300 Subject: [PATCH 07/13] feat(client/ui): enhance note search navigation experience - Update SearchResults to pass folder information when navigating to notes - Modify NotesPageLayout to automatically open the correct folder - Ensure selected note is displayed immediately after search - Improve user workflow by reducing clicks needed to access notes This change ensures that when users click on a note in search results, they're taken directly to the note with its containing folder opened. --- client/src/components/SearchResults.jsx | 11 ++++++++--- .../components/layout/NotesPageLayout.jsx | 18 +++++++++++++++--- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/client/src/components/SearchResults.jsx b/client/src/components/SearchResults.jsx index 31e7ab1..692f3df 100644 --- a/client/src/components/SearchResults.jsx +++ b/client/src/components/SearchResults.jsx @@ -55,14 +55,19 @@ export function SearchResults() { (results.notes && results.notes.length > 0) || (results.habits && results.habits.length > 0)); - const navigateToItem = (type, id) => { + const navigateToItem = (type, id, item = {}) => { closeSearch(); switch (type) { case "task": navigate(`/dashboard/taskboard?taskId=${id}`); break; case "note": - navigate(`/dashboard/notepanel?noteId=${id}`); + navigate(`/dashboard/notepanel`, { + state: { + initialNoteId: id, + folderToOpen: item.folder, + }, + }); break; case "habit": navigate(`/dashboard/habits?habitId=${id}`); @@ -218,7 +223,7 @@ export function SearchResults() { ? "bg-accent text-accent-foreground" : "hover:bg-muted" }`} - onClick={() => navigateToItem("note", note._id)} + onClick={() => navigateToItem("note", note._id, note)} >
diff --git a/client/src/features/Notes/components/layout/NotesPageLayout.jsx b/client/src/features/Notes/components/layout/NotesPageLayout.jsx index a7c51dc..ab3ac7b 100644 --- a/client/src/features/Notes/components/layout/NotesPageLayout.jsx +++ b/client/src/features/Notes/components/layout/NotesPageLayout.jsx @@ -36,7 +36,7 @@ const NotesPageLayout = () => { const [isNewNote, setIsNewNote] = useState(false); const [isFullScreen, setIsFullScreen] = useState(false); - // Get location to check for initial state passed from dashboard + // Get location to check for initial state passed from dashboard or search const location = useLocation(); // React Query hooks @@ -52,9 +52,21 @@ const NotesPageLayout = () => { const renameFolderMutation = useRenameFolderMutation(); // For initial note loading - const { data: initialNote } = useNoteQuery(location.state?.initialNoteId); + const { data: initialNote } = useNoteQuery(location.state?.initialNoteId, { + enabled: !!location.state?.initialNoteId, + }); + + // Handle folder selection from search navigation + useEffect(() => { + if ( + location.state?.folderToOpen && + folders.includes(location.state.folderToOpen) + ) { + setCurrentFolder(location.state.folderToOpen); + } + }, [location.state?.folderToOpen, folders]); - // Handle initial note loading from dashboard navigation + // Handle initial note loading from dashboard navigation or search useEffect(() => { if (initialNote) { setSelectedNote(initialNote); From a73ea6f1ba11e232a01328e65fcbc5bdcae7fd81 Mon Sep 17 00:00:00 2001 From: ihab4real Date: Fri, 6 Jun 2025 04:27:47 +0300 Subject: [PATCH 08/13] refactor(client/ui): improve search results index generation Replace mutable index counter with functional index generator in SearchResults component. This change addresses CodeRabbit AI feedback by: - Eliminating direct mutations during render - Using a closure-based approach for sequential index generation - Making the code more predictable and maintainable - Better aligning with React's functional paradigm The functionality remains identical, but the implementation is now more robust. --- client/src/components/SearchResults.jsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/client/src/components/SearchResults.jsx b/client/src/components/SearchResults.jsx index 692f3df..be92f3d 100644 --- a/client/src/components/SearchResults.jsx +++ b/client/src/components/SearchResults.jsx @@ -23,6 +23,15 @@ export function SearchResults() { const navigate = useNavigate(); const resultsRef = useRef(null); + // Function to generate indexes for items + const createIndexGenerator = () => { + let index = -1; + return () => { + index += 1; + return index; + }; + }; + // Close dropdown when clicking outside useEffect(() => { function handleClickOutside(event) { @@ -77,8 +86,8 @@ export function SearchResults() { } }; - // Calculate current index for keyboard navigation - let currentIndex = -1; + // Create index generator for this render + const getNextIndex = createIndexGenerator(); return (
    {results.tasks.map((task) => { - currentIndex++; + const currentIndex = getNextIndex(); const isFocused = focusedIndex === currentIndex; return (
    • {results.notes.map((note) => { - currentIndex++; + const currentIndex = getNextIndex(); const isFocused = focusedIndex === currentIndex; return (
      • {results.habits.map((habit) => { - currentIndex++; + const currentIndex = getNextIndex(); const isFocused = focusedIndex === currentIndex; return (
      • Date: Fri, 6 Jun 2025 04:40:14 +0300 Subject: [PATCH 09/13] fix(client/notes): remove redundant enabled condition from useNoteQuery call as per coderabbit review The change removes a redundant enabled condition that was passed as a second argument to the useNoteQuery hook in NotesPageLayout.jsx. The hook implementation already includes the proper enabled condition internally (enabled: !!id && !!userId), making the additional condition unnecessary. --- .../src/features/Notes/components/layout/NotesPageLayout.jsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/src/features/Notes/components/layout/NotesPageLayout.jsx b/client/src/features/Notes/components/layout/NotesPageLayout.jsx index ab3ac7b..c7918c5 100644 --- a/client/src/features/Notes/components/layout/NotesPageLayout.jsx +++ b/client/src/features/Notes/components/layout/NotesPageLayout.jsx @@ -52,9 +52,7 @@ const NotesPageLayout = () => { const renameFolderMutation = useRenameFolderMutation(); // For initial note loading - const { data: initialNote } = useNoteQuery(location.state?.initialNoteId, { - enabled: !!location.state?.initialNoteId, - }); + const { data: initialNote } = useNoteQuery(location.state?.initialNoteId); // Handle folder selection from search navigation useEffect(() => { From 77e48d3b64bbde6760694608ac210bc7e61ffb13 Mon Sep 17 00:00:00 2001 From: ihab4real Date: Fri, 6 Jun 2025 04:46:42 +0300 Subject: [PATCH 10/13] perf(server/search): replace regex search with MongoDB text search indexes - as per coderabbit suggestion Replace case-insensitive regex searches with MongoDB's native text search capabilities to improve search performance and relevance. This change: - Utilizes existing text indexes in the models - Adds relevance scoring to sort results by match quality - Improves performance for large datasets - Simplifies search condition code with a unified baseCondition --- server/controllers/searchController.js | 37 +++++++------------------- 1 file changed, 9 insertions(+), 28 deletions(-) diff --git a/server/controllers/searchController.js b/server/controllers/searchController.js index f5a85dd..2e440fe 100644 --- a/server/controllers/searchController.js +++ b/server/controllers/searchController.js @@ -23,28 +23,9 @@ export const searchAll = asyncHandler(async (req, res, next) => { }); } - // Create text search conditions - const searchCondition = { - $or: [ - { title: { $regex: query, $options: "i" } }, - { description: { $regex: query, $options: "i" } }, - ], - user: userId, - }; - - const noteSearchCondition = { - $or: [ - { title: { $regex: query, $options: "i" } }, - { content: { $regex: query, $options: "i" } }, - ], - user: userId, - }; - - const habitSearchCondition = { - $or: [ - { name: { $regex: query, $options: "i" } }, - { description: { $regex: query, $options: "i" } }, - ], + // Create text search conditions using MongoDB text search + const baseCondition = { + $text: { $search: query }, user: userId, }; @@ -56,9 +37,9 @@ export const searchAll = asyncHandler(async (req, res, next) => { if (type === "all" || type === "tasks") { searchPromises.push( - Task.find(searchCondition) + Task.find(baseCondition) .limit(limit) - .sort({ updatedAt: -1 }) + .sort({ score: { $meta: "textScore" }, updatedAt: -1 }) .select("title description status priority dueDate updatedAt") .lean() .then((tasks) => { @@ -71,9 +52,9 @@ export const searchAll = asyncHandler(async (req, res, next) => { if (type === "all" || type === "notes") { searchPromises.push( - Note.find(noteSearchCondition) + Note.find(baseCondition) .limit(limit) - .sort({ updatedAt: -1 }) + .sort({ score: { $meta: "textScore" }, updatedAt: -1 }) .select("title content folder updatedAt") .lean() .then((notes) => { @@ -98,9 +79,9 @@ export const searchAll = asyncHandler(async (req, res, next) => { if (type === "all" || type === "habits") { searchPromises.push( - Habit.find(habitSearchCondition) + Habit.find(baseCondition) .limit(limit) - .sort({ updatedAt: -1 }) + .sort({ score: { $meta: "textScore" }, updatedAt: -1 }) .select("name description category type targetValue unit") .lean() .then((habits) => { From 91faf49f6c883df741b59c6daef4c8790f60b291 Mon Sep 17 00:00:00 2001 From: ihab4real Date: Fri, 6 Jun 2025 04:48:23 +0300 Subject: [PATCH 11/13] fix(server/search): add input validation for search API parameters - as per coderabbit suggestion Add validation for search API parameters to improve security and prevent misuse: - Limit the maximum number of results to 100 - Validate the 'type' parameter against a list of allowed values --- server/controllers/searchController.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/server/controllers/searchController.js b/server/controllers/searchController.js index 2e440fe..194797f 100644 --- a/server/controllers/searchController.js +++ b/server/controllers/searchController.js @@ -15,6 +15,19 @@ export const searchAll = asyncHandler(async (req, res, next) => { const limit = parseInt(req.query.limit) || 20; const type = req.query.type || "all"; // 'all', 'tasks', 'notes', 'habits' + // Validate inputs + if (limit > 100) { + throw errorTypes.validationError( + "Limit cannot exceed 100", + "LIMIT_EXCEEDED" + ); + } + + const validTypes = ["all", "tasks", "notes", "habits"]; + if (!validTypes.includes(type)) { + throw errorTypes.validationError("Invalid type parameter", "INVALID_TYPE"); + } + if (!query || query.trim() === "") { return res.status(200).json({ tasks: [], From f1d715c65868aa1abdf80d16fc6677678bc5003d Mon Sep 17 00:00:00 2001 From: ihab4real Date: Fri, 6 Jun 2025 04:53:21 +0300 Subject: [PATCH 12/13] fix(client/search): reset focusedIndex on new search query - as per coderabbit suggestion Reset the keyboard navigation index when starting a new search to ensure consistent user experience. This prevents highlighting stale results from previous searches and ensures that keyboard navigation always starts fresh with each new query, avoiding potential out-of-bounds errors when search results length changes. --- client/src/stores/useSearchStore.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/src/stores/useSearchStore.js b/client/src/stores/useSearchStore.js index 580aaf2..59bc39d 100644 --- a/client/src/stores/useSearchStore.js +++ b/client/src/stores/useSearchStore.js @@ -41,7 +41,12 @@ export const useSearchStore = create((set, get) => { // Actions search: (searchQuery) => { - set({ query: searchQuery, isSearching: true, error: null }); + set({ + query: searchQuery, + isSearching: true, + error: null, + focusedIndex: -1, + }); if (!searchQuery.trim()) { set({ results: null, isOpen: false, isSearching: false }); From 10e74a899fc9a365068a3f4d605797f421499a97 Mon Sep 17 00:00:00 2001 From: ihab4real Date: Fri, 6 Jun 2025 05:29:35 +0300 Subject: [PATCH 13/13] fix(client/ui): resolve keyboard navigation in search results - Fixed issue where focused items would jump or focus incorrectly due to unstable indexes generated on each render. - Implemented stable indexing with a memoized flattened results array. - Added Enter key support for navigating to focused items. - Addressed from Cursor BugBot bug report. --- client/src/components/SearchResults.jsx | 118 ++++++++++++++++-------- 1 file changed, 81 insertions(+), 37 deletions(-) diff --git a/client/src/components/SearchResults.jsx b/client/src/components/SearchResults.jsx index be92f3d..840f6c2 100644 --- a/client/src/components/SearchResults.jsx +++ b/client/src/components/SearchResults.jsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect } from "react"; +import React, { useRef, useEffect, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { FileText, @@ -23,14 +23,32 @@ export function SearchResults() { const navigate = useNavigate(); const resultsRef = useRef(null); - // Function to generate indexes for items - const createIndexGenerator = () => { - let index = -1; - return () => { - index += 1; - return index; - }; - }; + // Get flattened results for stable indexing across renders + const flattenedResults = React.useMemo(() => { + if (!results) return []; + + let items = []; + + if (selectedCategory === "all" || selectedCategory === "tasks") { + (results.tasks || []).forEach((task) => { + items.push({ type: "task", id: task._id, data: task }); + }); + } + + if (selectedCategory === "all" || selectedCategory === "notes") { + (results.notes || []).forEach((note) => { + items.push({ type: "note", id: note._id, data: note }); + }); + } + + if (selectedCategory === "all" || selectedCategory === "habits") { + (results.habits || []).forEach((habit) => { + items.push({ type: "habit", id: habit._id, data: habit }); + }); + } + + return items; + }, [results, selectedCategory]); // Close dropdown when clicking outside useEffect(() => { @@ -56,6 +74,48 @@ export function SearchResults() { } }, [focusedIndex]); + const navigateToItem = useCallback( + (type, id, item = {}) => { + closeSearch(); + switch (type) { + case "task": + navigate(`/dashboard/taskboard?taskId=${id}`); + break; + case "note": + navigate(`/dashboard/notepanel`, { + state: { + initialNoteId: id, + folderToOpen: item.folder, + }, + }); + break; + case "habit": + navigate(`/dashboard/habits?habitId=${id}`); + break; + default: + break; + } + }, + [closeSearch, navigate] + ); + + // Handle keyboard events for navigation + useEffect(() => { + const handleKeyDown = (e) => { + if ( + e.key === "Enter" && + focusedIndex >= 0 && + focusedIndex < flattenedResults.length + ) { + const selectedItem = flattenedResults[focusedIndex]; + navigateToItem(selectedItem.type, selectedItem.id, selectedItem.data); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [focusedIndex, flattenedResults, navigateToItem]); + if (!isOpen) return null; const hasResults = @@ -64,31 +124,6 @@ export function SearchResults() { (results.notes && results.notes.length > 0) || (results.habits && results.habits.length > 0)); - const navigateToItem = (type, id, item = {}) => { - closeSearch(); - switch (type) { - case "task": - navigate(`/dashboard/taskboard?taskId=${id}`); - break; - case "note": - navigate(`/dashboard/notepanel`, { - state: { - initialNoteId: id, - folderToOpen: item.folder, - }, - }); - break; - case "habit": - navigate(`/dashboard/habits?habitId=${id}`); - break; - default: - break; - } - }; - - // Create index generator for this render - const getNextIndex = createIndexGenerator(); - return (
          {results.tasks.map((task) => { - const currentIndex = getNextIndex(); + // Calculate stable index based on position + const currentIndex = flattenedResults.findIndex( + (item) => item.type === "task" && item.id === task._id + ); const isFocused = focusedIndex === currentIndex; return (
          • {results.notes.map((note) => { - const currentIndex = getNextIndex(); + // Calculate stable index based on position + const currentIndex = flattenedResults.findIndex( + (item) => item.type === "note" && item.id === note._id + ); const isFocused = focusedIndex === currentIndex; return (
            • {results.habits.map((habit) => { - const currentIndex = getNextIndex(); + // Calculate stable index based on position + const currentIndex = flattenedResults.findIndex( + (item) => item.type === "habit" && item.id === habit._id + ); const isFocused = focusedIndex === currentIndex; return (