From b0b613f7bdfe49067a2a4b88e7c02b826e59dd57 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 9 Dec 2025 10:51:51 +0200 Subject: [PATCH] Reorder elements (#687) * Reorder elements * Enhance global search --- ...201000001-add-defer-and-extras-to-views.js | 34 ++ backend/models/view.js | 15 + backend/routes/search.js | 175 +++++-- backend/routes/views.js | 19 +- backend/services/backupService.js | 2 + backend/tests/integration/search.test.js | 191 ++++++++ backend/tests/integration/views.test.js | 20 + e2e/bin/run-e2e.sh | 20 +- e2e/bin/run-single-test.sh | 22 +- frontend/App.tsx | 5 +- frontend/components/Backup/BackupRestore.tsx | 460 ++++++++++++------ frontend/components/Navbar.tsx | 10 +- .../Productivity/ProductivityAssistant.tsx | 6 +- frontend/components/Project/ProjectItem.tsx | 132 +++-- frontend/components/Sidebar/SidebarNav.tsx | 7 +- frontend/components/Task/TaskDetails.tsx | 88 ++-- .../Task/TaskDetails/TaskDetailsHeader.tsx | 21 +- .../Task/TaskDetails/TaskRecurrenceCard.tsx | 5 +- .../components/UniversalSearch/SearchMenu.tsx | 124 +++-- .../UniversalSearch/SearchResults.tsx | 11 +- frontend/components/ViewDetail.tsx | 87 +++- frontend/components/Views.tsx | 29 +- frontend/utils/backupService.ts | 4 +- frontend/utils/searchService.ts | 6 +- public/locales/ar/translation.json | 12 +- public/locales/bg/translation.json | 10 +- public/locales/da/translation.json | 10 +- public/locales/de/translation.json | 10 +- public/locales/el/translation.json | 10 +- public/locales/en/translation.json | 14 +- public/locales/es/translation.json | 10 +- public/locales/fi/translation.json | 10 +- public/locales/fr/translation.json | 10 +- public/locales/id/translation.json | 10 +- public/locales/it/translation.json | 10 +- public/locales/jp/translation.json | 10 +- public/locales/ko/translation.json | 10 +- public/locales/nl/translation.json | 10 +- public/locales/no/translation.json | 10 +- public/locales/pl/translation.json | 10 +- public/locales/pt/translation.json | 10 +- public/locales/ro/translation.json | 10 +- public/locales/ru/translation.json | 10 +- public/locales/sl/translation.json | 10 +- public/locales/sv/translation.json | 10 +- public/locales/tr/translation.json | 10 +- public/locales/ua/translation.json | 10 +- public/locales/vi/translation.json | 10 +- public/locales/zh/translation.json | 10 +- webpack.config.js | 18 +- 50 files changed, 1313 insertions(+), 454 deletions(-) create mode 100644 backend/migrations/20260201000001-add-defer-and-extras-to-views.js diff --git a/backend/migrations/20260201000001-add-defer-and-extras-to-views.js b/backend/migrations/20260201000001-add-defer-and-extras-to-views.js new file mode 100644 index 0000000..9e3bf90 --- /dev/null +++ b/backend/migrations/20260201000001-add-defer-and-extras-to-views.js @@ -0,0 +1,34 @@ +'use strict'; + +const { + safeAddColumns, + safeRemoveColumn, +} = require('../utils/migration-utils'); + +module.exports = { + up: async (queryInterface, Sequelize) => { + await safeAddColumns(queryInterface, 'views', [ + { + name: 'defer', + definition: { + type: Sequelize.STRING, + allowNull: true, + comment: 'Defer timeframe filter', + }, + }, + { + name: 'extras', + definition: { + type: Sequelize.TEXT, + allowNull: true, + comment: 'JSON array of extras filters', + }, + }, + ]); + }, + + down: async (queryInterface) => { + await safeRemoveColumn(queryInterface, 'views', 'extras'); + await safeRemoveColumn(queryInterface, 'views', 'defer'); + }, +}; diff --git a/backend/models/view.js b/backend/models/view.js index 7cb4542..a2ede11 100644 --- a/backend/models/view.js +++ b/backend/models/view.js @@ -51,6 +51,10 @@ module.exports = (sequelize) => { type: DataTypes.STRING, allowNull: true, }, + defer: { + type: DataTypes.STRING, + allowNull: true, + }, tags: { type: DataTypes.TEXT, allowNull: true, @@ -62,6 +66,17 @@ module.exports = (sequelize) => { this.setDataValue('tags', JSON.stringify(value)); }, }, + extras: { + type: DataTypes.TEXT, + allowNull: true, + get() { + const rawValue = this.getDataValue('extras'); + return rawValue ? JSON.parse(rawValue) : []; + }, + set(value) { + this.setDataValue('extras', JSON.stringify(value)); + }, + }, recurring: { type: DataTypes.STRING, allowNull: true, diff --git a/backend/routes/search.js b/backend/routes/search.js index 7f49894..4994e17 100644 --- a/backend/routes/search.js +++ b/backend/routes/search.js @@ -27,6 +27,7 @@ const priorityToInt = (priorityStr) => { * - due: filter by due date (today,tomorrow,next_week,next_month) * - tags: comma-separated list of tag names to filter by * - recurring: filter by recurrence type (recurring,non_recurring,instances) + * - extras: comma-separated list of extra filters (recurring,overdue,has_content,deferred,has_tags,assigned_to_project) * - limit: number of results to return (default: 20) * - offset: number of results to skip (default: 0) * - excludeSubtasks: if 'true', exclude tasks that have a parent_task_id or recurring_parent_id @@ -46,6 +47,7 @@ router.get('/', async (req, res) => { defer, tags: tagsParam, recurring, + extras: extrasParam, limit: limitParam, offset: offsetParam, excludeSubtasks, @@ -57,6 +59,18 @@ router.get('/', async (req, res) => { const tagNames = tagsParam ? tagsParam.split(',').map((t) => t.trim()) : []; + const extras = + extrasParam && typeof extrasParam === 'string' + ? extrasParam + .split(',') + .map((extra) => extra.trim()) + .filter(Boolean) + : []; + const extrasSet = new Set(extras); + const userTimezone = req.currentUser?.timezone || 'UTC'; + const nowMoment = moment().tz(userTimezone); + const startOfToday = nowMoment.clone().startOf('day'); + const nowDate = nowMoment.toDate(); // Pagination support const hasPagination = @@ -88,35 +102,31 @@ router.get('/', async (req, res) => { // 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'); + startDate = startOfToday.clone(); + endDate = startOfToday.clone().endOf('day'); break; case 'tomorrow': - startDate = now.clone().add(1, 'day'); - endDate = now.clone().add(1, 'day').endOf('day'); + startDate = startOfToday.clone().add(1, 'day'); + endDate = startOfToday.clone().add(1, 'day').endOf('day'); break; case 'next_week': - startDate = now.clone(); - endDate = now.clone().add(7, 'days').endOf('day'); + startDate = startOfToday.clone(); + endDate = startOfToday.clone().add(7, 'days').endOf('day'); break; case 'next_month': - startDate = now.clone(); - endDate = now.clone().add(1, 'month').endOf('day'); + startDate = startOfToday.clone(); + endDate = startOfToday.clone().add(1, 'month').endOf('day'); break; } if (startDate && endDate) { dueDateCondition = { due_date: { - [Op.between]: [ - startDate.toISOString(), - endDate.toISOString(), - ], + [Op.between]: [startDate.toDate(), endDate.toDate()], }, }; } @@ -125,35 +135,31 @@ router.get('/', async (req, res) => { // Calculate defer until date range based on filter let deferDateCondition = null; if (defer) { - const now = moment().startOf('day'); let startDate, endDate; switch (defer) { case 'today': - startDate = now.clone(); - endDate = now.clone().endOf('day'); + startDate = startOfToday.clone(); + endDate = startOfToday.clone().endOf('day'); break; case 'tomorrow': - startDate = now.clone().add(1, 'day'); - endDate = now.clone().add(1, 'day').endOf('day'); + startDate = startOfToday.clone().add(1, 'day'); + endDate = startOfToday.clone().add(1, 'day').endOf('day'); break; case 'next_week': - startDate = now.clone(); - endDate = now.clone().add(7, 'days').endOf('day'); + startDate = startOfToday.clone(); + endDate = startOfToday.clone().add(7, 'days').endOf('day'); break; case 'next_month': - startDate = now.clone(); - endDate = now.clone().add(1, 'month').endOf('day'); + startDate = startOfToday.clone(); + endDate = startOfToday.clone().add(1, 'month').endOf('day'); break; } if (startDate && endDate) { deferDateCondition = { defer_until: { - [Op.between]: [ - startDate.toISOString(), - endDate.toISOString(), - ], + [Op.between]: [startDate.toDate(), endDate.toDate()], }, }; } @@ -164,6 +170,7 @@ router.get('/', async (req, res) => { const taskConditions = { user_id: userId, }; + const taskExtraConditions = []; // Exclude subtasks and recurring instances if requested if (excludeSubtasks === 'true') { @@ -196,12 +203,12 @@ router.get('/', async (req, res) => { // Add due date filter if specified if (dueDateCondition) { - Object.assign(taskConditions, dueDateCondition); + taskExtraConditions.push(dueDateCondition); } // Add defer until filter if specified if (deferDateCondition) { - Object.assign(taskConditions, deferDateCondition); + taskExtraConditions.push(deferDateCondition); } // Add recurring filter if specified @@ -227,6 +234,64 @@ router.get('/', async (req, res) => { } } + if (extrasSet.has('recurring')) { + taskExtraConditions.push({ + [Op.or]: [ + { recurrence_type: { [Op.ne]: 'none' } }, + { recurring_parent_id: { [Op.ne]: null } }, + ], + }); + } + + if (extrasSet.has('overdue')) { + taskExtraConditions.push({ + due_date: { [Op.lt]: nowDate }, + }); + taskExtraConditions.push({ + completed_at: null, + }); + } + + if (extrasSet.has('has_content')) { + const noteHasContent = sequelize.where( + sequelize.fn( + 'LENGTH', + sequelize.fn('TRIM', sequelize.col('Task.note')) + ), + { [Op.gt]: 0 } + ); + const descriptionHasContent = sequelize.where( + sequelize.fn( + 'LENGTH', + sequelize.fn('TRIM', sequelize.col('Task.description')) + ), + { [Op.gt]: 0 } + ); + taskExtraConditions.push({ + [Op.or]: [noteHasContent, descriptionHasContent], + }); + } + + if (extrasSet.has('deferred')) { + taskExtraConditions.push({ + defer_until: { [Op.gt]: nowDate }, + }); + } + + if (extrasSet.has('assigned_to_project')) { + taskExtraConditions.push({ + project_id: { [Op.ne]: null }, + }); + } + + if (taskExtraConditions.length > 0) { + if (taskConditions[Op.and]) { + taskConditions[Op.and].push(...taskExtraConditions); + } else { + taskConditions[Op.and] = taskExtraConditions; + } + } + const taskInclude = [ { model: Project, @@ -245,32 +310,28 @@ router.get('/', async (req, res) => { }, ]; - // Add tag filter if specified + const requireTags = tagIds.length > 0 || extrasSet.has('has_tags'); + const tagInclude = { + model: Tag, + through: { attributes: [] }, + attributes: ['id', 'name', 'uid'], + required: requireTags, + }; + if (tagIds.length > 0) { - taskInclude.push({ - model: Tag, - where: { - id: { [Op.in]: tagIds }, - }, - through: { attributes: [] }, - attributes: ['id', 'name', 'uid'], - required: true, - }); - } else { - // Always include tags for display, even if not filtering - taskInclude.push({ - model: Tag, - through: { attributes: [] }, - attributes: ['id', 'name', 'uid'], - required: false, - }); + tagInclude.where = { + id: { [Op.in]: tagIds }, + }; } + taskInclude.push(tagInclude); + // Count total tasks if pagination is requested if (hasPagination) { + const countInclude = requireTags ? [tagInclude] : undefined; totalCount += await Task.count({ where: taskConditions, - include: tagIds.length > 0 ? taskInclude : undefined, + include: countInclude, distinct: true, }); } @@ -333,19 +394,25 @@ router.get('/', async (req, res) => { Object.assign(projectConditions, projectDueCondition); } + const requireProjectTags = + tagIds.length > 0 || extrasSet.has('has_tags'); const projectInclude = []; - // Add tag filter if specified - if (tagIds.length > 0) { - projectInclude.push({ + if (requireProjectTags) { + const projectTagInclude = { model: Tag, - where: { - id: { [Op.in]: tagIds }, - }, through: { attributes: [] }, attributes: [], required: true, - }); + }; + + if (tagIds.length > 0) { + projectTagInclude.where = { + id: { [Op.in]: tagIds }, + }; + } + + projectInclude.push(projectTagInclude); } // Count total projects if pagination is requested diff --git a/backend/routes/views.js b/backend/routes/views.js index 4aaf93b..64e86d1 100644 --- a/backend/routes/views.js +++ b/backend/routes/views.js @@ -64,8 +64,17 @@ router.get('/:identifier', async (req, res) => { // POST /api/views - Create a new view router.post('/', async (req, res) => { try { - const { name, search_query, filters, priority, due, tags, recurring } = - req.body; + const { + name, + search_query, + filters, + priority, + due, + defer, + tags, + extras, + recurring, + } = req.body; if (!name || name.trim() === '') { return res.status(400).json({ error: 'View name is required' }); @@ -78,7 +87,9 @@ router.post('/', async (req, res) => { filters: filters || [], priority: priority || null, due: due || null, + defer: defer || null, tags: tags || [], + extras: extras || [], recurring: recurring || null, is_pinned: false, }); @@ -114,7 +125,9 @@ router.patch('/:identifier', async (req, res) => { filters, priority, due, + defer, tags, + extras, recurring, is_pinned, } = req.body; @@ -125,7 +138,9 @@ router.patch('/:identifier', async (req, res) => { if (filters !== undefined) updates.filters = filters; if (priority !== undefined) updates.priority = priority; if (due !== undefined) updates.due = due; + if (defer !== undefined) updates.defer = defer; if (tags !== undefined) updates.tags = tags; + if (extras !== undefined) updates.extras = extras; if (recurring !== undefined) updates.recurring = recurring; if (is_pinned !== undefined) updates.is_pinned = is_pinned; diff --git a/backend/services/backupService.js b/backend/services/backupService.js index e9394e5..43754d0 100644 --- a/backend/services/backupService.js +++ b/backend/services/backupService.js @@ -629,7 +629,9 @@ async function importUserData(userId, backupData, options = { merge: true }) { filters: viewData.filters, priority: viewData.priority, due: viewData.due, + defer: viewData.defer, tags: viewData.tags, + extras: viewData.extras, recurring: viewData.recurring, is_pinned: viewData.is_pinned, user_id: userId, diff --git a/backend/tests/integration/search.test.js b/backend/tests/integration/search.test.js index 33241bb..3400ed4 100644 --- a/backend/tests/integration/search.test.js +++ b/backend/tests/integration/search.test.js @@ -649,6 +649,197 @@ describe('Universal Search Routes', () => { }); }); + describe('Extras Filters', () => { + beforeEach(async () => { + const project = await Project.create({ + user_id: user.id, + name: 'Extras Project', + state: 'active', + }); + + const workTag = await Tag.create({ + user_id: user.id, + name: 'extras-tag', + }); + + const recurringTemplate = await Task.create({ + user_id: user.id, + name: 'Recurring Template', + recurrence_type: 'weekly', + status: 0, + }); + + await Task.create({ + user_id: user.id, + name: 'Recurring Instance', + recurring_parent_id: recurringTemplate.id, + status: 0, + }); + + await Task.create({ + user_id: user.id, + name: 'Regular Task', + status: 0, + }); + + await Task.create({ + user_id: user.id, + name: 'Overdue Task', + due_date: moment().subtract(2, 'days').toDate(), + status: 0, + }); + + await Task.create({ + user_id: user.id, + name: 'Completed Overdue Task', + due_date: moment().subtract(3, 'days').toDate(), + completed_at: new Date(), + status: 3, + }); + + await Task.create({ + user_id: user.id, + name: 'Content Task', + note: 'Detailed context lives here', + status: 0, + }); + + await Task.create({ + user_id: user.id, + name: 'Deferred Task', + defer_until: moment().add(2, 'days').toDate(), + status: 0, + }); + + const taggedTask = await Task.create({ + user_id: user.id, + name: 'Tagged Task', + status: 0, + }); + await taggedTask.addTag(workTag); + + await Task.create({ + user_id: user.id, + name: 'Project Task', + project_id: project.id, + status: 0, + }); + }); + + const getTaskNames = (response) => + response.body.results + .filter((r) => r.type === 'Task') + .map((task) => task.original_name || task.name); + + it('should return only recurring tasks when extras contains recurring', async () => { + const response = await agent.get('/api/search').query({ + filters: 'Task', + extras: 'recurring', + }); + + expect(response.status).toBe(200); + const names = getTaskNames(response); + expect(names).toEqual( + expect.arrayContaining([ + 'Recurring Template', + 'Recurring Instance', + ]) + ); + expect(names).not.toContain('Regular Task'); + }); + + it('should return overdue tasks and exclude completed ones', async () => { + const response = await agent.get('/api/search').query({ + filters: 'Task', + extras: 'overdue', + }); + + expect(response.status).toBe(200); + const names = getTaskNames(response); + expect(names).toContain('Overdue Task'); + expect(names).not.toContain('Completed Overdue Task'); + }); + + it('should return tasks that have content', async () => { + const response = await agent.get('/api/search').query({ + filters: 'Task', + extras: 'has_content', + }); + + expect(response.status).toBe(200); + const names = getTaskNames(response); + expect(names).toContain('Content Task'); + expect(names).not.toContain('Regular Task'); + }); + + it('should return deferred tasks', async () => { + const response = await agent.get('/api/search').query({ + filters: 'Task', + extras: 'deferred', + }); + + expect(response.status).toBe(200); + const names = getTaskNames(response); + expect(names).toContain('Deferred Task'); + expect(names).not.toContain('Regular Task'); + }); + + it('should return tasks with tags', async () => { + const response = await agent.get('/api/search').query({ + filters: 'Task', + extras: 'has_tags', + }); + + expect(response.status).toBe(200); + const names = getTaskNames(response); + expect(names).toContain('Tagged Task'); + expect(names).not.toContain('Regular Task'); + }); + + it('should return tasks assigned to projects', async () => { + const response = await agent.get('/api/search').query({ + filters: 'Task', + extras: 'assigned_to_project', + }); + + expect(response.status).toBe(200); + const names = getTaskNames(response); + expect(names).toContain('Project Task'); + expect(names).not.toContain('Regular Task'); + }); + + it('should return only projects that have tags when extras include has_tags', async () => { + const taggedProject = await Project.create({ + user_id: user.id, + name: 'Tagged Project', + state: 'active', + }); + const plainProject = await Project.create({ + user_id: user.id, + name: 'Plain Project', + state: 'active', + }); + const projectTag = await Tag.create({ + user_id: user.id, + name: 'project-tag', + }); + await taggedProject.addTag(projectTag); + + const response = await agent.get('/api/search').query({ + filters: 'Project', + extras: 'has_tags', + }); + + expect(response.status).toBe(200); + const projectResults = response.body.results.filter( + (r) => r.type === 'Project' + ); + const projectNames = projectResults.map((p) => p.name); + expect(projectNames).toContain('Tagged Project'); + expect(projectNames).not.toContain('Plain Project'); + }); + }); + describe('User Isolation', () => { let otherUser, otherAgent; diff --git a/backend/tests/integration/views.test.js b/backend/tests/integration/views.test.js index ae37f42..9b190b0 100644 --- a/backend/tests/integration/views.test.js +++ b/backend/tests/integration/views.test.js @@ -76,7 +76,9 @@ describe('Views Routes', () => { filters: ['Task', 'Project'], priority: 'high', due: 'today', + defer: 'tomorrow', tags: ['work', 'important'], + extras: ['recurring', 'has_content'], }); expect(response.status).toBe(201); @@ -85,7 +87,21 @@ describe('Views Routes', () => { expect(response.body.filters).toEqual(['Task', 'Project']); expect(response.body.priority).toBe('high'); expect(response.body.due).toBe('today'); + expect(response.body.defer).toBe('tomorrow'); expect(response.body.tags).toEqual(['work', 'important']); + expect(response.body.extras).toEqual(['recurring', 'has_content']); + }); + + it('should create a view that persists extras without tags', async () => { + const response = await agent.post('/api/views').send({ + name: 'Recurring Tasks', + filters: ['Task'], + extras: ['recurring', 'overdue'], + }); + + expect(response.status).toBe(201); + expect(response.body.extras).toEqual(['recurring', 'overdue']); + expect(response.body.tags).toEqual([]); }); it('should require view name', async () => { @@ -246,7 +262,9 @@ describe('Views Routes', () => { filters: ['Task', 'Project'], priority: 'high', due: 'today', + defer: 'next_week', tags: ['work', 'urgent'], + extras: ['recurring'], is_pinned: true, }); @@ -256,7 +274,9 @@ describe('Views Routes', () => { expect(response.body.filters).toEqual(['Task', 'Project']); expect(response.body.priority).toBe('high'); expect(response.body.due).toBe('today'); + expect(response.body.defer).toBe('next_week'); expect(response.body.tags).toEqual(['work', 'urgent']); + expect(response.body.extras).toEqual(['recurring']); expect(response.body.is_pinned).toBe(true); }); }); diff --git a/e2e/bin/run-e2e.sh b/e2e/bin/run-e2e.sh index 2a09bcc..9d09c4f 100755 --- a/e2e/bin/run-e2e.sh +++ b/e2e/bin/run-e2e.sh @@ -2,10 +2,15 @@ set -euo pipefail # Config -APP_URL_DEFAULT="http://localhost:8080" -BACKEND_URL="http://localhost:3002" +FRONTEND_PORT="${FRONTEND_PORT:-4180}" +FRONTEND_HOST="${FRONTEND_HOST:-127.0.0.1}" +BACKEND_PORT="${BACKEND_PORT:-3310}" +BACKEND_HOST="${BACKEND_HOST:-127.0.0.1}" +BACKEND_URL="${BACKEND_URL:-http://${BACKEND_HOST}:${BACKEND_PORT}}" BACKEND_HEALTH="${BACKEND_URL}/api/health" +APP_URL_DEFAULT="http://${FRONTEND_HOST}:${FRONTEND_PORT}" FRONTEND_URL="${APP_URL:-$APP_URL_DEFAULT}" +FRONTEND_ORIGIN="${FRONTEND_ORIGIN:-$FRONTEND_URL}" # Colors red() { printf "\033[31m%s\033[0m\n" "$*"; } @@ -35,7 +40,8 @@ rm -f backend/db/test.sqlite3 yellow "Starting backend with test database..." (cd backend && \ NODE_ENV=test \ -PORT=3002 \ +PORT=$BACKEND_PORT \ +HOST=$BACKEND_HOST \ DB_FILE=db/test.sqlite3 \ TUDUDI_USER_EMAIL="${E2E_EMAIL:-test@tududi.com}" \ TUDUDI_USER_PASSWORD="${E2E_PASSWORD:-password123}" \ @@ -51,8 +57,8 @@ cleanup() { # Kill by known ports (best-effort) if command -v lsof >/dev/null 2>&1; then - FRONTEND_PIDS_KILL=$(lsof -ti tcp:8080 || true) - BACKEND_PIDS_KILL=$(lsof -ti tcp:3002 || true) + FRONTEND_PIDS_KILL=$(lsof -ti tcp:${FRONTEND_PORT} || true) + BACKEND_PIDS_KILL=$(lsof -ti tcp:${BACKEND_PORT} || true) if [ -n "${FRONTEND_PIDS_KILL:-}" ]; then kill ${FRONTEND_PIDS_KILL} >/dev/null 2>&1 || true; fi if [ -n "${BACKEND_PIDS_KILL:-}" ]; then kill ${BACKEND_PIDS_KILL} >/dev/null 2>&1 || true; fi fi @@ -82,6 +88,10 @@ for i in {1..60}; do done yellow "Starting frontend dev server..." +BACKEND_URL="$BACKEND_URL" \ +FRONTEND_PORT="$FRONTEND_PORT" \ +FRONTEND_HOST="$FRONTEND_HOST" \ +FRONTEND_ORIGIN="$FRONTEND_ORIGIN" \ npm run frontend:dev >/dev/null 2>&1 & FRONTEND_PID=$! diff --git a/e2e/bin/run-single-test.sh b/e2e/bin/run-single-test.sh index 37847ec..70e4df3 100755 --- a/e2e/bin/run-single-test.sh +++ b/e2e/bin/run-single-test.sh @@ -14,10 +14,15 @@ TEST_PATTERN="$1" BROWSER="${2:-Chromium}" # Config -APP_URL_DEFAULT="http://localhost:8080" -BACKEND_URL="http://localhost:3002" +FRONTEND_PORT="${FRONTEND_PORT:-4180}" +FRONTEND_HOST="${FRONTEND_HOST:-127.0.0.1}" +BACKEND_PORT="${BACKEND_PORT:-3310}" +BACKEND_HOST="${BACKEND_HOST:-127.0.0.1}" +BACKEND_URL="${BACKEND_URL:-http://${BACKEND_HOST}:${BACKEND_PORT}}" BACKEND_HEALTH="${BACKEND_URL}/api/health" +APP_URL_DEFAULT="http://${FRONTEND_HOST}:${FRONTEND_PORT}" FRONTEND_URL="${APP_URL:-$APP_URL_DEFAULT}" +FRONTEND_ORIGIN="${FRONTEND_ORIGIN:-$FRONTEND_URL}" # Colors red() { printf "\033[31m%s\033[0m\n" "$*"; } @@ -51,7 +56,8 @@ rm -f backend/db/test.sqlite3 yellow "Starting backend with test database..." (cd backend && \ NODE_ENV=test \ -PORT=3002 \ +PORT=$BACKEND_PORT \ +HOST=$BACKEND_HOST \ DB_FILE=db/test.sqlite3 \ TUDUDI_USER_EMAIL="${E2E_EMAIL:-test@tududi.com}" \ TUDUDI_USER_PASSWORD="${E2E_PASSWORD:-password123}" \ @@ -67,8 +73,8 @@ cleanup() { # Kill by ports (best-effort) if command -v lsof >/dev/null 2>&1; then - FRONTEND_PIDS_KILL=$(lsof -ti tcp:8080 || true) - BACKEND_PIDS_KILL=$(lsof -ti tcp:3002 || true) + FRONTEND_PIDS_KILL=$(lsof -ti tcp:${FRONTEND_PORT} || true) + BACKEND_PIDS_KILL=$(lsof -ti tcp:${BACKEND_PORT} || true) if [ -n "${FRONTEND_PIDS_KILL:-}" ]; then kill ${FRONTEND_PIDS_KILL} >/dev/null 2>&1 || true; fi if [ -n "${BACKEND_PIDS_KILL:-}" ]; then kill ${BACKEND_PIDS_KILL} >/dev/null 2>&1 || true; fi fi @@ -98,6 +104,10 @@ for i in {1..60}; do done yellow "Starting frontend dev server..." +BACKEND_URL="$BACKEND_URL" \ +FRONTEND_PORT="$FRONTEND_PORT" \ +FRONTEND_HOST="$FRONTEND_HOST" \ +FRONTEND_ORIGIN="$FRONTEND_ORIGIN" \ npm run frontend:dev >/dev/null 2>&1 & FRONTEND_PID=$! @@ -122,4 +132,4 @@ yellow "Running Playwright tests matching: ${TEST_PATTERN} on ${BROWSER}..." APP_URL="$FRONTEND_URL" \ E2E_EMAIL="${E2E_EMAIL:-test@tududi.com}" \ E2E_PASSWORD="${E2E_PASSWORD:-password123}" \ -npx playwright test --grep "$TEST_PATTERN" --project="$BROWSER" \ No newline at end of file +npx playwright test --grep "$TEST_PATTERN" --project="$BROWSER" diff --git a/frontend/App.tsx b/frontend/App.tsx index d6c9787..f953fe6 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -261,10 +261,7 @@ const App: React.FC = () => { path="/about" element={} /> - } - /> + } /> = ({ onImportSuccess }) => { await loadBackups(); } catch (error) { console.error('Export error:', error); - showErrorToast( - t('backup.exportError', 'Failed to create backup') - ); + showErrorToast(t('backup.exportError', 'Failed to create backup')); } finally { setIsExporting(false); } @@ -164,7 +162,10 @@ const BackupRestore: React.FC = ({ onImportSuccess }) => { try { await deleteSavedBackup(backupUid); showSuccessToast( - t('backup.deleteSuccess', 'Backup deleted successfully!') + t( + 'backup.deleteSuccess', + 'Backup deleted successfully!' + ) ); // Reload the backup list await loadBackups(); @@ -234,9 +235,7 @@ const BackupRestore: React.FC = ({ onImportSuccess }) => { } } catch (error) { console.error('Import error:', error); - showErrorToast( - t('backup.importError', 'Failed to import backup') - ); + showErrorToast(t('backup.importError', 'Failed to import backup')); } finally { setIsImporting(false); } @@ -260,7 +259,9 @@ const BackupRestore: React.FC = ({ onImportSuccess }) => { title={confirmDialog.title} message={confirmDialog.message} onConfirm={confirmDialog.onConfirm} - onCancel={() => setConfirmDialog({ ...confirmDialog, isOpen: false })} + onCancel={() => + setConfirmDialog({ ...confirmDialog, isOpen: false }) + } /> )}
@@ -291,7 +292,12 @@ const BackupRestore: React.FC = ({ onImportSuccess }) => { >
- {t('backup.createBackup', 'Create Backup')} + + {t( + 'backup.createBackup', + 'Create Backup' + )} +
@@ -316,7 +327,10 @@ const BackupRestore: React.FC = ({ onImportSuccess }) => {

- {t('backup.createNewBackup', 'Create New Backup')} + {t( + 'backup.createNewBackup', + 'Create New Backup' + )}

{t( @@ -353,12 +367,18 @@ const BackupRestore: React.FC = ({ onImportSuccess }) => { d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" > - {t('backup.creating', 'Creating backup...')} + {t( + 'backup.creating', + 'Creating backup...' + )} ) : ( <> - {t('backup.createBackupNow', 'Create Backup Now')} + {t( + 'backup.createBackupNow', + 'Create Backup Now' + )} )} @@ -367,14 +387,19 @@ const BackupRestore: React.FC = ({ onImportSuccess }) => {

- {t('backup.savedBackups', 'Saved Backups')} + {t( + 'backup.savedBackups', + 'Saved Backups' + )}

@@ -385,7 +410,10 @@ const BackupRestore: React.FC = ({ onImportSuccess }) => {
) : savedBackups.length === 0 ? (
- {t('backup.noBackups', 'No backups found. Create your first backup above.')} + {t( + 'backup.noBackups', + 'No backups found. Create your first backup above.' + )}
) : (
@@ -393,74 +421,136 @@ const BackupRestore: React.FC = ({ onImportSuccess }) => { - {t('backup.createdAt', 'Created')} + {t( + 'backup.createdAt', + 'Created' + )} - {t('backup.version', 'Version')} + {t( + 'backup.version', + 'Version' + )} - {t('backup.size', 'Size')} + {t( + 'backup.size', + 'Size' + )} - {t('backup.contents', 'Contents')} + {t( + 'backup.contents', + 'Contents' + )} - {t('backup.actions', 'Actions')} + {t( + 'backup.actions', + 'Actions' + )} - {savedBackups.map((backup) => ( - - - {formatDate(backup.created_at)} - - - {backup.version} - - - {formatFileSize(backup.file_size)} - - -
- - {backup.item_counts.tasks} tasks - - - {backup.item_counts.projects} projects - - - {backup.item_counts.notes} notes - -
- - -
- - - -
- - - ))} + {savedBackups.map( + (backup) => ( + + + {formatDate( + backup.created_at + )} + + + { + backup.version + } + + + {formatFileSize( + backup.file_size + )} + + +
+ + { + backup + .item_counts + .tasks + }{' '} + tasks + + + { + backup + .item_counts + .projects + }{' '} + projects + + + { + backup + .item_counts + .notes + }{' '} + notes + +
+ + +
+ + + +
+ + + ) + )}
@@ -471,7 +561,10 @@ const BackupRestore: React.FC = ({ onImportSuccess }) => {

- {t('backup.importTitle', 'Import from File')} + {t( + 'backup.importTitle', + 'Import from File' + )}

{t( @@ -502,16 +595,24 @@ const BackupRestore: React.FC = ({ onImportSuccess }) => { /> @@ -524,7 +625,10 @@ const BackupRestore: React.FC = ({ onImportSuccess }) => { {selectedFile.name}

- {(selectedFile.size / 1024).toFixed(2)} KB + {( + selectedFile.size / 1024 + ).toFixed(2)}{' '} + KB

{isValidating && ( @@ -554,71 +658,139 @@ const BackupRestore: React.FC = ({ onImportSuccess }) => { )}
- {validationResult?.valid && validationResult.summary && ( -
-

- {t('backup.backupContents', 'Backup contents:')} -

-
-
- - {validationResult.summary.tasks} tasks -
-
- - {validationResult.summary.projects} projects -
-
- - {validationResult.summary.notes} notes -
-
- - {validationResult.summary.tags} tags -
-
- - {validationResult.summary.areas} areas -
-
- - {validationResult.summary.views} views + {validationResult?.valid && + validationResult.summary && ( +
+

+ {t( + 'backup.backupContents', + 'Backup contents:' + )} +

+
+
+ + { + validationResult + .summary + .tasks + }{' '} + tasks +
+
+ + { + validationResult + .summary + .projects + }{' '} + projects +
+
+ + { + validationResult + .summary + .notes + }{' '} + notes +
+
+ + { + validationResult + .summary + .tags + }{' '} + tags +
+
+ + { + validationResult + .summary + .areas + }{' '} + areas +
+
+ + { + validationResult + .summary + .views + }{' '} + views +
-
- )} + )} - {validationResult && !validationResult.valid && ( -
- {validationResult.versionIncompatible ? ( - <> -

- {t('backup.versionIncompatible', 'Version Incompatible')} -

-

- {validationResult.message} -

-

- {t('backup.backupVersion', 'Backup version')}: {validationResult.backupVersion} -

-

- {t('backup.currentVersion', 'Current version')}: {appVersion} -

- - ) : ( - <> -

- {t('backup.validationErrors', 'Validation errors:')} -

-
    - {validationResult.errors?.map((error, index) => ( -
  • • {error}
  • - ))} -
- - )} -
- )} + {validationResult && + !validationResult.valid && ( +
+ {validationResult.versionIncompatible ? ( + <> +

+ {t( + 'backup.versionIncompatible', + 'Version Incompatible' + )} +

+

+ { + validationResult.message + } +

+

+ {t( + 'backup.backupVersion', + 'Backup version' + )} + :{' '} + { + validationResult.backupVersion + } +

+

+ {t( + 'backup.currentVersion', + 'Current version' + )} + : {appVersion} +

+ + ) : ( + <> +

+ {t( + 'backup.validationErrors', + 'Validation errors:' + )} +

+
    + {validationResult.errors?.map( + ( + error, + index + ) => ( +
  • + •{' '} + { + error + } +
  • + ) + )} +
+ + )} +
+ )}
)} @@ -650,12 +822,18 @@ const BackupRestore: React.FC = ({ onImportSuccess }) => { d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" > - {t('backup.importing', 'Importing...')} + {t( + 'backup.importing', + 'Importing...' + )} ) : ( <> - {t('backup.restoreBackup', 'Restore Backup')} + {t( + 'backup.restoreBackup', + 'Restore Backup' + )} )} diff --git a/frontend/components/Navbar.tsx b/frontend/components/Navbar.tsx index ff4be93..effde41 100644 --- a/frontend/components/Navbar.tsx +++ b/frontend/components/Navbar.tsx @@ -38,7 +38,10 @@ const Navbar: React.FC = ({ const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false); const [pomodoroEnabled, setPomodoroEnabled] = useState(true); // Default to true - const [featureFlags, setFeatureFlags] = useState({ backups: false, calendar: false }); + const [featureFlags, setFeatureFlags] = useState({ + backups: false, + calendar: false, + }); const dropdownRef = useRef(null); const navigate = useNavigate(); @@ -264,7 +267,10 @@ const Navbar: React.FC = ({ className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700" onClick={() => setIsDropdownOpen(false)} > - {t('navigation.backupRestore', 'Backup & Restore')} + {t( + 'navigation.backupRestore', + 'Backup & Restore' + )} )} {currentUser?.is_admin === true && ( diff --git a/frontend/components/Productivity/ProductivityAssistant.tsx b/frontend/components/Productivity/ProductivityAssistant.tsx index 71ee790..d2b0671 100644 --- a/frontend/components/Productivity/ProductivityAssistant.tsx +++ b/frontend/components/Productivity/ProductivityAssistant.tsx @@ -299,7 +299,6 @@ const ProductivityAssistant: React.FC = ({ } }; - if (totalIssues === 0) { return null; } @@ -313,10 +312,7 @@ const ProductivityAssistant: React.FC = ({

- {t( - 'productivity.issuesFound', - { count: totalIssues } - )} + {t('productivity.issuesFound', { count: totalIssues })}

{t( diff --git a/frontend/components/Project/ProjectItem.tsx b/frontend/components/Project/ProjectItem.tsx index 36f1b3b..b53223f 100644 --- a/frontend/components/Project/ProjectItem.tsx +++ b/frontend/components/Project/ProjectItem.tsx @@ -98,7 +98,6 @@ const getShareInitials = (value?: string | null) => { return cleaned.substring(0, 2) || '?'; }; - const ProjectItem: React.FC = ({ project, viewMode, @@ -221,14 +220,10 @@ const ProjectItem: React.FC = ({ } return { - text: t( - 'projectItem.overdue', - 'Overdue {{count}} {{unit}} ago', - { - count: Math.abs(diff), - unit, - } - ), + text: t('projectItem.overdue', 'Overdue {{count}} {{unit}} ago', { + count: Math.abs(diff), + unit, + }), isOverdue: true, }; }, [project.due_date_at, t]); @@ -244,9 +239,7 @@ const ProjectItem: React.FC = ({ const knownShares = sharedUsers ?? []; const avatars = knownShares.slice(0, MAX_SHARE_AVATARS); const totalCount = - (sharedUsers?.length ?? - project.share_count ?? - avatars.length) || 0; + (sharedUsers?.length ?? project.share_count ?? avatars.length) || 0; const remaining = Math.max(0, totalCount - avatars.length); return { avatars, remaining }; @@ -313,7 +306,10 @@ const ProjectItem: React.FC = ({ return ( ); })()} @@ -353,10 +349,14 @@ const ProjectItem: React.FC = ({ 'Permission denied' ) ); - setActiveDropdown(null); + setActiveDropdown( + null + ); return; } - handleEditProject(project); + handleEditProject( + project + ); setActiveDropdown(null); }} className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left" @@ -369,8 +369,12 @@ const ProjectItem: React.FC = ({ onClick={(e) => { e.preventDefault(); e.stopPropagation(); - onOpenShare(project); - setActiveDropdown(null); + onOpenShare( + project + ); + setActiveDropdown( + null + ); }} className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left" > @@ -395,8 +399,12 @@ const ProjectItem: React.FC = ({ ); return; } - setProjectToDelete(project); - setIsConfirmDialogOpen(true); + setProjectToDelete( + project + ); + setIsConfirmDialogOpen( + true + ); setActiveDropdown(null); }} className="block px-4 py-2 text-sm text-red-500 dark:text-red-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left" @@ -453,9 +461,13 @@ const ProjectItem: React.FC = ({ title={ (project as any).task_status ? `${(project as any).task_status.done} of ${(project as any).task_status.total} tasks completed (${getCompletionPercentage()}%)` - : t('projectItem.completionPercentage', { - percentage: getCompletionPercentage(), - }) + : t( + 'projectItem.completionPercentage', + { + percentage: + getCompletionPercentage(), + } + ) } >

= ({ : '0/0'}
-
-
+
+
{dueInfo.isOverdue ? ( - + {dueInfo.text} ) : ( - {dueInfo.text} + + {dueInfo.text} + )}
{project.is_shared && (
<> - {shareAvatars.avatars.map((share) => ( - - {share.avatar_image ? ( - {getShareDisplayName(share.email)} - ) : ( - - {getShareInitials(share.email)} - - )} - - ))} + {shareAvatars.avatars.map( + (share) => ( + + {share.avatar_image ? ( + {getShareDisplayName( + ) : ( + + {getShareInitials( + share.email + )} + + )} + + ) + )} {shareAvatars.remaining > 0 && ( = ({ )} > - +{shareAvatars.remaining} + + + {shareAvatars.remaining} )} diff --git a/frontend/components/Sidebar/SidebarNav.tsx b/frontend/components/Sidebar/SidebarNav.tsx index 64727f5..e731197 100644 --- a/frontend/components/Sidebar/SidebarNav.tsx +++ b/frontend/components/Sidebar/SidebarNav.tsx @@ -27,7 +27,10 @@ const SidebarNav: React.FC = ({ }) => { const { t } = useTranslation(); const store = useStore(); - const [featureFlags, setFeatureFlags] = useState({ backups: false, calendar: false }); + const [featureFlags, setFeatureFlags] = useState({ + backups: false, + calendar: false, + }); const inboxItemsCount = store.inboxStore.pagination.total; @@ -72,7 +75,7 @@ const SidebarNav: React.FC = ({ }, ]; - const navLinks = allNavLinks.filter(link => { + const navLinks = allNavLinks.filter((link) => { if (link.featureFlag) { return featureFlags[link.featureFlag as keyof FeatureFlags]; } diff --git a/frontend/components/Task/TaskDetails.tsx b/frontend/components/Task/TaskDetails.tsx index 34ee217..c792969 100644 --- a/frontend/components/Task/TaskDetails.tsx +++ b/frontend/components/Task/TaskDetails.tsx @@ -1248,6 +1248,7 @@ const TaskDetails: React.FC = () => { onToggleTodayPlan={handleToggleTodayPlan} onQuickStatusToggle={handleQuickStatusToggle} attachmentCount={attachmentCount} + subtasksCount={subtasks.length} /> {/* Content - Full width layout */} @@ -1285,49 +1286,47 @@ const TaskDetails: React.FC = () => { onLoadTags={() => tagsStore.loadTags()} getTagLink={getTagLink} /> + + + +
)} - {/* Schedule Pill */} - {activePill === 'schedule' && ( -
- + - - - -
- -
)} @@ -1362,16 +1361,11 @@ const TaskDetails: React.FC = () => { {/* Activity Pill */} {activePill === 'activity' && ( -
-

- {t('task.recentActivity', 'Recent Activity')} -

-
- -
+
+
)}
diff --git a/frontend/components/Task/TaskDetails/TaskDetailsHeader.tsx b/frontend/components/Task/TaskDetails/TaskDetailsHeader.tsx index 7d45e18..ef2fc64 100644 --- a/frontend/components/Task/TaskDetails/TaskDetailsHeader.tsx +++ b/frontend/components/Task/TaskDetails/TaskDetailsHeader.tsx @@ -40,6 +40,7 @@ interface TaskDetailsHeaderProps { onToggleTodayPlan?: () => void; onQuickStatusToggle?: () => void; attachmentCount?: number; + subtasksCount?: number; } const TaskDetailsHeader: React.FC = ({ @@ -61,6 +62,7 @@ const TaskDetailsHeader: React.FC = ({ onToggleTodayPlan, onQuickStatusToggle, attachmentCount = 0, + subtasksCount = 0, }) => { const { t } = useTranslation(); const [isEditingTitle, setIsEditingTitle] = useState(false); @@ -657,7 +659,7 @@ const TaskDetailsHeader: React.FC = ({ {t( 'task.lastUpdatedAt', - 'Last updated at' + 'Updated at' )} :{' '} @@ -761,23 +763,30 @@ const TaskDetailsHeader: React.FC = ({
)} - {view.recurring && ( -
-

- {t('views.recurring')} -

- - {view.recurring.replace( - /_/g, - ' ' - )} - -
- )} + {view.extras && + view.extras.length > 0 && ( +
+

+ {t('search.extras')} +

+
+ {view.extras.map( + ( + extra, + index + ) => ( + + {extra.replace( + /_/g, + ' ' + )} + + ) + )} +
+
+ )} {!view.filters.length && !view.search_query && !view.priority && !view.due && (!view.tags || view.tags.length === 0) && - !view.recurring && ( + (!view.extras || + view.extras.length === + 0) && (

{t( 'views.noCriteriaSet' diff --git a/frontend/components/Views.tsx b/frontend/components/Views.tsx index eab1b4f..2d4205a 100644 --- a/frontend/components/Views.tsx +++ b/frontend/components/Views.tsx @@ -18,6 +18,9 @@ interface View { filters: string[]; priority: string | null; due: string | null; + defer: string | null; + tags: string[]; + extras: string[] | null; is_pinned: boolean; } @@ -43,7 +46,13 @@ const Views: React.FC = () => { }); if (response.ok) { const data = await response.json(); - setViews(data); + const normalized: View[] = data.map((view: View) => ({ + ...view, + tags: view.tags || [], + extras: view.extras || [], + defer: view.defer || null, + })); + setViews(normalized); } } catch (error) { console.error('Error fetching views:', error); @@ -236,6 +245,24 @@ const Views: React.FC = () => { {view.due}

)} + {view.defer && ( +

+ •{' '} + {t('search.deferUntil')}{' '} + {view.defer} +

+ )} + {view.extras && + view.extras.length > 0 && ( +

+ •{' '} + {t('search.extras')} + :{' '} + {view.extras.join( + ', ' + )} +

+ )}
diff --git a/frontend/utils/backupService.ts b/frontend/utils/backupService.ts index 1813fd9..14ef9fe 100644 --- a/frontend/utils/backupService.ts +++ b/frontend/utils/backupService.ts @@ -222,9 +222,7 @@ export const importBackup = async ( /** * Validate backup file without importing */ -export const validateBackup = async ( - file: File -): Promise => { +export const validateBackup = async (file: File): Promise => { const formData = new FormData(); formData.append('backup', file); diff --git a/frontend/utils/searchService.ts b/frontend/utils/searchService.ts index 72fa1b6..75d13a1 100644 --- a/frontend/utils/searchService.ts +++ b/frontend/utils/searchService.ts @@ -7,7 +7,7 @@ interface SearchParams { due?: string; defer?: string; tags?: string[]; - recurring?: string; + extras?: string[]; limit?: number; offset?: number; excludeSubtasks?: boolean; @@ -66,8 +66,8 @@ export const searchUniversal = async ( queryParams.append('tags', params.tags.join(',')); } - if (params.recurring) { - queryParams.append('recurring', params.recurring); + if (params.extras && params.extras.length > 0) { + queryParams.append('extras', params.extras.join(',')); } if (params.limit !== undefined) { diff --git a/public/locales/ar/translation.json b/public/locales/ar/translation.json index 0ef4eab..2faa647 100644 --- a/public/locales/ar/translation.json +++ b/public/locales/ar/translation.json @@ -745,7 +745,7 @@ "dueDate": "تاريخ الاستحقاق", "deferUntil": "تأجيل حتى", "recurringSetup": "إعداد متكرر", - "notRecurring": "هذه المهمة ليست متكررة بعد.", + "notRecurring": "أضف تفاصيل التكرار", "clickToEditTitle": "انقر لتحرير العنوان", "clickToEditContent": "انقر لتحرير المحتوى", "clickToAddContent": "انقر لإضافة محتوى", @@ -1206,7 +1206,15 @@ "instances": "حالات متكررة" }, "deferUntilFilter": "تأجيل حتى", - "deferUntil": "، تأجيل حتى" + "deferUntil": "، تأجيل حتى", + "extrasFilter": { + "isRecurring": "يتكرر", + "isOverdue": "متأخر", + "hasContent": "يمتلك محتوى", + "isDeferred": "مؤجل", + "hasTags": "يمتلك علامات", + "isAssignedToProject": "مخصص لمشروع" + } }, "subtasks": { "placeholder": "أضف مهمة فرعية..." diff --git a/public/locales/bg/translation.json b/public/locales/bg/translation.json index a2f14d6..8b701c4 100644 --- a/public/locales/bg/translation.json +++ b/public/locales/bg/translation.json @@ -1206,7 +1206,15 @@ "instances": "повтарящи се инстанции" }, "deferUntilFilter": "Отложи до", - "deferUntil": ", отложи до" + "deferUntil": ", отложи до", + "extrasFilter": { + "isRecurring": "е Повтарящо се", + "isOverdue": "е Просрочено", + "hasContent": "има Съдържание", + "isDeferred": "е Отложено", + "hasTags": "има Тагове", + "isAssignedToProject": "е Назначено на Проект" + } }, "subtasks": { "placeholder": "Добавете подзадача..." diff --git a/public/locales/da/translation.json b/public/locales/da/translation.json index f18ed29..52e3bfb 100644 --- a/public/locales/da/translation.json +++ b/public/locales/da/translation.json @@ -1206,7 +1206,15 @@ "instances": "gentagende instanser" }, "deferUntilFilter": "Udskyd indtil", - "deferUntil": ", udskyd indtil" + "deferUntil": ", udskyd indtil", + "extrasFilter": { + "isRecurring": "er tilbagevendende", + "isOverdue": "er forfalden", + "hasContent": "har indhold", + "isDeferred": "er udsat", + "hasTags": "har tags", + "isAssignedToProject": "er tildelt projekt" + } }, "subtasks": { "placeholder": "Tilføj en underopgave..." diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index 7699b1f..6c93d93 100644 --- a/public/locales/de/translation.json +++ b/public/locales/de/translation.json @@ -1215,7 +1215,15 @@ "instances": "wiederkehrende Instanzen" }, "deferUntilFilter": "Bis zu", - "deferUntil": ", bis zu" + "deferUntil": ", bis zu", + "extrasFilter": { + "isRecurring": "ist wiederkehrend", + "isOverdue": "ist überfällig", + "hasContent": "hat Inhalt", + "isDeferred": "ist verschoben", + "hasTags": "hat Tags", + "isAssignedToProject": "ist einem Projekt zugewiesen" + } }, "subtasks": { "placeholder": "Fügen Sie eine Unteraufgabe hinzu..." diff --git a/public/locales/el/translation.json b/public/locales/el/translation.json index d88c911..00ad0d8 100644 --- a/public/locales/el/translation.json +++ b/public/locales/el/translation.json @@ -1210,7 +1210,15 @@ "instances": "επαναλαμβανόμενες περιπτώσεις" }, "deferUntilFilter": "Αναβολή μέχρι", - "deferUntil": ", αναβολή μέχρι" + "deferUntil": ", αναβολή μέχρι", + "extrasFilter": { + "isRecurring": "είναι Επαναλαμβανόμενο", + "isOverdue": "είναι Υπερβολικό", + "hasContent": "έχει Περιεχόμενο", + "isDeferred": "είναι Αναβληθέν", + "hasTags": "έχει Ετικέτες", + "isAssignedToProject": "είναι Ανατεθειμένο σε Έργο" + } }, "subtasks": { "placeholder": "Προσθέστε μια υποεργασία..." diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index d478bbf..aef8a66 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -723,7 +723,7 @@ "dueDate": "Due Date", "noDueDate": "No due date", "recurringSetup": "Recurring Setup", - "notRecurring": "This task is not recurring yet.", + "notRecurring": "Add recurrence details", "instanceOf": "This is an instance of a recurring task", "parentTask": "Parent Task", "clickToEditTitle": "Click to edit title", @@ -1179,11 +1179,13 @@ "deferUntilFilter": "Defer Until", "deferUntil": ", defer until", "tagsFilter": "Tags", - "recurringFilter": { - "label": "Recurring", - "recurring": "recurring templates", - "nonRecurring": "non-recurring", - "instances": "recurring instances" + "extrasFilter": { + "isRecurring": "is Recurring", + "isOverdue": "is Overdue", + "hasContent": "has Content", + "isDeferred": "is Deferred", + "hasTags": "has Tags", + "isAssignedToProject": "is Assigned to Project" }, "saveAsSmartView": "Save as Smart View", "viewName": "View Name", diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json index aa0dd77..701b68f 100644 --- a/public/locales/es/translation.json +++ b/public/locales/es/translation.json @@ -1207,7 +1207,15 @@ "instances": "instancias recurrentes" }, "deferUntilFilter": "Aplazar hasta", - "deferUntil": ", aplazar hasta" + "deferUntil": ", aplazar hasta", + "extrasFilter": { + "isRecurring": "es Recurrente", + "isOverdue": "está Vencido", + "hasContent": "tiene Contenido", + "isDeferred": "está Diferido", + "hasTags": "tiene Etiquetas", + "isAssignedToProject": "está Asignado al Proyecto" + } }, "subtasks": { "placeholder": "Agregar una subtarea..." diff --git a/public/locales/fi/translation.json b/public/locales/fi/translation.json index e18f611..e2bc84c 100644 --- a/public/locales/fi/translation.json +++ b/public/locales/fi/translation.json @@ -1206,7 +1206,15 @@ "instances": "toistuvat instanssit" }, "deferUntilFilter": "Viivästytä kunnes", - "deferUntil": ", viivästytä kunnes" + "deferUntil": ", viivästytä kunnes", + "extrasFilter": { + "isRecurring": "on Toistuva", + "isOverdue": "on Erääntynyt", + "hasContent": "on Sisältöä", + "isDeferred": "on Siirretty", + "hasTags": "on Tunnisteita", + "isAssignedToProject": "on Määrätty Projektiin" + } }, "subtasks": { "placeholder": "Lisää alitehtävä..." diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index ebaa609..68bc369 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -1206,7 +1206,15 @@ "instances": "instances récurrentes" }, "deferUntilFilter": "Différer jusqu'à", - "deferUntil": ", différer jusqu'à" + "deferUntil": ", différer jusqu'à", + "extrasFilter": { + "isRecurring": "est Récurrent", + "isOverdue": "est En Retard", + "hasContent": "a du Contenu", + "isDeferred": "est Différé", + "hasTags": "a des Étiquettes", + "isAssignedToProject": "est Assigné au Projet" + } }, "subtasks": { "placeholder": "Ajouter une sous-tâche..." diff --git a/public/locales/id/translation.json b/public/locales/id/translation.json index 7d9f722..e1633e0 100644 --- a/public/locales/id/translation.json +++ b/public/locales/id/translation.json @@ -1206,7 +1206,15 @@ "instances": "instansi berulang" }, "deferUntilFilter": "Tunda Hingga", - "deferUntil": ", tunda hingga" + "deferUntil": ", tunda hingga", + "extrasFilter": { + "isRecurring": "adalah Berulang", + "isOverdue": "adalah Terlambat", + "hasContent": "memiliki Konten", + "isDeferred": "adalah Ditunda", + "hasTags": "memiliki Tag", + "isAssignedToProject": "adalah Ditugaskan ke Proyek" + } }, "subtasks": { "placeholder": "Tambahkan subtugas..." diff --git a/public/locales/it/translation.json b/public/locales/it/translation.json index fefb30c..5213144 100644 --- a/public/locales/it/translation.json +++ b/public/locales/it/translation.json @@ -1206,7 +1206,15 @@ "instances": "istanze ricorrenti" }, "deferUntilFilter": "Rimanda fino a", - "deferUntil": ", rimanda fino a" + "deferUntil": ", rimanda fino a", + "extrasFilter": { + "isRecurring": "è Ricorrente", + "isOverdue": "è Scaduto", + "hasContent": "ha Contenuto", + "isDeferred": "è Rinviato", + "hasTags": "ha Tag", + "isAssignedToProject": "è Assegnato al Progetto" + } }, "subtasks": { "placeholder": "Aggiungi un sottocompito..." diff --git a/public/locales/jp/translation.json b/public/locales/jp/translation.json index c2d02d7..056eee0 100644 --- a/public/locales/jp/translation.json +++ b/public/locales/jp/translation.json @@ -1206,7 +1206,15 @@ "instances": "定期的なインスタンス" }, "deferUntilFilter": "フィルターまで遅延", - "deferUntil": "、遅延するまで" + "deferUntil": "、遅延するまで", + "extrasFilter": { + "isRecurring": "繰り返し", + "isOverdue": "期限切れ", + "hasContent": "コンテンツあり", + "isDeferred": "保留中", + "hasTags": "タグあり", + "isAssignedToProject": "プロジェクトに割り当てられている" + } }, "subtasks": { "placeholder": "サブタスクを追加..." diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index 0b96eb4..0607e31 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -1206,7 +1206,15 @@ "instances": "반복 인스턴스" }, "deferUntilFilter": "지연할 때까지", - "deferUntil": ", 지연할 때까지" + "deferUntil": ", 지연할 때까지", + "extrasFilter": { + "isRecurring": "반복됨", + "isOverdue": "연체됨", + "hasContent": "내용이 있음", + "isDeferred": "연기됨", + "hasTags": "태그가 있음", + "isAssignedToProject": "프로젝트에 할당됨" + } }, "subtasks": { "placeholder": "하위 작업 추가..." diff --git a/public/locales/nl/translation.json b/public/locales/nl/translation.json index a92803a..e94ce6a 100644 --- a/public/locales/nl/translation.json +++ b/public/locales/nl/translation.json @@ -1206,7 +1206,15 @@ "instances": "herhalende instanties" }, "deferUntilFilter": "Uitstellen tot", - "deferUntil": ", uitstellen tot" + "deferUntil": ", uitstellen tot", + "extrasFilter": { + "isRecurring": "is Herhalend", + "isOverdue": "is Achterstallig", + "hasContent": "heeft Inhoud", + "isDeferred": "is Uitgesteld", + "hasTags": "heeft Tags", + "isAssignedToProject": "is Toegewezen aan Project" + } }, "subtasks": { "placeholder": "Voeg een subtaak toe..." diff --git a/public/locales/no/translation.json b/public/locales/no/translation.json index 341d5e3..623c740 100644 --- a/public/locales/no/translation.json +++ b/public/locales/no/translation.json @@ -1206,7 +1206,15 @@ "instances": "gjentakende instanser" }, "deferUntilFilter": "Utsett til", - "deferUntil": ", utsett til" + "deferUntil": ", utsett til", + "extrasFilter": { + "isRecurring": "er Gjentakende", + "isOverdue": "er Forfalt", + "hasContent": "har Innhold", + "isDeferred": "er Utsatt", + "hasTags": "har Tagger", + "isAssignedToProject": "er Tildelt Prosjekt" + } }, "subtasks": { "placeholder": "Legg til en underoppgave..." diff --git a/public/locales/pl/translation.json b/public/locales/pl/translation.json index 0c1bdde..09c9b40 100644 --- a/public/locales/pl/translation.json +++ b/public/locales/pl/translation.json @@ -1206,7 +1206,15 @@ "instances": "powtarzające się instancje" }, "deferUntilFilter": "Odłóż do", - "deferUntil": ", odłóż do" + "deferUntil": ", odłóż do", + "extrasFilter": { + "isRecurring": "jest powtarzający się", + "isOverdue": "jest przeterminowany", + "hasContent": "ma zawartość", + "isDeferred": "jest odroczony", + "hasTags": "ma tagi", + "isAssignedToProject": "jest przypisany do projektu" + } }, "subtasks": { "placeholder": "Dodaj podzadanie..." diff --git a/public/locales/pt/translation.json b/public/locales/pt/translation.json index 461cfd0..6017622 100644 --- a/public/locales/pt/translation.json +++ b/public/locales/pt/translation.json @@ -1206,7 +1206,15 @@ "instances": "instâncias recorrentes" }, "deferUntilFilter": "Aguardar Até", - "deferUntil": ", aguardar até" + "deferUntil": ", aguardar até", + "extrasFilter": { + "isRecurring": "é Recorrente", + "isOverdue": "está Atrasado", + "hasContent": "tem Conteúdo", + "isDeferred": "está Adiado", + "hasTags": "tem Tags", + "isAssignedToProject": "está Atribuído ao Projeto" + } }, "subtasks": { "placeholder": "Adicionar uma subtarefa..." diff --git a/public/locales/ro/translation.json b/public/locales/ro/translation.json index fb248f8..ea5b017 100644 --- a/public/locales/ro/translation.json +++ b/public/locales/ro/translation.json @@ -1206,7 +1206,15 @@ "instances": "instanțe recurente" }, "deferUntilFilter": "Amână până la", - "deferUntil": ", amână până la" + "deferUntil": ", amână până la", + "extrasFilter": { + "isRecurring": "este Recurent", + "isOverdue": "este Întârziat", + "hasContent": "are Conținut", + "isDeferred": "este Amânat", + "hasTags": "are Etichete", + "isAssignedToProject": "este Atribuit Proiectului" + } }, "subtasks": { "placeholder": "Adaugă o subtask..." diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index fcead9a..3d9f272 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -1206,7 +1206,15 @@ "instances": "повторяющиеся экземпляры" }, "deferUntilFilter": "Отложить до", - "deferUntil": ", отложить до" + "deferUntil": ", отложить до", + "extrasFilter": { + "isRecurring": "является повторяющимся", + "isOverdue": "просрочено", + "hasContent": "имеет содержимое", + "isDeferred": "отложено", + "hasTags": "имеет теги", + "isAssignedToProject": "назначено на проект" + } }, "subtasks": { "placeholder": "Добавить подзадачу..." diff --git a/public/locales/sl/translation.json b/public/locales/sl/translation.json index 38c1f7d..c7e1c59 100644 --- a/public/locales/sl/translation.json +++ b/public/locales/sl/translation.json @@ -1206,7 +1206,15 @@ "instances": "ponavljajoče se instance" }, "deferUntilFilter": "Odloži do", - "deferUntil": ", odloži do" + "deferUntil": ", odloži do", + "extrasFilter": { + "isRecurring": "je ponavljajoče", + "isOverdue": "je zapadlo", + "hasContent": "ima vsebino", + "isDeferred": "je odloženo", + "hasTags": "ima oznake", + "isAssignedToProject": "je dodeljeno projektu" + } }, "subtasks": { "placeholder": "Dodaj podnalogo..." diff --git a/public/locales/sv/translation.json b/public/locales/sv/translation.json index 874fd1d..077fd36 100644 --- a/public/locales/sv/translation.json +++ b/public/locales/sv/translation.json @@ -1206,7 +1206,15 @@ "instances": "återkommande instanser" }, "deferUntilFilter": "Skjut upp tills", - "deferUntil": ", skjuta upp tills" + "deferUntil": ", skjuta upp tills", + "extrasFilter": { + "isRecurring": "är Återkommande", + "isOverdue": "är Försenad", + "hasContent": "har Innehåll", + "isDeferred": "är Utsatt", + "hasTags": "har Taggar", + "isAssignedToProject": "är Tilldelad Projekt" + } }, "subtasks": { "placeholder": "Lägg till en deluppgift..." diff --git a/public/locales/tr/translation.json b/public/locales/tr/translation.json index a8aae6e..4a9757d 100644 --- a/public/locales/tr/translation.json +++ b/public/locales/tr/translation.json @@ -1206,7 +1206,15 @@ "instances": "tekrarlayan örnekler" }, "deferUntilFilter": "Ertele", - "deferUntil": ", ertele" + "deferUntil": ", ertele", + "extrasFilter": { + "isRecurring": "Tekrarlayan", + "isOverdue": "Vadesi Geçmiş", + "hasContent": "İçerik Var", + "isDeferred": "Ertelenmiş", + "hasTags": "Etiket Var", + "isAssignedToProject": "Projeye Atanmış" + } }, "subtasks": { "placeholder": "Bir alt görev ekle..." diff --git a/public/locales/ua/translation.json b/public/locales/ua/translation.json index 6c65d88..d8f087f 100644 --- a/public/locales/ua/translation.json +++ b/public/locales/ua/translation.json @@ -1206,7 +1206,15 @@ "instances": "повторювані екземпляри" }, "deferUntilFilter": "Відкласти до", - "deferUntil": ", відкласти до" + "deferUntil": ", відкласти до", + "extrasFilter": { + "isRecurring": "є повторюваним", + "isOverdue": "прострочено", + "hasContent": "має вміст", + "isDeferred": "відкладено", + "hasTags": "має теги", + "isAssignedToProject": "призначено проекту" + } }, "subtasks": { "placeholder": "Додати підзадачу..." diff --git a/public/locales/vi/translation.json b/public/locales/vi/translation.json index 463cc56..e389a28 100644 --- a/public/locales/vi/translation.json +++ b/public/locales/vi/translation.json @@ -1206,7 +1206,15 @@ "instances": "các phiên bản lặp lại" }, "deferUntilFilter": "Hoãn lại cho đến", - "deferUntil": ", hoãn lại cho đến" + "deferUntil": ", hoãn lại cho đến", + "extrasFilter": { + "isRecurring": "là Lặp lại", + "isOverdue": "đã Quá hạn", + "hasContent": "có Nội dung", + "isDeferred": "đã Hoãn lại", + "hasTags": "có Thẻ", + "isAssignedToProject": "được Giao cho Dự án" + } }, "subtasks": { "placeholder": "Thêm một công việc phụ..." diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index eb1615e..8ec1cef 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -1206,7 +1206,15 @@ "instances": "循环实例" }, "deferUntilFilter": "延迟到", - "deferUntil": ",延迟到" + "deferUntil": ",延迟到", + "extrasFilter": { + "isRecurring": "是重复的", + "isOverdue": "已逾期", + "hasContent": "有内容", + "isDeferred": "已延迟", + "hasTags": "有标签", + "isAssignedToProject": "已分配给项目" + } }, "subtasks": { "placeholder": "添加子任务..." diff --git a/webpack.config.js b/webpack.config.js index eeb54b4..29de4e9 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -5,6 +5,14 @@ const CopyWebpackPlugin = require('copy-webpack-plugin'); const webpack = require('webpack'); const isDevelopment = process.env.NODE_ENV !== 'production'; +const frontendPort = parseInt(process.env.FRONTEND_PORT || '8080', 10); +const frontendHost = process.env.FRONTEND_HOST || '0.0.0.0'; +const backendUrl = process.env.BACKEND_URL || 'http://localhost:3002'; +const frontendUrl = new URL( + process.env.FRONTEND_ORIGIN || `http://localhost:${frontendPort}` +); +const frontendOrigin = frontendUrl.origin; +const frontendCookieDomain = frontendUrl.hostname; module.exports = { entry: './frontend/index.tsx', @@ -29,22 +37,22 @@ module.exports = { }, hot: isDevelopment, watchFiles: isDevelopment ? ['frontend/**/*'] : [], - port: 8080, - host: '0.0.0.0', + port: frontendPort, + host: frontendHost, historyApiFallback: true, proxy: [ { context: ['/api', '/locales'], - target: 'http://localhost:3002', + target: backendUrl, changeOrigin: true, secure: false, - cookieDomainRewrite: 'localhost', + cookieDomainRewrite: frontendCookieDomain, headers: { 'Access-Control-Allow-Origin': '*', }, onProxyRes: function (proxyRes, req, res) { proxyRes.headers['Access-Control-Allow-Origin'] = - 'http://localhost:8080'; + frontendOrigin; proxyRes.headers['Access-Control-Allow-Credentials'] = 'true'; },