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", diff --git a/client/src/components/DashboardHeader.jsx b/client/src/components/DashboardHeader.jsx index 8fb4d2b..2a1ffbb 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 "@/stores/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..840f6c2 --- /dev/null +++ b/client/src/components/SearchResults.jsx @@ -0,0 +1,349 @@ +import React, { useRef, useEffect, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { + FileText, + CheckSquare, + Activity, + Loader2, + AlertCircle, +} from "lucide-react"; +import { useSearchStore } from "@/stores/useSearchStore"; + +export function SearchResults() { + const { + results, + isSearching, + isOpen, + selectedCategory, + focusedIndex, + closeSearch, + setSelectedCategory, + error, + } = useSearchStore(); + const navigate = useNavigate(); + const resultsRef = useRef(null); + + // 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(() => { + 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]); + + 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 = + results && + ((results.tasks && results.tasks.length > 0) || + (results.notes && results.notes.length > 0) || + (results.habits && results.habits.length > 0)); + + 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) => { + // Calculate stable index based on position + const currentIndex = flattenedResults.findIndex( + (item) => item.type === "task" && item.id === task._id + ); + 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) => { + // Calculate stable index based on position + const currentIndex = flattenedResults.findIndex( + (item) => item.type === "note" && item.id === note._id + ); + const isFocused = focusedIndex === currentIndex; + return ( +
  • navigateToItem("note", note._id, note)} + > + +
    +
    {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) => { + // Calculate stable index based on position + const currentIndex = flattenedResults.findIndex( + (item) => item.type === "habit" && item.id === habit._id + ); + 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}`} +
    +
    +
  • + ); + })} +
+
+ )} +
+ )} +
+ ); +} diff --git a/client/src/features/Notes/components/layout/NotesPageLayout.jsx b/client/src/features/Notes/components/layout/NotesPageLayout.jsx index a7c51dc..c7918c5 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 @@ -54,7 +54,17 @@ const NotesPageLayout = () => { // For initial note loading const { data: initialNote } = useNoteQuery(location.state?.initialNoteId); - // Handle initial note loading from dashboard navigation + // 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 or search useEffect(() => { if (initialNote) { setSelectedNote(initialNote); 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; + } +}; diff --git a/client/src/stores/useSearchStore.js b/client/src/stores/useSearchStore.js new file mode 100644 index 0000000..59bc39d --- /dev/null +++ b/client/src/stores/useSearchStore.js @@ -0,0 +1,98 @@ +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, + focusedIndex: -1, + }); + + 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 }); + } + }, + }; +}); 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..194797f --- /dev/null +++ b/server/controllers/searchController.js @@ -0,0 +1,111 @@ +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' + + // 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: [], + notes: [], + habits: [], + }); + } + + // Create text search conditions using MongoDB text search + const baseCondition = { + $text: { $search: query }, + 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(baseCondition) + .limit(limit) + .sort({ score: { $meta: "textScore" }, 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(baseCondition) + .limit(limit) + .sort({ score: { $meta: "textScore" }, 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(baseCondition) + .limit(limit) + .sort({ score: { $meta: "textScore" }, 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;