From 02b493d61f8cdff6d2584d7cbe8836bfeddde8d0 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 22 Oct 2025 22:00:45 +0300 Subject: [PATCH] Universal search (#412) * Global search scaffold * Add search preview text * Add generic fallback for preview text in search * fixup! Add generic fallback for preview text in search * Add more tweaks * fixup! Add more tweaks * Fix an issue with criteria * fixup! Fix an issue with criteria * fixup! fixup! Fix an issue with criteria * fixup! fixup! fixup! Fix an issue with criteria * Fix an issue with priority filter * fixup! Fix an issue with priority filter * Add sortable pins * fixup! Add sortable pins * Make options collapsed by default * Tweak UI * Add tests * Add translations * Add more translations * fixup! Add more translations * Add minor tweaks --- .sequelizerc | 8 + backend/app.js | 2 + .../migrations/20251014000001-create-views.js | 78 ++ ...014202005-add-sidebar-settings-to-users.js | 26 + backend/models/index.js | 6 + backend/models/user.js | 7 + backend/models/view.js | 74 ++ backend/routes/search.js | 341 +++++++ backend/routes/users.js | 35 + backend/routes/views.js | 157 ++++ backend/tests/integration/search.test.js | 856 ++++++++++++++++++ .../services/parentChildRelationship.test.js | 4 +- e2e/bin/run-e2e.sh | 2 +- frontend/App.tsx | 7 + frontend/Layout.tsx | 24 +- frontend/components/Inbox/InboxItems.tsx | 30 +- frontend/components/Navbar.tsx | 50 +- frontend/components/Sidebar.tsx | 6 + frontend/components/Sidebar/SidebarViews.tsx | 316 +++++++ frontend/components/Tasks.tsx | 43 +- .../UniversalSearch/FilterBadge.tsx | 53 ++ .../UniversalSearch/SaveViewModal.tsx | 152 ++++ .../components/UniversalSearch/SearchMenu.tsx | 512 +++++++++++ .../UniversalSearch/SearchResults.tsx | 212 +++++ .../UniversalSearch/UniversalSearch.tsx | 109 +++ frontend/components/ViewDetail.tsx | 649 +++++++++++++ frontend/components/Views.tsx | 260 ++++++ frontend/utils/searchService.ts | 64 ++ package-lock.json | 57 +- package.json | 3 + public/index.html | 4 + public/locales/ar/translation.json | 65 +- public/locales/bg/translation.json | 65 +- public/locales/da/translation.json | 65 +- public/locales/de/translation.json | 65 +- public/locales/el/translation.json | 69 +- public/locales/en/translation.json | 79 +- public/locales/es/translation.json | 65 +- public/locales/fi/translation.json | 65 +- public/locales/fr/translation.json | 65 +- public/locales/id/translation.json | 65 +- public/locales/it/translation.json | 65 +- public/locales/jp/translation.json | 65 +- public/locales/ko/translation.json | 65 +- public/locales/nl/translation.json | 65 +- public/locales/no/translation.json | 65 +- public/locales/pl/translation.json | 65 +- public/locales/pt/translation.json | 65 +- public/locales/ro/translation.json | 65 +- public/locales/ru/translation.json | 65 +- public/locales/sl/translation.json | 65 +- public/locales/sv/translation.json | 65 +- public/locales/tr/translation.json | 65 +- public/locales/ua/translation.json | 65 +- public/locales/vi/translation.json | 65 +- public/locales/zh/translation.json | 65 +- 56 files changed, 5630 insertions(+), 160 deletions(-) create mode 100644 .sequelizerc create mode 100644 backend/migrations/20251014000001-create-views.js create mode 100644 backend/migrations/20251014202005-add-sidebar-settings-to-users.js create mode 100644 backend/models/view.js create mode 100644 backend/routes/search.js create mode 100644 backend/routes/views.js create mode 100644 backend/tests/integration/search.test.js create mode 100644 frontend/components/Sidebar/SidebarViews.tsx create mode 100644 frontend/components/UniversalSearch/FilterBadge.tsx create mode 100644 frontend/components/UniversalSearch/SaveViewModal.tsx create mode 100644 frontend/components/UniversalSearch/SearchMenu.tsx create mode 100644 frontend/components/UniversalSearch/SearchResults.tsx create mode 100644 frontend/components/UniversalSearch/UniversalSearch.tsx create mode 100644 frontend/components/ViewDetail.tsx create mode 100644 frontend/components/Views.tsx create mode 100644 frontend/utils/searchService.ts diff --git a/.sequelizerc b/.sequelizerc new file mode 100644 index 0000000..e78c98e --- /dev/null +++ b/.sequelizerc @@ -0,0 +1,8 @@ +const path = require('path'); + +module.exports = { + 'config': path.resolve('backend', 'config', 'database.js'), + 'models-path': path.resolve('backend', 'models'), + 'seeders-path': path.resolve('backend', 'seeders'), + 'migrations-path': path.resolve('migrations') +}; diff --git a/backend/app.js b/backend/app.js index 193d220..a471cc0 100644 --- a/backend/app.js +++ b/backend/app.js @@ -117,6 +117,8 @@ app.use('/api', requireAuth, require('./routes/url')); app.use('/api', requireAuth, require('./routes/telegram')); app.use('/api', requireAuth, require('./routes/quotes')); app.use('/api', requireAuth, require('./routes/task-events')); +app.use('/api/search', requireAuth, require('./routes/search')); +app.use('/api/views', requireAuth, require('./routes/views')); // SPA fallback app.get('*', (req, res) => { diff --git a/backend/migrations/20251014000001-create-views.js b/backend/migrations/20251014000001-create-views.js new file mode 100644 index 0000000..fdac4e5 --- /dev/null +++ b/backend/migrations/20251014000001-create-views.js @@ -0,0 +1,78 @@ +'use strict'; + +const { safeCreateTable, safeAddIndex } = require('../utils/migration-utils'); + +module.exports = { + up: async (queryInterface, Sequelize) => { + await safeCreateTable(queryInterface, 'views', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + uid: { + type: Sequelize.STRING, + allowNull: false, + unique: true, + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + user_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + onDelete: 'CASCADE', + }, + search_query: { + type: Sequelize.STRING, + allowNull: true, + }, + filters: { + type: Sequelize.TEXT, + allowNull: true, + comment: 'JSON array of entity type filters', + }, + priority: { + type: Sequelize.STRING, + allowNull: true, + }, + due: { + type: Sequelize.STRING, + allowNull: true, + }, + is_pinned: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + }); + + await safeAddIndex(queryInterface, 'views', ['user_id'], { + name: 'views_user_id_index', + }); + + await safeAddIndex(queryInterface, 'views', ['user_id', 'is_pinned'], { + name: 'views_user_pinned_index', + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable('views'); + }, +}; diff --git a/backend/migrations/20251014202005-add-sidebar-settings-to-users.js b/backend/migrations/20251014202005-add-sidebar-settings-to-users.js new file mode 100644 index 0000000..2fefc16 --- /dev/null +++ b/backend/migrations/20251014202005-add-sidebar-settings-to-users.js @@ -0,0 +1,26 @@ +'use strict'; + +const { + safeAddColumns, + safeRemoveColumn, +} = require('../utils/migration-utils'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await safeAddColumns(queryInterface, 'users', [ + { + name: 'sidebar_settings', + definition: { + type: Sequelize.JSON, + allowNull: true, + defaultValue: JSON.stringify({ pinnedViewsOrder: [] }), + }, + }, + ]); + }, + + async down(queryInterface) { + await safeRemoveColumn(queryInterface, 'users', 'sidebar_settings'); + }, +}; diff --git a/backend/models/index.js b/backend/models/index.js index 3f8adcb..d989da8 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -32,6 +32,7 @@ const TaskEvent = require('./task_event')(sequelize); const Role = require('./role')(sequelize); const Action = require('./action')(sequelize); const Permission = require('./permission')(sequelize); +const View = require('./view')(sequelize); // Define associations User.hasMany(Area, { foreignKey: 'user_id' }); @@ -134,6 +135,10 @@ Permission.belongsTo(User, { Action.belongsTo(User, { foreignKey: 'actor_user_id', as: 'Actor' }); Action.belongsTo(User, { foreignKey: 'target_user_id', as: 'Target' }); +// View associations +User.hasMany(View, { foreignKey: 'user_id' }); +View.belongsTo(User, { foreignKey: 'user_id' }); + module.exports = { sequelize, User, @@ -147,4 +152,5 @@ module.exports = { Role, Action, Permission, + View, }; diff --git a/backend/models/user.js b/backend/models/user.js index a5af6f6..b1ede51 100644 --- a/backend/models/user.js +++ b/backend/models/user.js @@ -158,6 +158,13 @@ module.exports = (sequelize) => { showDailyQuote: true, }, }, + sidebar_settings: { + type: DataTypes.JSON, + allowNull: true, + defaultValue: { + pinnedViewsOrder: [], + }, + }, }, { tableName: 'users', diff --git a/backend/models/view.js b/backend/models/view.js new file mode 100644 index 0000000..03f04b7 --- /dev/null +++ b/backend/models/view.js @@ -0,0 +1,74 @@ +const { DataTypes } = require('sequelize'); +const { uid } = require('../utils/uid'); + +module.exports = (sequelize) => { + const View = sequelize.define( + 'View', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + uid: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + defaultValue: uid, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + }, + search_query: { + type: DataTypes.STRING, + allowNull: true, + }, + filters: { + type: DataTypes.TEXT, + allowNull: true, + get() { + const rawValue = this.getDataValue('filters'); + return rawValue ? JSON.parse(rawValue) : []; + }, + set(value) { + this.setDataValue('filters', JSON.stringify(value)); + }, + }, + priority: { + type: DataTypes.STRING, + allowNull: true, + }, + due: { + type: DataTypes.STRING, + allowNull: true, + }, + is_pinned: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + }, + { + tableName: 'views', + indexes: [ + { + fields: ['user_id'], + }, + { + fields: ['user_id', 'is_pinned'], + }, + ], + } + ); + + return View; +}; diff --git a/backend/routes/search.js b/backend/routes/search.js new file mode 100644 index 0000000..f511b9f --- /dev/null +++ b/backend/routes/search.js @@ -0,0 +1,341 @@ +const express = require('express'); +const { Task, Tag, Project, Area, Note, sequelize } = require('../models'); +const { Op } = require('sequelize'); +const moment = require('moment-timezone'); +const router = express.Router(); + +// Helper function to convert priority string to integer +const priorityToInt = (priorityStr) => { + const priorityMap = { + low: 0, + medium: 1, + high: 2, + }; + return priorityMap[priorityStr] !== undefined + ? priorityMap[priorityStr] + : null; +}; + +/** + * Universal search endpoint + * GET /api/search + * Query params: + * - q: search query string + * - filters: comma-separated list of entity types (Task,Project,Area,Note,Tag) + * - priority: filter by priority (low,medium,high) + * - due: filter by due date (today,tomorrow,next_week,next_month) + * - tags: comma-separated list of tag names to filter by + */ +router.get('/', async (req, res) => { + try { + const userId = req.currentUser?.id; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const { q: query, filters, priority, due, tags: tagsParam } = req.query; + const searchQuery = query ? query.trim() : ''; + const filterTypes = filters + ? filters.split(',').map((f) => f.trim()) + : ['Task', 'Project', 'Area', 'Note', 'Tag']; + const tagNames = tagsParam + ? tagsParam.split(',').map((t) => t.trim()) + : []; + + const results = []; + + // If tags are specified, find their IDs first + let tagIds = []; + if (tagNames.length > 0) { + const tags = await Tag.findAll({ + where: { + user_id: userId, + name: { [Op.in]: tagNames }, + }, + attributes: ['id'], + }); + tagIds = tags.map((tag) => tag.id); + + // If no matching tags found, return empty results + if (tagIds.length === 0) { + return res.json({ results: [] }); + } + } + + // Calculate due date range based on filter + let dueDateCondition = null; + if (due) { + const now = moment().startOf('day'); + let startDate, endDate; + + switch (due) { + case 'today': + startDate = now.clone(); + endDate = now.clone().endOf('day'); + break; + case 'tomorrow': + startDate = now.clone().add(1, 'day'); + endDate = now.clone().add(1, 'day').endOf('day'); + break; + case 'next_week': + startDate = now.clone(); + endDate = now.clone().add(7, 'days').endOf('day'); + break; + case 'next_month': + startDate = now.clone(); + endDate = now.clone().add(1, 'month').endOf('day'); + break; + } + + if (startDate && endDate) { + dueDateCondition = { + due_date: { + [Op.between]: [ + startDate.toISOString(), + endDate.toISOString(), + ], + }, + }; + } + } + + // Search Tasks + if (filterTypes.includes('Task')) { + const taskConditions = { + user_id: userId, + }; + + // Add search query filter if specified + if (searchQuery) { + taskConditions[Op.or] = [ + { name: { [Op.like]: `%${searchQuery}%` } }, + { note: { [Op.like]: `%${searchQuery}%` } }, + ]; + } + + // Add priority filter if specified (convert string to integer) + if (priority) { + const priorityInt = priorityToInt(priority); + if (priorityInt !== null) { + taskConditions.priority = priorityInt; + } + } + + // Add due date filter if specified + if (dueDateCondition) { + Object.assign(taskConditions, dueDateCondition); + } + + const taskInclude = [ + { + model: Project, + attributes: ['id', 'uid', 'name'], + }, + ]; + + // Add tag filter if specified + if (tagIds.length > 0) { + taskInclude.push({ + model: Tag, + where: { + id: { [Op.in]: tagIds }, + }, + through: { attributes: [] }, + attributes: [], + required: true, + }); + } + + const tasks = await Task.findAll({ + where: taskConditions, + include: taskInclude, + limit: 20, + order: [['updated_at', 'DESC']], + }); + + results.push( + ...tasks.map((task) => ({ + type: 'Task', + id: task.id, + uid: task.uid, + name: task.name, + description: task.note, + priority: task.priority, + status: task.status, + })) + ); + } + + // Search Projects + if (filterTypes.includes('Project')) { + const projectConditions = { + user_id: userId, + }; + + if (searchQuery) { + projectConditions[Op.or] = [ + { name: { [Op.like]: `%${searchQuery}%` } }, + { description: { [Op.like]: `%${searchQuery}%` } }, + ]; + } + + if (priority) { + projectConditions.priority = priority; + } + + // Add due date filter if specified (projects use due_date_at field) + if (dueDateCondition) { + const projectDueCondition = { + due_date_at: dueDateCondition.due_date, + }; + Object.assign(projectConditions, projectDueCondition); + } + + const projectInclude = []; + + // Add tag filter if specified + if (tagIds.length > 0) { + projectInclude.push({ + model: Tag, + where: { + id: { [Op.in]: tagIds }, + }, + through: { attributes: [] }, + attributes: [], + required: true, + }); + } + + const projects = await Project.findAll({ + where: projectConditions, + include: projectInclude.length > 0 ? projectInclude : undefined, + limit: 20, + order: [['updated_at', 'DESC']], + }); + + results.push( + ...projects.map((project) => ({ + type: 'Project', + id: project.id, + uid: project.uid, + name: project.name, + description: project.description, + priority: project.priority, + status: project.state, + })) + ); + } + + // Search Areas + if (filterTypes.includes('Area')) { + const areaConditions = { + user_id: userId, + }; + + if (searchQuery) { + areaConditions[Op.or] = [ + { name: { [Op.like]: `%${searchQuery}%` } }, + { description: { [Op.like]: `%${searchQuery}%` } }, + ]; + } + + const areas = await Area.findAll({ + where: areaConditions, + limit: 20, + order: [['updated_at', 'DESC']], + }); + + results.push( + ...areas.map((area) => ({ + type: 'Area', + id: area.id, + uid: area.uid, + name: area.name, + description: area.description, + })) + ); + } + + // Search Notes + if (filterTypes.includes('Note')) { + const noteConditions = { + user_id: userId, + }; + + if (searchQuery) { + noteConditions[Op.or] = [ + { title: { [Op.like]: `%${searchQuery}%` } }, + { content: { [Op.like]: `%${searchQuery}%` } }, + ]; + } + + const noteInclude = []; + + // Add tag filter if specified + if (tagIds.length > 0) { + noteInclude.push({ + model: Tag, + where: { + id: { [Op.in]: tagIds }, + }, + through: { attributes: [] }, + attributes: [], + required: true, + }); + } + + const notes = await Note.findAll({ + where: noteConditions, + include: noteInclude.length > 0 ? noteInclude : undefined, + limit: 20, + order: [['updated_at', 'DESC']], + }); + + results.push( + ...notes.map((note) => ({ + type: 'Note', + id: note.id, + uid: note.uid, + name: note.title, + title: note.title, + description: note.content + ? note.content.substring(0, 100) + : '', + })) + ); + } + + // Search Tags + if (filterTypes.includes('Tag')) { + const tagConditions = { + user_id: userId, + }; + + if (searchQuery) { + tagConditions.name = { [Op.like]: `%${searchQuery}%` }; + } + + const tags = await Tag.findAll({ + where: tagConditions, + limit: 20, + order: [['name', 'ASC']], + }); + + results.push( + ...tags.map((tag) => ({ + type: 'Tag', + id: tag.id, + uid: tag.uid, + name: tag.name, + })) + ); + } + + res.json({ results }); + } catch (error) { + console.error('Search error:', error); + res.status(500).json({ error: 'Search failed' }); + } +}); + +module.exports = router; diff --git a/backend/routes/users.js b/backend/routes/users.js index 4c0e17f..7b152d0 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -67,6 +67,7 @@ router.get('/profile', async (req, res) => { 'auto_suggest_next_actions_enabled', 'pomodoro_enabled', 'today_settings', + 'sidebar_settings', 'productivity_assistant_enabled', 'next_task_suggestion_enabled', ], @@ -481,4 +482,38 @@ router.put('/profile/today-settings', async (req, res) => { } }); +// PUT /api/profile/sidebar-settings +router.put('/profile/sidebar-settings', async (req, res) => { + try { + const user = await User.findByPk(req.session.userId); + if (!user) { + return res.status(404).json({ error: 'User not found.' }); + } + + const { pinnedViewsOrder } = req.body; + + if (!Array.isArray(pinnedViewsOrder)) { + return res.status(400).json({ + error: 'pinnedViewsOrder must be an array', + }); + } + + const sidebarSettings = { + pinnedViewsOrder, + }; + + await user.update({ + sidebar_settings: sidebarSettings, + }); + + res.json({ + success: true, + sidebar_settings: sidebarSettings, + }); + } catch (error) { + logError('Error updating sidebar settings:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + module.exports = router; diff --git a/backend/routes/views.js b/backend/routes/views.js new file mode 100644 index 0000000..6be6426 --- /dev/null +++ b/backend/routes/views.js @@ -0,0 +1,157 @@ +const express = require('express'); +const { View } = require('../models'); +const { Op } = require('sequelize'); +const { logError } = require('../services/logService'); +const router = express.Router(); + +// GET /api/views - Get all views for the current user +router.get('/', async (req, res) => { + try { + const views = await View.findAll({ + where: { user_id: req.currentUser.id }, + order: [ + ['is_pinned', 'DESC'], + ['created_at', 'DESC'], + ], + }); + res.json(views); + } catch (error) { + logError('Error fetching views:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// GET /api/views/pinned - Get pinned views for the current user +router.get('/pinned', async (req, res) => { + try { + const views = await View.findAll({ + where: { + user_id: req.currentUser.id, + is_pinned: true, + }, + order: [['created_at', 'DESC']], + }); + res.json(views); + } catch (error) { + logError('Error fetching pinned views:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// GET /api/views/:identifier - Get a specific view by uid +router.get('/:identifier', async (req, res) => { + try { + const identifier = decodeURIComponent(req.params.identifier); + + const view = await View.findOne({ + where: { + uid: identifier, + user_id: req.currentUser.id, + }, + }); + + if (!view) { + return res.status(404).json({ error: 'View not found' }); + } + + res.json(view); + } catch (error) { + logError('Error fetching view:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// POST /api/views - Create a new view +router.post('/', async (req, res) => { + try { + const { name, search_query, filters, priority, due } = req.body; + + if (!name || name.trim() === '') { + return res.status(400).json({ error: 'View name is required' }); + } + + const view = await View.create({ + name: name.trim(), + user_id: req.currentUser.id, + search_query: search_query || null, + filters: filters || [], + priority: priority || null, + due: due || null, + is_pinned: false, + }); + + res.status(201).json(view); + } catch (error) { + logError('Error creating view:', error); + res.status(400).json({ + error: 'There was a problem creating the view.', + }); + } +}); + +// PATCH /api/views/:identifier - Update a view +router.patch('/:identifier', async (req, res) => { + try { + const identifier = decodeURIComponent(req.params.identifier); + + const view = await View.findOne({ + where: { + uid: identifier, + user_id: req.currentUser.id, + }, + }); + + if (!view) { + return res.status(404).json({ error: 'View not found' }); + } + + const { name, search_query, filters, priority, due, is_pinned } = + req.body; + + const updates = {}; + if (name !== undefined) updates.name = name.trim(); + if (search_query !== undefined) updates.search_query = search_query; + if (filters !== undefined) updates.filters = filters; + if (priority !== undefined) updates.priority = priority; + if (due !== undefined) updates.due = due; + if (is_pinned !== undefined) updates.is_pinned = is_pinned; + + await view.update(updates); + + res.json(view); + } catch (error) { + logError('Error updating view:', error); + res.status(400).json({ + error: 'There was a problem updating the view.', + }); + } +}); + +// DELETE /api/views/:identifier - Delete a view +router.delete('/:identifier', async (req, res) => { + try { + const identifier = decodeURIComponent(req.params.identifier); + + const view = await View.findOne({ + where: { + uid: identifier, + user_id: req.currentUser.id, + }, + }); + + if (!view) { + return res.status(404).json({ error: 'View not found' }); + } + + await view.destroy(); + + res.json({ message: 'View successfully deleted' }); + } catch (error) { + logError('Error deleting view:', error); + res.status(400).json({ + error: 'There was a problem deleting the view.', + }); + } +}); + +module.exports = router; diff --git a/backend/tests/integration/search.test.js b/backend/tests/integration/search.test.js new file mode 100644 index 0000000..d53b610 --- /dev/null +++ b/backend/tests/integration/search.test.js @@ -0,0 +1,856 @@ +const request = require('supertest'); +const app = require('../../app'); +const { Task, Project, Area, Note, Tag, User } = require('../../models'); +const { createTestUser } = require('../helpers/testUtils'); +const moment = require('moment-timezone'); + +describe('Universal Search Routes', () => { + let user, agent; + + beforeEach(async () => { + user = await createTestUser({ + email: 'search-test@example.com', + }); + + // Create authenticated agent + agent = request.agent(app); + await agent.post('/api/login').send({ + email: 'search-test@example.com', + password: 'password123', + }); + }); + + describe('GET /api/search', () => { + describe('Authentication', () => { + it('should require authentication', async () => { + const response = await request(app).get('/api/search'); + expect(response.status).toBe(401); + expect(response.body.error).toBe('Authentication required'); + }); + }); + + describe('Basic Search', () => { + beforeEach(async () => { + // Create test data + await Task.create({ + user_id: user.id, + name: 'Buy groceries', + note: 'Milk, eggs, bread', + priority: 1, + status: 0, + }); + + await Task.create({ + user_id: user.id, + name: 'Call dentist', + note: 'Schedule appointment', + priority: 2, + status: 0, + }); + + await Project.create({ + user_id: user.id, + name: 'Website redesign', + description: 'Redesign company website', + state: 'active', + }); + + await Note.create({ + user_id: user.id, + title: 'Meeting notes', + content: 'Discussed project timeline', + }); + }); + + it('should search across all entity types by default', async () => { + const response = await agent + .get('/api/search') + .query({ q: '' }); + + expect(response.status).toBe(200); + expect(response.body.results).toBeDefined(); + expect(response.body.results.length).toBeGreaterThan(0); + + const types = new Set(response.body.results.map((r) => r.type)); + expect(types.has('Task')).toBe(true); + expect(types.has('Project')).toBe(true); + expect(types.has('Note')).toBe(true); + }); + + it('should search tasks by name', async () => { + const response = await agent.get('/api/search').query({ + q: 'groceries', + }); + + expect(response.status).toBe(200); + const tasks = response.body.results.filter( + (r) => r.type === 'Task' + ); + expect(tasks.length).toBeGreaterThanOrEqual(1); + expect(tasks[0].name).toContain('groceries'); + }); + + it('should search tasks by note content', async () => { + const response = await agent.get('/api/search').query({ + q: 'eggs', + }); + + expect(response.status).toBe(200); + const tasks = response.body.results.filter( + (r) => r.type === 'Task' + ); + expect(tasks.length).toBeGreaterThanOrEqual(1); + }); + + it('should search projects by name', async () => { + const response = await agent.get('/api/search').query({ + q: 'Website', + }); + + expect(response.status).toBe(200); + const projects = response.body.results.filter( + (r) => r.type === 'Project' + ); + expect(projects.length).toBeGreaterThanOrEqual(1); + expect(projects[0].name).toContain('Website'); + }); + + it('should search notes by title', async () => { + const response = await agent.get('/api/search').query({ + q: 'Meeting', + }); + + expect(response.status).toBe(200); + const notes = response.body.results.filter( + (r) => r.type === 'Note' + ); + expect(notes.length).toBeGreaterThanOrEqual(1); + expect(notes[0].title).toContain('Meeting'); + }); + + it('should be case-insensitive', async () => { + const response = await agent.get('/api/search').query({ + q: 'GROCERIES', + }); + + expect(response.status).toBe(200); + const tasks = response.body.results.filter( + (r) => r.type === 'Task' + ); + expect(tasks.length).toBeGreaterThanOrEqual(1); + }); + + it('should handle empty search query', async () => { + const response = await agent + .get('/api/search') + .query({ q: '' }); + + expect(response.status).toBe(200); + expect(response.body.results).toBeDefined(); + expect(Array.isArray(response.body.results)).toBe(true); + }); + }); + + describe('Filter by Entity Type', () => { + beforeEach(async () => { + await Task.create({ + user_id: user.id, + name: 'Test task', + status: 0, + }); + + await Project.create({ + user_id: user.id, + name: 'Test project', + state: 'active', + }); + + await Note.create({ + user_id: user.id, + title: 'Test note', + content: 'Content', + }); + + await Area.create({ + user_id: user.id, + name: 'Test area', + }); + + await Tag.create({ + user_id: user.id, + name: 'test-tag', + }); + }); + + it('should filter by Task only', async () => { + const response = await agent.get('/api/search').query({ + q: 'Test', + filters: 'Task', + }); + + expect(response.status).toBe(200); + const types = new Set(response.body.results.map((r) => r.type)); + expect(types.has('Task')).toBe(true); + expect(types.has('Project')).toBe(false); + expect(types.has('Note')).toBe(false); + }); + + it('should filter by multiple types', async () => { + const response = await agent.get('/api/search').query({ + q: 'Test', + filters: 'Task,Project', + }); + + expect(response.status).toBe(200); + const types = new Set(response.body.results.map((r) => r.type)); + expect(types.has('Task')).toBe(true); + expect(types.has('Project')).toBe(true); + expect(types.has('Note')).toBe(false); + expect(types.has('Area')).toBe(false); + }); + + it('should filter by Note only', async () => { + const response = await agent.get('/api/search').query({ + q: 'Test', + filters: 'Note', + }); + + expect(response.status).toBe(200); + const types = new Set(response.body.results.map((r) => r.type)); + expect(types.has('Note')).toBe(true); + expect(types.has('Task')).toBe(false); + }); + + it('should filter by Area only', async () => { + const response = await agent.get('/api/search').query({ + q: 'Test', + filters: 'Area', + }); + + expect(response.status).toBe(200); + const types = new Set(response.body.results.map((r) => r.type)); + expect(types.has('Area')).toBe(true); + expect(types.has('Task')).toBe(false); + }); + + it('should filter by Tag only', async () => { + const response = await agent.get('/api/search').query({ + q: 'test', + filters: 'Tag', + }); + + expect(response.status).toBe(200); + const types = new Set(response.body.results.map((r) => r.type)); + expect(types.has('Tag')).toBe(true); + expect(types.has('Task')).toBe(false); + }); + }); + + describe('Filter by Priority', () => { + beforeEach(async () => { + await Task.create({ + user_id: user.id, + name: 'Low priority task', + priority: 0, + status: 0, + }); + + await Task.create({ + user_id: user.id, + name: 'Medium priority task', + priority: 1, + status: 0, + }); + + await Task.create({ + user_id: user.id, + name: 'High priority task', + priority: 2, + status: 0, + }); + + await Project.create({ + user_id: user.id, + name: 'High priority project', + priority: 'high', + state: 'active', + }); + }); + + it('should filter tasks by low priority', async () => { + const response = await agent.get('/api/search').query({ + priority: 'low', + filters: 'Task', + }); + + expect(response.status).toBe(200); + const tasks = response.body.results.filter( + (r) => r.type === 'Task' + ); + expect(tasks.length).toBeGreaterThanOrEqual(1); + expect(tasks.every((t) => t.priority === 0)).toBe(true); + }); + + it('should filter tasks by medium priority', async () => { + const response = await agent.get('/api/search').query({ + priority: 'medium', + filters: 'Task', + }); + + expect(response.status).toBe(200); + const tasks = response.body.results.filter( + (r) => r.type === 'Task' + ); + expect(tasks.length).toBeGreaterThanOrEqual(1); + expect(tasks.every((t) => t.priority === 1)).toBe(true); + }); + + it('should filter tasks by high priority', async () => { + const response = await agent.get('/api/search').query({ + priority: 'high', + filters: 'Task', + }); + + expect(response.status).toBe(200); + const tasks = response.body.results.filter( + (r) => r.type === 'Task' + ); + expect(tasks.length).toBeGreaterThanOrEqual(1); + expect(tasks.every((t) => t.priority === 2)).toBe(true); + }); + + it('should filter projects by priority', async () => { + const response = await agent.get('/api/search').query({ + priority: 'high', + filters: 'Project', + }); + + expect(response.status).toBe(200); + const projects = response.body.results.filter( + (r) => r.type === 'Project' + ); + expect(projects.length).toBeGreaterThanOrEqual(1); + expect(projects.every((p) => p.priority === 'high')).toBe(true); + }); + }); + + describe('Filter by Due Date', () => { + beforeEach(async () => { + const now = moment(); + + // Task due today + await Task.create({ + user_id: user.id, + name: 'Task due today', + due_date: now.format('YYYY-MM-DD HH:mm:ss'), + status: 0, + }); + + // Task due tomorrow + await Task.create({ + user_id: user.id, + name: 'Task due tomorrow', + due_date: now + .clone() + .add(1, 'day') + .format('YYYY-MM-DD HH:mm:ss'), + status: 0, + }); + + // Task due next week + await Task.create({ + user_id: user.id, + name: 'Task due next week', + due_date: now + .clone() + .add(5, 'days') + .format('YYYY-MM-DD HH:mm:ss'), + status: 0, + }); + + // Task due next month + await Task.create({ + user_id: user.id, + name: 'Task due next month', + due_date: now + .clone() + .add(20, 'days') + .format('YYYY-MM-DD HH:mm:ss'), + status: 0, + }); + }); + + it('should filter tasks due today', async () => { + const response = await agent.get('/api/search').query({ + due: 'today', + filters: 'Task', + }); + + expect(response.status).toBe(200); + const tasks = response.body.results.filter( + (r) => r.type === 'Task' + ); + expect(tasks.length).toBeGreaterThanOrEqual(1); + expect(tasks.some((t) => t.name === 'Task due today')).toBe( + true + ); + }); + + it('should filter tasks due tomorrow', async () => { + const response = await agent.get('/api/search').query({ + due: 'tomorrow', + filters: 'Task', + }); + + expect(response.status).toBe(200); + const tasks = response.body.results.filter( + (r) => r.type === 'Task' + ); + expect(tasks.length).toBeGreaterThanOrEqual(1); + expect(tasks.some((t) => t.name === 'Task due tomorrow')).toBe( + true + ); + }); + + it('should filter tasks due next week', async () => { + const response = await agent.get('/api/search').query({ + due: 'next_week', + filters: 'Task', + }); + + expect(response.status).toBe(200); + const tasks = response.body.results.filter( + (r) => r.type === 'Task' + ); + expect(tasks.length).toBeGreaterThanOrEqual(2); // Should include today, tomorrow, and next week + }); + + it('should filter tasks due next month', async () => { + const response = await agent.get('/api/search').query({ + due: 'next_month', + filters: 'Task', + }); + + expect(response.status).toBe(200); + const tasks = response.body.results.filter( + (r) => r.type === 'Task' + ); + expect(tasks.length).toBeGreaterThanOrEqual(3); // Should include all tasks due within 30 days + }); + }); + + describe('Filter by Tags', () => { + let workTag, personalTag, urgentTag; + + beforeEach(async () => { + // Create tags + workTag = await Tag.create({ + user_id: user.id, + name: 'work', + }); + + personalTag = await Tag.create({ + user_id: user.id, + name: 'personal', + }); + + urgentTag = await Tag.create({ + user_id: user.id, + name: 'urgent', + }); + + // Create tasks with tags + const task1 = await Task.create({ + user_id: user.id, + name: 'Work task', + status: 0, + }); + await task1.addTag(workTag); + + const task2 = await Task.create({ + user_id: user.id, + name: 'Personal task', + status: 0, + }); + await task2.addTag(personalTag); + + const task3 = await Task.create({ + user_id: user.id, + name: 'Urgent work task', + status: 0, + }); + await task3.addTag(workTag); + await task3.addTag(urgentTag); + + // Create project with tag + const project1 = await Project.create({ + user_id: user.id, + name: 'Work project', + state: 'active', + }); + await project1.addTag(workTag); + + // Create note with tag + const note1 = await Note.create({ + user_id: user.id, + title: 'Personal note', + content: 'Some content', + }); + await note1.addTag(personalTag); + }); + + it('should filter by single tag', async () => { + const response = await agent.get('/api/search').query({ + tags: 'work', + filters: 'Task', + }); + + expect(response.status).toBe(200); + const tasks = response.body.results.filter( + (r) => r.type === 'Task' + ); + expect(tasks.length).toBe(2); // Work task and Urgent work task + }); + + it('should filter by multiple tags', async () => { + const response = await agent.get('/api/search').query({ + tags: 'work,urgent', + filters: 'Task', + }); + + expect(response.status).toBe(200); + const tasks = response.body.results.filter( + (r) => r.type === 'Task' + ); + // Should return tasks that have either work OR urgent tag + expect(tasks.length).toBeGreaterThanOrEqual(1); + }); + + it('should filter projects by tag', async () => { + const response = await agent.get('/api/search').query({ + tags: 'work', + filters: 'Project', + }); + + expect(response.status).toBe(200); + const projects = response.body.results.filter( + (r) => r.type === 'Project' + ); + expect(projects.length).toBe(1); + expect(projects[0].name).toBe('Work project'); + }); + + it('should filter notes by tag', async () => { + const response = await agent.get('/api/search').query({ + tags: 'personal', + filters: 'Note', + }); + + expect(response.status).toBe(200); + const notes = response.body.results.filter( + (r) => r.type === 'Note' + ); + expect(notes.length).toBe(1); + expect(notes[0].title).toBe('Personal note'); + }); + + it('should return empty results for non-existent tag', async () => { + const response = await agent.get('/api/search').query({ + tags: 'nonexistent', + }); + + expect(response.status).toBe(200); + expect(response.body.results).toEqual([]); + }); + }); + + describe('Combined Filters', () => { + beforeEach(async () => { + const now = moment(); + const workTag = await Tag.create({ + user_id: user.id, + name: 'work', + }); + + // High priority work task due today with tag + const task1 = await Task.create({ + user_id: user.id, + name: 'Important work meeting', + priority: 2, + due_date: now.format('YYYY-MM-DD HH:mm:ss'), + status: 0, + }); + await task1.addTag(workTag); + + // Low priority personal task + await Task.create({ + user_id: user.id, + name: 'Personal errand', + priority: 0, + status: 0, + }); + + // Medium priority work task (no due date) + const task3 = await Task.create({ + user_id: user.id, + name: 'Work review', + priority: 1, + status: 0, + }); + await task3.addTag(workTag); + }); + + it('should combine search query and priority filter', async () => { + const response = await agent.get('/api/search').query({ + q: 'work', + priority: 'high', + filters: 'Task', + }); + + expect(response.status).toBe(200); + const tasks = response.body.results.filter( + (r) => r.type === 'Task' + ); + expect(tasks.length).toBeGreaterThanOrEqual(1); + expect(tasks.every((t) => t.priority === 2)).toBe(true); + }); + + it('should combine priority, due date, and tag filters', async () => { + const response = await agent.get('/api/search').query({ + priority: 'high', + due: 'today', + tags: 'work', + filters: 'Task', + }); + + expect(response.status).toBe(200); + const tasks = response.body.results.filter( + (r) => r.type === 'Task' + ); + expect(tasks.length).toBeGreaterThanOrEqual(1); + expect(tasks[0].name).toBe('Important work meeting'); + }); + + it('should combine all filters with search query', async () => { + const response = await agent.get('/api/search').query({ + q: 'meeting', + priority: 'high', + due: 'today', + tags: 'work', + filters: 'Task', + }); + + expect(response.status).toBe(200); + const tasks = response.body.results.filter( + (r) => r.type === 'Task' + ); + expect(tasks.length).toBe(1); + expect(tasks[0].name).toBe('Important work meeting'); + }); + }); + + describe('User Isolation', () => { + let otherUser, otherAgent; + + beforeEach(async () => { + // Create another user + otherUser = await createTestUser({ + email: 'other-user@example.com', + }); + + otherAgent = request.agent(app); + await otherAgent.post('/api/login').send({ + email: 'other-user@example.com', + password: 'password123', + }); + + // Create data for first user + await Task.create({ + user_id: user.id, + name: 'User 1 task', + status: 0, + }); + + // Create data for second user + await Task.create({ + user_id: otherUser.id, + name: 'User 2 task', + status: 0, + }); + }); + + it('should only return results for authenticated user', async () => { + const response = await agent.get('/api/search').query({ + q: 'task', + filters: 'Task', + }); + + expect(response.status).toBe(200); + const tasks = response.body.results.filter( + (r) => r.type === 'Task' + ); + expect(tasks.length).toBe(1); + expect(tasks[0].name).toBe('User 1 task'); + }); + + it('should not return other users data', async () => { + const response = await otherAgent.get('/api/search').query({ + q: 'task', + filters: 'Task', + }); + + expect(response.status).toBe(200); + const tasks = response.body.results.filter( + (r) => r.type === 'Task' + ); + expect(tasks.length).toBe(1); + expect(tasks[0].name).toBe('User 2 task'); + }); + }); + + describe('Result Format', () => { + beforeEach(async () => { + await Task.create({ + user_id: user.id, + name: 'Test task', + note: 'Task description', + priority: 1, + status: 0, + }); + + await Project.create({ + user_id: user.id, + name: 'Test project', + description: 'Project description', + state: 'active', + priority: 'medium', + }); + + await Note.create({ + user_id: user.id, + title: 'Test note', + content: 'Note content goes here', + }); + }); + + it('should return task with correct structure', async () => { + const response = await agent.get('/api/search').query({ + q: 'Test', + filters: 'Task', + }); + + expect(response.status).toBe(200); + const task = response.body.results.find( + (r) => r.type === 'Task' + ); + expect(task).toBeDefined(); + expect(task.type).toBe('Task'); + expect(task.id).toBeDefined(); + expect(task.uid).toBeDefined(); + expect(task.name).toBe('Test task'); + expect(task.description).toBe('Task description'); + expect(task.priority).toBe(1); + expect(task.status).toBe(0); + }); + + it('should return project with correct structure', async () => { + const response = await agent.get('/api/search').query({ + q: 'Test', + filters: 'Project', + }); + + expect(response.status).toBe(200); + const project = response.body.results.find( + (r) => r.type === 'Project' + ); + expect(project).toBeDefined(); + expect(project.type).toBe('Project'); + expect(project.id).toBeDefined(); + expect(project.uid).toBeDefined(); + expect(project.name).toBe('Test project'); + expect(project.description).toBe('Project description'); + expect(project.priority).toBe('medium'); + expect(project.status).toBe('active'); + }); + + it('should return note with correct structure', async () => { + const response = await agent.get('/api/search').query({ + q: 'Test', + filters: 'Note', + }); + + expect(response.status).toBe(200); + const note = response.body.results.find( + (r) => r.type === 'Note' + ); + expect(note).toBeDefined(); + expect(note.type).toBe('Note'); + expect(note.id).toBeDefined(); + expect(note.uid).toBeDefined(); + expect(note.name).toBe('Test note'); + expect(note.title).toBe('Test note'); + expect(note.description).toBe('Note content goes here'); + }); + }); + + describe('Edge Cases', () => { + it('should handle special characters in search query', async () => { + await Task.create({ + user_id: user.id, + name: 'Task with special chars: @#$%', + status: 0, + }); + + const response = await agent.get('/api/search').query({ + q: '@#$', + filters: 'Task', + }); + + expect(response.status).toBe(200); + // Should not error, even if results might be empty + expect(response.body.results).toBeDefined(); + }); + + it('should handle very long search queries', async () => { + const longQuery = 'a'.repeat(1000); + const response = await agent.get('/api/search').query({ + q: longQuery, + }); + + expect(response.status).toBe(200); + expect(response.body.results).toBeDefined(); + }); + + it('should handle invalid filter types gracefully', async () => { + const response = await agent.get('/api/search').query({ + filters: 'InvalidType', + }); + + expect(response.status).toBe(200); + expect(response.body.results).toEqual([]); + }); + + it('should respect limit of 20 results per type', async () => { + // Create 25 tasks + const tasks = Array.from({ length: 25 }, (_, i) => + Task.create({ + user_id: user.id, + name: `Task ${i + 1}`, + status: 0, + }) + ); + await Promise.all(tasks); + + const response = await agent.get('/api/search').query({ + filters: 'Task', + }); + + expect(response.status).toBe(200); + const returnedTasks = response.body.results.filter( + (r) => r.type === 'Task' + ); + expect(returnedTasks.length).toBeLessThanOrEqual(20); + }); + }); + }); +}); diff --git a/backend/tests/unit/services/parentChildRelationship.test.js b/backend/tests/unit/services/parentChildRelationship.test.js index c2943c9..7d49c39 100644 --- a/backend/tests/unit/services/parentChildRelationship.test.js +++ b/backend/tests/unit/services/parentChildRelationship.test.js @@ -466,13 +466,13 @@ describe('Parent-Child Relationship Functionality', () => { const uniqueDueDates = [...new Set(dueDates)]; expect(uniqueDueDates.length).toBe(dueDates.length); - // Verify children have sequential due dates (within tolerance for DST transitions) + // Verify children have sequential due dates (within tolerance for DST/timezone differences) const sortedDueDates = dueDates.sort(); for (let i = 1; i < sortedDueDates.length; i++) { const dayDiff = (sortedDueDates[i] - sortedDueDates[i - 1]) / (24 * 60 * 60 * 1000); - expect(Math.abs(dayDiff - 1)).toBeLessThan(0.05); // Each task should be ~1 day apart (allowing for DST) + expect(Math.abs(dayDiff - 1)).toBeLessThan(0.05); // Each task should be ~1 day apart (tolerance for DST) } }); diff --git a/e2e/bin/run-e2e.sh b/e2e/bin/run-e2e.sh index 28667f0..3f58634 100755 --- a/e2e/bin/run-e2e.sh +++ b/e2e/bin/run-e2e.sh @@ -110,6 +110,6 @@ bash -c ' # Respect E2E_SLOWMO and run only Firefox sequentially npx playwright test --headed --project=Firefox --workers=1 else - npx playwright test --workers=10 + npx playwright test --workers=5 fi ' diff --git a/frontend/App.tsx b/frontend/App.tsx index 4061555..5da0124 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -9,6 +9,8 @@ import AreaDetails from './components/Area/AreaDetails'; import Areas from './components/Areas'; import TagDetails from './components/Tag/TagDetails'; import Tags from './components/Tags'; +import Views from './components/Views'; +import ViewDetail from './components/ViewDetail'; import Notes from './components/Notes'; import NoteDetails from './components/Note/NoteDetails'; import Calendar from './components/Calendar'; @@ -230,6 +232,11 @@ const App: React.FC = () => { path="/tag/:uidSlug" element={} /> + } /> + } + /> } /> = ({ const [isSidebarOpen, setIsSidebarOpen] = useState( window.innerWidth >= 1024 ); + const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false); const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); const [isProjectModalOpen, setIsProjectModalOpen] = useState(false); const [isNoteModalOpen, setIsNoteModalOpen] = useState(false); @@ -88,6 +89,23 @@ const Layout: React.FC = ({ return () => window.removeEventListener('resize', handleResize); }, []); + useEffect(() => { + // Listen for mobile search toggle events from Navbar + const handleMobileSearchToggle = (event: CustomEvent) => { + setIsMobileSearchOpen(event.detail.isOpen); + }; + + window.addEventListener( + 'mobileSearchToggle', + handleMobileSearchToggle as EventListener + ); + return () => + window.removeEventListener( + 'mobileSearchToggle', + handleMobileSearchToggle as EventListener + ); + }, []); + useEffect(() => { // Load projects into global store if not already loaded const loadProjects = async () => { @@ -439,7 +457,11 @@ const Layout: React.FC = ({ className={`transition-all duration-300 ease-in-out ${mainContentMarginLeft}`} >
-
+
{children}
diff --git a/frontend/components/Inbox/InboxItems.tsx b/frontend/components/Inbox/InboxItems.tsx index 9aeb811..9e3e60b 100644 --- a/frontend/components/Inbox/InboxItems.tsx +++ b/frontend/components/Inbox/InboxItems.tsx @@ -13,7 +13,7 @@ import { import InboxItemDetail from './InboxItemDetail'; import { useToast } from '../Shared/ToastContext'; import { useTranslation } from 'react-i18next'; -import { InboxIcon } from '@heroicons/react/24/outline'; +import { InboxIcon, InformationCircleIcon } from '@heroicons/react/24/outline'; import LoadingScreen from '../Shared/LoadingScreen'; import TaskModal from '../Task/TaskModal'; import ProjectModal from '../Project/ProjectModal'; @@ -475,19 +475,7 @@ const InboxItems: React.FC = () => { } title={isInfoExpanded ? 'Hide info' : 'About Inbox'} > - - - + {isInfoExpanded ? 'Hide info' : 'About Inbox'} @@ -505,19 +493,7 @@ const InboxItems: React.FC = () => {
{/* Large low-opacity info icon */}
- - - +

diff --git a/frontend/components/Navbar.tsx b/frontend/components/Navbar.tsx index c6d99b3..5230bf5 100644 --- a/frontend/components/Navbar.tsx +++ b/frontend/components/Navbar.tsx @@ -6,9 +6,10 @@ import { BoltIcon, InboxIcon, } from '@heroicons/react/24/solid'; -import { EnvelopeIcon } from '@heroicons/react/24/outline'; +import { EnvelopeIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { useTranslation } from 'react-i18next'; import PomodoroTimer from './Shared/PomodoroTimer'; +import UniversalSearch from './UniversalSearch/UniversalSearch'; interface NavbarProps { isDarkMode: boolean; @@ -33,10 +34,20 @@ const Navbar: React.FC = ({ }) => { const { t } = useTranslation(); const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false); const [pomodoroEnabled, setPomodoroEnabled] = useState(true); // Default to true const dropdownRef = useRef(null); const navigate = useNavigate(); + // Dispatch event when mobile search state changes + useEffect(() => { + window.dispatchEvent( + new CustomEvent('mobileSearchToggle', { + detail: { isOpen: isMobileSearchOpen }, + }) + ); + }, [isMobileSearchOpen]); + useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( @@ -116,11 +127,12 @@ const Navbar: React.FC = ({ }; return ( -