From 75a1e687304b7e8873e45f4d68f3d0d391d3c8b5 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 23 Nov 2025 21:48:49 +0200 Subject: [PATCH] Tc refactor pt1 (#589) * Refactor ProfileSettings * Cleanup comments * Refactor TaskDetails * Refactor InboxModal * fixup! Refactor InboxModal * Fix project layout * Add visuals to project details * Refactor projectdetails * Remake project metrics * Complete project details refactor * Fix note issues and enhance view * Add filters * Fix project tasks filters * Add filters to task lists * Add filters to task lists * fixup! Add filters to task lists --- ...20251120000001-add-ui-settings-to-users.js | 27 + backend/models/user.js | 11 + backend/routes/projects.js | 2 +- .../routes/tasks/queries/query-builders.js | 60 +- backend/routes/users.js | 53 + backend/seeders/massive-tasks.js | 398 +++- frontend/App.tsx | 2 +- frontend/Layout.tsx | 6 +- frontend/components/Inbox/InboxItemDetail.tsx | 72 +- frontend/components/Inbox/InboxItems.tsx | 72 +- frontend/components/Inbox/InboxModal.tsx | 445 +---- .../components/Inbox/InboxNotification.tsx | 0 .../components/Inbox/InboxSelectedChips.tsx | 143 ++ .../components/Inbox/SuggestionsDropdown.tsx | 49 + frontend/components/Notes.tsx | 23 +- .../components/Profile/ProfileSettings.tsx | 1753 ++--------------- frontend/components/Profile/tabs/AiTab.tsx | 198 ++ .../components/Profile/tabs/ApiKeysTab.tsx | 340 ++++ .../components/Profile/tabs/GeneralTab.tsx | 212 ++ .../Profile/tabs/ProductivityTab.tsx | 65 + .../components/Profile/tabs/SecurityTab.tsx | 168 ++ frontend/components/Profile/tabs/TabsNav.tsx | 40 + .../components/Profile/tabs/TelegramTab.tsx | 432 ++++ frontend/components/Profile/types.ts | 42 + frontend/components/Project/ProjectBanner.tsx | 175 ++ .../components/Project/ProjectDetails.tsx | 1675 +++++++--------- .../Project/ProjectInsightsPanel.tsx | 460 +++++ frontend/components/Project/ProjectModal.tsx | 57 +- .../Project/ProjectNotesSection.tsx | 63 + .../Project/ProjectTasksSection.tsx | 97 + .../components/Project/useProjectMetrics.ts | 527 +++++ .../components/Shared/IconSortDropdown.tsx | 17 +- frontend/components/Shared/NoteCard.tsx | 2 +- frontend/components/Sidebar/SidebarNav.tsx | 12 +- frontend/components/Task/GroupedTaskList.tsx | 128 +- frontend/components/Task/NewTask.tsx | 4 +- frontend/components/Task/TaskDetails.tsx | 833 ++------ ...ContentSection.tsx => TaskContentCard.tsx} | 6 +- .../Task/TaskDetails/TaskDueDateCard.tsx | 191 ++ ...ioritySection.tsx => TaskPriorityCard.tsx} | 6 +- ...ProjectSection.tsx => TaskProjectCard.tsx} | 101 +- .../Task/TaskDetails/TaskRecurrenceCard.tsx | 302 +++ .../Task/TaskDetails/TaskSubtasksCard.tsx | 127 ++ .../{TaskTagsSection.tsx => TaskTagsCard.tsx} | 6 +- frontend/components/Task/TaskDetails/index.ts | 11 +- frontend/components/Task/TaskItem.tsx | 5 +- frontend/components/Tasks.tsx | 495 ++++- frontend/components/ViewDetail.tsx | 182 +- frontend/utils/tasksService.ts | 14 +- public/locales/ar/translation.json | 28 +- public/locales/bg/translation.json | 28 +- public/locales/da/translation.json | 28 +- public/locales/de/translation.json | 28 +- public/locales/el/translation.json | 28 +- public/locales/en/translation.json | 33 +- public/locales/es/translation.json | 28 +- public/locales/fi/translation.json | 28 +- public/locales/fr/translation.json | 28 +- public/locales/id/translation.json | 28 +- public/locales/it/translation.json | 28 +- public/locales/jp/translation.json | 28 +- public/locales/ko/translation.json | 28 +- public/locales/nl/translation.json | 28 +- public/locales/no/translation.json | 28 +- public/locales/pl/translation.json | 28 +- public/locales/pt/translation.json | 28 +- public/locales/ro/translation.json | 28 +- public/locales/ru/translation.json | 28 +- public/locales/sl/translation.json | 28 +- public/locales/sv/translation.json | 28 +- public/locales/tr/translation.json | 28 +- public/locales/ua/translation.json | 28 +- public/locales/vi/translation.json | 28 +- public/locales/zh/translation.json | 28 +- 74 files changed, 6635 insertions(+), 4179 deletions(-) create mode 100644 backend/migrations/20251120000001-add-ui-settings-to-users.js delete mode 100644 frontend/components/Inbox/InboxNotification.tsx create mode 100644 frontend/components/Inbox/InboxSelectedChips.tsx create mode 100644 frontend/components/Inbox/SuggestionsDropdown.tsx create mode 100644 frontend/components/Profile/tabs/AiTab.tsx create mode 100644 frontend/components/Profile/tabs/ApiKeysTab.tsx create mode 100644 frontend/components/Profile/tabs/GeneralTab.tsx create mode 100644 frontend/components/Profile/tabs/ProductivityTab.tsx create mode 100644 frontend/components/Profile/tabs/SecurityTab.tsx create mode 100644 frontend/components/Profile/tabs/TabsNav.tsx create mode 100644 frontend/components/Profile/tabs/TelegramTab.tsx create mode 100644 frontend/components/Profile/types.ts create mode 100644 frontend/components/Project/ProjectBanner.tsx create mode 100644 frontend/components/Project/ProjectInsightsPanel.tsx create mode 100644 frontend/components/Project/ProjectNotesSection.tsx create mode 100644 frontend/components/Project/ProjectTasksSection.tsx create mode 100644 frontend/components/Project/useProjectMetrics.ts rename frontend/components/Task/TaskDetails/{TaskContentSection.tsx => TaskContentCard.tsx} (98%) create mode 100644 frontend/components/Task/TaskDetails/TaskDueDateCard.tsx rename frontend/components/Task/TaskDetails/{TaskPrioritySection.tsx => TaskPriorityCard.tsx} (95%) rename frontend/components/Task/TaskDetails/{TaskProjectSection.tsx => TaskProjectCard.tsx} (55%) create mode 100644 frontend/components/Task/TaskDetails/TaskRecurrenceCard.tsx create mode 100644 frontend/components/Task/TaskDetails/TaskSubtasksCard.tsx rename frontend/components/Task/TaskDetails/{TaskTagsSection.tsx => TaskTagsCard.tsx} (97%) diff --git a/backend/migrations/20251120000001-add-ui-settings-to-users.js b/backend/migrations/20251120000001-add-ui-settings-to-users.js new file mode 100644 index 0000000..222f4ce --- /dev/null +++ b/backend/migrations/20251120000001-add-ui-settings-to-users.js @@ -0,0 +1,27 @@ +'use strict'; + +const { + safeAddColumns, + safeRemoveColumn, +} = require('../utils/migration-utils'); + +module.exports = { + async up(queryInterface, Sequelize) { + await safeAddColumns(queryInterface, 'users', [ + { + name: 'ui_settings', + definition: { + type: Sequelize.JSON, + allowNull: true, + defaultValue: JSON.stringify({ + project: { details: { showMetrics: true } }, + }), + }, + }, + ]); + }, + + async down(queryInterface) { + await safeRemoveColumn(queryInterface, 'users', 'ui_settings'); + }, +}; diff --git a/backend/models/user.js b/backend/models/user.js index 6389bb5..e20857c 100644 --- a/backend/models/user.js +++ b/backend/models/user.js @@ -165,6 +165,17 @@ module.exports = (sequelize) => { pinnedViewsOrder: [], }, }, + ui_settings: { + type: DataTypes.JSON, + allowNull: true, + defaultValue: { + project: { + details: { + showMetrics: true, + }, + }, + }, + }, }, { tableName: 'users', diff --git a/backend/routes/projects.js b/backend/routes/projects.js index 34957e3..66eeb26 100644 --- a/backend/routes/projects.js +++ b/backend/routes/projects.js @@ -64,7 +64,7 @@ const storage = multer.diskStorage({ const upload = multer({ storage: storage, limits: { - fileSize: 5 * 1024 * 1024, // 5MB limit + fileSize: 10 * 1024 * 1024, // 10MB limit }, fileFilter: function (req, file, cb) { const allowedTypes = /jpeg|jpg|png|gif|webp/; diff --git a/backend/routes/tasks/queries/query-builders.js b/backend/routes/tasks/queries/query-builders.js index 1693b08..bb60cdd 100644 --- a/backend/routes/tasks/queries/query-builders.js +++ b/backend/routes/tasks/queries/query-builders.js @@ -180,8 +180,24 @@ async function filterTasksByParams( ], }; - if (params.status === 'done') { - whereClause.status = { [Op.in]: [Task.STATUS.DONE, 'done'] }; + if (params.status === 'done' || params.status === 'completed') { + whereClause.status = { + [Op.in]: [ + Task.STATUS.DONE, + Task.STATUS.ARCHIVED, + 'done', + 'archived', + ], + }; + } else if (params.status === 'active') { + whereClause.status = { + [Op.notIn]: [ + Task.STATUS.DONE, + Task.STATUS.ARCHIVED, + 'done', + 'archived', + ], + }; } else if (!params.client_side_filtering) { whereClause.status = { [Op.notIn]: [Task.STATUS.DONE, 'done'] }; } @@ -205,8 +221,24 @@ async function filterTasksByParams( whereClause.status = Task.STATUS.WAITING; break; case 'all': - if (params.status === 'done') { - whereClause.status = { [Op.in]: [Task.STATUS.DONE, 'done'] }; + if (params.status === 'done' || params.status === 'completed') { + whereClause.status = { + [Op.in]: [ + Task.STATUS.DONE, + Task.STATUS.ARCHIVED, + 'done', + 'archived', + ], + }; + } else if (params.status === 'active') { + whereClause.status = { + [Op.notIn]: [ + Task.STATUS.DONE, + Task.STATUS.ARCHIVED, + 'done', + 'archived', + ], + }; } else if (!params.client_side_filtering) { whereClause.status = { [Op.notIn]: [Task.STATUS.DONE, 'done'] }; } @@ -215,8 +247,24 @@ async function filterTasksByParams( if (!params.include_instances) { whereClause.recurring_parent_id = null; } - if (params.status === 'done') { - whereClause.status = { [Op.in]: [Task.STATUS.DONE, 'done'] }; + if (params.status === 'done' || params.status === 'completed') { + whereClause.status = { + [Op.in]: [ + Task.STATUS.DONE, + Task.STATUS.ARCHIVED, + 'done', + 'archived', + ], + }; + } else if (params.status === 'active') { + whereClause.status = { + [Op.notIn]: [ + Task.STATUS.DONE, + Task.STATUS.ARCHIVED, + 'done', + 'archived', + ], + }; } else if (!params.client_side_filtering) { whereClause.status = { [Op.notIn]: [Task.STATUS.DONE, 'done'] }; } diff --git a/backend/routes/users.js b/backend/routes/users.js index 66d57cf..c8bfa40 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -145,6 +145,14 @@ router.get('/profile', async (req, res) => { user.today_settings = null; } } + if (user.ui_settings && typeof user.ui_settings === 'string') { + try { + user.ui_settings = JSON.parse(user.ui_settings); + } catch (error) { + logError('Error parsing ui_settings:', error); + user.ui_settings = null; + } + } res.json(user); } catch (error) { @@ -177,6 +185,7 @@ router.patch('/profile', async (req, res) => { productivity_assistant_enabled, next_task_suggestion_enabled, pomodoro_enabled, + ui_settings, currentPassword, newPassword, } = req.body; @@ -213,6 +222,7 @@ router.patch('/profile', async (req, res) => { next_task_suggestion_enabled; if (pomodoro_enabled !== undefined) allowedUpdates.pomodoro_enabled = pomodoro_enabled; + if (ui_settings !== undefined) allowedUpdates.ui_settings = ui_settings; // Validate first_day_of_week if provided if (first_day_of_week !== undefined) { @@ -644,6 +654,7 @@ router.put('/profile/today-settings', async (req, res) => { const { showMetrics, + projectShowMetrics, showProductivity, showNextTaskSuggestion, showSuggestions, @@ -654,6 +665,10 @@ router.put('/profile/today-settings', async (req, res) => { } = req.body; const todaySettings = { + projectShowMetrics: + projectShowMetrics !== undefined + ? projectShowMetrics + : (user.today_settings?.projectShowMetrics ?? true), showMetrics: showMetrics !== undefined ? showMetrics @@ -743,4 +758,42 @@ router.put('/profile/sidebar-settings', async (req, res) => { } }); +// Update generic UI settings (e.g., project metrics preferences) +router.put('/profile/ui-settings', async (req, res) => { + try { + const user = await User.findByPk(req.authUserId); + if (!user) { + return res.status(404).json({ error: 'User not found.' }); + } + + const { project } = req.body; + + const currentSettings = (user.ui_settings && + typeof user.ui_settings === 'object' + ? user.ui_settings + : {}) || { project: { details: {} } }; + + const newSettings = { + ...currentSettings, + project: { + ...(currentSettings.project || {}), + ...(project || {}), + details: { + ...((currentSettings.project && + currentSettings.project.details) || + {}), + ...((project && project.details) || {}), + }, + }, + }; + + await user.update({ ui_settings: newSettings }); + + res.json({ success: true, ui_settings: newSettings }); + } catch (error) { + logError('Error updating ui settings:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + module.exports = router; diff --git a/backend/seeders/massive-tasks.js b/backend/seeders/massive-tasks.js index 6e77e20..1ab481d 100644 --- a/backend/seeders/massive-tasks.js +++ b/backend/seeders/massive-tasks.js @@ -520,12 +520,73 @@ function createMassiveTaskData(projects, getRandomDate, getPastDate) { }, // Investment Portfolio - triggers financial AI features + // Research & Analysis Tasks { name: 'Research ESG investment options', project_id: projects[5].id, priority: 1, + status: 2, + completed_at: getPastDate(15), + }, + { + name: 'Analyze S&P 500 index fund options', + project_id: projects[5].id, + priority: 2, + status: 2, + completed_at: getPastDate(20), + }, + { + name: 'Research low-cost bond index funds', + project_id: projects[5].id, + priority: 1, + status: 2, + completed_at: getPastDate(18), + }, + { + name: 'Compare Vanguard vs Fidelity vs Schwab platforms', + project_id: projects[5].id, + priority: 2, + status: 2, + completed_at: getPastDate(25), + }, + { + name: 'Research international market exposure', + project_id: projects[5].id, + priority: 1, + status: 1, + }, + { + name: 'Analyze emerging markets funds (VWO, IEMG)', + project_id: projects[5].id, + priority: 1, status: 0, }, + { + name: 'Research REIT investment opportunities', + project_id: projects[5].id, + priority: 1, + status: 0, + }, + { + name: 'Compare target-date retirement funds', + project_id: projects[5].id, + priority: 1, + status: 1, + }, + { + name: 'Research dividend aristocrats stocks', + project_id: projects[5].id, + priority: 0, + status: 0, + }, + { + name: 'Analyze tech sector ETF options (QQQ, VGT)', + project_id: projects[5].id, + priority: 1, + status: 0, + }, + + // Portfolio Management Tasks { name: 'Rebalance portfolio allocation', project_id: projects[5].id, @@ -536,22 +597,353 @@ function createMassiveTaskData(projects, getRandomDate, getPastDate) { { name: 'Review quarterly performance', project_id: projects[5].id, - priority: 1, + priority: 2, status: 1, }, { - name: 'Set up automatic dividend reinvestment', + name: 'Calculate portfolio risk metrics (Sharpe ratio)', project_id: projects[5].id, priority: 1, status: 0, }, { - name: 'Research international market exposure', + name: 'Review asset allocation percentages', + project_id: projects[5].id, + priority: 2, + status: 0, + due_date: getRandomDate(7), + }, + { + name: 'Analyze portfolio expense ratios', + project_id: projects[5].id, + priority: 1, + status: 1, + }, + { + name: 'Review and optimize tax-loss harvesting', + project_id: projects[5].id, + priority: 2, + status: 0, + }, + { + name: 'Check portfolio diversification metrics', + project_id: projects[5].id, + priority: 1, + status: 0, + }, + { + name: 'Calculate year-to-date returns', + project_id: projects[5].id, + priority: 1, + status: 1, + }, + + // Account Setup & Administration + { + name: 'Open Vanguard brokerage account', + project_id: projects[5].id, + priority: 2, + status: 2, + completed_at: getPastDate(30), + }, + { + name: 'Set up automatic dividend reinvestment', + project_id: projects[5].id, + priority: 2, + status: 2, + completed_at: getPastDate(10), + }, + { + name: 'Configure automatic monthly contributions', + project_id: projects[5].id, + priority: 2, + status: 2, + completed_at: getPastDate(12), + }, + { + name: 'Link bank account for transfers', + project_id: projects[5].id, + priority: 2, + status: 2, + completed_at: getPastDate(28), + }, + { + name: 'Set up 2-factor authentication', + project_id: projects[5].id, + priority: 2, + status: 2, + completed_at: getPastDate(27), + }, + { + name: 'Configure email alerts for large transactions', + project_id: projects[5].id, + priority: 1, + status: 2, + completed_at: getPastDate(8), + }, + { + name: 'Set up account beneficiaries', + project_id: projects[5].id, + priority: 2, + status: 0, + due_date: getRandomDate(14), + }, + { + name: 'Review account security settings', + project_id: projects[5].id, + priority: 1, + status: 0, + }, + + // Purchases & Transactions + { + name: 'Purchase VTSAX (Vanguard Total Stock)', + project_id: projects[5].id, + priority: 2, + status: 2, + completed_at: getPastDate(5), + }, + { + name: 'Purchase VBTLX (Vanguard Total Bond)', + project_id: projects[5].id, + priority: 2, + status: 2, + completed_at: getPastDate(5), + }, + { + name: 'Purchase VTIAX (Vanguard International)', + project_id: projects[5].id, + priority: 2, + status: 2, + completed_at: getPastDate(4), + }, + { + name: 'Make $1000 monthly contribution', + project_id: projects[5].id, + priority: 2, + status: 0, + due_date: getRandomDate(3), + }, + { + name: 'Sell underperforming position', + project_id: projects[5].id, + priority: 1, + status: 0, + }, + { + name: 'Execute rebalancing trades', + project_id: projects[5].id, + priority: 2, + status: 0, + due_date: getRandomDate(10), + }, + + // Tax Planning & Documentation + { + name: 'Download tax documents for filing', + project_id: projects[5].id, + priority: 2, + status: 0, + due_date: getRandomDate(30), + }, + { + name: 'Review capital gains/losses for tax year', + project_id: projects[5].id, + priority: 2, + status: 0, + }, + { + name: 'Maximize IRA contribution for year', + project_id: projects[5].id, + priority: 2, + status: 1, + }, + { + name: 'Research Roth IRA conversion strategy', + project_id: projects[5].id, + priority: 1, + status: 0, + }, + { + name: 'Track cost basis for all positions', + project_id: projects[5].id, + priority: 1, + status: 0, + }, + { + name: 'Document investment decisions for records', project_id: projects[5].id, priority: 0, status: 0, }, + // Education & Learning + { + name: 'Read "The Simple Path to Wealth" book', + project_id: projects[5].id, + priority: 1, + status: 2, + completed_at: getPastDate(40), + }, + { + name: 'Read "A Random Walk Down Wall Street"', + project_id: projects[5].id, + priority: 1, + status: 1, + }, + { + name: 'Complete Bogleheads investment course', + project_id: projects[5].id, + priority: 1, + status: 0, + }, + { + name: 'Watch Warren Buffett shareholder letters', + project_id: projects[5].id, + priority: 0, + status: 0, + }, + { + name: 'Join r/Bogleheads community discussions', + project_id: projects[5].id, + priority: 0, + status: 2, + completed_at: getPastDate(35), + }, + { + name: 'Subscribe to investment newsletter', + project_id: projects[5].id, + priority: 0, + status: 2, + completed_at: getPastDate(22), + }, + { + name: 'Learn about modern portfolio theory', + project_id: projects[5].id, + priority: 1, + status: 0, + }, + + // Monitoring & Review + { + name: 'Set up portfolio tracking spreadsheet', + project_id: projects[5].id, + priority: 1, + status: 2, + completed_at: getPastDate(20), + }, + { + name: 'Create monthly performance dashboard', + project_id: projects[5].id, + priority: 1, + status: 1, + }, + { + name: 'Review portfolio monthly (recurring)', + project_id: projects[5].id, + priority: 2, + status: 0, + due_date: getRandomDate(5), + }, + { + name: 'Track expenses and fee analysis', + project_id: projects[5].id, + priority: 1, + status: 0, + }, + { + name: 'Monitor market volatility and VIX', + project_id: projects[5].id, + priority: 0, + status: 0, + }, + { + name: 'Review inflation-adjusted returns', + project_id: projects[5].id, + priority: 1, + status: 0, + }, + + // Strategy & Planning + { + name: 'Define investment time horizon', + project_id: projects[5].id, + priority: 2, + status: 2, + completed_at: getPastDate(45), + }, + { + name: 'Set retirement savings goals', + project_id: projects[5].id, + priority: 2, + status: 2, + completed_at: getPastDate(42), + }, + { + name: 'Create investment policy statement', + project_id: projects[5].id, + priority: 2, + status: 1, + }, + { + name: 'Plan asset allocation glide path', + project_id: projects[5].id, + priority: 1, + status: 0, + }, + { + name: 'Define risk tolerance level', + project_id: projects[5].id, + priority: 2, + status: 2, + completed_at: getPastDate(38), + }, + { + name: 'Create emergency fund strategy', + project_id: projects[5].id, + priority: 2, + status: 2, + completed_at: getPastDate(50), + }, + { + name: 'Plan for major life events (house, kids)', + project_id: projects[5].id, + priority: 1, + status: 0, + }, + + // Advanced Topics + { + name: 'Research options trading strategies', + project_id: projects[5].id, + priority: 0, + status: 0, + }, + { + name: 'Explore cryptocurrency allocation (5% max)', + project_id: projects[5].id, + priority: 0, + status: 0, + }, + { + name: 'Research factor investing (value, momentum)', + project_id: projects[5].id, + priority: 1, + status: 0, + }, + { + name: 'Analyze sector rotation strategies', + project_id: projects[5].id, + priority: 0, + status: 0, + }, + { + name: 'Review alternative investments (gold, commodities)', + project_id: projects[5].id, + priority: 1, + status: 0, + }, + // Side Business - triggers entrepreneurship AI features { name: 'Create business plan document', diff --git a/frontend/App.tsx b/frontend/App.tsx index d3baadc..3389e92 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -3,7 +3,7 @@ import { Routes, Route, Navigate, Outlet } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import Login from './components/Login'; import NotFound from './components/Shared/NotFound'; -import ProjectDetails from './components/Project/ProjectDetails'; +import ProjectDetails from './components/Project/ProjectDetails.tsx'; import Projects from './components/Projects'; import AreaDetails from './components/Area/AreaDetails'; import Areas from './components/Areas'; diff --git a/frontend/Layout.tsx b/frontend/Layout.tsx index 8ea4969..3f7bb1b 100644 --- a/frontend/Layout.tsx +++ b/frontend/Layout.tsx @@ -338,11 +338,7 @@ const Layout: React.FC = ({ } }; - const mainContentMarginLeft = isUpcomingView - ? 'ml-0' - : isSidebarOpen - ? 'ml-72' - : 'ml-0'; + const mainContentMarginLeft = isSidebarOpen ? 'ml-72' : 'ml-0'; const isLoading = isNotesLoading || diff --git a/frontend/components/Inbox/InboxItemDetail.tsx b/frontend/components/Inbox/InboxItemDetail.tsx index 7850e22..c1c3c46 100644 --- a/frontend/components/Inbox/InboxItemDetail.tsx +++ b/frontend/components/Inbox/InboxItemDetail.tsx @@ -19,7 +19,6 @@ import { useStore } from '../../store/useStore'; interface InboxItemDetailProps { item: InboxItem; - onProcess: (uid: string) => void; onDelete: (uid: string) => void; onUpdate?: (uid: string) => Promise; openTaskModal: (task: Task, inboxItemUid?: string) => void; @@ -30,7 +29,6 @@ interface InboxItemDetailProps { const InboxItemDetail: React.FC = ({ item, - onProcess, // eslint-disable-line @typescript-eslint/no-unused-vars onDelete, onUpdate, openTaskModal, @@ -51,7 +49,6 @@ const InboxItemDetail: React.FC = ({ `dropdown-${Math.random().toString(36).substr(2, 9)}` ).current; - // Close dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (isDropdownOpen && buttonRef.current) { @@ -68,7 +65,6 @@ const InboxItemDetail: React.FC = ({ } }; - // Listen for custom event to close this dropdown when another opens const handleCloseOtherDropdowns = (event: CustomEvent) => { if (event.detail.dropdownId !== dropdownId && isDropdownOpen) { setIsDropdownOpen(false); @@ -92,21 +88,16 @@ const InboxItemDetail: React.FC = ({ }; }, [isDropdownOpen, dropdownId]); - // Helper function to parse hashtags from text (consecutive groups anywhere) const parseHashtags = (text: string): string[] => { const trimmedText = text.trim(); const matches: string[] = []; - // Split text into words const words = trimmedText.split(/\s+/); if (words.length === 0) return matches; - // Find all consecutive groups of tags/projects let i = 0; while (i < words.length) { - // Check if current word starts a tag/project group if (words[i].startsWith('#') || words[i].startsWith('+')) { - // Found start of a group, collect all consecutive tags/projects let groupEnd = i; while ( groupEnd < words.length && @@ -116,7 +107,6 @@ const InboxItemDetail: React.FC = ({ groupEnd++; } - // Process all hashtags in this group for (let j = i; j < groupEnd; j++) { if (words[j].startsWith('#')) { const tagName = words[j].substring(1); @@ -130,7 +120,6 @@ const InboxItemDetail: React.FC = ({ } } - // Skip to end of this group i = groupEnd; } else { i++; @@ -140,20 +129,15 @@ const InboxItemDetail: React.FC = ({ return matches; }; - // Helper function to parse project references from text (consecutive groups anywhere) const parseProjectRefs = (text: string): string[] => { const trimmedText = text.trim(); const matches: string[] = []; - // Tokenize the text handling quoted strings properly const tokens = tokenizeText(trimmedText); - // Find consecutive groups of tags/projects let i = 0; while (i < tokens.length) { - // Check if current token starts a tag/project group if (tokens[i].startsWith('#') || tokens[i].startsWith('+')) { - // Found start of a group, collect all consecutive tags/projects let groupEnd = i; while ( groupEnd < tokens.length && @@ -163,12 +147,10 @@ const InboxItemDetail: React.FC = ({ groupEnd++; } - // Process all project references in this group for (let j = i; j < groupEnd; j++) { if (tokens[j].startsWith('+')) { let projectName = tokens[j].substring(1); - // Handle quoted project names if ( projectName.startsWith('"') && projectName.endsWith('"') @@ -182,7 +164,6 @@ const InboxItemDetail: React.FC = ({ } } - // Skip to end of this group i = groupEnd; } else { i++; @@ -192,7 +173,6 @@ const InboxItemDetail: React.FC = ({ return matches; }; - // Helper function to tokenize text handling quoted strings const tokenizeText = (text: string): string[] => { const tokens: string[] = []; let currentToken = ''; @@ -203,27 +183,22 @@ const InboxItemDetail: React.FC = ({ const char = text[i]; if (char === '"' && (i === 0 || text[i - 1] === '+')) { - // Start of a quoted string after + inQuotes = true; currentToken += char; } else if (char === '"' && inQuotes) { - // End of quoted string inQuotes = false; currentToken += char; } else if (char === ' ' && !inQuotes) { - // Space outside quotes - end current token if (currentToken) { tokens.push(currentToken); currentToken = ''; } } else { - // Regular character currentToken += char; } i++; } - // Add final token if (currentToken) { tokens.push(currentToken); } @@ -231,7 +206,6 @@ const InboxItemDetail: React.FC = ({ return tokens; }; - // Helper function to clean text by removing tags and project references (consecutive groups anywhere) const cleanTextFromTagsAndProjects = (text: string): string => { const trimmedText = text.trim(); const tokens = tokenizeText(trimmedText); @@ -239,9 +213,7 @@ const InboxItemDetail: React.FC = ({ let i = 0; while (i < tokens.length) { - // Check if current token starts a tag/project group if (tokens[i].startsWith('#') || tokens[i].startsWith('+')) { - // Skip this entire consecutive group while ( i < tokens.length && (tokens[i].startsWith('#') || tokens[i].startsWith('+')) @@ -249,7 +221,6 @@ const InboxItemDetail: React.FC = ({ i++; } } else { - // Keep regular tokens cleanedTokens.push(tokens[i]); i++; } @@ -264,9 +235,7 @@ const InboxItemDetail: React.FC = ({ const handleConvertToTask = () => { try { - // Convert hashtags to Tag objects const taskTags = hashtags.map((hashtagName) => { - // Find existing tag or create a placeholder for new tag const existingTag = tags.find( (tag) => tag.name.toLowerCase() === hashtagName.toLowerCase() @@ -274,10 +243,8 @@ const InboxItemDetail: React.FC = ({ return existingTag || { name: hashtagName }; }); - // Find the project to assign (use first project reference if any) let projectId = undefined; if (projectRefs.length > 0) { - // Look for an existing project with the first project reference name const projectName = projectRefs[0]; const matchingProject = projects.find( (project) => @@ -309,9 +276,7 @@ const InboxItemDetail: React.FC = ({ const handleConvertToProject = () => { try { - // Convert hashtags to Tag objects (ignore any existing project references) const projectTags = hashtags.map((hashtagName) => { - // Find existing tag or create a placeholder for new tag const existingTag = tags.find( (tag) => tag.name.toLowerCase() === hashtagName.toLowerCase() @@ -350,13 +315,8 @@ const InboxItemDetail: React.FC = ({ if (isUrl(item.content.trim())) { setLoading(true); try { - // Add a timeout to prevent infinite loading - const timeoutPromise = new Promise( - (_, reject) => - setTimeout( - () => reject(new Error('Timeout')), - 10000 - ) // 10 second timeout + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout')), 10000) ); const result = (await Promise.race([ @@ -371,8 +331,6 @@ const InboxItemDetail: React.FC = ({ } } catch (titleError) { console.error('Error extracting URL title:', titleError); - // Continue with default title if URL title extraction fails - // Still mark as bookmark if it's a URL isBookmark = true; } finally { setLoading(false); @@ -383,28 +341,22 @@ const InboxItemDetail: React.FC = ({ setLoading(false); } - // Convert hashtags to Tag objects and include bookmark tag if needed const hashtagTags = hashtags.map((hashtagName) => { - // Find existing tag or create a placeholder for new tag const existingTag = tags.find( (tag) => tag.name.toLowerCase() === hashtagName.toLowerCase() ); return existingTag || { name: hashtagName }; }); - // Combine hashtag tags with bookmark tag if it's a URL const bookmarkTag = isBookmark ? [{ name: 'bookmark' }] : []; const tagObjects = [...hashtagTags, ...bookmarkTag]; - // Use cleaned content for note title if no URL title was extracted const finalTitle = title === content ? cleanedContent || item.content : title; const finalContent = cleanedContent || item.content; - // Find the project to assign (use first project reference if any) let projectId = undefined; if (projectRefs.length > 0) { - // Look for an existing project with the first project reference name const projectName = projectRefs[0]; const matchingProject = projects.find( (project) => @@ -459,17 +411,14 @@ const InboxItemDetail: React.FC = ({ {cleanedContent || item.content} - {/* Tags and Projects display - TaskHeader style */} {(hashtags.length > 0 || projectRefs.length > 0) && (
- {/* Projects display first */} {projectRefs.length > 0 && (
{projectRefs.map( (projectRef, index) => { - // Find matching project const matchingProject = projects.find( (project) => @@ -526,12 +475,10 @@ const InboxItemDetail: React.FC = ({
)} - {/* Add spacing between project and tags */} {projectRefs.length > 0 && hashtags.length > 0 && ( )} - {/* Tags display */} {hashtags.length > 0 && (
@@ -558,11 +505,9 @@ const InboxItemDetail: React.FC = ({ )}
- {/* Desktop view (md and larger) */}
{loading &&
} - {/* Edit Button */} - {/* Convert to Task Button */} - {/* Convert to Project Button */} - {/* Convert to Note Button */} - {/* Delete Button */}
- {/* Mobile 3-dot dropdown menu */}
{loading &&
} - {/* Dropdown Menu - Positioned Relatively */} {isDropdownOpen && (
= ({ onClick={(e) => e.stopPropagation()} >
- {/* Edit Button */} - {/* Convert to Task Button */} - {/* Convert to Project Button */} - {/* Convert to Note Button */} - {/* Delete Button */}
- {/* Info section below title row */}
{ } overflow-hidden`} >
- {/* Large low-opacity info icon */}
@@ -525,7 +477,6 @@ const InboxItems: React.FC = () => { { ))}
- {/* Load more button */} {pagination.hasMore && (
)} - {/* Pagination info */} {inboxItems.length > 0 && (
{t( @@ -597,8 +546,6 @@ const InboxItems: React.FC = () => {
)} - {/* Task Modal - Always render it but control visibility with isOpen */} - {/* Add error boundary protection for modal rendering */} {(() => { try { return ( @@ -610,7 +557,7 @@ const InboxItems: React.FC = () => { }} task={taskToEdit || defaultTask} onSave={handleSaveTask} - onDelete={async () => {}} // No need to delete since it's a new task + onDelete={async () => {}} projects={ Array.isArray(projects) ? projects : [] } @@ -624,7 +571,6 @@ const InboxItems: React.FC = () => { } })()} - {/* Project Modal - Only render when needed to prevent infinite loops */} {(() => { return ( isProjectModalOpen && @@ -655,7 +601,6 @@ const InboxItems: React.FC = () => { ); })()} - {/* Note Modal - Always render it but control visibility with isOpen */} {(() => { try { return ( @@ -679,7 +624,6 @@ const InboxItems: React.FC = () => { } })()} - {/* Edit Inbox Item Modal */} {isEditModalOpen && itemToEdit !== null && ( void; onSave: (task: Task) => Promise; - onSaveNote?: (note: Note) => Promise; // For note creation + onSaveNote?: (note: Note) => Promise; initialText?: string; editMode?: boolean; onEdit?: (text: string) => Promise; - onConvertToTask?: () => Promise; // Called when editing item gets converted to task - onConvertToNote?: () => Promise; // Called when editing item gets converted to note - projects?: Project[]; // Projects passed as props to avoid duplicate API calls + onConvertToTask?: () => Promise; + onConvertToNote?: () => Promise; + projects?: Project[]; } const InboxModal: React.FC = ({ @@ -57,7 +56,6 @@ const InboxModal: React.FC = ({ const [filteredTags, setFilteredTags] = useState([]); const [showProjectSuggestions, setShowProjectSuggestions] = useState(false); const [filteredProjects, setFilteredProjects] = useState([]); - // Use projects from props instead of local state const projects = propProjects; const [cursorPosition, setCursorPosition] = useState(0); const [, setCurrentHashtagQuery] = useState(''); @@ -67,9 +65,7 @@ const InboxModal: React.FC = ({ top: 0, }); const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); - // const [urlPreview, setUrlPreview] = useState(null); - // Real-time text analysis state const [analysisResult, setAnalysisResult] = useState<{ parsed_tags: string[]; parsed_projects: string[]; @@ -80,23 +76,16 @@ const InboxModal: React.FC = ({ const [isAnalyzing, setIsAnalyzing] = useState(false); const analysisTimeoutRef = useRef(); - // Dispatch global modal events to hide floating + button - - // Helper function to parse hashtags from text (consecutive groups anywhere) const parseHashtags = (text: string): string[] => { const trimmedText = text.trim(); const matches: string[] = []; - // Split text into words const words = trimmedText.split(/\s+/); if (words.length === 0) return matches; - // Find all consecutive groups of tags/projects let i = 0; while (i < words.length) { - // Check if current word starts a tag/project group if (words[i].startsWith('#') || words[i].startsWith('+')) { - // Found start of a group, collect all consecutive tags/projects let groupEnd = i; while ( groupEnd < words.length && @@ -106,7 +95,6 @@ const InboxModal: React.FC = ({ groupEnd++; } - // Process all hashtags in this group for (let j = i; j < groupEnd; j++) { if (words[j].startsWith('#')) { const tagName = words[j].substring(1); @@ -120,7 +108,6 @@ const InboxModal: React.FC = ({ } } - // Skip to end of this group i = groupEnd; } else { i++; @@ -130,20 +117,15 @@ const InboxModal: React.FC = ({ return matches; }; - // Helper function to parse project references from text (consecutive groups anywhere) const parseProjectRefs = (text: string): string[] => { const trimmedText = text.trim(); const matches: string[] = []; - // Tokenize the text handling quoted strings properly const tokens = tokenizeText(trimmedText); - // Find consecutive groups of tags/projects let i = 0; while (i < tokens.length) { - // Check if current token starts a tag/project group if (tokens[i].startsWith('#') || tokens[i].startsWith('+')) { - // Found start of a group, collect all consecutive tags/projects let groupEnd = i; while ( groupEnd < tokens.length && @@ -153,12 +135,10 @@ const InboxModal: React.FC = ({ groupEnd++; } - // Process all project references in this group for (let j = i; j < groupEnd; j++) { if (tokens[j].startsWith('+')) { let projectName = tokens[j].substring(1); - // Handle quoted project names if ( projectName.startsWith('"') && projectName.endsWith('"') @@ -172,7 +152,6 @@ const InboxModal: React.FC = ({ } } - // Skip to end of this group i = groupEnd; } else { i++; @@ -182,7 +161,6 @@ const InboxModal: React.FC = ({ return matches; }; - // Helper function to tokenize text handling quoted strings const tokenizeText = (text: string): string[] => { const tokens: string[] = []; let currentToken = ''; @@ -193,27 +171,22 @@ const InboxModal: React.FC = ({ const char = text[i]; if (char === '"' && (i === 0 || text[i - 1] === '+')) { - // Start of a quoted string after + inQuotes = true; currentToken += char; } else if (char === '"' && inQuotes) { - // End of quoted string inQuotes = false; currentToken += char; } else if (char === ' ' && !inQuotes) { - // Space outside quotes - end current token if (currentToken) { tokens.push(currentToken); currentToken = ''; } } else { - // Regular character currentToken += char; } i++; } - // Add final token if (currentToken) { tokens.push(currentToken); } @@ -221,7 +194,6 @@ const InboxModal: React.FC = ({ return tokens; }; - // Helper function to get current hashtag query at cursor position (only at start/end) const getCurrentHashtagQuery = (text: string, position: number): string => { const beforeCursor = text.substring(0, position); const afterCursor = text.substring(position); @@ -229,22 +201,18 @@ const InboxModal: React.FC = ({ if (!hashtagMatch) return ''; - // Check if hashtag is at start or end position const hashtagStart = beforeCursor.lastIndexOf('#'); const textBeforeHashtag = text.substring(0, hashtagStart).trim(); const textAfterCursor = afterCursor.trim(); - // Check if we're at the very end (no text after cursor) if (textAfterCursor === '') { return hashtagMatch[1]; } - // Check if we're at the very beginning if (textBeforeHashtag === '') { return hashtagMatch[1]; } - // Check if we're in a consecutive group of tags/projects at the beginning const wordsBeforeHashtag = textBeforeHashtag .split(/\s+/) .filter((word) => word.length > 0); @@ -259,36 +227,29 @@ const InboxModal: React.FC = ({ return ''; }; - // Helper function to get current project query at cursor position (only at start/end) const getCurrentProjectQuery = (text: string, position: number): string => { const beforeCursor = text.substring(0, position); const afterCursor = text.substring(position); - // Match both quoted and unquoted project references const projectMatch = beforeCursor.match( /\+(?:"([^"]*)"|([a-zA-Z0-9_\s]*))$/ ); if (!projectMatch) return ''; - // Get the project name (from quoted or unquoted match) const projectQuery = projectMatch[1] || projectMatch[2] || ''; - // Check if project ref is at start or end position const projectStart = beforeCursor.lastIndexOf('+'); const textBeforeProject = text.substring(0, projectStart).trim(); const textAfterCursor = afterCursor.trim(); - // Check if we're at the very end (no text after cursor) if (textAfterCursor === '') { return projectQuery; } - // Check if we're at the very beginning if (textBeforeProject === '') { return projectQuery; } - // Check if we're in a consecutive group of tags/projects at the beginning const wordsBeforeProject = textBeforeProject .split(/\s+/) .filter((word) => word.length > 0); @@ -303,7 +264,6 @@ const InboxModal: React.FC = ({ return ''; }; - // Helper function to remove a tag from the input text const removeTagFromText = (tagToRemove: string) => { const words = inputText.trim().split(/\s+/); const filteredWords = words.filter( @@ -316,7 +276,6 @@ const InboxModal: React.FC = ({ } }; - // Helper function to remove a project from the input text const removeProjectFromText = (projectToRemove: string) => { const words = inputText.trim().split(/\s+/); const filteredWords = words.filter( @@ -329,38 +288,10 @@ const InboxModal: React.FC = ({ } }; - // Helper function to render text with clickable hashtags - // const renderTextWithHashtags = (text: string) => { - // const parts = text.split(/(#[a-zA-Z0-9_]+)/g); - // return parts.map((part, index) => { - // if (part.startsWith('#')) { - // const tagName = part.substring(1); - // const tag = tags.find( - // (t) => t.name.toLowerCase() === tagName.toLowerCase() - // ); - // if (tag) { - // return ( - // e.stopPropagation()} - // > - // {part} - // - // ); - // } - // } - // return {part}; - // }); - // }; - - // Helper function to calculate dropdown position based on cursor const calculateDropdownPosition = ( input: HTMLInputElement, cursorPos: number ) => { - // Create a temporary element to measure text width const temp = document.createElement('span'); temp.style.visibility = 'hidden'; temp.style.position = 'absolute'; @@ -373,7 +304,6 @@ const InboxModal: React.FC = ({ const textWidth = temp.getBoundingClientRect().width; document.body.removeChild(temp); - // Get the # position for the current hashtag or + for project (only at start/end) const beforeCursor = inputText.substring(0, cursorPos); const afterCursor = inputText.substring(cursorPos); const hashtagMatch = beforeCursor.match(/#[a-zA-Z0-9_]*$/); @@ -386,13 +316,11 @@ const InboxModal: React.FC = ({ .trim(); const textAfterCursor = afterCursor.trim(); - // Check if we're at the very end, very beginning, or in a consecutive group at start let showDropdown = false; if (textAfterCursor === '' || textBeforeHashtag === '') { showDropdown = true; } else { - // Check if we're in a consecutive group of tags/projects at the beginning const wordsBeforeHashtag = textBeforeHashtag .split(/\s+/) .filter((word) => word.length > 0); @@ -405,7 +333,6 @@ const InboxModal: React.FC = ({ } if (showDropdown) { - // Create temp element for text up to hashtag start const tempToHashtag = document.createElement('span'); tempToHashtag.style.visibility = 'hidden'; tempToHashtag.style.position = 'absolute'; @@ -438,13 +365,11 @@ const InboxModal: React.FC = ({ .trim(); const textAfterCursor = afterCursor.trim(); - // Check if we're at the very end, very beginning, or in a consecutive group at start let showDropdown = false; if (textAfterCursor === '' || textBeforeProject === '') { showDropdown = true; } else { - // Check if we're in a consecutive group of tags/projects at the beginning const wordsBeforeProject = textBeforeProject .split(/\s+/) .filter((word) => word.length > 0); @@ -457,7 +382,6 @@ const InboxModal: React.FC = ({ } if (showDropdown) { - // Create temp element for text up to project start const tempToProject = document.createElement('span'); tempToProject.style.visibility = 'hidden'; tempToProject.style.position = 'absolute'; @@ -492,9 +416,6 @@ const InboxModal: React.FC = ({ } }, [isOpen]); - // Projects are now passed as props, no need to load them - - // Prevent body scroll when modal is open useEffect(() => { if (isOpen) { document.body.style.overflow = 'hidden'; @@ -502,7 +423,6 @@ const InboxModal: React.FC = ({ document.body.style.overflow = 'unset'; } - // Cleanup function to restore scroll when component unmounts return () => { document.body.style.overflow = 'unset'; }; @@ -515,34 +435,28 @@ const InboxModal: React.FC = ({ setInputText(newText); setCursorPosition(newCursorPosition); - // Check if user is typing a hashtag const hashtagQuery = getCurrentHashtagQuery(newText, newCursorPosition); setCurrentHashtagQuery(hashtagQuery); - // Check if user is typing a project reference const projectQuery = getCurrentProjectQuery(newText, newCursorPosition); setCurrentProjectQuery(projectQuery); - // Only show suggestions if hashtag/project is at start or end if ( (newText.charAt(newCursorPosition - 1) === '#' || hashtagQuery) && hashtagQuery !== '' ) { - // Hide project suggestions when showing tag suggestions setShowProjectSuggestions(false); setFilteredProjects([]); setSelectedSuggestionIndex(-1); - // Filter tags based on current query const filtered = tags .filter((tag) => tag.name .toLowerCase() .startsWith(hashtagQuery.toLowerCase()) ) - .slice(0, 5); // Limit to 5 suggestions + .slice(0, 5); - // Calculate dropdown position const position = calculateDropdownPosition( e.target, newCursorPosition @@ -556,21 +470,18 @@ const InboxModal: React.FC = ({ (newText.charAt(newCursorPosition - 1) === '+' || projectQuery) && projectQuery !== '' ) { - // Hide tag suggestions when showing project suggestions setShowTagSuggestions(false); setFilteredTags([]); setSelectedSuggestionIndex(-1); - // Filter projects based on current query const filtered = projects .filter((project) => project.name .toLowerCase() .includes(projectQuery.toLowerCase()) ) - .slice(0, 5); // Limit to 5 suggestions + .slice(0, 5); - // Calculate dropdown position const position = calculateDropdownPosition( e.target, newCursorPosition @@ -589,13 +500,10 @@ const InboxModal: React.FC = ({ } }; - // Helper function to get all tags including auto-detected bookmark const getAllTags = (text: string): string[] => { - // Use analysis result if available, otherwise fall back to local parsing if (analysisResult) { const explicitTags = analysisResult.parsed_tags; - // Auto-add bookmark if text contains URL or backend suggests URL note const isUrlContent = isUrl(text.trim()) || analysisResult.suggested_reason === 'url_detected'; @@ -611,10 +519,8 @@ const InboxModal: React.FC = ({ return explicitTags; } - // Fallback to local parsing const explicitTags = parseHashtags(text); - // Auto-add bookmark if text contains URL and bookmark tag isn't already present if (isUrl(text.trim())) { const hasBookmarkTag = explicitTags.some( (tag) => tag.toLowerCase() === 'bookmark' @@ -627,32 +533,25 @@ const InboxModal: React.FC = ({ return explicitTags; }; - // Helper function to get all project references const getAllProjects = (text: string): string[] => { - // Use analysis result if available, otherwise fall back to local parsing if (analysisResult) { return analysisResult.parsed_projects; } - // Fallback to local parsing return parseProjectRefs(text); }; - // Helper function to get cleaned content const getCleanedContent = (text: string): string => { - // Use analysis result if available, otherwise fall back to local cleaning if (analysisResult) { return analysisResult.cleaned_content; } - // Fallback to local cleaning (simplified version) return text .replace(/#[a-zA-Z0-9_-]+/g, '') .replace(/\+\S+/g, '') .trim(); }; - // Helper function to get suggestion const getSuggestion = (): { type: 'note' | 'task' | null; message: string | null; @@ -666,7 +565,6 @@ const InboxModal: React.FC = ({ const type = analysisResult.suggested_type; if (type === 'note') { - // Check if this is a URL (bookmark) note const isUrlNote = analysisResult.suggested_reason === 'url_detected'; const message = isUrlNote @@ -689,7 +587,6 @@ const InboxModal: React.FC = ({ return { type: null, message: null, projectName: null }; }; - // Debounced text analysis function const analyzeText = useCallback(async (text: string) => { if (!text.trim()) { setAnalysisResult(null); @@ -722,7 +619,6 @@ const InboxModal: React.FC = ({ } }, []); - // Debounced text analysis effect useEffect(() => { if (analysisTimeoutRef.current) { clearTimeout(analysisTimeoutRef.current); @@ -730,7 +626,7 @@ const InboxModal: React.FC = ({ analysisTimeoutRef.current = setTimeout(() => { analyzeText(inputText); - }, 300); // 300ms debounce + }, 300); return () => { if (analysisTimeoutRef.current) { @@ -739,7 +635,6 @@ const InboxModal: React.FC = ({ }; }, [inputText, analyzeText]); - // Handle tag suggestion selection const handleTagSelect = (tagName: string) => { const beforeCursor = inputText.substring(0, cursorPosition); const afterCursor = inputText.substring(cursorPosition); @@ -752,13 +647,11 @@ const InboxModal: React.FC = ({ .trim(); const textAfterCursor = afterCursor.trim(); - // Check if we're at the very end, very beginning, or in a consecutive group at start let allowReplacement = false; if (textAfterCursor === '' || textBeforeHashtag === '') { allowReplacement = true; } else { - // Check if we're in a consecutive group of tags/projects at the beginning const wordsBeforeHashtag = textBeforeHashtag .split(/\s+/) .filter((word) => word.length > 0); @@ -779,7 +672,6 @@ const InboxModal: React.FC = ({ setFilteredTags([]); setSelectedSuggestionIndex(-1); - // Focus back on input and set cursor position setTimeout(() => { if (nameInputRef.current) { nameInputRef.current.focus(); @@ -797,11 +689,9 @@ const InboxModal: React.FC = ({ } }; - // Handle project suggestion selection const handleProjectSelect = (projectName: string) => { const beforeCursor = inputText.substring(0, cursorPosition); const afterCursor = inputText.substring(cursorPosition); - // Match both quoted and unquoted project references const projectMatch = beforeCursor.match( /\+(?:"([^"]*)"|([a-zA-Z0-9_\s]*))$/ ); @@ -813,13 +703,11 @@ const InboxModal: React.FC = ({ .trim(); const textAfterCursor = afterCursor.trim(); - // Check if we're at the very end, very beginning, or in a consecutive group at start let allowReplacement = false; if (textAfterCursor === '' || textBeforeProject === '') { allowReplacement = true; } else { - // Check if we're in a consecutive group of tags/projects at the beginning const wordsBeforeProject = textBeforeProject .split(/\s+/) .filter((word) => word.length > 0); @@ -832,7 +720,6 @@ const InboxModal: React.FC = ({ } if (allowReplacement) { - // Automatically add quotes if project name contains spaces const formattedProjectName = projectName.includes(' ') ? `"${projectName}"` : projectName; @@ -847,7 +734,6 @@ const InboxModal: React.FC = ({ setFilteredProjects([]); setSelectedSuggestionIndex(-1); - // Focus back on input and set cursor position setTimeout(() => { if (nameInputRef.current) { nameInputRef.current.focus(); @@ -865,7 +751,6 @@ const InboxModal: React.FC = ({ } }; - // Helper function to clean text by removing tags and project references at start/end const cleanTextFromTagsAndProjects = (text: string): string => { const trimmedText = text.trim(); const tokens = tokenizeText(trimmedText); @@ -873,9 +758,7 @@ const InboxModal: React.FC = ({ let i = 0; while (i < tokens.length) { - // Check if current token starts a tag/project group if (tokens[i].startsWith('#') || tokens[i].startsWith('+')) { - // Skip this entire consecutive group while ( i < tokens.length && (tokens[i].startsWith('#') || tokens[i].startsWith('+')) @@ -883,7 +766,6 @@ const InboxModal: React.FC = ({ i++; } } else { - // Keep regular tokens cleanedTokens.push(tokens[i]); i++; } @@ -892,7 +774,6 @@ const InboxModal: React.FC = ({ return cleanedTokens.join(' ').trim(); }; - // Create missing tags automatically const createMissingTags = async (text: string): Promise => { const hashtagsInText = getAllTags(text); const existingTagNames = tags.map((tag) => tag.name.toLowerCase()); @@ -903,16 +784,13 @@ const InboxModal: React.FC = ({ for (const tagName of missingTags) { try { const newTag = await createTag({ name: tagName }); - // Update the global tags store setTags([...tags, newTag]); } catch (error) { console.error(`Failed to create tag "${tagName}":`, error); - // Don't fail the entire operation if tag creation fails } } }; - // Create missing projects automatically const createMissingProjects = async (text: string): Promise => { const projectsInText = getAllProjects(text); const existingProjectNames = projects.map((project) => @@ -929,14 +807,11 @@ const InboxModal: React.FC = ({ name: projectName, state: 'planned', }); - // Projects are managed by the parent component through props - // No need to update local state } catch (error) { console.error( `Failed to create project "${projectName}":`, error ); - // Don't fail the entire operation if project creation fails } } }; @@ -948,18 +823,14 @@ const InboxModal: React.FC = ({ setIsSaving(true); try { - // Check if suggestions are present first, even in edit mode (unless forced to inbox mode) if (analysisResult?.suggested_type === 'task' && !forceInbox) { - // Auto-convert to task using the same logic as convert to task action await createMissingTags(inputText.trim()); await createMissingProjects(inputText.trim()); const cleanedText = getCleanedContent(inputText.trim()); - // Convert parsed tags to Tag objects const taskTags = analysisResult.parsed_tags.map( (tagName) => { - // Find existing tag or create a placeholder for new tag const existingTag = tags.find( (tag) => tag.name.toLowerCase() === @@ -969,10 +840,8 @@ const InboxModal: React.FC = ({ } ); - // Find the project to assign (use first project reference if any) let projectId = undefined; if (analysisResult.parsed_projects.length > 0) { - // Look for an existing project with the first project reference name const projectName = analysisResult.parsed_projects[0]; const matchingProject = projects.find( (project) => @@ -997,7 +866,6 @@ const InboxModal: React.FC = ({ await onSave(newTask); showSuccessToast(t('task.createSuccess')); - // If in edit mode, we need to mark the original inbox item as processed if (editMode && onConvertToTask) { await onConvertToTask(); } @@ -1013,15 +881,12 @@ const InboxModal: React.FC = ({ } } - // Check if it's a note suggestion (bookmark + project) (unless forced to inbox mode) if (analysisResult?.suggested_type === 'note' && !forceInbox) { - // Auto-convert to note using similar logic await createMissingTags(inputText.trim()); await createMissingProjects(inputText.trim()); const cleanedText = getCleanedContent(inputText.trim()); - // Convert parsed tags to Tag objects and include bookmark tag const hashtagTags = analysisResult.parsed_tags.map( (tagName) => { const existingTag = tags.find( @@ -1033,7 +898,6 @@ const InboxModal: React.FC = ({ } ); - // Add bookmark tag for URLs or when suggested reason is url_detected const isUrlContent = isUrl(inputText.trim()) || analysisResult.suggested_reason === 'url_detected'; @@ -1041,7 +905,6 @@ const InboxModal: React.FC = ({ ? [{ name: 'bookmark' }] : []; - // Make sure we don't duplicate bookmark tag if it's already in parsed tags const hasBookmarkInParsed = hashtagTags.some( (tag) => tag.name.toLowerCase() === 'bookmark' ); @@ -1051,7 +914,6 @@ const InboxModal: React.FC = ({ const taskTags = [...hashtagTags, ...finalBookmarkTag]; - // Find the project to assign let projectId = undefined; if (analysisResult.parsed_projects.length > 0) { const projectName = analysisResult.parsed_projects[0]; @@ -1082,7 +944,6 @@ const InboxModal: React.FC = ({ ) ); - // If in edit mode, we need to mark the original inbox item as processed if (editMode && onConvertToNote) { await onConvertToNote(); } @@ -1090,8 +951,6 @@ const InboxModal: React.FC = ({ setInputText(''); handleClose(); return; - } else { - // If no note creation handler, fall back to inbox mode } } catch (error: any) { console.error('Error in note creation flow:', error); @@ -1103,20 +962,18 @@ const InboxModal: React.FC = ({ } if (editMode && onEdit) { - // For edit mode, store the original text with tags/projects await onEdit(inputText.trim()); setIsClosing(true); setTimeout(() => { onClose(); setIsClosing(false); }, 300); - return; // Exit early to prevent creating duplicates + return; } const effectiveSaveMode = saveMode; if (effectiveSaveMode === 'task') { - // For task mode, create missing tags and projects, then clean the text await createMissingTags(inputText.trim()); await createMissingProjects(inputText.trim()); @@ -1134,7 +991,6 @@ const InboxModal: React.FC = ({ setInputText(''); handleClose(); } catch (error: any) { - // If it's an auth error, don't show error toast (user will be redirected) if (isAuthError(error)) { return; } @@ -1142,8 +998,6 @@ const InboxModal: React.FC = ({ } } else { try { - // For inbox mode, store the original text with tags/projects - // Tags and projects will be created and assigned when the item is processed later await createInboxItemWithStore(inputText.trim()); showSuccessToast(t('inbox.itemAdded')); @@ -1268,7 +1122,6 @@ const InboxModal: React.FC = ({ isClosing ? 'scale-95' : 'scale-100' } flex flex-col`} > - {/* Close button - only visible on mobile */} - - ); - } else { - return ( - - {tagName} - - - ); - } - } - )} -
-
+ 0 + } + items={filteredTags} + position={dropdownPosition} + selectedIndex={selectedSuggestionIndex} + onSelect={(tag) => + handleTagSelect(tag.name) + } + renderLabel={(tag) => <>#{tag.name}} + /> + + 0 + } + items={filteredProjects} + position={dropdownPosition} + selectedIndex={selectedSuggestionIndex} + onSelect={(project) => + handleProjectSelect(project.name) + } + renderLabel={(project) => ( + <>+{project.name} )} + /> - {/* Projects display like TaskItem */} - {inputText && - getAllProjects(inputText).length > 0 && ( -
- -
- {getAllProjects(inputText).map( - (projectName, index) => { - const project = - projects.find( - (p) => - p.name.toLowerCase() === - projectName.toLowerCase() - ); - - if (project) { - return ( - - - e.stopPropagation() - } - > - { - projectName - } - - - - ); - } else { - return ( - - { - projectName - } - - - ); - } - } - )} -
-
- )} - - {/* Tag Suggestions Dropdown */} - {showTagSuggestions && - filteredTags.length > 0 && ( -
- {filteredTags.map((tag, index) => ( - - ))} -
- )} - - {/* Project Suggestions Dropdown */} - {showProjectSuggestions && - filteredProjects.length > 0 && ( -
- {filteredProjects.map( - (project, index) => ( - - ) - )} -
- )} - - {/* Intelligent Suggestion */} {(() => { const suggestion = getSuggestion(); return suggestion.type && @@ -1714,7 +1373,6 @@ const InboxModal: React.FC = ({
- {/* AI Stars Icon */} = ({ ); handleSubmit( true - ); // Pass true to force inbox mode + ); }} className="text-purple-600 dark:text-purple-400 hover:underline" > @@ -1760,7 +1418,7 @@ const InboxModal: React.FC = ({
- {/* URL Preview disabled */} - {/* */}
diff --git a/frontend/components/Inbox/InboxNotification.tsx b/frontend/components/Inbox/InboxNotification.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/components/Inbox/InboxSelectedChips.tsx b/frontend/components/Inbox/InboxSelectedChips.tsx new file mode 100644 index 0000000..8d823e3 --- /dev/null +++ b/frontend/components/Inbox/InboxSelectedChips.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { TagIcon, FolderIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import { Tag } from '../../entities/Tag'; +import { Project } from '../../entities/Project'; + +interface InboxSelectedChipsProps { + selectedTags: string[]; + selectedProjects: string[]; + tags: Tag[]; + projects: Project[]; + onRemoveTag: (tagName: string) => void; + onRemoveProject: (projectName: string) => void; +} + +const InboxSelectedChips: React.FC = ({ + selectedTags, + selectedProjects, + tags, + projects, + onRemoveTag, + onRemoveProject, +}) => { + const renderTagChip = (tagName: string, index: number) => { + const tag = tags.find( + (t) => t.name.toLowerCase() === tagName.toLowerCase() + ); + + if (tag) { + return ( + + e.stopPropagation()} + > + {tagName} + + + + ); + } + + return ( + + {tagName} + + + ); + }; + + const renderProjectChip = (projectName: string, index: number) => { + const project = projects.find( + (p) => p.name.toLowerCase() === projectName.toLowerCase() + ); + + if (project) { + return ( + + e.stopPropagation()} + > + {projectName} + + + + ); + } + + return ( + + {projectName} + + + ); + }; + + return ( + <> + {selectedTags.length > 0 && ( +
+ +
+ {selectedTags.map((tagName, index) => + renderTagChip(tagName, index) + )} +
+
+ )} + + {selectedProjects.length > 0 && ( +
+ +
+ {selectedProjects.map((projectName, index) => + renderProjectChip(projectName, index) + )} +
+
+ )} + + ); +}; + +export default InboxSelectedChips; diff --git a/frontend/components/Inbox/SuggestionsDropdown.tsx b/frontend/components/Inbox/SuggestionsDropdown.tsx new file mode 100644 index 0000000..3dc91f1 --- /dev/null +++ b/frontend/components/Inbox/SuggestionsDropdown.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +interface SuggestionsDropdownProps { + isVisible: boolean; + items: T[]; + position: { left: number; top: number }; + selectedIndex: number; + onSelect: (item: T) => void; + renderLabel: (item: T) => React.ReactNode; +} + +const SuggestionsDropdown = ({ + isVisible, + items, + position, + selectedIndex, + onSelect, + renderLabel, +}: SuggestionsDropdownProps) => { + if (!isVisible || items.length === 0) return null; + + return ( +
+ {items.map((item, index) => ( + + ))} +
+ ); +}; + +export default SuggestionsDropdown; diff --git a/frontend/components/Notes.tsx b/frontend/components/Notes.tsx index 0dc1b8a..f1059b0 100644 --- a/frontend/components/Notes.tsx +++ b/frontend/components/Notes.tsx @@ -524,7 +524,7 @@ const Notes: React.FC = () => {
handleSelectNote(note)} - className={`p-5 cursor-pointer ${ + className={`relative p-5 cursor-pointer ${ previewNote?.uid === note.uid ? 'bg-white dark:bg-gray-900 border-b border-transparent mx-4 rounded-lg' : index !== @@ -533,7 +533,10 @@ const Notes: React.FC = () => { : 'border-b border-transparent hover:bg-gray-50 dark:hover:bg-gray-800 mx-4' }`} > -

+ {previewNote?.uid === note.uid && ( + + )} +

{note.title || t( 'notes.untitled', @@ -569,7 +572,7 @@ const Notes: React.FC = () => { {isEditing && editingNote ? (
{/* Editor Header - matches preview structure */} -
+
{/* Back button for mobile */}

- {/* Navigation Tabs */} -
-
- -
-
+ setActiveTab(id)} + />
- {/* General Tab */} - {activeTab === 'general' && ( -
-

- - {t( - 'profile.accountSettings', - 'Account & Preferences' - )} -

+ + setFormData((prev) => ({ ...prev, appearance })) + } + onLanguageChange={(languageCode) => { + const localeFirstDay = + getLocaleFirstDayOfWeek(languageCode); + setFormData((prev) => ({ + ...prev, + language: languageCode, + first_day_of_week: localeFirstDay, + })); + }} + onTimezoneChange={(timezone) => + setFormData((prev) => ({ ...prev, timezone })) + } + onFirstDayChange={(value) => + setFormData((prev) => ({ + ...prev, + first_day_of_week: value, + })) + } + avatarPreview={avatarPreview} + onAvatarSelect={handleAvatarSelect} + onAvatarRemove={handleAvatarRemove} + timezonesByRegion={timezonesByRegion} + getRegionDisplayName={getRegionDisplayName} + /> - {/* Avatar Upload Section */} -
-
- {avatarPreview || formData.avatar_image ? ( - Avatar - ) : ( -
- -
- )} - -
- {(formData.avatar_image || avatarPreview) && ( - - )} -

- {t( - 'profile.avatarDescription', - 'Upload a profile photo (max 5MB)' - )} -

-
+ + setShowCurrentPassword((prev) => !prev) + } + onToggleNewPassword={() => + setShowNewPassword((prev) => !prev) + } + onToggleConfirmPassword={() => + setShowConfirmPassword((prev) => !prev) + } + /> -
-
- - -
+ setApiKeyToDelete(apiKey)} + onUpdateNewName={setNewApiKeyName} + onUpdateNewExpiration={setNewApiKeyExpiration} + getApiKeyStatus={getApiKeyStatus} + formatDateTime={formatDateTime} + isCreatingApiKey={isCreatingApiKey} + /> -
- - -
+ + setFormData((prev) => ({ + ...prev, + pomodoro_enabled: !prev.pomodoro_enabled, + })) + } + /> -
- -
- - -
-
+ + setFormData((prev) => ({ + ...prev, + task_summary_enabled: + !prev.task_summary_enabled, + })) + } + onSelectFrequency={(frequency) => + setFormData((prev) => ({ + ...prev, + task_summary_frequency: frequency, + })) + } + onSendTestSummary={handleSendTestSummary} + formatFrequency={formatFrequency} + /> -
- - { - // Auto-set first day of week based on language/locale - const localeFirstDay = - getLocaleFirstDayOfWeek( - languageCode - ); - setFormData((prev) => ({ - ...prev, - language: languageCode, - first_day_of_week: - localeFirstDay, - })); - }} - /> -
+ + setFormData((prev) => ({ + ...prev, + [field]: !prev[field], + })) + } + /> -
- - - setFormData((prev) => ({ - ...prev, - timezone, - })) - } - timezonesByRegion={timezonesByRegion} - getRegionDisplayName={ - getRegionDisplayName - } - /> -
- -
- - { - setFormData((prev) => ({ - ...prev, - first_day_of_week: value, - })); - }} - /> -
-
-
- )} - - {/* Security Tab */} - {activeTab === 'security' && ( -
-

- - {t('profile.security', 'Security Settings')} -

- - {/* Password Change Section */} -
-

- - {t( - 'profile.changePassword', - 'Change Password' - )} -

- -
-

- - {t( - 'profile.passwordChangeOptional', - 'Leave password fields empty to update other settings without changing your password.' - )} -

-
- -
-
- -
- - -
-
- -
- -
- - -
-
- -
- -
- - -
-
- -
- {t( - 'profile.passwordChangeNote', - 'Password changes will be saved when you click "Save Changes" at the bottom of the form.' - )} -
-
-
-
- )} - - {/* API Keys Tab */} - {activeTab === 'apiKeys' && ( -
-

- - {t('profile.apiKeys.title', 'API Keys')} -

- -

- {t( - 'profile.apiKeys.description', - 'Generate personal access tokens for integrations or CLI usage. You can revoke or delete keys at any time.' - )} -

- -
-
- - - setNewApiKeyName(event.target.value) - } - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - handleCreateApiKey(); - } - }} - className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - placeholder={t( - 'profile.apiKeys.namePlaceholder', - 'e.g. Personal laptop' - )} - /> -
-
- - - setNewApiKeyExpiration( - event.target.value - ) - } - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - handleCreateApiKey(); - } - }} - className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - /> -
-
- -
-
- - {generatedApiToken && ( -
-

- {t( - 'profile.apiKeys.copyNotice', - 'Copy this token now. It will not be shown again.' - )} -

-
- - {generatedApiToken} - - -
-
- )} - -
- {apiKeysLoading && ( -

- {t( - 'profile.apiKeys.loading', - 'Loading API keys...' - )} -

- )} - - {!apiKeysLoading && apiKeys.length === 0 && ( -

- {t( - 'profile.apiKeys.empty', - 'No API keys yet. Generate one to begin.' - )} -

- )} - - {!apiKeysLoading && apiKeys.length > 0 && ( -
- - - - - - - - - - - - - {apiKeys.map((key) => { - const status = - getApiKeyStatus(key); - return ( - - - - - - - - - ); - })} - -
- {t( - 'profile.apiKeys.table.name', - 'Name' - )} - - {t( - 'profile.apiKeys.table.prefix', - 'Prefix' - )} - - {t( - 'profile.apiKeys.table.status', - 'Status' - )} - - {t( - 'profile.apiKeys.table.lastUsed', - 'Last used' - )} - - {t( - 'profile.apiKeys.table.expires', - 'Expires' - )} - - {t( - 'profile.apiKeys.table.actions', - 'Actions' - )} -
-
- {key.name} -
-
- {t( - 'profile.apiKeys.createdAt', - 'Created {{date}}', - { - date: formatDateTime( - key.created_at - ), - } - )} -
-
- { - key.token_prefix - } - ... - - - { - status.label - } - - - {formatDateTime( - key.last_used_at - )} - - {key.expires_at - ? formatDateTime( - key.expires_at - ) - : t( - 'profile.apiKeys.noExpiry', - 'None' - )} - -
- - -
-
-
- )} -
-
- )} - - {/* Productivity Tab */} - {activeTab === 'productivity' && ( -
-

- - {t( - 'profile.productivityFeatures', - 'Productivity Features' - )} -

- -
- {/* Pomodoro Timer */} -
-
- -

- {t( - 'profile.pomodoroDescription', - 'Enable the Pomodoro timer in the navigation bar for focused work sessions.' - )} -

-
-
{ - setFormData((prev) => ({ - ...prev, - pomodoro_enabled: - !prev.pomodoro_enabled, - })); - }} - > - -
-
-
-
- )} - - {/* Telegram Tab */} - {activeTab === 'telegram' && ( -
-

- - {t( - 'profile.telegramIntegration', - 'Telegram Integration' - )} -

- - {/* Bot Setup Subsection */} -
-

- - {t('profile.botSetup', 'Bot Setup')} -

- -
-
- -

- {t( - 'profile.telegramDescription', - 'Connect your tududi account to a Telegram bot to add items to your inbox via Telegram messages.' - )} -

-
- -
- - -

- {t( - 'profile.telegramTokenDescription', - 'Create a bot with @BotFather on Telegram and paste the token here.' - )} -

-
- -
- - -
-

- {t( - 'profile.telegramAllowedUsersDescription', - 'Control who can send messages to your bot. Leave empty to allow all users.' - )} -

-
-

- {t( - 'profile.examples', - 'Examples:' - )} -

-
    -
  • - - @alice, @bob - - {' - '} - {t( - 'profile.exampleUsernames', - 'Allow specific usernames' - )} -
  • -
  • - - 123456789, 987654321 - - {' - '} - {t( - 'profile.exampleUserIds', - 'Allow specific user IDs' - )} -
  • -
  • - - @alice, 123456789 - - {' - '} - {t( - 'profile.exampleMixed', - 'Mix usernames and user IDs' - )} -
  • -
-
-
-
- - {profile?.telegram_chat_id && ( -
-

- {t( - 'profile.telegramConnected', - 'Your Telegram account is connected! Send messages to your bot to add items to your tududi inbox.' - )} -

-
- )} - - {(telegramBotInfo || - profile?.telegram_bot_token) && ( -
-

- {t( - 'profile.botConfigured', - 'Bot configured successfully!' - )} -

-
- {telegramBotInfo?.first_name && ( -

- - Bot Name:{' '} - - { - telegramBotInfo.first_name - } -

- )} - {telegramBotInfo?.username && ( -

- - {t( - 'profile.botUsername', - 'Bot Username:' - )}{' '} - - @ - { - telegramBotInfo.username - } -

- )} -
-

- {t( - 'profile.pollingStatus', - 'Polling Status:' - )}{' '} -

-
-
- - {isPolling - ? t( - 'profile.pollingActive' - ) - : t( - 'profile.pollingInactive' - )} - -
-

- {t( - 'profile.pollingNote', - 'Polling periodically checks for new messages from Telegram and adds them to your inbox.' - )} -

-
- {isPolling ? ( - - ) : ( - - )} - {telegramBotInfo?.chat_url && ( - - {t( - 'profile.openTelegram', - 'Open in Telegram' - )} - - )} -
-
-
-
- )} - - - - {/* Status indicator */} - {telegramSetupStatus === 'success' && ( -
- - - - - Bot configured successfully! - -
- )} - - {telegramSetupStatus === 'error' && ( -
- - - - - Setup failed. Please check your - token. - -
- )} -
-
- - {/* Task Summary Notifications Subsection */} -
-

- - {t( - 'profile.taskSummaryNotifications', - 'Task Summary Notifications' - )} -

- -
- -

- {t( - 'profile.taskSummaryDescription', - 'Receive regular summaries of your tasks via Telegram. This feature requires your Telegram integration to be set up.' - )} -

-
- -
- -
{ - setFormData((prev) => ({ - ...prev, - task_summary_enabled: - !prev.task_summary_enabled, - })); - }} - > - -
-
- -
- -
- {[ - '1h', - '2h', - '4h', - '8h', - '12h', - 'daily', - 'weekly', - ].map((frequency) => ( - - ))} -
-

- {t( - 'profile.frequencyHelp', - 'Choose how often you want to receive task summaries.' - )} -

-
- -
- - {(!profile?.telegram_bot_token || - !profile?.telegram_chat_id) && ( -

- {t( - 'profile.telegramRequiredForSummaries', - 'Telegram integration must be set up to use task summaries.' - )} -

- )} -
-
-
- )} - - {/* AI Features Tab */} - {activeTab === 'ai' && ( -
-

- - {t( - 'profile.aiProductivityFeatures', - 'AI & Productivity Features' - )} -

- - {/* Task Intelligence Subsection */} -
-

- - {t( - 'profile.taskIntelligence', - 'Task Intelligence' - )} -

- -
- -

- {t( - 'profile.taskIntelligenceDescription', - 'Show popup alerts while typing task names that suggest improvements like "Make it more descriptive!", "Be more specific!", or "Add an action verb!". Disable this if you prefer typing in your own shorthand without suggestions.' - )} -

-
- -
- -
{ - setFormData((prev) => ({ - ...prev, - task_intelligence_enabled: - !prev.task_intelligence_enabled, - })); - }} - > - -
-
-
- - {/* Auto-Suggest Next Actions Subsection */} -
-

- - {t( - 'profile.autoSuggestNextActions', - 'Auto-Suggest Next Actions' - )} -

- -
- -

- {t( - 'profile.autoSuggestNextActionsDescription', - 'When creating a project, automatically prompt for the very next physical action to take.' - )} -

-
- -
- -
{ - setFormData((prev) => ({ - ...prev, - auto_suggest_next_actions_enabled: - !prev.auto_suggest_next_actions_enabled, - })); - }} - > - -
-
-
- - {/* Productivity Assistant Subsection */} -
-

- - {t( - 'profile.productivityAssistant', - 'Productivity Assistant' - )} -

- -
- -

- {t( - 'profile.productivityAssistantDescription', - 'Show productivity insights that help identify stalled projects, vague tasks, and workflow improvements on your Today page.' - )} -

-
- -
- -
{ - setFormData((prev) => ({ - ...prev, - productivity_assistant_enabled: - !prev.productivity_assistant_enabled, - })); - }} - > - -
-
-
- - {/* Next Task Suggestion Subsection */} -
-

- - {t( - 'profile.nextTaskSuggestion', - 'Next Task Suggestion' - )} -

- -
- -

- {t( - 'profile.nextTaskSuggestionDescription', - 'Automatically suggest the next best task to work on when you have nothing in progress, prioritizing due today tasks, then suggested tasks, then next actions.' - )} -

-
- -
- -
{ - setFormData((prev) => ({ - ...prev, - next_task_suggestion_enabled: - !prev.next_task_suggestion_enabled, - })); - }} - > - -
-
-
-
- )} - - {/* Save Button */}
+
+

+ + {generatedApiToken && ( +
+

+ {t( + 'profile.apiKeys.copyNotice', + 'Copy this token now. It will not be shown again.' + )} +

+
+ + {generatedApiToken} + + +
+
+ )} + +
+ {apiKeysLoading && ( +

+ {t('profile.apiKeys.loading', 'Loading API keys...')} +

+ )} + + {!apiKeysLoading && apiKeys.length === 0 && ( +

+ {t( + 'profile.apiKeys.empty', + 'No API keys yet. Generate one to begin.' + )} +

+ )} + + {!apiKeysLoading && apiKeys.length > 0 && ( +
+ + + + + + + + + + + + + {apiKeys.map((key) => { + const status = getApiKeyStatus(key); + return ( + + + + + + + + + ); + })} + +
+ {t( + 'profile.apiKeys.table.name', + 'Name' + )} + + {t( + 'profile.apiKeys.table.prefix', + 'Prefix' + )} + + {t( + 'profile.apiKeys.table.status', + 'Status' + )} + + {t( + 'profile.apiKeys.table.lastUsed', + 'Last used' + )} + + {t( + 'profile.apiKeys.table.expires', + 'Expires' + )} + + {t( + 'profile.apiKeys.table.actions', + 'Actions' + )} +
+
+ {key.name} +
+
+ {t( + 'profile.apiKeys.createdAt', + 'Created {{date}}', + { + date: formatDateTime( + key.created_at + ), + } + )} +
+
+ {key.token_prefix}... + + + {status.label} + + + {formatDateTime( + key.last_used_at + )} + + {key.expires_at + ? formatDateTime( + key.expires_at + ) + : t( + 'profile.apiKeys.noExpiry', + 'None' + )} + +
+ + +
+
+
+ )} +
+
+ ); +}; + +export default ApiKeysTab; diff --git a/frontend/components/Profile/tabs/GeneralTab.tsx b/frontend/components/Profile/tabs/GeneralTab.tsx new file mode 100644 index 0000000..cebdda7 --- /dev/null +++ b/frontend/components/Profile/tabs/GeneralTab.tsx @@ -0,0 +1,212 @@ +import React, { ChangeEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + SunIcon, + MoonIcon, + PhotoIcon, + UserCircleIcon, + UserIcon, +} from '@heroicons/react/24/outline'; +import { getApiPath } from '../../../config/paths'; +import LanguageDropdown from '../../Shared/LanguageDropdown'; +import TimezoneDropdown from '../../Shared/TimezoneDropdown'; +import FirstDayOfWeekDropdown from '../../Shared/FirstDayOfWeekDropdown'; +import type { ProfileFormData } from '../types'; +import type { + getRegionDisplayName, + getTimezonesByRegion, +} from '../../../utils/timezoneUtils'; + +interface GeneralTabProps { + isActive: boolean; + formData: ProfileFormData; + onChange: (e: ChangeEvent) => void; + onAppearanceChange: (appearance: 'light' | 'dark') => void; + onLanguageChange: (languageCode: string) => void; + onTimezoneChange: (timezone: string) => void; + onFirstDayChange: (value: number) => void; + avatarPreview: string | null; + onAvatarSelect: (file: File) => void; + onAvatarRemove: () => void; + timezonesByRegion: ReturnType; + getRegionDisplayName: typeof getRegionDisplayName; +} + +const GeneralTab: React.FC = ({ + isActive, + formData, + onChange, + onAppearanceChange, + onLanguageChange, + onTimezoneChange, + onFirstDayChange, + avatarPreview, + onAvatarSelect, + onAvatarRemove, + timezonesByRegion, + getRegionDisplayName, +}) => { + const { t } = useTranslation(); + + if (!isActive) return null; + + return ( +
+

+ + {t('profile.accountSettings', 'Account & Preferences')} +

+ +
+
+ {avatarPreview || formData.avatar_image ? ( + Avatar + ) : ( +
+ +
+ )} + +
+ {(formData.avatar_image || avatarPreview) && ( + + )} +

+ {t( + 'profile.avatarDescription', + 'Upload a profile photo (max 5MB)' + )} +

+
+ +
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ ); +}; + +export default GeneralTab; diff --git a/frontend/components/Profile/tabs/ProductivityTab.tsx b/frontend/components/Profile/tabs/ProductivityTab.tsx new file mode 100644 index 0000000..26a2243 --- /dev/null +++ b/frontend/components/Profile/tabs/ProductivityTab.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ClockIcon } from '@heroicons/react/24/outline'; + +interface ProductivityTabProps { + isActive: boolean; + pomodoroEnabled: boolean; + onTogglePomodoro: () => void; +} + +const ProductivityTab: React.FC = ({ + isActive, + pomodoroEnabled, + onTogglePomodoro, +}) => { + const { t } = useTranslation(); + + if (!isActive) return null; + + return ( +
+

+ + {t('profile.productivityFeatures', 'Productivity Features')} +

+ +
+
+
+ +

+ {t( + 'profile.pomodoroDescription', + 'Enable the Pomodoro timer in the navigation bar for focused work sessions.' + )} +

+
+
+ +
+
+
+
+ ); +}; + +export default ProductivityTab; diff --git a/frontend/components/Profile/tabs/SecurityTab.tsx b/frontend/components/Profile/tabs/SecurityTab.tsx new file mode 100644 index 0000000..26d5e71 --- /dev/null +++ b/frontend/components/Profile/tabs/SecurityTab.tsx @@ -0,0 +1,168 @@ +import React, { ChangeEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ShieldCheckIcon, + UserIcon, + EyeIcon, + EyeSlashIcon, + InformationCircleIcon, +} from '@heroicons/react/24/outline'; +import type { ProfileFormData } from '../types'; + +interface SecurityTabProps { + isActive: boolean; + formData: ProfileFormData; + showCurrentPassword: boolean; + showNewPassword: boolean; + showConfirmPassword: boolean; + onChange: (e: ChangeEvent) => void; + onToggleCurrentPassword: () => void; + onToggleNewPassword: () => void; + onToggleConfirmPassword: () => void; +} + +const SecurityTab: React.FC = ({ + isActive, + formData, + showCurrentPassword, + showNewPassword, + showConfirmPassword, + onChange, + onToggleCurrentPassword, + onToggleNewPassword, + onToggleConfirmPassword, +}) => { + const { t } = useTranslation(); + + if (!isActive) return null; + + return ( +
+

+ + {t('profile.security', 'Security Settings')} +

+ +
+

+ + {t('profile.changePassword', 'Change Password')} +

+ +
+

+ + {t( + 'profile.passwordChangeOptional', + 'Leave password fields empty to update other settings without changing your password.' + )} +

+
+ +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ {t( + 'profile.passwordChangeNote', + 'Password changes will be saved when you click "Save Changes" at the bottom of the form.' + )} +
+
+
+
+ ); +}; + +export default SecurityTab; diff --git a/frontend/components/Profile/tabs/TabsNav.tsx b/frontend/components/Profile/tabs/TabsNav.tsx new file mode 100644 index 0000000..ab9d41e --- /dev/null +++ b/frontend/components/Profile/tabs/TabsNav.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +interface TabConfig { + id: string; + name: string; + icon: React.ReactNode; +} + +interface TabsNavProps { + tabs: TabConfig[]; + activeTab: string; + onChange: (id: string) => void; +} + +const TabsNav: React.FC = ({ tabs, activeTab, onChange }) => ( +
+
+ +
+
+); + +export type { TabConfig }; +export default TabsNav; diff --git a/frontend/components/Profile/tabs/TelegramTab.tsx b/frontend/components/Profile/tabs/TelegramTab.tsx new file mode 100644 index 0000000..49ac7ca --- /dev/null +++ b/frontend/components/Profile/tabs/TelegramTab.tsx @@ -0,0 +1,432 @@ +import React, { ChangeEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + InformationCircleIcon, + CogIcon, + ClipboardDocumentListIcon, +} from '@heroicons/react/24/outline'; +import TelegramIcon from '../../Icons/TelegramIcon'; +import type { Profile, ProfileFormData, TelegramBotInfo } from '../types'; + +interface TelegramTabProps { + isActive: boolean; + formData: ProfileFormData; + profile: Profile | null; + telegramBotInfo: TelegramBotInfo | null; + isPolling: boolean; + telegramSetupStatus: 'idle' | 'loading' | 'success' | 'error'; + onChange: (e: ChangeEvent) => void; + onSetup: () => void; + onStartPolling: () => void; + onStopPolling: () => void; + onToggleSummary: () => void; + onSelectFrequency: (frequency: string) => void; + onSendTestSummary: () => void; + formatFrequency: (frequency: string) => string; +} + +const TelegramTab: React.FC = ({ + isActive, + formData, + profile, + telegramBotInfo, + isPolling, + telegramSetupStatus, + onChange, + onSetup, + onStartPolling, + onStopPolling, + onToggleSummary, + onSelectFrequency, + onSendTestSummary, + formatFrequency, +}) => { + const { t } = useTranslation(); + + if (!isActive) return null; + + return ( +
+

+ + {t('profile.telegramIntegration', 'Telegram Integration')} +

+ +
+

+ + {t('profile.botSetup', 'Bot Setup')} +

+ +
+
+ +

+ {t( + 'profile.telegramDescription', + 'Connect your tududi account to a Telegram bot to add items to your inbox via Telegram messages.' + )} +

+
+ +
+ + +

+ {t( + 'profile.telegramTokenDescription', + 'Create a bot with @BotFather on Telegram and paste the token here.' + )} +

+
+ +
+ + +
+

+ {t( + 'profile.telegramAllowedUsersDescription', + 'Control who can send messages to your bot. Leave empty to allow all users.' + )} +

+
+

+ {t('profile.examples', 'Examples:')} +

+
    +
  • + + @alice, @bob + + {' - '} + {t( + 'profile.exampleUsernames', + 'Allow specific usernames' + )} +
  • +
  • + + 123456789, 987654321 + + {' - '} + {t( + 'profile.exampleUserIds', + 'Allow specific user IDs' + )} +
  • +
  • + + @alice, 123456789 + + {' - '} + {t( + 'profile.exampleMixed', + 'Mix usernames and user IDs' + )} +
  • +
+
+
+
+ + {profile?.telegram_chat_id && ( +
+

+ {t( + 'profile.telegramConnected', + 'Your Telegram account is connected! Send messages to your bot to add items to your tududi inbox.' + )} +

+
+ )} + + {(telegramBotInfo || profile?.telegram_bot_token) && ( +
+

+ {t( + 'profile.botConfigured', + 'Bot configured successfully!' + )} +

+
+ {telegramBotInfo?.first_name && ( +

+ + Bot Name:{' '} + + {telegramBotInfo.first_name} +

+ )} + {telegramBotInfo?.username && ( +

+ + {t( + 'profile.botUsername', + 'Bot Username:' + )}{' '} + + @{telegramBotInfo.username} +

+ )} +
+

+ {t( + 'profile.pollingStatus', + 'Polling Status:' + )}{' '} +

+
+
+ + {isPolling + ? t('profile.pollingActive') + : t('profile.pollingInactive')} + +
+

+ {t( + 'profile.pollingNote', + 'Polling periodically checks for new messages from Telegram and adds them to your inbox.' + )} +

+
+ {isPolling ? ( + + ) : ( + + )} + {telegramBotInfo?.chat_url && ( + + {t( + 'profile.openTelegram', + 'Open in Telegram' + )} + + )} +
+
+
+
+ )} + + + + {telegramSetupStatus === 'success' && ( +
+ + + + + {t( + 'profile.botConfigured', + 'Bot configured successfully!' + )} + +
+ )} + + {telegramSetupStatus === 'error' && ( +
+ + + + + {t( + 'profile.telegramSetupFailed', + 'Setup failed. Please check your token.' + )} + +
+ )} +
+
+ +
+

+ + {t( + 'profile.taskSummaryNotifications', + 'Task Summary Notifications' + )} +

+ +
+ +

+ {t( + 'profile.taskSummaryDescription', + 'Receive regular summaries of your tasks via Telegram. This feature requires your Telegram integration to be set up.' + )} +

+
+ +
+ +
+ +
+
+ +
+ +
+ {['1h', '2h', '4h', '8h', '12h', 'daily', 'weekly'].map( + (frequency) => ( + + ) + )} +
+

+ {t( + 'profile.frequencyHelp', + 'Choose how often you want to receive task summaries.' + )} +

+
+ +
+ + {(!profile?.telegram_bot_token || + !profile?.telegram_chat_id) && ( +

+ {t( + 'profile.telegramRequiredForSummaries', + 'Telegram integration must be set up to use task summaries.' + )} +

+ )} +
+
+
+ ); +}; + +export default TelegramTab; diff --git a/frontend/components/Profile/types.ts b/frontend/components/Profile/types.ts new file mode 100644 index 0000000..7a71ae7 --- /dev/null +++ b/frontend/components/Profile/types.ts @@ -0,0 +1,42 @@ +export interface ProfileSettingsProps { + currentUser: { uid: string; email: string }; + isDarkMode?: boolean; + toggleDarkMode?: () => void; +} + +export interface Profile { + uid: string; + email: string; + name?: string; + surname?: string; + appearance: 'light' | 'dark'; + language: string; + timezone: string; + first_day_of_week: number; + avatar_image: string | null; + telegram_bot_token: string | null; + telegram_chat_id: string | null; + telegram_allowed_users: string | null; + task_summary_enabled: boolean; + task_summary_frequency: string; + task_intelligence_enabled: boolean; + auto_suggest_next_actions_enabled: boolean; + productivity_assistant_enabled: boolean; + next_task_suggestion_enabled: boolean; + pomodoro_enabled: boolean; +} + +export interface TelegramBotInfo { + username: string; + first_name?: string; + polling_status: any; + chat_url: string; +} + +export type ProfileFormData = Partial< + Profile & { + currentPassword: string; + newPassword: string; + confirmPassword: string; + } +>; diff --git a/frontend/components/Project/ProjectBanner.tsx b/frontend/components/Project/ProjectBanner.tsx new file mode 100644 index 0000000..cc87b0d --- /dev/null +++ b/frontend/components/Project/ProjectBanner.tsx @@ -0,0 +1,175 @@ +import React, { RefObject } from 'react'; +import { + TagIcon, + Squares2X2Icon, + PencilSquareIcon, + TrashIcon, + ShareIcon, +} from '@heroicons/react/24/outline'; +import BannerBadge from '../Shared/BannerBadge'; +import { Project } from '../../entities/Project'; +import { Area } from '../../entities/Area'; +import { useNavigate } from 'react-router-dom'; +import { TFunction } from 'i18next'; + +interface ProjectBannerProps { + project: Project; + areas: Area[]; + t: TFunction; + getStateIcon: (state: string) => React.ReactNode; + onDeleteClick: () => void; + editButtonRef: RefObject; +} + +const ProjectBanner: React.FC = ({ + project, + areas, + t, + getStateIcon, + onDeleteClick, + editButtonRef, +}) => { + const navigate = useNavigate(); + + return ( +
+
+ {project.image_url ? ( + {project.name} + ) : ( +
+ )} + +
+
+

+ {project.name} +

+ {project.description && ( +

+ {project.description} +

+ )} +
+
+ +
+ {project.state && ( + + {getStateIcon(project.state)} + + {t(`projects.states.${project.state}`)} + + + )} + + {project.tags && project.tags.length > 0 && ( + + + + {project.tags.map((tag, index) => ( + + + {index < + (project.tags?.length || 0) - 1 && ( + + ,{' '} + + )} + + ))} + + + )} + + {(project.area || (project as any).Area) && ( + + + + + )} + + {project.is_shared && ( + + + + {t('projects.shared', 'Shared')} + + + )} +
+ +
+ + +
+
+
+ ); +}; + +export default ProjectBanner; diff --git a/frontend/components/Project/ProjectDetails.tsx b/frontend/components/Project/ProjectDetails.tsx index 2983ed7..d70bc59 100644 --- a/frontend/components/Project/ProjectDetails.tsx +++ b/frontend/components/Project/ProjectDetails.tsx @@ -1,29 +1,22 @@ -import React, { useEffect, useState, useRef, useMemo } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { useToast } from '../Shared/ToastContext'; import { - PencilSquareIcon, - TrashIcon, - TagIcon, - PlusCircleIcon, - Squares2X2Icon, - PlayIcon, + MagnifyingGlassIcon, LightBulbIcon, ClipboardDocumentListIcon, + PlayIcon, ExclamationTriangleIcon, CheckCircleIcon, - ShareIcon, - MagnifyingGlassIcon, + ChartBarIcon, + CheckIcon, } from '@heroicons/react/24/outline'; -import TaskList from '../Task/TaskList'; -import ProjectModal from '../Project/ProjectModal'; +import { useToast } from '../Shared/ToastContext'; +import ProjectModal from './ProjectModal'; import ConfirmDialog from '../Shared/ConfirmDialog'; import NoteModal from '../Note/NoteModal'; import { useStore } from '../../store/useStore'; -import NewTask from '../Task/NewTask'; import { Project } from '../../entities/Project'; -import NoteCard from '../Shared/NoteCard'; import { Task } from '../../entities/Task'; import { Note } from '../../entities/Note'; import { @@ -42,237 +35,282 @@ import { deleteNote as apiDeleteNote, } from '../../utils/notesService'; import { createNote } from '../../utils/notesService'; -import { isAuthError } from '../../utils/authUtils'; import { getAutoSuggestNextActionsEnabled } from '../../utils/profileService'; -import AutoSuggestNextActionBox from './AutoSuggestNextActionBox'; -import { SortOption } from '../Shared/SortFilterButton'; import IconSortDropdown from '../Shared/IconSortDropdown'; import LoadingSpinner from '../Shared/LoadingSpinner'; import { usePersistedModal } from '../../hooks/usePersistedModal'; -import BannerBadge from '../Shared/BannerBadge'; import { getApiPath } from '../../config/paths'; +import ProjectInsightsPanel from './ProjectInsightsPanel'; +import ProjectBanner from './ProjectBanner'; +import ProjectTasksSection from './ProjectTasksSection'; +import ProjectNotesSection from './ProjectNotesSection'; +import { useProjectMetrics } from './useProjectMetrics'; const ProjectDetails: React.FC = () => { + const UI_OPTIONS_KEY = 'ui_app_options'; + const { uidSlug } = useParams<{ uidSlug: string }>(); const navigate = useNavigate(); const { t } = useTranslation(); const { showSuccessToast } = useToast(); - - // Load areas from store (similar to how we handle tags) const { areasStore, projectsStore } = useStore(); const areas = areasStore.areas; - - // Load areas when component mounts - useEffect(() => { - if (!areasStore.hasLoaded && !areasStore.isLoading) { - areasStore.loadAreas(); - } - }, [areasStore.hasLoaded, areasStore.isLoading, areasStore.loadAreas]); const [allProjects, setAllProjects] = useState([]); - // Use local state to isolate from global store changes that cause remounting const [project, setProject] = useState(null); const [tasks, setTasks] = useState([]); - const [notes, setNotes] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(false); - // Use persisted modal state that survives component remounts + const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); + const [noteToDelete, setNoteToDelete] = useState(null); + const [selectedNote, setSelectedNote] = useState(null); + const [isNoteModalOpen, setIsNoteModalOpen] = useState(false); + const [activeTab, setActiveTab] = useState<'tasks' | 'notes'>('tasks'); + const [taskStatusFilter, setTaskStatusFilter] = useState< + 'all' | 'active' | 'completed' + >(() => { + const saved = localStorage.getItem('project_task_status_filter'); + return (saved as 'all' | 'active' | 'completed') || 'active'; + }); + const [showMetrics, setShowMetrics] = useState(true); + const [showAutoSuggestForm, setShowAutoSuggestForm] = useState(false); + const [autoSuggestEnabled, setAutoSuggestEnabled] = useState(false); + const hasCheckedAutoSuggest = useRef(false); + const [orderBy, setOrderBy] = useState('status:inProgressFirst'); + const [taskSearchQuery, setTaskSearchQuery] = useState(''); + const [isSearchExpanded, setIsSearchExpanded] = useState(false); const { isOpen: isModalOpen, openModal, closeModal, } = usePersistedModal(project?.id); const editButtonRef = useRef(null); - - const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); - - const [showCompleted, setShowCompleted] = useState(false); - const [showAutoSuggestForm, setShowAutoSuggestForm] = useState(false); - const [autoSuggestEnabled, setAutoSuggestEnabled] = useState(false); - const hasCheckedAutoSuggest = useRef(false); - const [orderBy, setOrderBy] = useState('created_at:desc'); - const [activeTab, setActiveTab] = useState<'tasks' | 'notes'>('tasks'); - - // Search state - const [taskSearchQuery, setTaskSearchQuery] = useState(''); - const [isSearchExpanded, setIsSearchExpanded] = useState(false); - - // Sort options for tasks - const sortOptions: SortOption[] = [ - { value: 'created_at:desc', label: 'Created at' }, - { value: 'due_date:asc', label: 'Due date' }, - { value: 'priority:desc', label: 'Priority' }, - ]; - - // Note modal state - const [isNoteModalOpen, setIsNoteModalOpen] = useState(false); - const [noteToDelete, setNoteToDelete] = useState(null); - const [selectedNote, setSelectedNote] = useState(null); - - // Dispatch global modal events + const sortOptions = useMemo( + () => [ + { + value: 'status:inProgressFirst', + label: t('tasks.status', 'Status'), + }, + { value: 'created_at:desc', label: 'Created at' }, + { value: 'due_date:asc', label: 'Due date' }, + { value: 'priority:desc', label: 'Priority' }, + ], + [t] + ); useEffect(() => { - const fetchAutoSuggestSetting = async () => { - if (!hasCheckedAutoSuggest.current) { - hasCheckedAutoSuggest.current = true; - const enabled = await getAutoSuggestNextActionsEnabled(); - setAutoSuggestEnabled(enabled); - } - }; + if (!areasStore.hasLoaded && !areasStore.isLoading) { + areasStore.loadAreas(); + } + }, [areasStore]); - fetchAutoSuggestSetting(); + useEffect(() => { + if (!hasCheckedAutoSuggest.current) { + hasCheckedAutoSuggest.current = true; + getAutoSuggestNextActionsEnabled().then(setAutoSuggestEnabled); + } }, []); - // Load projects if not already loaded useEffect(() => { - const loadProjectsIfNeeded = async () => { - if (allProjects.length === 0) { - try { - const projectsData = await fetchProjects(); - setAllProjects(projectsData); - } catch (error) { - console.error('Failed to fetch projects:', error); + // Load persisted UI options (local or remote) + const load = async () => { + let localShow: boolean | undefined; + try { + const stored = localStorage.getItem(UI_OPTIONS_KEY); + if (stored) { + const parsed = JSON.parse(stored); + if (typeof parsed.showMetrics === 'boolean') { + localShow = parsed.showMetrics; + setShowMetrics(parsed.showMetrics); + } } + } catch { + // ignore parse errors + } + + try { + const response = await fetch(getApiPath('profile'), { + credentials: 'include', + }); + if (response.ok) { + const profile = await response.json(); + if ( + profile.ui_settings && + typeof profile.ui_settings.project?.details + ?.showMetrics === 'boolean' + ) { + setShowMetrics( + profile.ui_settings.project.details.showMetrics + ); + localStorage.setItem( + UI_OPTIONS_KEY, + JSON.stringify({ + showMetrics: + profile.ui_settings.project.details + .showMetrics, + }) + ); + } else if (localShow === undefined) { + setShowMetrics(true); + } + } else if (localShow === undefined) { + setShowMetrics(true); + } + } catch { + if (localShow === undefined) setShowMetrics(true); } }; - loadProjectsIfNeeded(); + load(); + }, [getApiPath]); + + const persistUiSettings = async (nextShowMetrics: boolean) => { + try { + localStorage.setItem( + UI_OPTIONS_KEY, + JSON.stringify({ showMetrics: nextShowMetrics }) + ); + } catch { + // ignore storage errors + } + + try { + await fetch(getApiPath('profile/ui-settings'), { + method: 'PUT', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + project: { + details: { + showMetrics: nextShowMetrics, + }, + }, + }), + }); + } catch { + // ignore network errors + } + }; + + const toggleMetrics = () => { + setShowMetrics((prev) => { + const next = !prev; + persistUiSettings(next); + return next; + }); + }; + + useEffect(() => { + if (allProjects.length === 0) { + fetchProjects() + .then(setAllProjects) + .catch(() => undefined); + } }, [allProjects.length]); - // Check if we should show auto-suggest form for projects with no tasks useEffect(() => { - if ( - project && - tasks.length === 0 && - !loading && - !showCompleted && - autoSuggestEnabled - ) { - setShowAutoSuggestForm(true); + const storedSort = localStorage.getItem('project_order_by'); + const defaultSort = 'status:inProgressFirst'; + if (!storedSort || storedSort === 'created_at:desc') { + setOrderBy(defaultSort); + localStorage.setItem('project_order_by', defaultSort); } else { - setShowAutoSuggestForm(false); + setOrderBy(storedSort); } - }, [project, tasks.length, loading, showCompleted, autoSuggestEnabled]); - - // Load initial sort order from localStorage (URL params removed to prevent conflicts) - useEffect(() => { - const sortParam = - localStorage.getItem('project_order_by') || 'created_at:desc'; - setOrderBy(sortParam); }, []); - // Fetch project data when uidSlug changes useEffect(() => { if (!uidSlug) return; - - // Skip loading if we already have the project data for this uidSlug - if ( - project && - project.uid && - `${project.uid}-${project.name - ?.toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '')}` === uidSlug - ) { - return; - } - const loadProjectData = async () => { try { - // Only show loading if we don't have any project data yet - if (!project) { - setLoading(true); - } + if (!project) setLoading(true); setError(false); - const projectData = await fetchProjectBySlug(uidSlug); setProject(projectData); setTasks(projectData.tasks || projectData.Tasks || []); - - // Load saved preferences from project data - if (projectData.task_show_completed !== undefined) { - setShowCompleted(projectData.task_show_completed); - } - if (projectData.task_sort_order) { + const savedSort = localStorage.getItem('project_order_by'); + if (!savedSort && projectData.task_sort_order) { setOrderBy(projectData.task_sort_order); } const fetchedNotes = projectData.notes || projectData.Notes || []; - - // Normalize tags field - backend returns 'Tags' but frontend expects 'tags' - const normalizedNotes = fetchedNotes.map((note) => { - if (note.Tags && !note.tags) { - note.tags = note.Tags; - } - return note; - }); - - setNotes(normalizedNotes); + setNotes( + fetchedNotes.map((note) => { + if (note.Tags && !note.tags) note.tags = note.Tags; + return note; + }) + ); setLoading(false); } catch { setError(true); setLoading(false); } }; - loadProjectData(); }, [uidSlug]); + useEffect(() => { + const button = editButtonRef.current; + if (!button) return; + const handleClick = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + openModal(); + }; + button.addEventListener('click', handleClick); + return () => button.removeEventListener('click', handleClick); + }, [openModal]); + + useEffect(() => { + if ( + project && + tasks.length === 0 && + !loading && + taskStatusFilter === 'active' && + autoSuggestEnabled + ) { + setShowAutoSuggestForm(true); + } else { + setShowAutoSuggestForm(false); + } + }, [project, tasks.length, loading, taskStatusFilter, autoSuggestEnabled]); + const handleTaskCreate = async (taskName: string) => { - if (!project) { - throw new Error('Cannot create task: Project is missing'); - } - - try { - const newTask = await createTask({ - name: taskName, - status: 0, // Use numeric status: 0 = not_started - project_id: project.id, - completed_at: null, - }); - setTasks([...tasks, newTask]); - - // Show success toast with task link - const taskLink = ( - - {t('task.created', 'Task')}{' '} - - {newTask.name} - {' '} - {t('task.createdSuccessfully', 'created successfully!')} - - ); - showSuccessToast(taskLink); - } catch (err: any) { - // Check if it's an authentication error - if (isAuthError(err)) { - return; - } - throw err; // Re-throw to allow proper error handling by NewTask component - } + if (!project) throw new Error('Cannot create task: Project is missing'); + const newTask = await createTask({ + name: taskName, + status: 0, + project_id: project.id, + completed_at: null, + }); + setTasks([...tasks, newTask]); + const taskLink = ( + + {t('task.created', 'Task')}{' '} + + {newTask.name} + {' '} + {t('task.createdSuccessfully', 'created successfully!')} + + ); + showSuccessToast(taskLink); }; const handleTaskUpdate = async (updatedTask: Task) => { - if (!updatedTask.id) { - return; - } - - // Only skip API call for specific operations that already have fresh data from the server - // (like toggleTaskCompletion), not for general modal updates + if (!updatedTask.id) return; const hasUpdatedData = updatedTask.parent_child_logic_executed !== undefined; - if (hasUpdatedData) { - // Use the provided data directly, preserving existing subtasks if not included - setTasks( - tasks.map((task) => + setTasks((prev) => + prev.map((task) => task.id === updatedTask.id ? { ...task, ...updatedTask, - // Explicitly preserve subtasks data subtasks: updatedTask.subtasks || updatedTask.Subtasks || @@ -291,314 +329,196 @@ const ProjectDetails: React.FC = () => { ); return; } - - try { - // Use direct fetch call like Tasks.tsx to ensure proper tag saving - const response = await fetch(getApiPath(`task/${updatedTask.id}`), { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify(updatedTask), - }); - - if (!response.ok) { - await response.json(); - throw new Error('Failed to update task'); - } - - const savedTask = await response.json(); - - // If the task's project was changed/cleared and no longer belongs to this project, remove it - // Handle both null and undefined project_id values - const savedTaskProjectId = savedTask.project_id ?? null; - const currentProjectId = project?.id ?? null; - - if (savedTaskProjectId !== currentProjectId) { - setTasks(tasks.filter((task) => task.id !== updatedTask.id)); - } else { - // Otherwise, update the task in place - setTasks( - tasks.map((task) => - task.id === updatedTask.id - ? { - ...task, - ...savedTask, - // Explicitly preserve subtasks data - subtasks: - savedTask.subtasks || - savedTask.Subtasks || - updatedTask.subtasks || - updatedTask.Subtasks || - task.subtasks || - task.Subtasks || - [], - Subtasks: - savedTask.subtasks || - savedTask.Subtasks || - updatedTask.subtasks || - updatedTask.Subtasks || - task.subtasks || - task.Subtasks || - [], - } - : task - ) - ); - } - } catch { - // Error updating task - silently handled + const response = await fetch(getApiPath(`task/${updatedTask.id}`), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(updatedTask), + }); + if (!response.ok) { + await response.json(); + throw new Error('Failed to update task'); } - }; - - const handleTaskDelete = async (taskId: number | undefined) => { - if (!taskId) { - return; - } - try { - await deleteTask(taskId); - setTasks(tasks.filter((task) => task.id !== taskId)); - } catch { - // Error deleting task - silently handled - } - }; - - const handleToggleToday = async ( - taskId: number, - task?: Task - ): Promise => { - try { - const updatedTask = await toggleTaskToday(taskId, task); - // Update the task in the local state immediately to avoid UI flashing - setTasks( - tasks.map((task) => - task.id === taskId + const savedTask = await response.json(); + const savedTaskProjectId = savedTask.project_id ?? null; + const currentProjectId = project?.id ?? null; + if (savedTaskProjectId !== currentProjectId) { + setTasks(tasks.filter((task) => task.id !== updatedTask.id)); + } else { + setTasks((prev) => + prev.map((task) => + task.id === updatedTask.id ? { ...task, - today: updatedTask.today, - today_move_count: updatedTask.today_move_count, + ...savedTask, + subtasks: + savedTask.subtasks || + savedTask.Subtasks || + updatedTask.subtasks || + updatedTask.Subtasks || + task.subtasks || + task.Subtasks || + [], + Subtasks: + savedTask.subtasks || + savedTask.Subtasks || + updatedTask.subtasks || + updatedTask.Subtasks || + task.subtasks || + task.Subtasks || + [], } : task ) ); + } + }; + + const handleTaskDelete = async (taskId: number | undefined) => { + if (!taskId) return; + await deleteTask(taskId); + setTasks(tasks.filter((task) => task.id !== taskId)); + }; + + const handleTaskCompletionToggle = (updatedTask: Task) => { + if (!updatedTask.id) return; + setTasks((prev) => + prev.map((task) => + task.id === updatedTask.id + ? { + ...task, + ...updatedTask, + subtasks: + updatedTask.subtasks || + updatedTask.Subtasks || + task.subtasks || + task.Subtasks || + [], + Subtasks: + updatedTask.subtasks || + updatedTask.Subtasks || + task.subtasks || + task.Subtasks || + [], + } + : task + ) + ); + }; + + const handleToggleToday = async (taskId: number, task?: Task) => { + try { + const updatedTask = await toggleTaskToday(taskId, task); + setTasks((prev) => + prev.map((t) => + t.id === taskId + ? { + ...t, + today: updatedTask.today, + today_move_count: updatedTask.today_move_count, + } + : t + ) + ); } catch { - // Optionally refetch data on error to ensure consistency - if (uidSlug) { - // Refetch project data on error to ensure consistency - try { - const projectData = await fetchProjectBySlug(uidSlug); - setProject(projectData); - setTasks(projectData.tasks || projectData.Tasks || []); - const fetchedNotes = - projectData.notes || projectData.Notes || []; - - // Normalize tags field - backend returns 'Tags' but frontend expects 'tags' - const normalizedNotes = fetchedNotes.map((note) => { - if (note.Tags && !note.tags) { - note.tags = note.Tags; - } + if (!uidSlug) return; + try { + const projectData = await fetchProjectBySlug(uidSlug); + setProject(projectData); + setTasks(projectData.tasks || projectData.Tasks || []); + const fetchedNotes = + projectData.notes || projectData.Notes || []; + setNotes( + fetchedNotes.map((note) => { + if (note.Tags && !note.tags) note.tags = note.Tags; return note; - }); - - setNotes(normalizedNotes); - } catch { - // Error refetching project data - silently handled - } + }) + ); + } catch { + // silent } } }; - // Setup native event listener for edit button to avoid React event system conflicts - useEffect(() => { - const button = editButtonRef.current; - if (button) { - const handleClick = (e: Event) => { - e.preventDefault(); - e.stopPropagation(); - openModal(); - }; - - button.addEventListener('click', handleClick); - return () => { - button.removeEventListener('click', handleClick); - }; - } - }, [openModal]); - const handleSaveProject = async (updatedProject: Project) => { - if (!updatedProject.uid) { - return; - } - - try { - const savedProject = await updateProject( - updatedProject.uid, - updatedProject - ); - // Merge the saved project with existing project to preserve area data - setProject((prevProject) => ({ - ...savedProject, - // Preserve area info if it's missing from the response - area: savedProject.area || prevProject?.area, - Area: (savedProject as any).Area || (prevProject as any)?.Area, - })); - closeModal(); - } catch { - // Error saving project - silently handled - } + if (!updatedProject.uid) return; + const savedProject = await updateProject( + updatedProject.uid, + updatedProject + ); + setProject((prev) => ({ + ...savedProject, + area: savedProject.area || prev?.area, + Area: (savedProject as any).Area || (prev as any)?.Area, + })); + closeModal(); }; const handleCreateNextAction = async ( projectId: number, actionDescription: string ) => { - try { - const newTask = await createTask({ - name: actionDescription, - status: 0, // Use numeric status: 0 = not_started - project_id: projectId, - priority: 0, // Use numeric priority: 0 = low - completed_at: null, - }); - - // Update the tasks list to include the new task - setTasks([...tasks, newTask]); - setShowAutoSuggestForm(false); - - // Show success toast with task link - const taskLink = ( - - {t('task.created', 'Task')}{' '} - - {newTask.name} - {' '} - {t('task.createdSuccessfully', 'created successfully!')} - - ); - showSuccessToast(taskLink); - } catch { - // Error creating next action - silently handled - } - }; - - const handleSkipNextAction = () => { + const newTask = await createTask({ + name: actionDescription, + status: 0, + project_id: projectId, + priority: 0, + completed_at: null, + }); + setTasks([...tasks, newTask]); setShowAutoSuggestForm(false); + const taskLink = ( + + {t('task.created', 'Task')}{' '} + + {newTask.name} + {' '} + {t('task.createdSuccessfully', 'created successfully!')} + + ); + showSuccessToast(taskLink); }; - const saveProjectPreferences = async ( - showCompleted: boolean, - orderBy: string + const handleSkipNextAction = () => setShowAutoSuggestForm(false); + + const handleTaskStatusFilterChange = ( + status: 'all' | 'active' | 'completed' ) => { - if (!project?.id) return; - - try { - // Save preferences directly via API call - const response = await fetch(getApiPath(`project/${project.id}`), { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ - task_show_completed: showCompleted, - task_sort_order: orderBy, - }), - }); - - if (!response.ok) { - throw new Error('Failed to save project preferences'); - } - } catch (error) { - console.error('Error saving project preferences:', error); - } - }; - - const handleShowCompletedChange = (checked: boolean) => { - setShowCompleted(checked); - - // Save to project (remove navigation to prevent re-render) - saveProjectPreferences(checked, orderBy); + setTaskStatusFilter(status); + localStorage.setItem('project_task_status_filter', status); }; const handleSortChange = (newOrderBy: string) => { setOrderBy(newOrderBy); - // Save to project - saveProjectPreferences(showCompleted, newOrderBy); + localStorage.setItem('project_order_by', newOrderBy); }; - const renderShowCompletedToggle = () => ( - - ); - const handleDeleteProject = async () => { - if (!project?.uid) { - return; - } - - try { - await deleteProject(project.uid); - - // Update the global projects store to remove the deleted project - const currentProjects = projectsStore.projects; - const updatedProjects = currentProjects.filter( - (p) => p.uid !== project.uid - ); - projectsStore.setProjects(updatedProjects); - - navigate('/projects'); - } catch { - // Error deleting project - silently handled - } + if (!project?.uid) return; + await deleteProject(project.uid); + const updatedProjects = projectsStore.projects.filter( + (p) => p.uid !== project.uid + ); + projectsStore.setProjects(updatedProjects); + navigate('/projects'); }; - // Note handlers const handleEditNote = async (note: Note) => { try { - // Fetch the complete note data including tags const response = await fetch(getApiPath(`note/${note.uid}`), { credentials: 'include', headers: { Accept: 'application/json' }, }); - if (response.ok) { const fullNote = await response.json(); setSelectedNote(fullNote); } else { - // Fallback to the original note if fetch fails setSelectedNote(note); } } catch (error) { - // Fallback to the original note if fetch fails console.error('Error fetching note details:', error); setSelectedNote(note); } @@ -606,63 +526,47 @@ const ProjectDetails: React.FC = () => { }; const handleDeleteNote = async (noteIdentifier: string) => { - try { - await apiDeleteNote(noteIdentifier); - setNotes( - notes.filter((n) => { - const currentIdentifier = - n.uid ?? - (n.id !== undefined ? String(n.id) : undefined); - return currentIdentifier !== noteIdentifier; - }) - ); - // Remove note from global store - const globalNotes = useStore.getState().notesStore.notes; - useStore.getState().notesStore.setNotes( - globalNotes.filter((note) => { - const currentIdentifier = - note.uid ?? - (note.id !== undefined ? String(note.id) : undefined); - return currentIdentifier !== noteIdentifier; - }) - ); - setNoteToDelete(null); - setIsConfirmDialogOpen(false); - } catch { - // Error deleting note - silently handled - } + await apiDeleteNote(noteIdentifier); + setNotes( + notes.filter((n) => { + const currentIdentifier = + n.uid ?? (n.id !== undefined ? String(n.id) : undefined); + return currentIdentifier !== noteIdentifier; + }) + ); + const globalNotes = useStore.getState().notesStore.notes; + useStore.getState().notesStore.setNotes( + globalNotes.filter((note) => { + const currentIdentifier = + note.uid ?? + (note.id !== undefined ? String(note.id) : undefined); + return currentIdentifier !== noteIdentifier; + }) + ); + setNoteToDelete(null); + setIsConfirmDialogOpen(false); }; - // Create or update note and keep local notes list in sync const handleSaveNote = async (noteData: Note) => { try { let savedNote: Note; const noteIdentifier = noteData.uid ?? (noteData.id !== undefined ? String(noteData.id) : null); - let isUpdate = false; - if (noteIdentifier) { savedNote = await updateNote(noteIdentifier, noteData); isUpdate = true; } else { savedNote = await createNote(noteData); } - - // Normalize tags field - backend returns 'Tags' but frontend expects 'tags' if ((savedNote as any).Tags && !(savedNote as any).tags) { (savedNote as any).tags = (savedNote as any).Tags; } - - // If updated note moved to another project, remove it from this list - // Handle both null and undefined project_id values const savedNoteProjectId = savedNote.project_id ?? null; const currentProjectId = project?.id ?? null; - if (savedNote.id && savedNoteProjectId !== currentProjectId) { setNotes(notes.filter((n) => n.id !== savedNote.id)); - // Update global store - update or remove note const globalNotes = useStore.getState().notesStore.notes; useStore .getState() @@ -675,19 +579,16 @@ const ProjectDetails: React.FC = () => { const savedIdentifier = savedNote.uid ?? (savedNote.id !== undefined ? String(savedNote.id) : null); - setNotes( notes.map((n) => { const currentIdentifier = n.uid ?? (n.id !== undefined ? String(n.id) : undefined); - return currentIdentifier === savedIdentifier ? savedNote : n; }) ); - // Update global store const globalNotes = useStore.getState().notesStore.notes; useStore .getState() @@ -698,26 +599,22 @@ const ProjectDetails: React.FC = () => { ); } else { setNotes([savedNote, ...notes]); - // Add new note to global store const globalNotes = useStore.getState().notesStore.notes; useStore .getState() .notesStore.setNotes([savedNote, ...globalNotes]); } - setIsNoteModalOpen(false); setSelectedNote(null); } catch { - // Error saving note - silently handled + // silent } }; - // Filter and sort tasks (backend filtering/sorting not working reliably) const displayTasks = useMemo(() => { - // First, filter tasks based on completed state - let filteredTasks; - if (showCompleted) { - // Show only completed tasks (done=2 or archived=3) + let filteredTasks: Task[]; + + if (taskStatusFilter === 'completed') { filteredTasks = tasks.filter( (task) => task.status === 'done' || @@ -725,18 +622,20 @@ const ProjectDetails: React.FC = () => { task.status === 2 || task.status === 3 ); - } else { - // Show only non-completed tasks (not_started=0, in_progress=1) + } else if (taskStatusFilter === 'active') { filteredTasks = tasks.filter( (task) => task.status === 'not_started' || task.status === 'in_progress' || + task.status === 'waiting' || task.status === 0 || - task.status === 1 + task.status === 1 || + task.status === 4 ); + } else { + // taskStatusFilter === 'all' + filteredTasks = tasks; } - - // Filter by search query if (taskSearchQuery.trim()) { const query = taskSearchQuery.toLowerCase(); filteredTasks = filteredTasks.filter( @@ -746,62 +645,90 @@ const ProjectDetails: React.FC = () => { task.note?.toLowerCase().includes(query) ); } - - // Then, sort the filtered tasks - const sortedTasks = [...filteredTasks].sort((a, b) => { + const getStatusRank = (status: Task['status']) => { + if (status === 'in_progress' || status === 1) return 0; + if (status === 'not_started' || status === 0) return 1; + if (status === 'waiting' || status === 4) return 2; + if (status === 'done' || status === 2) return 3; + if (status === 'archived' || status === 3) return 4; + return 5; + }; + return [...filteredTasks].sort((a, b) => { + if (orderBy === 'status:inProgressFirst') { + const rankA = getStatusRank(a.status); + const rankB = getStatusRank(b.status); + if (rankA !== rankB) return rankA - rankB; + const dueA = a.due_date + ? new Date(a.due_date).getTime() + : Number.MAX_SAFE_INTEGER; + const dueB = b.due_date + ? new Date(b.due_date).getTime() + : Number.MAX_SAFE_INTEGER; + if (dueA !== dueB) return dueA - dueB; + return (a.id || 0) - (b.id || 0); + } const [field, direction] = orderBy.split(':'); const isAsc = direction === 'asc'; - - let valueA, valueB; - + const compare = (valueA: any, valueB: any) => { + if (valueA < valueB) return isAsc ? -1 : 1; + if (valueA > valueB) return isAsc ? 1 : -1; + return 0; + }; switch (field) { case 'name': - valueA = a.name?.toLowerCase() || ''; - valueB = b.name?.toLowerCase() || ''; - break; + return compare( + a.name?.toLowerCase() || '', + b.name?.toLowerCase() || '' + ); case 'due_date': - valueA = a.due_date ? new Date(a.due_date).getTime() : 0; - valueB = b.due_date ? new Date(b.due_date).getTime() : 0; - break; + return compare( + a.due_date ? new Date(a.due_date).getTime() : 0, + b.due_date ? new Date(b.due_date).getTime() : 0 + ); case 'priority': { - // Convert priority to numeric for sorting (high=2, medium=1, low=0) const priorityMap = { high: 2, medium: 1, low: 0 }; - valueA = + const valueA = typeof a.priority === 'string' ? priorityMap[a.priority] || 0 : a.priority || 0; - valueB = + const valueB = typeof b.priority === 'string' ? priorityMap[b.priority] || 0 : b.priority || 0; - break; + return compare(valueA, valueB); } case 'status': - valueA = - typeof a.status === 'string' ? a.status : a.status || 0; - valueB = - typeof b.status === 'string' ? b.status : b.status || 0; - break; + return compare( + typeof a.status === 'string' ? a.status : a.status || 0, + typeof b.status === 'string' ? b.status : b.status || 0 + ); case 'created_at': default: - valueA = a.created_at - ? new Date(a.created_at).getTime() - : 0; - valueB = b.created_at - ? new Date(b.created_at).getTime() - : 0; - break; + return compare( + a.created_at ? new Date(a.created_at).getTime() : 0, + b.created_at ? new Date(b.created_at).getTime() : 0 + ); } - - if (valueA < valueB) return isAsc ? -1 : 1; - if (valueA > valueB) return isAsc ? 1 : -1; - return 0; }); + }, [tasks, taskStatusFilter, orderBy, taskSearchQuery]); - return sortedTasks; - }, [tasks, showCompleted, orderBy, taskSearchQuery]); + const { + taskStats, + completionGradient, + dueBuckets, + dueHighlights, + nextBestAction, + getDueDescriptor, + handleStartNextAction, + completionTrend, + upcomingDueTrend, + createdTrend, + upcomingInsights, + eisenhower, + weeklyPace, + monthlyCompleted, + } = useProjectMetrics(tasks, handleTaskUpdate, t); - // Function to get the appropriate icon for project state const getStateIcon = (state: string) => { switch (state) { case 'idea': @@ -831,11 +758,8 @@ const ProjectDetails: React.FC = () => { } }; - if (loading) { - return ; - } - - if (error) { + if (loading) return ; + if (error) return (
@@ -843,312 +767,113 @@ const ProjectDetails: React.FC = () => {
); - } - - if (!project) { + if (!project) return (
Project not found.
); - } - return ( -
- {/* Project Banner - Full Width */} -
- {/* Project Banner - Unified for both with and without images */} -
- {/* Background - Image or Gradient */} - {project.image_url ? ( - {project.name} - ) : ( -
- )} - - {/* Title Overlay */} -
-
-

- {project.name} -

- {project.description && ( -

- {project.description} -

- )} -
-
- - {/* State, Tags and Area Display - Bottom Left */} -
- {/* Project State Display */} - {project.state && ( - - {getStateIcon(project.state)} - - {t(`projects.states.${project.state}`)} - - - )} - - {/* Tags Display */} - {project.tags && project.tags.length > 0 && ( - - - - {project.tags.map((tag, index) => ( - - - {index < - (project.tags?.length || 0) - - 1 && ( - - ,{' '} - - )} - - ))} - - - )} - - {/* Area Display */} - {(project.area || (project as any).Area) && ( - - - - - )} - - {/* Shared Badge */} - {project.is_shared && ( - - - - {t('projects.shared', 'Shared')} - - - )} -
- - {/* Edit/Delete Buttons - Bottom Right */} -
- - -
+ const renderStatusFilter = () => ( +
+
+
+ {t('tasks.show', 'Show')} +
+
+ {[ + { key: 'active', label: t('tasks.open', 'Open') }, + { key: 'all', label: t('tasks.all', 'All') }, + { + key: 'completed', + label: t('tasks.completed', 'Completed'), + }, + ].map((opt) => { + const isActive = taskStatusFilter === opt.key; + return ( + + ); + })}
+
+
+ {t('tasks.direction', 'Direction')} +
+
+ {[ + { + key: 'asc', + label: t('tasks.ascending', 'Ascending'), + }, + { + key: 'desc', + label: t('tasks.descending', 'Descending'), + }, + ].map((dir) => { + const currentDirection = orderBy.split(':')[1] || 'asc'; + const isActive = currentDirection === dir.key; + return ( + + ); + })} +
+
+
+ ); - {/* Content Container - Centered with max width */} -
-
- {/* Header with Tab Links and Controls */} + return ( +
+ { + setNoteToDelete(null); + setIsConfirmDialogOpen(true); + }} + editButtonRef={editButtonRef} + /> + +
+
- {/* Mobile Layout */} -
-
- {/* Tab Navigation Links */} -
- - -
- - {/* Inline Controls - Always visible for tasks tab */} - {activeTab === 'tasks' && ( -
- {/* Search Button */} - - -
- )} -
-
- - {/* Desktop Layout */}
- {/* Tab Navigation Links */}
- {/* Inline Controls - Always visible for tasks tab */} {activeTab === 'tasks' && (
- {/* Search Button */} + @@ -1225,166 +982,146 @@ const ProjectDetails: React.FC = () => { 'tasks.sortBy', 'Sort by' )} - extraContent={renderShowCompletedToggle()} + footerContent={renderStatusFilter()} />
)}
- {/* Search input section, collapsible - only for tasks tab */} - {activeTab === 'tasks' && ( -
-
- - - setTaskSearchQuery(e.target.value) - } - className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white" - /> -
-
- )} - - {/* Auto-suggest form for tasks with no items */} - {activeTab === 'tasks' && showAutoSuggestForm && ( -
- { - if (project?.id) { - handleCreateNextAction( - project.id, - actionDescription - ); - } - }} - onDismiss={handleSkipNextAction} - /> -
- )} - - {/* Tasks Tab Content */} {activeTab === 'tasks' && ( <>
- +
+ + + setTaskSearchQuery(e.target.value) + } + className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white" + /> +
-
- {displayTasks.length > 0 ? ( -
- +
+
+
- ) : ( -
-

- {taskSearchQuery.trim() - ? t( - 'tasks.noTasksAvailable', - 'No tasks available.' - ) - : showCompleted - ? t( - 'project.noCompletedTasks', - 'No completed tasks.' - ) - : t( - 'project.noTasks', - 'No tasks.' - )} -

+
+ +
+
+
- )} +
)} - {/* Notes Content */} - {activeTab === 'notes' && ( -
- {/* Create New Note Button - Always visible */} -
- -
- - {/* Notes Grid or Empty State */} - {notes.length > 0 ? ( -
- {notes.map((note) => ( - { - setNoteToDelete(note); - setIsConfirmDialogOpen(true); - }} - showActions={true} - showProject={false} - /> - ))} -
- ) : ( -
-

- {t( - 'project.noNotes', - 'No notes for this project.' - )} -

-
- )} -
+ {activeTab === 'notes' && project && ( + { + setSelectedNote({ + title: '', + content: '', + tags: [], + project_id: project.id, + project: { + id: project.id, + name: project.name, + uid: project.uid, + }, + project_uid: project.uid, + }); + setIsNoteModalOpen(true); + }} + onEditNote={handleEditNote} + onDeleteNote={(note) => { + setNoteToDelete(note); + setIsConfirmDialogOpen(true); + }} + /> )} { areas={areas} /> - {/* NoteModal */} { @@ -1417,10 +1153,7 @@ const ProjectDetails: React.FC = () => { (noteToDelete?.id !== undefined ? String(noteToDelete.id) : null); - - if (identifier) { - handleDeleteNote(identifier); - } + if (identifier) handleDeleteNote(identifier); }} onCancel={() => { setIsConfirmDialogOpen(false); diff --git a/frontend/components/Project/ProjectInsightsPanel.tsx b/frontend/components/Project/ProjectInsightsPanel.tsx new file mode 100644 index 0000000..2f21b60 --- /dev/null +++ b/frontend/components/Project/ProjectInsightsPanel.tsx @@ -0,0 +1,460 @@ +import React from 'react'; +import { Task } from '../../entities/Task'; +import { TFunction } from 'i18next'; + +interface DueBuckets { + overdue: Task[]; + week: Task[]; + month: Task[]; + unscheduled: Task[]; + totalDue: number; +} + +interface TaskStats { + total: number; + completed: number; + inProgress: number; + notStarted: number; + overdue: number; + dueSoon: number; + completionRate: number; +} + +interface ProjectInsightsPanelProps { + taskStats: TaskStats; + completionGradient: string; + dueBuckets: DueBuckets; + dueHighlights: Task[]; + nextBestAction: Task | null; + getDueDescriptor: (task: Task) => string; + onStartNextAction: () => Promise | void; + t: TFunction; + completionTrend: { label: string; count: number }[]; + upcomingDueTrend: { label: string; count: number }[]; + createdTrend: { label: string; count: number }[]; + weeklyPace: { lastWeek: number; prevWeek: number; delta: number }; + monthlyCompleted: number; + upcomingInsights?: { + peakLabel: string; + peakCount: number; + nextThreeDays: number; + nextWeek: number; + }; + eisenhower: { + urgentImportant: number; + urgentNotImportant: number; + notUrgentImportant: number; + notUrgentNotImportant: number; + }; +} + +const ProjectInsightsPanel: React.FC = ({ + taskStats, + completionGradient, + nextBestAction, + getDueDescriptor, + onStartNextAction, + t, + upcomingDueTrend, + weeklyPace, + monthlyCompleted, + upcomingInsights, + eisenhower, +}) => { + const maxUpcoming = Math.max(...upcomingDueTrend.map((d) => d.count), 1); + + return ( +
+
+
+
+

+ {t('projects.progress', 'Progress')} +

+

+ {t('projects.taskMomentum', 'Task momentum')} +

+
+ + {taskStats.total} {t('tasks.tasks', 'tasks')} + +
+ +
+
+
+
+ + {taskStats.completionRate}% + + + {t('common.done', 'done')} + +
+
+ +
+
+ + {t('projects.activeTasks', 'Active tasks')} + + + {Math.max( + taskStats.total - taskStats.completed, + 0 + )} + +
+
+ + + {taskStats.overdue}{' '} + {t('tasks.overdue', 'overdue')},{' '} + {taskStats.dueSoon}{' '} + {t('tasks.dueSoon', 'due soon')} + +
+
+
+ {t('tasks.progress', 'Progress')} + + {taskStats.completionRate}% + +
+
+
0 ? taskStats.completionRate : 0}%`, + }} + >
+
+
+
+
+
+ +
+
+

+ {t('projects.dueSchedule', 'Due schedule')} +

+ + {t('projects.next14Days', 'Next 14 days')} + +
+ {upcomingDueTrend.some((d) => d.count > 0) ? ( + <> +
+ {upcomingDueTrend.map((d, idx) => { + const intensity = + maxUpcoming > 0 + ? Math.max( + (d.count / maxUpcoming) * 0.8, + 0.12 + ) + : 0; + return ( +
+
+ + {d.label} + + + {d.count} + +
+ ); + })} +
+ {upcomingInsights && ( +
+ + {t('projects.peakDay', 'Peak')}:{' '} + {upcomingInsights.peakCount > 0 + ? `${upcomingInsights.peakLabel} · ${upcomingInsights.peakCount}` + : t('projects.none', 'None')} + + + {t('projects.next3days', 'Next 3 days')}:{' '} + {upcomingInsights.nextThreeDays} + + + {t('projects.nextWeek', 'Next 7 days')}:{' '} + {upcomingInsights.nextWeek} + +
+ )} + + ) : ( +

+ {t( + 'projects.noUpcomingDue', + 'No due dates in the next 14 days.' + )} +

+ )} +
+ +
+
+

+ {t('projects.recentCompletion', 'Recent completion')} +

+ + {t('projects.last7And30', 'Last 7 & 30 days')} + +
+ +
+
+
+

+ {t('projects.weeklyPace', 'Weekly pace')} +

+

+ {weeklyPace.lastWeek} +

+

+ {t( + 'projects.prevWeekCompleted', + '{{count}} prior week', + { + count: weeklyPace.prevWeek, + } + )} +

+
+
+
= 0 + ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-200' + : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-200' + }`} + > + {weeklyPace.delta >= 0 ? '+' : ''} + {weeklyPace.delta}{' '} + {t('projects.vsPrevWeek', 'vs prev week')} +
+
+
+
+
+
+ +
+
+

+ {t( + 'projects.monthlyCompletion', + '30-day completions' + )} +

+

+ {monthlyCompleted} +

+

+ {t('projects.last30Days', 'Last 30 days')} +

+
+
+
+
+
+
+
+ +
+
+

+ {t('projects.eisenhower', 'Eisenhower matrix')} +

+ + {t('projects.priorityVsUrgency', 'Priority vs urgency')} + +
+
+ {[ + { + label: t('projects.urgentImportant', 'Do now'), + value: eisenhower.urgentImportant, + accent: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200', + }, + { + label: t('projects.urgentNotImportant', 'Delegate'), + value: eisenhower.urgentNotImportant, + accent: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200', + }, + { + label: t('projects.notUrgentImportant', 'Schedule'), + value: eisenhower.notUrgentImportant, + accent: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200', + }, + { + label: t( + 'projects.notUrgentNotImportant', + 'Drop/avoid' + ), + value: eisenhower.notUrgentNotImportant, + accent: 'bg-gray-100 text-gray-700 dark:bg-gray-800/60 dark:text-gray-200', + }, + ].map((item, idx) => ( +
+
+ + {item.value} + + + {item.label} + +
+
+
+
+
+ ))} +
+
+ +
+
+
+

+ {t('projects.nextUp', 'Next best action')} +

+

+ {t('projects.focusTask', 'Most impactful task')} +

+
+ {nextBestAction && ( + + {getDueDescriptor(nextBestAction)} + + )} +
+ + {nextBestAction ? ( +
+
+
+
+

+ {nextBestAction.name} +

+ {nextBestAction.note && ( +

+ {nextBestAction.note} +

+ )} +
+
+
+ {nextBestAction.priority && ( + + {t('tasks.priority', 'Priority')}:{' '} + {String(nextBestAction.priority)} + + )} + {nextBestAction.today && ( + + {t('tasks.todayPlan', 'Today plan')} + + )} + {(nextBestAction.status === 'in_progress' || + nextBestAction.status === 1) && ( + + {t('task.status.inProgress', 'In progress')} + + )} +
+
+ + + {t( + 'projects.focusHint', + 'Shifts this task to in progress and today' + )} + +
+
+ ) : ( +

+ {t( + 'projects.noNextAction', + 'All clear—no outstanding tasks.' + )} +

+ )} +
+
+ ); +}; + +export default ProjectInsightsPanel; diff --git a/frontend/components/Project/ProjectModal.tsx b/frontend/components/Project/ProjectModal.tsx index 952e355..96a212f 100644 --- a/frontend/components/Project/ProjectModal.tsx +++ b/frontend/components/Project/ProjectModal.tsx @@ -276,16 +276,31 @@ const ProjectModal: React.FC = ({ const handleImageSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; - if (file) { - setImageFile(file); + if (!file) return; - // Create preview - const reader = new FileReader(); - reader.onload = (e) => { - setImagePreview(e.target?.result as string); - }; - reader.readAsDataURL(file); + // Simple client-side guard (10MB max) + const maxSizeBytes = 10 * 1024 * 1024; + if (file.size > maxSizeBytes) { + setError( + t( + 'errors.projectImageTooLarge', + 'Image is too large. Please choose a file under 10MB.' + ) + ); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + return; } + + setImageFile(file); + + // Create preview + const reader = new FileReader(); + reader.onload = (ev) => { + setImagePreview(ev.target?.result as string); + }; + reader.readAsDataURL(file); }; const handleImageUpload = async (): Promise => { @@ -303,13 +318,30 @@ const ProjectModal: React.FC = ({ }); if (!response.ok) { - throw new Error('Failed to upload image'); + let serverMessage = 'Failed to upload image'; + try { + const errData = await response.json(); + if (errData?.error) serverMessage = errData.error; + } catch { + // ignore parse errors + } + throw new Error(serverMessage); } const result = await response.json(); - return result.imageUrl; + if (result?.imageUrl) { + return result.imageUrl; + } + + throw new Error('Image URL missing from upload response'); } catch (error) { console.error('Error uploading image:', error); + setError( + t( + 'errors.projectImageUpload', + 'Failed to upload image. Please try a smaller file or a different format.' + ) + ); return null; } finally { setIsUploading(false); @@ -355,6 +387,9 @@ const ProjectModal: React.FC = ({ const uploadedImageUrl = await handleImageUpload(); if (uploadedImageUrl) { imageUrl = uploadedImageUrl; + } else { + setIsSaving(false); + return; } } @@ -741,7 +776,7 @@ const ProjectModal: React.FC = ({

{t( 'project.uploadImageHint', - 'Upload an image for your project (max 5MB)' + 'Upload an image for your project (max 10MB)' )}

diff --git a/frontend/components/Project/ProjectNotesSection.tsx b/frontend/components/Project/ProjectNotesSection.tsx new file mode 100644 index 0000000..6ec20c4 --- /dev/null +++ b/frontend/components/Project/ProjectNotesSection.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Note } from '../../entities/Note'; +import NoteCard from '../Shared/NoteCard'; +import { TFunction } from 'i18next'; +import { PlusCircleIcon } from '@heroicons/react/24/outline'; +import { Project } from '../../entities/Project'; + +interface ProjectNotesSectionProps { + project: Project; + notes: Note[]; + t: TFunction; + onCreateNote: () => void; + onEditNote: (note: Note) => Promise; + onDeleteNote: (note: Note) => void; +} + +const ProjectNotesSection: React.FC = ({ + project, + notes, + t, + onCreateNote, + onEditNote, + onDeleteNote, +}) => { + return ( +
+
+ +
+ + {notes.length > 0 ? ( +
+ {notes.map((note) => ( + + ))} +
+ ) : ( +
+

{t('project.noNotes', 'No notes for this project.')}

+
+ )} +
+ ); +}; + +export default ProjectNotesSection; diff --git a/frontend/components/Project/ProjectTasksSection.tsx b/frontend/components/Project/ProjectTasksSection.tsx new file mode 100644 index 0000000..d024c05 --- /dev/null +++ b/frontend/components/Project/ProjectTasksSection.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { Project } from '../../entities/Project'; +import { Task } from '../../entities/Task'; +import AutoSuggestNextActionBox from './AutoSuggestNextActionBox'; +import NewTask from '../Task/NewTask'; +import TaskList from '../Task/TaskList'; +import { TFunction } from 'i18next'; + +interface ProjectTasksSectionProps { + project: Project | null; + displayTasks: Task[]; + showAutoSuggestForm: boolean; + onAddNextAction: (projectId: number, description: string) => void; + onDismissNextAction: () => void; + onTaskCreate: (taskName: string) => Promise; + onTaskUpdate: (task: Task) => Promise; + onTaskCompletionToggle: (task: Task) => void; + onTaskDelete: (taskId: number) => void; + onToggleToday: (taskId: number, task?: Task) => Promise; + allProjects: Project[]; + showCompleted: boolean; + taskSearchQuery: string; + t: TFunction; +} + +const ProjectTasksSection: React.FC = ({ + project, + displayTasks, + showAutoSuggestForm, + onAddNextAction, + onDismissNextAction, + onTaskCreate, + onTaskUpdate, + onTaskCompletionToggle, + onTaskDelete, + onToggleToday, + allProjects, + showCompleted, + taskSearchQuery, + t, +}) => { + return ( +
+ {showAutoSuggestForm && ( +
+ { + if (project?.id) { + onAddNextAction(project.id, actionDescription); + } + }} + onDismiss={onDismissNextAction} + /> +
+ )} + +
+ +
+ +
+ {displayTasks.length > 0 ? ( +
+ +
+ ) : ( +
+

+ {taskSearchQuery.trim() + ? t( + 'tasks.noTasksAvailable', + 'No tasks available.' + ) + : showCompleted + ? t( + 'project.noCompletedTasks', + 'No completed tasks.' + ) + : t('project.noTasks', 'No tasks.')} +

+
+ )} +
+
+ ); +}; + +export default ProjectTasksSection; diff --git a/frontend/components/Project/useProjectMetrics.ts b/frontend/components/Project/useProjectMetrics.ts new file mode 100644 index 0000000..298af33 --- /dev/null +++ b/frontend/components/Project/useProjectMetrics.ts @@ -0,0 +1,527 @@ +import { useMemo, useCallback } from 'react'; +import { Task } from '../../entities/Task'; +import { TFunction } from 'i18next'; + +export const useProjectMetrics = ( + tasks: Task[], + handleTaskUpdate: (task: Task) => Promise, + t: TFunction +) => { + const taskStats = useMemo(() => { + const stats = { + total: tasks.length, + completed: 0, + inProgress: 0, + notStarted: 0, + overdue: 0, + dueSoon: 0, + }; + + const today = new Date(); + const startOfToday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() + ); + const soonBoundary = new Date(startOfToday); + soonBoundary.setDate(startOfToday.getDate() + 7); + + const isCompleted = (status: Task['status']) => + status === 'done' || + status === 'archived' || + status === 2 || + status === 3; + + const isInProgress = (status: Task['status']) => + status === 'in_progress' || status === 1; + + const isNotStarted = (status: Task['status']) => + status === 'not_started' || status === 0; + + tasks.forEach((task) => { + const status = task.status; + + if (isCompleted(status)) { + stats.completed += 1; + } else if (isInProgress(status)) { + stats.inProgress += 1; + } else if (isNotStarted(status)) { + stats.notStarted += 1; + } else { + stats.notStarted += 1; + } + + if (!isCompleted(status) && task.due_date) { + const dueDate = new Date(task.due_date); + if (!Number.isNaN(dueDate.getTime())) { + if (dueDate < startOfToday) { + stats.overdue += 1; + } else if (dueDate <= soonBoundary) { + stats.dueSoon += 1; + } + } + } + }); + + const completionRate = + stats.total > 0 + ? Math.round((stats.completed / stats.total) * 100) + : 0; + + return { + ...stats, + completionRate, + }; + }, [tasks]); + + const completionGradient = useMemo(() => { + if (taskStats.total === 0) { + return 'conic-gradient(#e5e7eb 0% 100%)'; + } + + const segments = [ + { value: taskStats.completed, color: '#22c55e' }, + { value: taskStats.inProgress, color: '#3b82f6' }, + { value: taskStats.notStarted, color: '#9ca3af' }, + ]; + + let current = 0; + const gradientStops: string[] = []; + + segments.forEach((segment) => { + if (segment.value === 0) return; + const start = current; + const percentage = (segment.value / taskStats.total) * 100; + const end = start + percentage; + gradientStops.push( + `${segment.color} ${start}% ${Math.min(end, 100)}%` + ); + current += percentage; + }); + + return gradientStops.length + ? `conic-gradient(${gradientStops.join(', ')})` + : 'conic-gradient(#e5e7eb 0% 100%)'; + }, [taskStats]); + + const dueBuckets = useMemo(() => { + const buckets = { + overdue: [] as Task[], + week: [] as Task[], + month: [] as Task[], + unscheduled: [] as Task[], + }; + + const now = new Date(); + const startOfToday = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate() + ); + const weekBoundary = new Date(startOfToday); + weekBoundary.setDate(startOfToday.getDate() + 7); + const monthBoundary = new Date(startOfToday); + monthBoundary.setDate(startOfToday.getDate() + 30); + + const isCompleted = (status: Task['status']) => + status === 'done' || + status === 'archived' || + status === 2 || + status === 3; + + tasks.forEach((task) => { + if (isCompleted(task.status)) return; + + if (!task.due_date) { + buckets.unscheduled.push(task); + return; + } + + const due = new Date(task.due_date); + if (Number.isNaN(due.getTime())) { + buckets.unscheduled.push(task); + return; + } + + if (due < startOfToday) { + buckets.overdue.push(task); + } else if (due <= weekBoundary) { + buckets.week.push(task); + } else if (due <= monthBoundary) { + buckets.month.push(task); + } else { + buckets.unscheduled.push(task); + } + }); + + const totalDue = + buckets.overdue.length + buckets.week.length + buckets.month.length; + + return { ...buckets, totalDue }; + }, [tasks]); + + const completionTrend = useMemo(() => { + const days = 14; + const today = new Date(); + const labels: { dateKey: string; label: string }[] = []; + for (let i = days - 1; i >= 0; i--) { + const d = new Date(today); + d.setDate(today.getDate() - i); + const key = d.toISOString().split('T')[0]; + labels.push({ + dateKey: key, + label: `${d.getMonth() + 1}/${d.getDate()}`, + }); + } + + const counts: Record = {}; + labels.forEach((l) => (counts[l.dateKey] = 0)); + + tasks.forEach((task) => { + if (!task.completed_at) return; + const key = new Date(task.completed_at).toISOString().split('T')[0]; + if (counts[key] !== undefined) { + counts[key] += 1; + } + }); + + return labels.map((l) => ({ + label: l.label, + dateKey: l.dateKey, + count: counts[l.dateKey] || 0, + })); + }, [tasks]); + + const createdTrend = useMemo(() => { + const days = 14; + const today = new Date(); + const labels: { dateKey: string; label: string }[] = []; + for (let i = days - 1; i >= 0; i--) { + const d = new Date(today); + d.setDate(today.getDate() - i); + const key = d.toISOString().split('T')[0]; + labels.push({ + dateKey: key, + label: `${d.getMonth() + 1}/${d.getDate()}`, + }); + } + + const counts: Record = {}; + labels.forEach((l) => (counts[l.dateKey] = 0)); + + tasks.forEach((task) => { + if (!task.created_at) return; + const key = new Date(task.created_at).toISOString().split('T')[0]; + if (counts[key] !== undefined) { + counts[key] += 1; + } + }); + + return labels.map((l) => ({ + label: l.label, + dateKey: l.dateKey, + count: counts[l.dateKey] || 0, + })); + }, [tasks]); + + const upcomingDueTrend = useMemo(() => { + const days = 14; + const today = new Date(); + const labels: { dateKey: string; label: string }[] = []; + for (let i = 0; i < days; i++) { + const d = new Date(today); + d.setDate(today.getDate() + i); + const key = d.toISOString().split('T')[0]; + labels.push({ + dateKey: key, + label: `${d.getMonth() + 1}/${d.getDate()}`, + }); + } + + const counts: Record = {}; + labels.forEach((l) => (counts[l.dateKey] = 0)); + + const isCompleted = (status: Task['status']) => + status === 'done' || + status === 'archived' || + status === 2 || + status === 3; + + tasks.forEach((task) => { + if (!task.due_date || isCompleted(task.status)) return; + const key = new Date(task.due_date).toISOString().split('T')[0]; + if (counts[key] !== undefined) { + counts[key] += 1; + } + }); + + return labels.map((l) => ({ + label: l.label, + dateKey: l.dateKey, + count: counts[l.dateKey] || 0, + })); + }, [tasks]); + + const upcomingInsights = useMemo(() => { + const peak = upcomingDueTrend.reduce( + (acc, cur) => (cur.count > acc.count ? cur : acc), + { label: '', count: 0 } + ); + const nextThreeDays = upcomingDueTrend + .slice(0, 3) + .reduce((sum, d) => sum + d.count, 0); + const nextWeek = upcomingDueTrend + .slice(0, 7) + .reduce((sum, d) => sum + d.count, 0); + + return { + peakLabel: peak.label, + peakCount: peak.count, + nextThreeDays, + nextWeek, + }; + }, [upcomingDueTrend]); + + const eisenhower = useMemo(() => { + const buckets = { + urgentImportant: 0, + urgentNotImportant: 0, + notUrgentImportant: 0, + notUrgentNotImportant: 0, + }; + + const today = new Date(); + const startOfToday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() + ); + const threeDays = new Date(startOfToday); + threeDays.setDate(startOfToday.getDate() + 3); + + const isCompleted = (status: Task['status']) => + status === 'done' || + status === 'archived' || + status === 2 || + status === 3; + + tasks.forEach((task) => { + if (isCompleted(task.status)) return; + + const isUrgent = (() => { + if (!task.due_date) return false; + const due = new Date(task.due_date); + if (Number.isNaN(due.getTime())) return false; + return due <= threeDays; + })(); + + const isImportant = + task.priority === 'high' || + task.priority === 2 || + task.priority === 'medium' || + task.priority === 1; + + if (isUrgent && isImportant) buckets.urgentImportant += 1; + else if (isUrgent && !isImportant) buckets.urgentNotImportant += 1; + else if (!isUrgent && isImportant) buckets.notUrgentImportant += 1; + else buckets.notUrgentNotImportant += 1; + }); + + return buckets; + }, [tasks]); + + const dueHighlights = useMemo(() => { + const combined = [ + ...dueBuckets.overdue, + ...dueBuckets.week, + ...dueBuckets.month, + ]; + + return combined + .sort((a, b) => { + const aDate = a.due_date ? new Date(a.due_date).getTime() : 0; + const bDate = b.due_date ? new Date(b.due_date).getTime() : 0; + return aDate - bDate; + }) + .slice(0, 3); + }, [dueBuckets]); + + const nextBestAction = useMemo(() => { + const isCompleted = (status: Task['status']) => + status === 'done' || + status === 'archived' || + status === 2 || + status === 3; + + const candidates = tasks.filter( + (task) => + !isCompleted(task.status) && + task.status !== 'in_progress' && + task.status !== 1 + ); + if (candidates.length === 0) return null; + + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + + const getPriorityScore = (priority: Task['priority']) => { + if (priority === 'high' || priority === 2) return -8; + if (priority === 'medium' || priority === 1) return -4; + return 0; + }; + + const scored = candidates + .map((task) => { + let score = 0; + + if (task.status === 'in_progress' || task.status === 1) { + score -= 30; + } + + if (task.due_date) { + const due = new Date(task.due_date); + const diffDays = Math.floor( + (due.getTime() - startOfToday.getTime()) / + (1000 * 60 * 60 * 24) + ); + if (diffDays < 0) score -= 25; + else if (diffDays === 0) score -= 20; + else if (diffDays <= 2) score -= 15; + else if (diffDays <= 7) score -= 10; + } + + score += getPriorityScore(task.priority); + + if (task.today) { + score -= 6; + } + + const createdAt = task.created_at + ? new Date(task.created_at).getTime() + : 0; + + return { + task, + score, + createdAt, + }; + }) + .sort((a, b) => { + if (a.score !== b.score) return a.score - b.score; + if (a.createdAt !== b.createdAt) + return a.createdAt - b.createdAt; + return (a.task.id || 0) - (b.task.id || 0); + }); + + return scored[0]?.task ?? null; + }, [tasks]); + + const getDueDescriptor = useCallback( + (task: Task) => { + if (!task.due_date) return t('tasks.noDue', 'No due date'); + + const now = new Date(); + const startOfToday = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate() + ); + const due = new Date(task.due_date); + if (Number.isNaN(due.getTime())) + return t('tasks.noDue', 'No due date'); + + const diffDays = Math.floor( + (due.getTime() - startOfToday.getTime()) / (1000 * 60 * 60 * 24) + ); + + if (diffDays < 0) { + return t('tasks.overdueBy', { + defaultValue: 'Overdue by {{days}}d', + days: Math.abs(diffDays), + } as any); + } + if (diffDays === 0) return t('dateIndicators.today', 'Today'); + if (diffDays === 1) return t('dateIndicators.tomorrow', 'Tomorrow'); + if (diffDays <= 7) + return t('tasks.dueInDays', { + defaultValue: 'Due in {{days}}d', + days: diffDays, + } as any); + + return t('tasks.dueInDays', { + defaultValue: 'Due in {{days}}d', + days: diffDays, + } as any); + }, + [t] + ); + + const handleStartNextAction = useCallback(async () => { + if (!nextBestAction?.id) return; + + const isAlreadyInProgress = + nextBestAction.status === 'in_progress' || + nextBestAction.status === 1; + const isAlreadyToday = !!nextBestAction.today; + + if (isAlreadyInProgress && isAlreadyToday) { + return; + } + + try { + await handleTaskUpdate({ + ...nextBestAction, + status: 'in_progress', + today: true, + }); + } catch { + // Silent fail + } + }, [handleTaskUpdate, nextBestAction]); + + const weeklyPace = useMemo(() => { + const lastWeek = completionTrend + .slice(-7) + .reduce((sum, d) => sum + d.count, 0); + const prevWeek = completionTrend + .slice(0, -7) + .reduce((sum, d) => sum + d.count, 0); + const delta = lastWeek - prevWeek; + return { lastWeek, prevWeek, delta }; + }, [completionTrend]); + + const monthlyCompleted = useMemo(() => { + const today = new Date(); + const startWindow = new Date(); + startWindow.setDate(today.getDate() - 30); + let count = 0; + tasks.forEach((task) => { + if (!task.completed_at) return; + const completedDate = new Date(task.completed_at); + if ( + !Number.isNaN(completedDate.getTime()) && + completedDate >= startWindow + ) { + count += 1; + } + }); + return count; + }, [tasks]); + + return { + taskStats, + completionGradient, + dueBuckets, + dueHighlights, + nextBestAction, + getDueDescriptor, + handleStartNextAction, + completionTrend, + upcomingDueTrend, + createdTrend, + upcomingInsights, + eisenhower, + weeklyPace, + monthlyCompleted, + }; +}; diff --git a/frontend/components/Shared/IconSortDropdown.tsx b/frontend/components/Shared/IconSortDropdown.tsx index c260b0d..98292c4 100644 --- a/frontend/components/Shared/IconSortDropdown.tsx +++ b/frontend/components/Shared/IconSortDropdown.tsx @@ -1,5 +1,5 @@ import React, { ReactNode, useEffect, useRef, useState } from 'react'; -import { FunnelIcon, CheckIcon } from '@heroicons/react/24/outline'; +import { ListBulletIcon, CheckIcon } from '@heroicons/react/24/outline'; import { SortOption } from './SortFilterButton'; interface IconSortDropdownProps { @@ -13,6 +13,7 @@ interface IconSortDropdownProps { dropdownLabel?: string; align?: 'left' | 'right'; extraContent?: ReactNode; + footerContent?: ReactNode; } const IconSortDropdown: React.FC = ({ @@ -26,6 +27,7 @@ const IconSortDropdown: React.FC = ({ dropdownLabel = 'Sort by', align = 'right', extraContent, + footerContent, }) => { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); @@ -58,14 +60,14 @@ const IconSortDropdown: React.FC = ({ aria-label={ariaLabel} title={title} > - + {isOpen && (
{dropdownLabel && ( -
+
{dropdownLabel}
)} @@ -93,10 +95,15 @@ const IconSortDropdown: React.FC = ({ ))}
{extraContent && ( -
+
{extraContent}
)} + {footerContent && ( +
+ {footerContent} +
+ )}
)}
diff --git a/frontend/components/Shared/NoteCard.tsx b/frontend/components/Shared/NoteCard.tsx index 8a0ca71..e6102a7 100644 --- a/frontend/components/Shared/NoteCard.tsx +++ b/frontend/components/Shared/NoteCard.tsx @@ -60,7 +60,7 @@ const NoteCard: React.FC = ({ .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '')}`} - className="bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-col hover:opacity-80 transition-opacity duration-300 ease-in-out cursor-pointer" + className="bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-col hover:opacity-80 transition-opacity duration-300 ease-in-out cursor-pointer border-l-4 border-l-blue-400 dark:border-l-blue-500" style={{ minHeight: '280px', maxHeight: '280px', diff --git a/frontend/components/Sidebar/SidebarNav.tsx b/frontend/components/Sidebar/SidebarNav.tsx index 7bed1f2..ff28f9c 100644 --- a/frontend/components/Sidebar/SidebarNav.tsx +++ b/frontend/components/Sidebar/SidebarNav.tsx @@ -44,7 +44,7 @@ const SidebarNav: React.FC = ({ query: 'type=today', }, { - path: '/upcoming', + path: '/upcoming?status=active', title: t('sidebar.upcoming', 'Upcoming'), icon: , }, @@ -57,13 +57,21 @@ const SidebarNav: React.FC = ({ const isActive = (path: string, query?: string) => { // Handle special case for paths without query parameters - if (path === '/inbox' || path === '/today' || path === '/upcoming') { + if (path === '/inbox' || path === '/today') { const isPathMatch = location.pathname === path; return isPathMatch ? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white' : 'text-gray-700 dark:text-gray-300'; } + // Handle upcoming with query parameters + if (path.startsWith('/upcoming')) { + const isPathMatch = location.pathname === '/upcoming'; + return isPathMatch + ? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white' + : 'text-gray-700 dark:text-gray-300'; + } + // Regular case for /tasks with query params const isPathMatch = location.pathname === '/tasks'; const isQueryMatch = query diff --git a/frontend/components/Task/GroupedTaskList.tsx b/frontend/components/Task/GroupedTaskList.tsx index c3d1c2b..cf6b9fc 100644 --- a/frontend/components/Task/GroupedTaskList.tsx +++ b/frontend/components/Task/GroupedTaskList.tsx @@ -13,6 +13,7 @@ import { GroupedTasks } from '../../utils/tasksService'; interface GroupedTaskListProps { tasks: Task[]; groupedTasks?: GroupedTasks | null; + groupBy?: 'none' | 'project'; onTaskUpdate: (task: Task) => Promise; onTaskCompletionToggle?: (task: Task) => void; onTaskCreate?: (task: Task) => void; @@ -32,6 +33,7 @@ interface TaskGroup { const GroupedTaskList: React.FC = ({ tasks, groupedTasks, + groupBy = 'none', onTaskUpdate, onTaskCompletionToggle, onTaskDelete, @@ -178,6 +180,53 @@ const GroupedTaskList: React.FC = ({ return filtered; }, [groupedTasks, showCompletedTasks, shouldUseDayGrouping, searchQuery]); + // Group tasks by project when requested (only applies to standalone view) + const groupedByProject = useMemo(() => { + if (groupBy !== 'project') return null; + + // Apply completion filter + const filtered = showCompletedTasks + ? tasks.filter((task) => { + const isCompleted = + task.status === 'done' || + task.status === 'archived' || + task.status === 2 || + task.status === 3; + return isCompleted; + }) + : tasks.filter((task) => { + const isCompleted = + task.status === 'done' || + task.status === 'archived' || + task.status === 2 || + task.status === 3; + return !isCompleted; + }); + + // Apply search + const filteredBySearch = searchQuery.trim() + ? filtered.filter((task) => + (task.name || '') + .toLowerCase() + .includes(searchQuery.toLowerCase()) + ) + : filtered; + + const byProject = new Map(); + filteredBySearch.forEach((task) => { + const key = task.project_id || 'no_project'; + const arr = byProject.get(key) || []; + arr.push(task); + byProject.set(key, arr); + }); + return Array.from(byProject.entries()).map( + ([projectId, projectTasks]) => ({ + projectId, + tasks: projectTasks, + }) + ); + }, [groupBy, tasks, showCompletedTasks, searchQuery]); + const toggleRecurringGroup = (templateId: number) => { setExpandedRecurringGroups((prev) => { const newSet = new Set(prev); @@ -312,22 +361,69 @@ const GroupedTaskList: React.FC = ({ return (
{/* Standalone tasks */} - {standaloneTask.map((task) => ( -
- -
- ))} + {groupBy === 'project' && groupedByProject + ? groupedByProject.map( + ({ projectId, tasks: projectTasks }, index) => { + const projectName = + projects.find((p) => p.id === projectId)?.name || + (projectId === 'no_project' + ? t('tasks.noProject', 'No project') + : t( + 'tasks.unknownProject', + 'Unknown project' + )); + return ( +
0 ? 'pt-4' : ''}`} + > +
+ + {projectName} + + + {projectTasks.length}{' '} + {t('tasks.tasks', 'tasks')} + +
+ {projectTasks.map((task) => ( +
+ +
+ ))} +
+ ); + } + ) + : standaloneTask.map((task) => ( +
+ +
+ ))} {/* Grouped recurring tasks */} {recurringGroups.map((group) => { diff --git a/frontend/components/Task/NewTask.tsx b/frontend/components/Task/NewTask.tsx index 5a07f0a..371dad4 100644 --- a/frontend/components/Task/NewTask.tsx +++ b/frontend/components/Task/NewTask.tsx @@ -82,7 +82,7 @@ const NewTask: React.FC = ({ onTaskCreate }) => { return (
-
+
@@ -91,7 +91,7 @@ const NewTask: React.FC = ({ onTaskCreate }) => { value={taskName} onChange={handleInputChange} onKeyDown={handleKeyDown} - className="font-medium text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-600 bg-transparent dark:bg-transparent focus:outline-none focus:ring-0 w-full appearance-none" + className="font-semibold text-base text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-600 bg-transparent dark:bg-transparent focus:outline-none focus:ring-0 w-full appearance-none" placeholder={t( 'tasks.addNewTask', 'Προσθήκη Νέας Εργασίας' diff --git a/frontend/components/Task/TaskDetails.tsx b/frontend/components/Task/TaskDetails.tsx index cdcff40..9306100 100644 --- a/frontend/components/Task/TaskDetails.tsx +++ b/frontend/components/Task/TaskDetails.tsx @@ -1,16 +1,9 @@ import React, { useEffect, useState, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { - CalendarIcon, - ExclamationTriangleIcon, - ListBulletIcon, - ClockIcon, -} from '@heroicons/react/24/outline'; +import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'; import ConfirmDialog from '../Shared/ConfirmDialog'; import TaskModal from './TaskModal'; -import RecurrenceDisplay from './RecurrenceDisplay'; -import TaskSubtasksSection from './TaskForm/TaskSubtasksSection'; import { Task } from '../../entities/Task'; import { Project } from '../../entities/Project'; import { @@ -24,19 +17,18 @@ import { import { createProject } from '../../utils/projectsService'; import { useStore } from '../../store/useStore'; import { useToast } from '../Shared/ToastContext'; -import TaskPriorityIcon from './TaskPriorityIcon'; import LoadingScreen from '../Shared/LoadingScreen'; import TaskTimeline from './TaskTimeline'; -import TaskDueDateSection from './TaskForm/TaskDueDateSection'; -import TaskRecurrenceSection from './TaskForm/TaskRecurrenceSection'; import { TaskDetailsHeader, TaskSummaryAlerts, - TaskContentSection, - TaskRecurringInstanceInfo, - TaskProjectSection, - TaskTagsSection, - TaskPrioritySection, + TaskContentCard, + TaskProjectCard, + TaskTagsCard, + TaskPriorityCard, + TaskSubtasksCard, + TaskRecurrenceCard, + TaskDueDateCard, } from './TaskDetails/'; const TaskDetails: React.FC = () => { @@ -276,11 +268,6 @@ const TaskDetails: React.FC = () => { }); }; - const handleRecurrenceCardClick = () => { - if (task.recurring_parent_id) return; - handleStartRecurrenceEdit(); - }; - const handleStartDueDateEdit = () => { setEditedDueDate(task?.due_date || ''); setIsEditingDueDate(true); @@ -337,66 +324,6 @@ const TaskDetails: React.FC = () => { setEditedDueDate(task?.due_date || ''); }; - const formatDateWithDayName = (dateString: string) => { - const date = new Date(dateString); - const today = new Date().toISOString().split('T')[0]; - const isToday = dateString === today; - - const dayName = date.toLocaleDateString(i18n.language, { - weekday: 'long', - }); - const formattedDate = date.toLocaleDateString(i18n.language, { - day: 'numeric', - month: 'long', - }); - - return { - dayName, - formattedDate, - fullText: `${dayName}, ${formattedDate}`, - isToday, - }; - }; - - const getDueDateDisplay = (dueDate: string) => { - const date = new Date(dueDate); - if (Number.isNaN(date.getTime())) return null; - - const formattedDate = date.toLocaleDateString(i18n.language, { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }); - - const today = new Date(); - today.setHours(0, 0, 0, 0); - const target = new Date(date); - target.setHours(0, 0, 0, 0); - - const diffDays = Math.round( - (target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24) - ); - - let relativeText = ''; - if (diffDays === 0) { - relativeText = t('dateIndicators.today', 'today'); - } else if (diffDays === 1) { - relativeText = t('dateIndicators.tomorrow', 'tomorrow'); - } else if (diffDays === -1) { - relativeText = t('dateIndicators.yesterday', 'yesterday'); - } else if (diffDays > 0) { - relativeText = t('task.inDays', 'in {{count}} days', { - count: diffDays, - }); - } else { - relativeText = t('task.daysAgo', '{{count}} days ago', { - count: Math.abs(diffDays), - }); - } - - return { formattedDate, relativeText }; - }; - const getStatusLabel = () => { switch (task.status) { case 'not_started': @@ -435,6 +362,54 @@ const TaskDetails: React.FC = () => { } }; + const getDueDateDisplay = (dueDate: string) => { + const date = new Date(dueDate); + if (Number.isNaN(date.getTime())) return null; + + const formattedDate = date.toLocaleDateString(i18n.language, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const target = new Date(date); + target.setHours(0, 0, 0, 0); + + const diffDays = Math.round( + (target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24) + ); + + if (diffDays === 0) { + return { + formattedDate, + relativeText: t('dateIndicators.today', 'today'), + }; + } + if (diffDays === 1) { + return { + formattedDate, + relativeText: t('dateIndicators.tomorrow', 'tomorrow'), + }; + } + if (diffDays === -1) { + return { + formattedDate, + relativeText: t('dateIndicators.yesterday', 'yesterday'), + }; + } + + const relativeText = + diffDays > 0 + ? t('task.inDays', 'in {{count}} days', { count: diffDays }) + : t('task.daysAgo', '{{count}} days ago', { + count: Math.abs(diffDays), + }); + + return { formattedDate, relativeText }; + }; + const getTaskPlainSummary = () => { const statusText = getStatusLabel(); const priorityText = getPriorityLabel(); @@ -636,6 +611,27 @@ const TaskDetails: React.FC = () => { setEditedSubtasks([]); }; + const handleToggleSubtaskCompletion = async (subtask: Task) => { + if (!subtask.id) return; + try { + await toggleTaskCompletion(subtask.id, subtask); + if (uid) { + const updatedTask = await fetchTaskByUid(uid); + const existingIndex = tasksStore.tasks.findIndex( + (t: Task) => t.uid === uid + ); + if (existingIndex >= 0) { + const updatedTasks = [...tasksStore.tasks]; + updatedTasks[existingIndex] = updatedTask; + tasksStore.setTasks(updatedTasks); + } + } + setTimelineRefreshKey((prev) => prev + 1); + } catch (error) { + console.error('Error toggling subtask completion:', error); + } + }; + const handleProjectSelection = async (project: Project) => { if (!task?.id) return; @@ -1080,500 +1076,45 @@ const TaskDetails: React.FC = () => { {/* Left Column - Main Content */}
{/* Notes Section - Always Visible */} - - {/* Subtasks Section - Always Visible */} -
-

- {t('task.subtasks', 'Subtasks')} -

- {isEditingSubtasks ? ( -
- -
-
- - -
-
-
- ) : subtasks.length > 0 ? ( -
- {subtasks.map((subtask: Task) => ( -
-
- { - console.log( - 'Toggling subtask:', - subtask.id - ); - if (subtask.id) { - try { - // Pass the current subtask to avoid fetching it - await toggleTaskCompletion( - subtask.id, - subtask - ); - // Refresh task data which includes updated subtasks - if (uid) { - const updatedTask = - await fetchTaskByUid( - uid - ); - const existingIndex = - tasksStore.tasks.findIndex( - ( - t: Task - ) => - t.uid === - uid - ); - if ( - existingIndex >= - 0 - ) { - const updatedTasks = - [ - ...tasksStore.tasks, - ]; - updatedTasks[ - existingIndex - ] = - updatedTask; - tasksStore.setTasks( - updatedTasks - ); - } - } + - // Refresh timeline to show subtask completion activity - setTimelineRefreshKey( - ( - prev - ) => - prev + - 1 - ); - } catch (error) { - console.error( - 'Error toggling subtask completion:', - error - ); - } - } - }} - /> - - {subtask.name} - -
-
- ))} -
- ) : ( -
-
- - - {t( - 'task.noSubtasksClickToAdd', - 'No subtasks yet, click to add' - )} - -
-
- )} -
- -
-

- {t( - 'task.recurringSetup', - 'Recurring Setup' - )} -

-
{ - if ( - !isEditingRecurrence && - !task.recurring_parent_id && - e.key === 'Enter' - ) { - e.preventDefault(); - handleRecurrenceCardClick(); - } - }} - > - - - {isEditingRecurrence && - !task.recurring_parent_id ? ( -
- -
- - -
-
- ) : ( - <> - {(task.recurrence_type && - task.recurrence_type !== - 'none') || - (parentTask?.recurrence_type && - parentTask.recurrence_type !== - 'none') ? ( -
- -
- ) : ( -
- {t( - 'task.notRecurring', - 'This task is not recurring yet.' - )} -
- )} - - {((task.recurrence_type && - task.recurrence_type !== - 'none') || - (task.recurring_parent_id && - parentTask?.recurrence_type && - parentTask.recurrence_type !== - 'none')) && ( -
-
- - - {task.recurring_parent_id - ? t( - 'task.nextOccurrencesAfterThis', - 'Next Occurrences After This' - ) - : t( - 'task.nextOccurrences', - 'Next Occurrences' - )} - {!loadingIterations && - nextIterations.length > - 0 && - nextIterations.some( - (iter) => - formatDateWithDayName( - iter.date - ) - .isToday - ) && ( - - ( - {t( - 'task.includingToday', - 'including today' - )} - ) - - )} - -
- - {loadingIterations ? ( -
-
- - {t( - 'common.loading', - 'Loading...' - )} - -
- ) : nextIterations.length > - 0 ? ( -
- {nextIterations.map( - ( - iteration, - index - ) => { - const dateInfo = - formatDateWithDayName( - iteration.date - ); - return ( -
-
- - {index + - 1} - -
-
-
- { - dateInfo.dayName - } - {dateInfo.isToday && ( - - {t( - 'dateIndicators.today', - 'TODAY' - )} - - )} -
-
- { - dateInfo.formattedDate - } -
-
-
- ); - } - )} -
- ) : ( -
- {t( - 'task.noMoreIterations', - 'No more iterations scheduled' - )} -
- )} -
- )} - - )} -
-
+
{/* Right Column - Metadata and Recent Activity */}
{/* Project Section */} - { /> {/* Tags Section */} - { /> {/* Priority Section */} - - {/* Due Date Section */} -
-

- {t('task.dueDate', 'Due Date')} -

-
{ - const dueDate = new Date( - task.due_date - ); - const today = new Date(); - today.setHours(0, 0, 0, 0); - dueDate.setHours(0, 0, 0, 0); - const isCompleted = - task.status === 'done' || - task.status === 2 || - task.status === 'archived' || - task.status === 3 || - task.completed_at; - return ( - dueDate < today && !isCompleted - ); - })() - ? 'border-red-500 dark:border-red-400' - : '' - }`} - > - {isEditingDueDate ? ( -
- -
- - -
-
- ) : ( - - )} -
-
+ {/* Recent Activity Section */}
diff --git a/frontend/components/Task/TaskDetails/TaskContentSection.tsx b/frontend/components/Task/TaskDetails/TaskContentCard.tsx similarity index 98% rename from frontend/components/Task/TaskDetails/TaskContentSection.tsx rename to frontend/components/Task/TaskDetails/TaskContentCard.tsx index 041af4a..8395f91 100644 --- a/frontend/components/Task/TaskDetails/TaskContentSection.tsx +++ b/frontend/components/Task/TaskDetails/TaskContentCard.tsx @@ -7,12 +7,12 @@ import { } from '@heroicons/react/24/outline'; import MarkdownRenderer from '../../Shared/MarkdownRenderer'; -interface TaskContentSectionProps { +interface TaskContentCardProps { content: string; onUpdate: (newContent: string) => Promise; } -const TaskContentSection: React.FC = ({ +const TaskContentCard: React.FC = ({ content, onUpdate, }) => { @@ -179,4 +179,4 @@ const TaskContentSection: React.FC = ({ ); }; -export default TaskContentSection; +export default TaskContentCard; diff --git a/frontend/components/Task/TaskDetails/TaskDueDateCard.tsx b/frontend/components/Task/TaskDetails/TaskDueDateCard.tsx new file mode 100644 index 0000000..b8e5e2a --- /dev/null +++ b/frontend/components/Task/TaskDetails/TaskDueDateCard.tsx @@ -0,0 +1,191 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + CalendarIcon, + ExclamationTriangleIcon, +} from '@heroicons/react/24/outline'; +import TaskDueDateSection from '../TaskForm/TaskDueDateSection'; +import { Task } from '../../../entities/Task'; + +interface TaskDueDateCardProps { + task: Task; + isEditing: boolean; + editedDueDate: string; + onChangeDate: (value: string) => void; + onStartEdit: () => void; + onSave: () => void; + onCancel: () => void; +} + +const TaskDueDateCard: React.FC = ({ + task, + isEditing, + editedDueDate, + onChangeDate, + onStartEdit, + onSave, + onCancel, +}) => { + const { t, i18n } = useTranslation(); + + const getDueDateDisplay = (dueDate: string) => { + const date = new Date(dueDate); + if (Number.isNaN(date.getTime())) return null; + + const formattedDate = date.toLocaleDateString(i18n.language, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const target = new Date(date); + target.setHours(0, 0, 0, 0); + + const diffDays = Math.round( + (target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24) + ); + + let relativeText = ''; + if (diffDays === 0) { + relativeText = t('dateIndicators.today', 'today'); + } else if (diffDays === 1) { + relativeText = t('dateIndicators.tomorrow', 'tomorrow'); + } else if (diffDays === -1) { + relativeText = t('dateIndicators.yesterday', 'yesterday'); + } else if (diffDays > 0) { + relativeText = t('task.inDays', 'in {{count}} days', { + count: diffDays, + }); + } else { + relativeText = t('task.daysAgo', '{{count}} days ago', { + count: Math.abs(diffDays), + }); + } + + return { formattedDate, relativeText }; + }; + + return ( +
+

+ {t('task.dueDate', 'Due Date')} +

+
{ + const dueDate = new Date(task.due_date); + const today = new Date(); + today.setHours(0, 0, 0, 0); + dueDate.setHours(0, 0, 0, 0); + const isCompleted = + task.status === 'done' || + task.status === 2 || + task.status === 'archived' || + task.status === 3 || + task.completed_at; + return dueDate < today && !isCompleted; + })() + ? 'border-red-500 dark:border-red-400' + : '' + }`} + > + {isEditing ? ( +
+ +
+ + +
+
+ ) : ( + + )} +
+
+ ); +}; + +export default TaskDueDateCard; diff --git a/frontend/components/Task/TaskDetails/TaskPrioritySection.tsx b/frontend/components/Task/TaskDetails/TaskPriorityCard.tsx similarity index 95% rename from frontend/components/Task/TaskDetails/TaskPrioritySection.tsx rename to frontend/components/Task/TaskDetails/TaskPriorityCard.tsx index 71b0571..5968e4c 100644 --- a/frontend/components/Task/TaskDetails/TaskPrioritySection.tsx +++ b/frontend/components/Task/TaskDetails/TaskPriorityCard.tsx @@ -2,12 +2,12 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { Task, PriorityType } from '../../../entities/Task'; -interface TaskPrioritySectionProps { +interface TaskPriorityCardProps { task: Task; onUpdate: (priority: PriorityType) => Promise; } -const TaskPrioritySection: React.FC = ({ +const TaskPriorityCard: React.FC = ({ task, onUpdate, }) => { @@ -72,4 +72,4 @@ const TaskPrioritySection: React.FC = ({ ); }; -export default TaskPrioritySection; +export default TaskPriorityCard; diff --git a/frontend/components/Task/TaskDetails/TaskProjectSection.tsx b/frontend/components/Task/TaskDetails/TaskProjectCard.tsx similarity index 55% rename from frontend/components/Task/TaskDetails/TaskProjectSection.tsx rename to frontend/components/Task/TaskDetails/TaskProjectCard.tsx index c470d89..0ccede1 100644 --- a/frontend/components/Task/TaskDetails/TaskProjectSection.tsx +++ b/frontend/components/Task/TaskDetails/TaskProjectCard.tsx @@ -1,12 +1,12 @@ import React, { useRef, useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; -import { ArrowRightIcon } from '@heroicons/react/24/outline'; +import { ArrowRightIcon, XMarkIcon } from '@heroicons/react/24/outline'; import ProjectDropdown from '../../Shared/ProjectDropdown'; import { Project } from '../../../entities/Project'; import { Task } from '../../../entities/Task'; -interface TaskProjectSectionProps { +interface TaskProjectCardProps { task: Task; projects: Project[]; onProjectSelect: (project: Project) => Promise; @@ -15,7 +15,7 @@ interface TaskProjectSectionProps { getProjectLink: (project: Project) => string; } -const TaskProjectSection: React.FC = ({ +const TaskProjectCard: React.FC = ({ task, projects, onProjectSelect, @@ -105,53 +105,70 @@ const TaskProjectSection: React.FC = ({ onClearProject={handleClearProject} /> ) : task.Project ? ( -
setProjectDropdownOpen(true)} - className="bg-gray-50 dark:bg-gray-900 rounded-lg shadow-sm relative cursor-pointer hover:opacity-90 transition-opacity" - > -
+
-
-
- - {task.Project.name} - - e.stopPropagation()} - className="p-1.5 rounded-full text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex-shrink-0 ml-auto" - title={t( - 'project.viewProject', - 'Go to project' - )} - > - - - {t( +
+ {task.Project.image_url ? ( + {task.Project.name} + ) : ( +
+ )} +
+
+
+ + {task.Project.name} + + + e.stopPropagation()} + className="p-1.5 rounded-full text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex-shrink-0 ml-2" + title={t( 'project.viewProject', 'Go to project' )} - - + > + + + {t( + 'project.viewProject', + 'Go to project' + )} + + +
-
+
) : (
setProjectDropdownOpen(true)} - className="rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-dashed border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600 p-6 cursor-pointer transition-colors flex items-center justify-center" + className="rounded-lg shadow-sm bg-white dark:bg-gray-900 hover:border-gray-400 dark:hover:border-gray-600 p-6 cursor-pointer transition-colors flex items-center justify-center" > {t( @@ -166,4 +183,4 @@ const TaskProjectSection: React.FC = ({ ); }; -export default TaskProjectSection; +export default TaskProjectCard; diff --git a/frontend/components/Task/TaskDetails/TaskRecurrenceCard.tsx b/frontend/components/Task/TaskDetails/TaskRecurrenceCard.tsx new file mode 100644 index 0000000..2872c22 --- /dev/null +++ b/frontend/components/Task/TaskDetails/TaskRecurrenceCard.tsx @@ -0,0 +1,302 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ClockIcon } from '@heroicons/react/24/outline'; +import RecurrenceDisplay from '../RecurrenceDisplay'; +import TaskRecurrenceSection from '../TaskForm/TaskRecurrenceSection'; +import TaskRecurringInstanceInfo from './TaskRecurringInstanceInfo'; +import { Task } from '../../../entities/Task'; +import { TaskIteration } from '../../../utils/tasksService'; + +interface TaskRecurrenceCardProps { + task: Task; + parentTask: Task | null; + loadingParent: boolean; + isEditing: boolean; + recurrenceForm: { + recurrence_type: string; + recurrence_interval: number; + recurrence_end_date: string | null; + recurrence_weekday: number | null; + recurrence_weekdays: number[] | null; + recurrence_month_day: number | null; + recurrence_week_of_month: number | null; + completion_based: boolean; + }; + onStartEdit: () => void; + onChange: (field: string, value: any) => void; + onSave: () => void; + onCancel: () => void; + loadingIterations: boolean; + nextIterations: TaskIteration[]; + canEdit: boolean; +} + +const TaskRecurrenceCard: React.FC = ({ + task, + parentTask, + loadingParent, + isEditing, + recurrenceForm, + onStartEdit, + onChange, + onSave, + onCancel, + loadingIterations, + nextIterations, + canEdit, +}) => { + const { t, i18n } = useTranslation(); + + const formatDateWithDayName = (dateString: string) => { + const date = new Date(dateString); + const today = new Date().toISOString().split('T')[0]; + const isToday = dateString === today; + + const dayName = date.toLocaleDateString(i18n.language, { + weekday: 'long', + }); + const formattedDate = date.toLocaleDateString(i18n.language, { + day: 'numeric', + month: 'long', + }); + + return { + dayName, + formattedDate, + fullText: `${dayName}, ${formattedDate}`, + isToday, + }; + }; + + const renderNextIterations = () => { + if (loadingIterations) { + return ( +
+
+ + {t('common.loading', 'Loading...')} + +
+ ); + } + + if (nextIterations.length === 0) { + return ( +
+ {t( + 'task.noUpcomingOccurrences', + 'No upcoming occurrences.' + )} +
+ ); + } + + return ( +
+ {nextIterations.map((iteration, index) => { + const dateInfo = formatDateWithDayName(iteration.date); + return ( +
+
+
+ {dateInfo.formattedDate} +
+
+ {dateInfo.dayName} +
+
+
+ ); + })} +
+ ); + }; + + return ( +
+

+ {t('task.recurringSetup', 'Recurring Setup')} +

+
{ + if (canEdit && !isEditing && e.key === 'Enter') { + e.preventDefault(); + onStartEdit(); + } + }} + > + + + {isEditing && canEdit ? ( +
+ +
+ + +
+
+ ) : ( + <> + {(task.recurrence_type && + task.recurrence_type !== 'none') || + (parentTask?.recurrence_type && + parentTask.recurrence_type !== 'none') ? ( +
+ +
+ ) : ( +
+ {t( + 'task.notRecurring', + 'This task is not recurring yet.' + )} +
+ )} + + {((task.recurrence_type && + task.recurrence_type !== 'none') || + (task.recurring_parent_id && + parentTask?.recurrence_type && + parentTask.recurrence_type !== 'none')) && ( +
+
+ + + {task.recurring_parent_id + ? t( + 'task.nextOccurrencesAfterThis', + 'Next Occurrences After This' + ) + : t( + 'task.nextOccurrences', + 'Next Occurrences' + )} + {!loadingIterations && + nextIterations.length > 0 && + nextIterations.some( + (iter) => + formatDateWithDayName( + iter.date + ).isToday + ) && ( + + ( + {t( + 'task.includingToday', + 'including today' + )} + ) + + )} + +
+ {renderNextIterations()} +
+ )} + + )} +
+
+ ); +}; + +export default TaskRecurrenceCard; diff --git a/frontend/components/Task/TaskDetails/TaskSubtasksCard.tsx b/frontend/components/Task/TaskDetails/TaskSubtasksCard.tsx new file mode 100644 index 0000000..6919890 --- /dev/null +++ b/frontend/components/Task/TaskDetails/TaskSubtasksCard.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ListBulletIcon } from '@heroicons/react/24/outline'; +import TaskSubtasksSection from '../TaskForm/TaskSubtasksSection'; +import TaskPriorityIcon from '../TaskPriorityIcon'; +import { Task } from '../../../entities/Task'; + +interface TaskSubtasksCardProps { + task: Task; + subtasks: Task[]; + isEditing: boolean; + editedSubtasks: Task[]; + onSubtasksChange: (subtasks: Task[]) => void; + onStartEdit: () => void; + onSave: () => void; + onCancel: () => void; + onToggleSubtaskCompletion: (subtask: Task) => Promise; +} + +const TaskSubtasksCard: React.FC = ({ + task, + subtasks, + isEditing, + editedSubtasks, + onSubtasksChange, + onStartEdit, + onSave, + onCancel, + onToggleSubtaskCompletion, +}) => { + const { t } = useTranslation(); + + return ( +
+

+ {t('task.subtasks', 'Subtasks')} +

+ {isEditing ? ( +
+ +
+
+ + +
+
+
+ ) : subtasks.length > 0 ? ( +
+ {subtasks.map((subtask: Task) => ( +
+
+ + onToggleSubtaskCompletion(subtask) + } + /> + + {subtask.name} + +
+
+ ))} +
+ ) : ( +
+
+ + + {t( + 'task.noSubtasksClickToAdd', + 'No subtasks yet, click to add' + )} + +
+
+ )} +
+ ); +}; + +export default TaskSubtasksCard; diff --git a/frontend/components/Task/TaskDetails/TaskTagsSection.tsx b/frontend/components/Task/TaskDetails/TaskTagsCard.tsx similarity index 97% rename from frontend/components/Task/TaskDetails/TaskTagsSection.tsx rename to frontend/components/Task/TaskDetails/TaskTagsCard.tsx index 151c53b..ee0d008 100644 --- a/frontend/components/Task/TaskDetails/TaskTagsSection.tsx +++ b/frontend/components/Task/TaskDetails/TaskTagsCard.tsx @@ -5,7 +5,7 @@ import TagInput from '../../Tag/TagInput'; import { Task } from '../../../entities/Task'; import { Tag } from '../../../entities/Tag'; -interface TaskTagsSectionProps { +interface TaskTagsCardProps { task: Task; availableTags: Tag[]; hasLoadedTags: boolean; @@ -14,7 +14,7 @@ interface TaskTagsSectionProps { onLoadTags: () => void; } -const TaskTagsSection: React.FC = ({ +const TaskTagsCard: React.FC = ({ task, availableTags, hasLoadedTags, @@ -131,4 +131,4 @@ const TaskTagsSection: React.FC = ({ ); }; -export default TaskTagsSection; +export default TaskTagsCard; diff --git a/frontend/components/Task/TaskDetails/index.ts b/frontend/components/Task/TaskDetails/index.ts index f481bf1..462bdcc 100644 --- a/frontend/components/Task/TaskDetails/index.ts +++ b/frontend/components/Task/TaskDetails/index.ts @@ -1,7 +1,10 @@ export { default as TaskDetailsHeader } from './TaskDetailsHeader'; export { default as TaskSummaryAlerts } from './TaskSummaryAlerts'; -export { default as TaskContentSection } from './TaskContentSection'; -export { default as TaskProjectSection } from './TaskProjectSection'; -export { default as TaskTagsSection } from './TaskTagsSection'; -export { default as TaskPrioritySection } from './TaskPrioritySection'; +export { default as TaskContentCard } from './TaskContentCard'; +export { default as TaskProjectCard } from './TaskProjectCard'; +export { default as TaskTagsCard } from './TaskTagsCard'; +export { default as TaskPriorityCard } from './TaskPriorityCard'; export { default as TaskRecurringInstanceInfo } from './TaskRecurringInstanceInfo'; +export { default as TaskSubtasksCard } from './TaskSubtasksCard'; +export { default as TaskRecurrenceCard } from './TaskRecurrenceCard'; +export { default as TaskDueDateCard } from './TaskDueDateCard'; diff --git a/frontend/components/Task/TaskItem.tsx b/frontend/components/Task/TaskItem.tsx index 70f57ae..c62b86a 100644 --- a/frontend/components/Task/TaskItem.tsx +++ b/frontend/components/Task/TaskItem.tsx @@ -66,7 +66,8 @@ const SubtasksDisplay: React.FC = ({ try { const updatedSubtask = await toggleTaskCompletion( - subtask.id + subtask.id, + subtask ); // Check if parent-child logic was executed @@ -344,7 +345,7 @@ const TaskItem: React.FC = ({ await new Promise((resolve) => setTimeout(resolve, 300)); } - const response = await toggleTaskCompletion(task.id); + const response = await toggleTaskCompletion(task.id, task); // Handle the updated task if (onTaskCompletionToggle) { diff --git a/frontend/components/Tasks.tsx b/frontend/components/Tasks.tsx index fcbdafc..8acde2d 100644 --- a/frontend/components/Tasks.tsx +++ b/frontend/components/Tasks.tsx @@ -22,6 +22,7 @@ import { QueueListIcon, InformationCircleIcon, MagnifyingGlassIcon, + CheckIcon, } from '@heroicons/react/24/outline'; import { getApiPath } from '../config/paths'; @@ -57,50 +58,81 @@ const Tasks: React.FC = () => { const [isSearchExpanded, setIsSearchExpanded] = useState(false); // Collapsed by default const [showCompleted, setShowCompleted] = useState(false); // Show completed tasks toggle const [isMobile, setIsMobile] = useState(window.innerWidth < 768); + const [groupBy, setGroupBy] = useState<'none' | 'project'>('none'); // Pagination state const [offset, setOffset] = useState(0); const [hasMore, setHasMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [totalCount, setTotalCount] = useState(0); - const limit = 20; + const DEFAULT_LIMIT = 20; + const [limit, setLimit] = useState(DEFAULT_LIMIT); const dropdownRef = useRef(null); const location = useLocation(); const navigate = useNavigate(); const query = new URLSearchParams(location.search); - const isUpcomingView = query.get('type') === 'upcoming'; + const isUpcomingView = + query.get('type') === 'upcoming' || location.pathname === '/upcoming'; + const status = query.get('status'); + const tag = query.get('tag'); + + // Sync showCompleted state with status URL parameter (skip for upcoming view) + useEffect(() => { + if (isUpcomingView) return; // Don't apply status filtering in upcoming view + + if (status === 'completed') { + setShowCompleted(true); + } else if (status === 'active') { + setShowCompleted(false); + } else if (status === null) { + // When status is null, we show "All" (both completed and active) + setShowCompleted(true); + } + }, [status, isUpcomingView]); // Filter tasks based on completion status and search query const displayTasks = useMemo(() => { - let filteredTasks: Task[]; + let filteredTasks: Task[] = tasks; - // Filter by completion status (applies to all views) - filteredTasks = showCompleted - ? tasks // Show everything when completed tasks are toggled on - : tasks.filter( - // Otherwise hide completed/archived items - (task: Task) => - task.status !== 'done' && - task.status !== 'archived' && - task.status !== 2 && - task.status !== 3 - ); + // Status-based filtering + if (status === 'completed') { + // Show only completed tasks + filteredTasks = filteredTasks.filter((task: Task) => { + const isCompleted = + task.status === 'done' || + task.status === 'archived' || + task.status === 2 || + task.status === 3; + return isCompleted; + }); + } else if (status === 'active') { + // Show only active (not completed) tasks + filteredTasks = filteredTasks.filter((task: Task) => { + const isCompleted = + task.status === 'done' || + task.status === 'archived' || + task.status === 2 || + task.status === 3; + return !isCompleted; + }); + } + // When status is null, show all tasks (no filtering) // Then filter by search query if provided (skip for upcoming view) if (taskSearchQuery.trim() && !isUpcomingView) { - const query = taskSearchQuery.toLowerCase(); + const queryLower = taskSearchQuery.toLowerCase(); filteredTasks = filteredTasks.filter( (task: Task) => - task.name.toLowerCase().includes(query) || - task.original_name?.toLowerCase().includes(query) || - task.note?.toLowerCase().includes(query) + task.name.toLowerCase().includes(queryLower) || + task.original_name?.toLowerCase().includes(queryLower) || + task.note?.toLowerCase().includes(queryLower) ); } return filteredTasks; - }, [tasks, showCompleted, taskSearchQuery, isUpcomingView]); + }, [tasks, showCompleted, status, taskSearchQuery, isUpcomingView]); // Handle the /upcoming route by setting type=upcoming in query params if (location.pathname === '/upcoming' && !query.get('type')) { @@ -113,13 +145,14 @@ const Tasks: React.FC = () => { stateTitle || getTitleAndIcon(query, projects, t, location.pathname).title; - const tag = query.get('tag'); - const status = query.get('status'); - useEffect(() => { const savedOrderBy = localStorage.getItem('order_by') || 'created_at:desc'; setOrderBy(savedOrderBy); + const savedGroupBy = + (localStorage.getItem('tasks_group_by') as 'none' | 'project') || + 'none'; + setGroupBy(savedGroupBy); const params = new URLSearchParams(location.search); if (!params.get('order_by')) { @@ -159,7 +192,15 @@ const Tasks: React.FC = () => { }; }, [dropdownOpen]); - const fetchData = async (resetPagination = true) => { + const fetchData = async ( + resetPagination = true, + options?: { + limitOverride?: number; + forceOffset?: number; + disableHasMore?: boolean; + disablePagination?: boolean; + } + ) => { setLoading(resetPagination); setError(null); try { @@ -181,10 +222,18 @@ const Tasks: React.FC = () => { allTasksUrl.set('isMobile', isMobile.toString()); } - // Add pagination parameters - const currentOffset = resetPagination ? 0 : offset; - allTasksUrl.set('limit', limit.toString()); - allTasksUrl.set('offset', currentOffset.toString()); + // Add pagination parameters (skip when explicitly disabled or for upcoming view) + if (!options?.disablePagination && type !== 'upcoming') { + const currentOffset = + options?.forceOffset !== undefined + ? options.forceOffset + : resetPagination + ? 0 + : offset; + const limitToUse = options?.limitOverride ?? limit; + allTasksUrl.set('limit', limitToUse.toString()); + allTasksUrl.set('offset', currentOffset.toString()); + } const searchParams = allTasksUrl.toString(); @@ -200,7 +249,10 @@ const Tasks: React.FC = () => { if (resetPagination) { setTasks(tasksData.tasks || []); setGroupedTasks(tasksData.groupedTasks || null); - setOffset(limit); + if (!options?.disablePagination) { + const limitToUse = options?.limitOverride ?? limit; + setOffset(limitToUse); + } } else { setTasks((prev) => [...prev, ...(tasksData.tasks || [])]); // For grouped tasks, merge them @@ -213,12 +265,23 @@ const Tasks: React.FC = () => { }; }); } - setOffset((prev) => prev + limit); + if (!options?.disablePagination) { + const limitToUse = options?.limitOverride ?? limit; + setOffset((prev) => prev + limitToUse); + } } - setHasMore(tasksData.pagination?.hasMore || false); + setHasMore( + options?.disableHasMore || + options?.disablePagination || + type === 'upcoming' + ? false + : tasksData.pagination?.hasMore || false + ); if (tasksData.pagination) { setTotalCount(tasksData.pagination.total); + } else if (options?.disablePagination || type === 'upcoming') { + setTotalCount(tasksData.tasks?.length || 0); } } else { throw new Error('Failed to fetch tasks.'); @@ -233,15 +296,45 @@ const Tasks: React.FC = () => { } }; - const loadMore = async () => { - if (!hasMore || isLoadingMore) return; + const loadMore = async (all: boolean) => { + if (isLoadingMore) return; + if (!hasMore && !all) return; setIsLoadingMore(true); - await fetchData(false); + const shouldDisablePagination = + !isUpcomingView && groupBy === 'project'; + if (all || shouldDisablePagination) { + const newLimit = totalCount > 0 ? totalCount : 10000; + await fetchData(true, { + limitOverride: newLimit, + forceOffset: 0, + disableHasMore: true, + disablePagination: true, + }); + setLimit(DEFAULT_LIMIT); + setHasMore(false); + } else { + await fetchData(false); + } + if (all) { + setHasMore(false); + } }; useEffect(() => { - fetchData(true); - }, [location, isSidebarOpen, isMobile]); + // Disable pagination for: upcoming view OR when grouping by project + const shouldDisablePagination = isUpcomingView || groupBy === 'project'; + fetchData( + true, + shouldDisablePagination + ? { + disablePagination: true, + disableHasMore: true, + limitOverride: 10000, + forceOffset: 0, + } + : undefined + ); + }, [location, isSidebarOpen, isMobile, groupBy, isUpcomingView]); // Handle window resize for mobile detection useEffect(() => { @@ -469,7 +562,7 @@ const Tasks: React.FC = () => { return (
{ ariaLabel={t('tasks.sortTasks', 'Sort tasks')} title={t('tasks.sortTasks', 'Sort tasks')} dropdownLabel={t('tasks.sortBy', 'Sort by')} - extraContent={ - + align="right" + footerContent={ + !isUpcomingView && ( +
+
+
+ {t('tasks.groupBy', 'Group by')} +
+
+ {['none', 'project'].map( + (val) => ( + + ) + )} +
+
+
+
+ {t('tasks.show', 'Show')} +
+
+ {[ + { + key: 'active', + label: t( + 'tasks.open', + 'Open' + ), + }, + { + key: 'all', + label: t( + 'tasks.all', + 'All' + ), + }, + { + key: 'completed', + label: t( + 'tasks.completed', + 'Completed' + ), + }, + ].map((opt) => { + const isActive = + (opt.key === 'all' && + status === null) || + (opt.key === + 'completed' && + status === + 'completed') || + (opt.key === 'active' && + status === + 'active'); + return ( + + ); + })} +
+
+
+
+ {t( + 'tasks.direction', + 'Direction' + )} +
+
+ {[ + { + key: 'asc', + label: t( + 'tasks.ascending', + 'Ascending' + ), + }, + { + key: 'desc', + label: t( + 'tasks.descending', + 'Descending' + ), + }, + ].map((dir) => { + const currentDirection = + orderBy.split(':')[1] || + 'asc'; + const isActive = + currentDirection === + dir.key; + return ( + + ); + })} +
+
+
+ ) } />
@@ -670,7 +950,7 @@ const Tasks: React.FC = () => { <> {/* New Task Form */} {isNewTaskAllowed() && ( -
+
await handleTaskCreate({ @@ -690,6 +970,7 @@ const Tasks: React.FC = () => { { showCompletedTasks={showCompleted} searchQuery={taskSearchQuery} /> + ) : groupBy === 'project' ? ( + ) : ( { showCompletedTasks={showCompleted} /> )} - {/* Load more button */} - {hasMore && ( -
+ {/* Load more button - hide in upcoming view */} + {!isUpcomingView && hasMore && ( +
+
)} - {/* Pagination info */} - {tasks.length > 0 && ( + {/* Pagination info - hide in upcoming view */} + {!isUpcomingView && tasks.length > 0 && (
{t( 'tasks.showingItems', diff --git a/frontend/components/ViewDetail.tsx b/frontend/components/ViewDetail.tsx index ed00178..340ad2e 100644 --- a/frontend/components/ViewDetail.tsx +++ b/frontend/components/ViewDetail.tsx @@ -9,6 +9,7 @@ import { InformationCircleIcon, PencilSquareIcon, MagnifyingGlassIcon, + CheckIcon, } from '@heroicons/react/24/outline'; import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid'; import { Task } from '../entities/Task'; @@ -52,7 +53,9 @@ const ViewDetail: React.FC = () => { // Search, filter, and sort state const [taskSearchQuery, setTaskSearchQuery] = useState(''); const [isSearchExpanded, setIsSearchExpanded] = useState(false); - const [showCompleted, setShowCompleted] = useState(false); + const [taskStatusFilter, setTaskStatusFilter] = useState< + 'all' | 'active' | 'completed' + >('active'); const [orderBy, setOrderBy] = useState('created_at:desc'); // Pagination state @@ -85,7 +88,7 @@ const ViewDetail: React.FC = () => { let filteredTasks: Task[]; // Filter by completion status - if (showCompleted) { + if (taskStatusFilter === 'completed') { filteredTasks = tasks.filter( (task: Task) => task.status === 'done' || @@ -93,7 +96,7 @@ const ViewDetail: React.FC = () => { task.status === 2 || task.status === 3 ); - } else { + } else if (taskStatusFilter === 'active') { filteredTasks = tasks.filter( (task: Task) => task.status !== 'done' && @@ -101,6 +104,9 @@ const ViewDetail: React.FC = () => { task.status !== 2 && task.status !== 3 ); + } else { + // taskStatusFilter === 'all' + filteredTasks = tasks; } // Filter by search query @@ -165,7 +171,7 @@ const ViewDetail: React.FC = () => { }); return sortedTasks; - }, [tasks, showCompleted, taskSearchQuery, orderBy, t]); + }, [tasks, taskStatusFilter, taskSearchQuery, orderBy, t]); useEffect(() => { fetchViewAndResults(); @@ -533,57 +539,121 @@ const ViewDetail: React.FC = () => { ariaLabel={t('views.sortTasks', 'Sort tasks')} title={t('views.sortTasks', 'Sort tasks')} dropdownLabel={t('tasks.sortBy', 'Sort by')} - extraContent={ - + footerContent={ +
+
+
+ {t('tasks.show', 'Show')} +
+
+ {[ + { + key: 'active', + label: t( + 'tasks.open', + 'Open' + ), + }, + { + key: 'all', + label: t( + 'tasks.all', + 'All' + ), + }, + { + key: 'completed', + label: t( + 'tasks.completed', + 'Completed' + ), + }, + ].map((opt) => { + const isActive = + taskStatusFilter === + opt.key; + return ( + + ); + })} +
+
+
+
+ {t('tasks.direction', 'Direction')} +
+
+ {[ + { + key: 'asc', + label: t( + 'tasks.ascending', + 'Ascending' + ), + }, + { + key: 'desc', + label: t( + 'tasks.descending', + 'Descending' + ), + }, + ].map((dir) => { + const currentDirection = + orderBy.split(':')[1] || + 'asc'; + const isActive = + currentDirection === + dir.key; + return ( + + ); + })} +
+
+
} />
@@ -781,7 +851,7 @@ const ViewDetail: React.FC = () => { projects={projects} hideProjectName={false} onToggleToday={handleToggleToday} - showCompletedTasks={showCompleted} + showCompletedTasks={taskStatusFilter !== 'active'} /> {/* Load more button */} {hasMore && ( diff --git a/frontend/utils/tasksService.ts b/frontend/utils/tasksService.ts index 28ff576..3fbf374 100644 --- a/frontend/utils/tasksService.ts +++ b/frontend/utils/tasksService.ts @@ -77,7 +77,7 @@ export const createTask = async (taskData: Task): Promise => { export const updateTask = async ( taskId: number, - taskData: Task + taskData: Partial ): Promise => { const response = await fetch(getApiPath(`task/${taskId}`), { method: 'PATCH', @@ -94,18 +94,12 @@ export const toggleTaskCompletion = async ( taskId: number, currentTask?: Task ): Promise => { - if (!currentTask) { - currentTask = await fetchTaskById(taskId); - } + const task = currentTask ?? (await fetchTaskById(taskId)); const newStatus = - currentTask.status === 2 || currentTask.status === 'done' - ? currentTask.note - ? 1 - : 0 - : 2; + task.status === 2 || task.status === 'done' ? (task.note ? 1 : 0) : 2; - return await updateTask(taskId, { ...currentTask, status: newStatus }); + return await updateTask(taskId, { status: newStatus }); }; export const deleteTask = async (taskId: number): Promise => { diff --git a/public/locales/ar/translation.json b/public/locales/ar/translation.json index 6914b05..14cfe61 100644 --- a/public/locales/ar/translation.json +++ b/public/locales/ar/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "عرض المهام الفرعية", "hideSubtasks": "إخفاء المهام الفرعية", "edit": "تعديل المهمة", - "delete": "حذف المهمة" + "delete": "حذف المهمة", + "sortTasks": "ترتيب المهام", + "sortBy": "ترتيب حسب", + "direction": "الاتجاه", + "ascending": "تصاعدي", + "descending": "تنازلي", + "groupBy": "تجميع حسب", + "groupByProject": "المشروع", + "grouping": { + "none": "بدون" + }, + "show": "عرض", + "all": "الكل", + "completedOnly": "المكتملة فقط", + "notCompleted": "غير مكتملة", + "noProject": "بدون مشروع", + "unknownProject": "مشروع غير معروف", + "tasks": "مهام", + "showingItems": "عرض {{current}} من {{total}} عنصر" }, "timeline": { "activityTimeline": "جدول الأنشطة", @@ -508,7 +526,7 @@ "project": { "name": "اسم المشروع", "projectImage": "صورة المشروع", - "uploadImageHint": "قم بتحميل صورة لمشروعك (حد أقصى 5 ميجابايت)", + "uploadImageHint": "قم بتحميل صورة لمشروعك (حد أقصى 10 ميجابايت)", "browseImage": "تصفح الصورة", "noNotes": "لا توجد ملاحظات لهذا المشروع.", "deleteProject": "حذف المشروع", @@ -765,7 +783,9 @@ "in_progress_desc": "يتم العمل النشط", "blocked_desc": "مؤقتًا متوقف أو عالق", "completed_desc": "تم الانتهاء منها" - } + }, + "showMetrics": "عرض المقاييس", + "hideMetrics": "إخفاء المقاييس" }, "projectItem": { "edit": "تعديل", @@ -1138,4 +1158,4 @@ "subtasks": { "placeholder": "أضف مهمة فرعية..." } -} +} \ No newline at end of file diff --git a/public/locales/bg/translation.json b/public/locales/bg/translation.json index d6040c4..e05dce6 100644 --- a/public/locales/bg/translation.json +++ b/public/locales/bg/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "Покажи подзадачи", "hideSubtasks": "Скрий подзадачи", "edit": "Редактиране на задача", - "delete": "Изтриване на задача" + "delete": "Изтриване на задача", + "sortTasks": "Сортиране на задачи", + "sortBy": "Сортиране по", + "direction": "Посока", + "ascending": "Възходящ", + "descending": "Низходящ", + "groupBy": "Групиране по", + "groupByProject": "Проект", + "grouping": { + "none": "Без" + }, + "show": "Покажи", + "all": "Всички", + "completedOnly": "Само завършени", + "notCompleted": "Незавършени", + "noProject": "Без проект", + "unknownProject": "Неизвестен проект", + "tasks": "задачи", + "showingItems": "Показване на {{current}} от {{total}} елемента" }, "timeline": { "activityTimeline": "Хронология на активността", @@ -508,7 +526,7 @@ "project": { "name": "Име на проекта", "projectImage": "Снимка на проекта", - "uploadImageHint": "Качете изображение за вашия проект (макс 5MB)", + "uploadImageHint": "Качете изображение за вашия проект (макс 10MB)", "browseImage": "Преглед на изображение", "noNotes": "Няма бележки за този проект.", "deleteProject": "Изтрий проекта", @@ -765,7 +783,9 @@ "in_progress_desc": "Активна работа в ход", "blocked_desc": "Временно спряно или блокирано", "completed_desc": "Завършено и готово" - } + }, + "showMetrics": "Покажи метрики", + "hideMetrics": "Скрий метрики" }, "projectItem": { "edit": "Редактиране", @@ -1138,4 +1158,4 @@ "subtasks": { "placeholder": "Добавете подзадача..." } -} +} \ No newline at end of file diff --git a/public/locales/da/translation.json b/public/locales/da/translation.json index 0269212..4932fc1 100644 --- a/public/locales/da/translation.json +++ b/public/locales/da/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "Vis underopgaver", "hideSubtasks": "Skjul underopgaver", "edit": "Rediger opgave", - "delete": "Slet opgave" + "delete": "Slet opgave", + "sortTasks": "Sortér opgaver", + "sortBy": "Sortér efter", + "direction": "Retning", + "ascending": "Stigende", + "descending": "Faldende", + "groupBy": "Gruppér efter", + "groupByProject": "Projekt", + "grouping": { + "none": "Ingen" + }, + "show": "Vis", + "all": "Alle", + "completedOnly": "Kun afsluttede", + "notCompleted": "Ikke afsluttet", + "noProject": "Intet projekt", + "unknownProject": "Ukendt projekt", + "tasks": "opgaver", + "showingItems": "Viser {{current}} af {{total}} elementer" }, "timeline": { "activityTimeline": "Aktivitets Tidslinje", @@ -508,7 +526,7 @@ "project": { "name": "Projekt Navn", "projectImage": "Projektbillede", - "uploadImageHint": "Upload et billede til dit projekt (maks 5MB)", + "uploadImageHint": "Upload et billede til dit projekt (maks 10MB)", "browseImage": "Gennemse Billede", "noNotes": "Ingen noter til dette projekt.", "deleteProject": "Slet Projekt", @@ -765,7 +783,9 @@ "in_progress_desc": "Aktivt arbejde i gang", "blocked_desc": "Midlertidigt pauseret eller fastlåst", "completed_desc": "Afsluttet og færdig" - } + }, + "showMetrics": "Vis målinger", + "hideMetrics": "Skjul målinger" }, "projectItem": { "edit": "Rediger", @@ -1138,4 +1158,4 @@ "subtasks": { "placeholder": "Tilføj en underopgave..." } -} +} \ No newline at end of file diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index a4fa56e..971ead1 100644 --- a/public/locales/de/translation.json +++ b/public/locales/de/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "Unteraufgaben anzeigen", "hideSubtasks": "Unteraufgaben ausblenden", "edit": "Aufgabe bearbeiten", - "delete": "Aufgabe löschen" + "delete": "Aufgabe löschen", + "sortTasks": "Aufgaben sortieren", + "sortBy": "Sortieren nach", + "direction": "Richtung", + "ascending": "Aufsteigend", + "descending": "Absteigend", + "groupBy": "Gruppieren nach", + "groupByProject": "Projekt", + "grouping": { + "none": "Keine" + }, + "show": "Anzeigen", + "all": "Alle", + "completedOnly": "Nur abgeschlossen", + "notCompleted": "Nicht abgeschlossen", + "noProject": "Kein Projekt", + "unknownProject": "Unbekanntes Projekt", + "tasks": "Aufgaben", + "showingItems": "{{current}} von {{total}} Elementen angezeigt" }, "timeline": { "activityTimeline": "Aktivitätsverlauf", @@ -384,7 +402,7 @@ }, "project": { "projectImage": "Projektbild", - "uploadImageHint": "Laden Sie ein Bild für Ihr Projekt hoch (max. 5MB)", + "uploadImageHint": "Laden Sie ein Bild für Ihr Projekt hoch (max. 10MB)", "browseImage": "Bild durchsuchen", "name": "Projektname", "noNotes": "Keine Notizen für dieses Projekt.", @@ -855,7 +873,9 @@ "in_progress_desc": "Aktive Arbeit im Gange", "blocked_desc": "Vorübergehend pausiert oder festgefahren", "completed_desc": "Fertiggestellt und abgeschlossen" - } + }, + "showMetrics": "Metriken anzeigen", + "hideMetrics": "Metriken ausblenden" }, "projectItem": { "edit": "Bearbeiten", @@ -1147,4 +1167,4 @@ "subtasks": { "placeholder": "Fügen Sie eine Unteraufgabe hinzu..." } -} +} \ No newline at end of file diff --git a/public/locales/el/translation.json b/public/locales/el/translation.json index e47721d..b942795 100644 --- a/public/locales/el/translation.json +++ b/public/locales/el/translation.json @@ -332,7 +332,25 @@ "showSubtasks": "Εμφάνιση υποκαθηκόντων", "hideSubtasks": "Απόκρυψη υποκαθηκόντων", "edit": "Επεξεργασία καθήκοντος", - "delete": "Διαγραφή καθήκοντος" + "delete": "Διαγραφή καθήκοντος", + "sortTasks": "Ταξινόμηση εργασιών", + "sortBy": "Ταξινόμηση κατά", + "direction": "Κατεύθυνση", + "ascending": "Αύξουσα", + "descending": "Φθίνουσα", + "groupBy": "Ομαδοποίηση κατά", + "groupByProject": "Έργο", + "grouping": { + "none": "Καμία" + }, + "show": "Εμφάνιση", + "all": "Όλα", + "completedOnly": "Μόνο ολοκληρωμένα", + "notCompleted": "Μη ολοκληρωμένα", + "noProject": "Χωρίς έργο", + "unknownProject": "Άγνωστο έργο", + "tasks": "εργασίες", + "showingItems": "Εμφάνιση {{current}} από {{total}} στοιχεία" }, "timeline": { "activityTimeline": "Χρονοδιάγραμμα Δραστηριότητας", @@ -403,7 +421,9 @@ "in_progress_desc": "Ενεργή εργασία σε εξέλιξη", "blocked_desc": "Προσωρινά παγωμένο ή κολλημένο", "completed_desc": "Ολοκληρώθηκε και τελείωσε" - } + }, + "showMetrics": "Εμφάνιση μετρήσεων", + "hideMetrics": "Απόκρυψη μετρήσεων" }, "notes": { "loading": "Φόρτωση σημειώσεων...", @@ -845,7 +865,7 @@ "project": { "name": "Όνομα Έργου", "projectImage": "Εικόνα Έργου", - "uploadImageHint": "Μεταφορτώστε μια εικόνα για το έργο σας (μέγ. 5MB)", + "uploadImageHint": "Μεταφορτώστε μια εικόνα για το έργο σας (μέγ. 10MB)", "browseImage": "Περιήγηση Εικόνας", "noNotes": "Δεν υπάρχουν σημειώσεις για αυτό το έργο.", "deleteProject": "Διαγραφή Έργου", @@ -1142,4 +1162,4 @@ "subtasks": { "placeholder": "Προσθέστε μια υποεργασία..." } -} +} \ No newline at end of file diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 0874e1c..0d11d74 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "Show subtasks", "hideSubtasks": "Hide subtasks", "edit": "Edit task", - "delete": "Delete task" + "delete": "Delete task", + "sortTasks": "Sort tasks", + "sortBy": "Sort by", + "direction": "Direction", + "ascending": "Ascending", + "descending": "Descending", + "groupBy": "Group by", + "groupByProject": "Project", + "grouping": { + "none": "None" + }, + "show": "Show", + "all": "All", + "completedOnly": "Completed only", + "notCompleted": "Not completed", + "noProject": "No project", + "unknownProject": "Unknown project", + "tasks": "tasks", + "showingItems": "Showing {{current}} of {{total}} items" }, "timeline": { "activityTimeline": "Activity Timeline", @@ -508,7 +526,7 @@ "project": { "name": "Project Name", "projectImage": "Project Image", - "uploadImageHint": "Upload an image for your project (max 5MB)", + "uploadImageHint": "Upload an image for your project (max 10MB)", "browseImage": "Browse Image", "noNotes": "No notes for this project.", "deleteProject": "Delete Project", @@ -768,7 +786,9 @@ "in_progress_desc": "Active work happening", "blocked_desc": "Temporarily paused or stuck", "completed_desc": "Finished and done" - } + }, + "showMetrics": "Show metrics", + "hideMetrics": "Hide metrics" }, "projectItem": { "edit": "Edit", @@ -874,7 +894,7 @@ }, "calendar": { "month": "Month", - "week": "Week", + "week": "Week", "day": "Day", "today": "Today", "addEvent": "Add Event", @@ -899,7 +919,7 @@ "close": "Close", "title": "Title", "status": "Status", - "dueDate": "Due Date", + "dueDate": "Due Date", "priority": "Priority", "project": "Project", "area": "Area", @@ -1030,8 +1050,7 @@ "viewOnGitHub": "View on GitHub", "license": "Licensed for personal use", "builtBy": "Built by" - } - , + }, "admin": { "manageUsers": "Manage users", "userManagement": "User Management", diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json index 608a0a6..f686932 100644 --- a/public/locales/es/translation.json +++ b/public/locales/es/translation.json @@ -332,7 +332,25 @@ "showSubtasks": "Mostrar subtareas", "hideSubtasks": "Ocultar subtareas", "edit": "Editar tarea", - "delete": "Eliminar tarea" + "delete": "Eliminar tarea", + "sortTasks": "Ordenar tareas", + "sortBy": "Ordenar por", + "direction": "Dirección", + "ascending": "Ascendente", + "descending": "Descendente", + "groupBy": "Agrupar por", + "groupByProject": "Proyecto", + "grouping": { + "none": "Ninguno" + }, + "show": "Mostrar", + "all": "Todos", + "completedOnly": "Solo completadas", + "notCompleted": "No completadas", + "noProject": "Sin proyecto", + "unknownProject": "Proyecto desconocido", + "tasks": "tareas", + "showingItems": "Mostrando {{current}} de {{total}} elementos" }, "timeline": { "activityTimeline": "Línea de Tiempo de Actividad", @@ -403,7 +421,9 @@ "in_progress_desc": "Trabajo activo en curso", "blocked_desc": "Pausado temporalmente o atascado", "completed_desc": "Terminado y completado" - } + }, + "showMetrics": "Mostrar métricas", + "hideMetrics": "Ocultar métricas" }, "projectItem": { "edit": "Editar", @@ -580,7 +600,7 @@ "project": { "name": "Nombre del Proyecto", "projectImage": "Imagen del Proyecto", - "uploadImageHint": "Sube una imagen para tu proyecto (máx. 5MB)", + "uploadImageHint": "Sube una imagen para tu proyecto (máx. 10MB)", "browseImage": "Examinar Imagen", "noNotes": "No hay notas para este proyecto.", "deleteProject": "Eliminar Proyecto", @@ -1139,4 +1159,4 @@ "subtasks": { "placeholder": "Agregar una subtarea..." } -} +} \ No newline at end of file diff --git a/public/locales/fi/translation.json b/public/locales/fi/translation.json index 292d895..9afba25 100644 --- a/public/locales/fi/translation.json +++ b/public/locales/fi/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "Näytä alitehtävät", "hideSubtasks": "Piilota alitehtävät", "edit": "Muokkaa tehtävää", - "delete": "Poista tehtävä" + "delete": "Poista tehtävä", + "sortTasks": "Lajittele tehtävät", + "sortBy": "Lajittele", + "direction": "Suunta", + "ascending": "Nouseva", + "descending": "Laskeva", + "groupBy": "Ryhmittele", + "groupByProject": "Projekti", + "grouping": { + "none": "Ei mitään" + }, + "show": "Näytä", + "all": "Kaikki", + "completedOnly": "Vain valmiit", + "notCompleted": "Keskeneräiset", + "noProject": "Ei projektia", + "unknownProject": "Tuntematon projekti", + "tasks": "tehtävät", + "showingItems": "Näytetään {{current}} / {{total}} kohdetta" }, "timeline": { "activityTimeline": "Toiminta-aikajana", @@ -508,7 +526,7 @@ "project": { "name": "Projektin nimi", "projectImage": "Projektikuva", - "uploadImageHint": "Lataa kuva projektiisi (max 5MB)", + "uploadImageHint": "Lataa kuva projektiisi (max 10MB)", "browseImage": "Selaa kuvaa", "noNotes": "Ei muistiinpanoja tälle projektille.", "deleteProject": "Poista projekti", @@ -765,7 +783,9 @@ "in_progress_desc": "Aktiivista työtä käynnissä", "blocked_desc": "Tilapäisesti keskeytetty tai jumissa", "completed_desc": "Valmistunut ja tehty" - } + }, + "showMetrics": "Näytä mittarit", + "hideMetrics": "Piilota mittarit" }, "projectItem": { "edit": "Muokkaa", @@ -1138,4 +1158,4 @@ "subtasks": { "placeholder": "Lisää alitehtävä..." } -} +} \ No newline at end of file diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index 017dea8..c24aee4 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "Afficher les sous-tâches", "hideSubtasks": "Masquer les sous-tâches", "edit": "Modifier la tâche", - "delete": "Supprimer la tâche" + "delete": "Supprimer la tâche", + "sortTasks": "Trier les tâches", + "sortBy": "Trier par", + "direction": "Direction", + "ascending": "Croissant", + "descending": "Décroissant", + "groupBy": "Grouper par", + "groupByProject": "Projet", + "grouping": { + "none": "Aucun" + }, + "show": "Afficher", + "all": "Tous", + "completedOnly": "Terminées uniquement", + "notCompleted": "Non terminées", + "noProject": "Aucun projet", + "unknownProject": "Projet inconnu", + "tasks": "tâches", + "showingItems": "Affichage de {{current}} sur {{total}} éléments" }, "timeline": { "activityTimeline": "Chronologie d'Activité", @@ -508,7 +526,7 @@ "project": { "name": "Nom du projet", "projectImage": "Image du projet", - "uploadImageHint": "Téléchargez une image pour votre projet (max 5 Mo)", + "uploadImageHint": "Téléchargez une image pour votre projet (max 10 Mo)", "browseImage": "Parcourir l'image", "noNotes": "Aucune note pour ce projet.", "deleteProject": "Supprimer le projet", @@ -765,7 +783,9 @@ "in_progress_desc": "Travail actif en cours", "blocked_desc": "Temporairement mis en pause ou bloqué", "completed_desc": "Fini et terminé" - } + }, + "showMetrics": "Afficher les métriques", + "hideMetrics": "Masquer les métriques" }, "projectItem": { "edit": "Modifier", @@ -1138,4 +1158,4 @@ "subtasks": { "placeholder": "Ajouter une sous-tâche..." } -} +} \ No newline at end of file diff --git a/public/locales/id/translation.json b/public/locales/id/translation.json index 8425d80..d5acfa9 100644 --- a/public/locales/id/translation.json +++ b/public/locales/id/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "Tampilkan subtugas", "hideSubtasks": "Sembunyikan subtugas", "edit": "Edit tugas", - "delete": "Hapus tugas" + "delete": "Hapus tugas", + "sortTasks": "Urutkan tugas", + "sortBy": "Urutkan berdasarkan", + "direction": "Arah", + "ascending": "Menaik", + "descending": "Menurun", + "groupBy": "Kelompokkan berdasarkan", + "groupByProject": "Proyek", + "grouping": { + "none": "Tidak ada" + }, + "show": "Tampilkan", + "all": "Semua", + "completedOnly": "Hanya selesai", + "notCompleted": "Belum selesai", + "noProject": "Tanpa proyek", + "unknownProject": "Proyek tidak dikenal", + "tasks": "tugas", + "showingItems": "Menampilkan {{current}} dari {{total}} item" }, "timeline": { "activityTimeline": "Garis Waktu Aktivitas", @@ -508,7 +526,7 @@ "project": { "name": "Nama Proyek", "projectImage": "Gambar Proyek", - "uploadImageHint": "Unggah gambar untuk proyek Anda (maks 5MB)", + "uploadImageHint": "Unggah gambar untuk proyek Anda (maks 10MB)", "browseImage": "Telusuri Gambar", "noNotes": "Tidak ada catatan untuk proyek ini.", "deleteProject": "Hapus Proyek", @@ -765,7 +783,9 @@ "in_progress_desc": "Pekerjaan aktif sedang berlangsung", "blocked_desc": "Sementara terhenti atau terjebak", "completed_desc": "Selesai dan selesai" - } + }, + "showMetrics": "Tampilkan metrik", + "hideMetrics": "Sembunyikan metrik" }, "projectItem": { "edit": "Sunting", @@ -1138,4 +1158,4 @@ "subtasks": { "placeholder": "Tambahkan subtugas..." } -} +} \ No newline at end of file diff --git a/public/locales/it/translation.json b/public/locales/it/translation.json index 9b7b402..444447b 100644 --- a/public/locales/it/translation.json +++ b/public/locales/it/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "Mostra sottocompiti", "hideSubtasks": "Nascondi sottocompiti", "edit": "Modifica attività", - "delete": "Elimina attività" + "delete": "Elimina attività", + "sortTasks": "Ordina attività", + "sortBy": "Ordina per", + "direction": "Direzione", + "ascending": "Crescente", + "descending": "Decrescente", + "groupBy": "Raggruppa per", + "groupByProject": "Progetto", + "grouping": { + "none": "Nessuno" + }, + "show": "Mostra", + "all": "Tutti", + "completedOnly": "Solo completate", + "notCompleted": "Non completate", + "noProject": "Nessun progetto", + "unknownProject": "Progetto sconosciuto", + "tasks": "attività", + "showingItems": "Visualizzazione di {{current}} su {{total}} elementi" }, "timeline": { "activityTimeline": "Timeline delle Attività", @@ -508,7 +526,7 @@ "project": { "name": "Nome Progetto", "projectImage": "Immagine Progetto", - "uploadImageHint": "Carica un'immagine per il tuo progetto (max 5MB)", + "uploadImageHint": "Carica un'immagine per il tuo progetto (max 10MB)", "browseImage": "Sfoglia Immagine", "noNotes": "Nessuna nota per questo progetto.", "deleteProject": "Elimina Progetto", @@ -765,7 +783,9 @@ "in_progress_desc": "Lavoro attivo in corso", "blocked_desc": "Pausa temporanea o bloccato", "completed_desc": "Finito e completato" - } + }, + "showMetrics": "Mostra metriche", + "hideMetrics": "Nascondi metriche" }, "projectItem": { "edit": "Modifica", @@ -1138,4 +1158,4 @@ "subtasks": { "placeholder": "Aggiungi un sottocompito..." } -} +} \ No newline at end of file diff --git a/public/locales/jp/translation.json b/public/locales/jp/translation.json index 2d7e85d..73fe843 100644 --- a/public/locales/jp/translation.json +++ b/public/locales/jp/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "サブタスクを表示", "hideSubtasks": "サブタスクを非表示", "edit": "タスクを編集", - "delete": "タスクを削除" + "delete": "タスクを削除", + "sortTasks": "タスクを並べ替え", + "sortBy": "並べ替え", + "direction": "方向", + "ascending": "昇順", + "descending": "降順", + "groupBy": "グループ化", + "groupByProject": "プロジェクト", + "grouping": { + "none": "なし" + }, + "show": "表示", + "all": "すべて", + "completedOnly": "完了のみ", + "notCompleted": "未完了", + "noProject": "プロジェクトなし", + "unknownProject": "不明なプロジェクト", + "tasks": "タスク", + "showingItems": "{{total}}件中{{current}}件を表示" }, "timeline": { "activityTimeline": "アクティビティタイムライン", @@ -563,7 +581,9 @@ "in_progress_desc": "アクティブな作業が行われている", "blocked_desc": "一時的に停止または行き詰まっている", "completed_desc": "完了し、終了した" - } + }, + "showMetrics": "メトリクスを表示", + "hideMetrics": "メトリクスを非表示" }, "projectItem": { "edit": "編集", @@ -684,7 +704,7 @@ }, "project": { "projectImage": "プロジェクト画像", - "uploadImageHint": "プロジェクト用の画像をアップロード(最大5MB)", + "uploadImageHint": "プロジェクト用の画像をアップロード(最大10MB)", "browseImage": "画像を参照", "name": "プロジェクト名", "noNotes": "このプロジェクトにはノートがありません。", @@ -1138,4 +1158,4 @@ "subtasks": { "placeholder": "サブタスクを追加..." } -} +} \ No newline at end of file diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index 4e96b31..2a3e82c 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "하위 작업 표시", "hideSubtasks": "하위 작업 숨기기", "edit": "작업 편집", - "delete": "작업 삭제" + "delete": "작업 삭제", + "sortTasks": "작업 정렬", + "sortBy": "정렬 기준", + "direction": "방향", + "ascending": "오름차순", + "descending": "내림차순", + "groupBy": "그룹화 기준", + "groupByProject": "프로젝트", + "grouping": { + "none": "없음" + }, + "show": "표시", + "all": "모두", + "completedOnly": "완료된 항목만", + "notCompleted": "미완료", + "noProject": "프로젝트 없음", + "unknownProject": "알 수 없는 프로젝트", + "tasks": "작업", + "showingItems": "{{total}}개 중 {{current}}개 표시" }, "timeline": { "activityTimeline": "활동 타임라인", @@ -508,7 +526,7 @@ "project": { "name": "프로젝트 이름", "projectImage": "프로젝트 이미지", - "uploadImageHint": "프로젝트를 위한 이미지를 업로드하세요 (최대 5MB)", + "uploadImageHint": "프로젝트를 위한 이미지를 업로드하세요 (최대 10MB)", "browseImage": "이미지 찾아보기", "noNotes": "이 프로젝트에 대한 메모가 없습니다.", "deleteProject": "프로젝트 삭제", @@ -765,7 +783,9 @@ "in_progress_desc": "활동적인 작업 진행 중", "blocked_desc": "일시적으로 중단되거나 막힘", "completed_desc": "완료되고 끝남" - } + }, + "showMetrics": "메트릭 표시", + "hideMetrics": "메트릭 숨기기" }, "projectItem": { "edit": "편집", @@ -1138,4 +1158,4 @@ "subtasks": { "placeholder": "하위 작업 추가..." } -} +} \ No newline at end of file diff --git a/public/locales/nl/translation.json b/public/locales/nl/translation.json index d1bbbed..3cbbca1 100644 --- a/public/locales/nl/translation.json +++ b/public/locales/nl/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "Toon subtaken", "hideSubtasks": "Verberg subtaken", "edit": "Bewerk taak", - "delete": "Verwijder taak" + "delete": "Verwijder taak", + "sortTasks": "Taken sorteren", + "sortBy": "Sorteren op", + "direction": "Richting", + "ascending": "Oplopend", + "descending": "Aflopend", + "groupBy": "Groeperen op", + "groupByProject": "Project", + "grouping": { + "none": "Geen" + }, + "show": "Tonen", + "all": "Alle", + "completedOnly": "Alleen voltooid", + "notCompleted": "Niet voltooid", + "noProject": "Geen project", + "unknownProject": "Onbekend project", + "tasks": "taken", + "showingItems": "{{current}} van {{total}} items weergegeven" }, "timeline": { "activityTimeline": "Activiteit Tijdlijn", @@ -508,7 +526,7 @@ "project": { "name": "Projectnaam", "projectImage": "Projectafbeelding", - "uploadImageHint": "Upload een afbeelding voor je project (max 5MB)", + "uploadImageHint": "Upload een afbeelding voor je project (max 10MB)", "browseImage": "Afbeelding Bladeren", "noNotes": "Geen notities voor dit project.", "deleteProject": "Project Verwijderen", @@ -765,7 +783,9 @@ "in_progress_desc": "Actief werk aan de gang", "blocked_desc": "Tijdelijk gepauzeerd of vastgelopen", "completed_desc": "Afgerond en gedaan" - } + }, + "showMetrics": "Toon statistieken", + "hideMetrics": "Verberg statistieken" }, "projectItem": { "edit": "Bewerken", @@ -1138,4 +1158,4 @@ "subtasks": { "placeholder": "Voeg een subtaak toe..." } -} +} \ No newline at end of file diff --git a/public/locales/no/translation.json b/public/locales/no/translation.json index dbb60f9..fc1af82 100644 --- a/public/locales/no/translation.json +++ b/public/locales/no/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "Vis undertasker", "hideSubtasks": "Skjul undertasker", "edit": "Rediger oppgave", - "delete": "Slett oppgave" + "delete": "Slett oppgave", + "sortTasks": "Sorter oppgaver", + "sortBy": "Sorter etter", + "direction": "Retning", + "ascending": "Stigende", + "descending": "Fallende", + "groupBy": "Grupper etter", + "groupByProject": "Prosjekt", + "grouping": { + "none": "Ingen" + }, + "show": "Vis", + "all": "Alle", + "completedOnly": "Kun fullførte", + "notCompleted": "Ikke fullført", + "noProject": "Ingen prosjekt", + "unknownProject": "Ukjent prosjekt", + "tasks": "oppgaver", + "showingItems": "Viser {{current}} av {{total}} elementer" }, "timeline": { "activityTimeline": "Aktivitetslinje", @@ -508,7 +526,7 @@ "project": { "name": "Prosjektnavn", "projectImage": "Prosjektbilde", - "uploadImageHint": "Last opp et bilde for prosjektet ditt (maks 5MB)", + "uploadImageHint": "Last opp et bilde for prosjektet ditt (maks 10MB)", "browseImage": "Bla gjennom bilde", "noNotes": "Ingen notater for dette prosjektet.", "deleteProject": "Slett prosjekt", @@ -765,7 +783,9 @@ "in_progress_desc": "Aktivt arbeid pågår", "blocked_desc": "Midlertidig pauset eller fastlåst", "completed_desc": "Ferdig og gjort" - } + }, + "showMetrics": "Vis målinger", + "hideMetrics": "Skjul målinger" }, "projectItem": { "edit": "Rediger", @@ -1138,4 +1158,4 @@ "subtasks": { "placeholder": "Legg til en underoppgave..." } -} +} \ No newline at end of file diff --git a/public/locales/pl/translation.json b/public/locales/pl/translation.json index 352b62e..b03bf9a 100644 --- a/public/locales/pl/translation.json +++ b/public/locales/pl/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "Pokaż podzadania", "hideSubtasks": "Ukryj podzadania", "edit": "Edytuj zadanie", - "delete": "Usuń zadanie" + "delete": "Usuń zadanie", + "sortTasks": "Sortuj zadania", + "sortBy": "Sortuj według", + "direction": "Kierunek", + "ascending": "Rosnąco", + "descending": "Malejąco", + "groupBy": "Grupuj według", + "groupByProject": "Projekt", + "grouping": { + "none": "Brak" + }, + "show": "Pokaż", + "all": "Wszystkie", + "completedOnly": "Tylko ukończone", + "notCompleted": "Nieukończone", + "noProject": "Brak projektu", + "unknownProject": "Nieznany projekt", + "tasks": "zadania", + "showingItems": "Wyświetlanie {{current}} z {{total}} elementów" }, "timeline": { "activityTimeline": "Oś czasu aktywności", @@ -508,7 +526,7 @@ "project": { "name": "Nazwa projektu", "projectImage": "Obraz projektu", - "uploadImageHint": "Prześlij obraz dla swojego projektu (maks. 5MB)", + "uploadImageHint": "Prześlij obraz dla swojego projektu (maks. 10MB)", "browseImage": "Przeglądaj obraz", "noNotes": "Brak notatek dla tego projektu.", "deleteProject": "Usuń projekt", @@ -765,7 +783,9 @@ "in_progress_desc": "Aktywna praca w toku", "blocked_desc": "Tymczasowo wstrzymane lub utknęło", "completed_desc": "Zakończone i gotowe" - } + }, + "showMetrics": "Pokaż metryki", + "hideMetrics": "Ukryj metryki" }, "projectItem": { "edit": "Edytuj", @@ -1138,4 +1158,4 @@ "subtasks": { "placeholder": "Dodaj podzadanie..." } -} +} \ No newline at end of file diff --git a/public/locales/pt/translation.json b/public/locales/pt/translation.json index ff2d482..f0bfc64 100644 --- a/public/locales/pt/translation.json +++ b/public/locales/pt/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "Mostrar subtarefas", "hideSubtasks": "Ocultar subtarefas", "edit": "Editar tarefa", - "delete": "Excluir tarefa" + "delete": "Excluir tarefa", + "sortTasks": "Ordenar tarefas", + "sortBy": "Ordenar por", + "direction": "Direção", + "ascending": "Ascendente", + "descending": "Descendente", + "groupBy": "Agrupar por", + "groupByProject": "Projeto", + "grouping": { + "none": "Nenhum" + }, + "show": "Mostrar", + "all": "Todos", + "completedOnly": "Somente concluídas", + "notCompleted": "Não concluídas", + "noProject": "Sem projeto", + "unknownProject": "Projeto desconhecido", + "tasks": "tarefas", + "showingItems": "Mostrando {{current}} de {{total}} itens" }, "timeline": { "activityTimeline": "Linha do Tempo de Atividades", @@ -508,7 +526,7 @@ "project": { "name": "Nome do Projeto", "projectImage": "Imagem do Projeto", - "uploadImageHint": "Carregue uma imagem para o seu projeto (máx 5MB)", + "uploadImageHint": "Carregue uma imagem para o seu projeto (máx 10MB)", "browseImage": "Procurar Imagem", "noNotes": "Nenhuma nota para este projeto.", "deleteProject": "Excluir Projeto", @@ -765,7 +783,9 @@ "in_progress_desc": "Trabalho ativo em andamento", "blocked_desc": "Pausado temporariamente ou preso", "completed_desc": "Finalizado e concluído" - } + }, + "showMetrics": "Mostrar métricas", + "hideMetrics": "Ocultar métricas" }, "projectItem": { "edit": "Editar", @@ -1138,4 +1158,4 @@ "subtasks": { "placeholder": "Adicionar uma subtarefa..." } -} +} \ No newline at end of file diff --git a/public/locales/ro/translation.json b/public/locales/ro/translation.json index cde7a41..b33935e 100644 --- a/public/locales/ro/translation.json +++ b/public/locales/ro/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "Arată subtask-uri", "hideSubtasks": "Ascunde subtask-uri", "edit": "Editează task-ul", - "delete": "Șterge task-ul" + "delete": "Șterge task-ul", + "sortTasks": "Sortează sarcini", + "sortBy": "Sortează după", + "direction": "Direcție", + "ascending": "Crescător", + "descending": "Descrescător", + "groupBy": "Grupează după", + "groupByProject": "Proiect", + "grouping": { + "none": "Niciunul" + }, + "show": "Afișează", + "all": "Toate", + "completedOnly": "Doar finalizate", + "notCompleted": "Nefinalizate", + "noProject": "Fără proiect", + "unknownProject": "Proiect necunoscut", + "tasks": "sarcini", + "showingItems": "Se afișează {{current}} din {{total}} elemente" }, "timeline": { "activityTimeline": "Cronologia activităților", @@ -508,7 +526,7 @@ "project": { "name": "Numele Proiectului", "projectImage": "Imaginea Proiectului", - "uploadImageHint": "Încărcați o imagine pentru proiectul dvs. (max 5MB)", + "uploadImageHint": "Încărcați o imagine pentru proiectul dvs. (max 10MB)", "browseImage": "Răsfoiți Imaginea", "noNotes": "Nu există note pentru acest proiect.", "deleteProject": "Șterge Proiectul", @@ -765,7 +783,9 @@ "in_progress_desc": "Lucru activ în desfășurare", "blocked_desc": "Pauză temporară sau blocat", "completed_desc": "Finalizat și terminat" - } + }, + "showMetrics": "Arată metrici", + "hideMetrics": "Ascunde metrici" }, "projectItem": { "edit": "Editează", @@ -1138,4 +1158,4 @@ "subtasks": { "placeholder": "Adaugă o subtask..." } -} +} \ No newline at end of file diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index 9843344..4fa5fc4 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "Показать подзадачи", "hideSubtasks": "Скрыть подзадачи", "edit": "Редактировать задачу", - "delete": "Удалить задачу" + "delete": "Удалить задачу", + "sortTasks": "Сортировка задач", + "sortBy": "Сортировать по", + "direction": "Направление", + "ascending": "По возрастанию", + "descending": "По убыванию", + "groupBy": "Группировать по", + "groupByProject": "Проект", + "grouping": { + "none": "Нет" + }, + "show": "Показать", + "all": "Все", + "completedOnly": "Только завершенные", + "notCompleted": "Незавершенные", + "noProject": "Без проекта", + "unknownProject": "Неизвестный проект", + "tasks": "задачи", + "showingItems": "Показано {{current}} из {{total}} элементов" }, "timeline": { "activityTimeline": "Хронология активности", @@ -508,7 +526,7 @@ "project": { "name": "Название проекта", "projectImage": "Изображение проекта", - "uploadImageHint": "Загрузите изображение для вашего проекта (макс 5МБ)", + "uploadImageHint": "Загрузите изображение для вашего проекта (макс 10МБ)", "browseImage": "Выбрать изображение", "noNotes": "Нет заметок для этого проекта.", "deleteProject": "Удалить проект", @@ -765,7 +783,9 @@ "in_progress_desc": "Активная работа идет", "blocked_desc": "Временно приостановлено или застряло", "completed_desc": "Завершено и выполнено" - } + }, + "showMetrics": "Показать метрики", + "hideMetrics": "Скрыть метрики" }, "projectItem": { "edit": "Редактировать", @@ -1138,4 +1158,4 @@ "subtasks": { "placeholder": "Добавить подзадачу..." } -} +} \ No newline at end of file diff --git a/public/locales/sl/translation.json b/public/locales/sl/translation.json index 7f1584a..ce3fdc8 100644 --- a/public/locales/sl/translation.json +++ b/public/locales/sl/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "Prikaži podnaloge", "hideSubtasks": "Skrij podnaloge", "edit": "Uredi nalogo", - "delete": "Izbriši nalogo" + "delete": "Izbriši nalogo", + "sortTasks": "Razvrsti naloge", + "sortBy": "Razvrsti po", + "direction": "Smer", + "ascending": "Naraščajoče", + "descending": "Padajoče", + "groupBy": "Združi po", + "groupByProject": "Projekt", + "grouping": { + "none": "Brez" + }, + "show": "Prikaži", + "all": "Vse", + "completedOnly": "Samo dokončane", + "notCompleted": "Nedokončane", + "noProject": "Brez projekta", + "unknownProject": "Neznan projekt", + "tasks": "naloge", + "showingItems": "Prikazovanje {{current}} od {{total}} elementov" }, "timeline": { "activityTimeline": "Časovnica aktivnosti", @@ -508,7 +526,7 @@ "project": { "name": "Ime projekta", "projectImage": "Slika projekta", - "uploadImageHint": "Naložite sliko za vaš projekt (max 5MB)", + "uploadImageHint": "Naložite sliko za vaš projekt (max 10MB)", "browseImage": "Prebrskaj sliko", "noNotes": "Ni opomb za ta projekt.", "deleteProject": "Izbriši projekt", @@ -765,7 +783,9 @@ "in_progress_desc": "Aktivno delo poteka", "blocked_desc": "Začasno ustavljeno ali zastojev", "completed_desc": "Dokončano in opravljeno" - } + }, + "showMetrics": "Prikaži metrike", + "hideMetrics": "Skrij metrike" }, "projectItem": { "edit": "Uredi", @@ -1138,4 +1158,4 @@ "subtasks": { "placeholder": "Dodaj podnalogo..." } -} +} \ No newline at end of file diff --git a/public/locales/sv/translation.json b/public/locales/sv/translation.json index 7c9ac44..92f0c6b 100644 --- a/public/locales/sv/translation.json +++ b/public/locales/sv/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "Visa deluppgifter", "hideSubtasks": "Dölj deluppgifter", "edit": "Ändra uppgift", - "delete": "Ta bort uppgift" + "delete": "Ta bort uppgift", + "sortTasks": "Sortera uppgifter", + "sortBy": "Sortera efter", + "direction": "Riktning", + "ascending": "Stigande", + "descending": "Fallande", + "groupBy": "Gruppera efter", + "groupByProject": "Projekt", + "grouping": { + "none": "Ingen" + }, + "show": "Visa", + "all": "Alla", + "completedOnly": "Endast slutförda", + "notCompleted": "Ej slutförda", + "noProject": "Inget projekt", + "unknownProject": "Okänt projekt", + "tasks": "uppgifter", + "showingItems": "Visar {{current}} av {{total}} objekt" }, "timeline": { "activityTimeline": "Aktivitetslinje", @@ -508,7 +526,7 @@ "project": { "name": "Projektnamn", "projectImage": "Projektbild", - "uploadImageHint": "Ladda upp en bild för ditt projekt (max 5 MB)", + "uploadImageHint": "Ladda upp en bild för ditt projekt (max 10 MB)", "browseImage": "Bläddra efter bild", "noNotes": "Inga anteckningar för detta projekt.", "deleteProject": "Ta bort projekt", @@ -765,7 +783,9 @@ "in_progress_desc": "Aktivt arbete pågår", "blocked_desc": "Tillfälligt pausad eller fast", "completed_desc": "Avslutad och klar" - } + }, + "showMetrics": "Visa mätvärden", + "hideMetrics": "Dölj mätvärden" }, "projectItem": { "edit": "Ändra", @@ -1138,4 +1158,4 @@ "subtasks": { "placeholder": "Lägg till en deluppgift..." } -} +} \ No newline at end of file diff --git a/public/locales/tr/translation.json b/public/locales/tr/translation.json index 30b0b54..a0a4aa3 100644 --- a/public/locales/tr/translation.json +++ b/public/locales/tr/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "Alt görevleri göster", "hideSubtasks": "Alt görevleri gizle", "edit": "Görevi düzenle", - "delete": "Görevi sil" + "delete": "Görevi sil", + "sortTasks": "Görevleri sırala", + "sortBy": "Sıralama ölçütü", + "direction": "Yön", + "ascending": "Artan", + "descending": "Azalan", + "groupBy": "Gruplandırma ölçütü", + "groupByProject": "Proje", + "grouping": { + "none": "Yok" + }, + "show": "Göster", + "all": "Tümü", + "completedOnly": "Yalnızca tamamlananlar", + "notCompleted": "Tamamlanmamış", + "noProject": "Proje yok", + "unknownProject": "Bilinmeyen proje", + "tasks": "görevler", + "showingItems": "{{total}} öğeden {{current}} tanesi gösteriliyor" }, "timeline": { "activityTimeline": "Etkinlik Zaman Çizelgesi", @@ -508,7 +526,7 @@ "project": { "name": "Proje Adı", "projectImage": "Proje Görseli", - "uploadImageHint": "Projeniz için bir görsel yükleyin (maks 5MB)", + "uploadImageHint": "Projeniz için bir görsel yükleyin (maks 10MB)", "browseImage": "Görseli Gözat", "noNotes": "Bu proje için not yok.", "deleteProject": "Projeyi Sil", @@ -765,7 +783,9 @@ "in_progress_desc": "Aktif çalışma devam ediyor", "blocked_desc": "Geçici olarak duraklatıldı veya takıldı", "completed_desc": "Tamamlandı ve sona erdi" - } + }, + "showMetrics": "Metrikleri göster", + "hideMetrics": "Metrikleri gizle" }, "projectItem": { "edit": "Düzenle", @@ -1138,4 +1158,4 @@ "subtasks": { "placeholder": "Bir alt görev ekle..." } -} +} \ No newline at end of file diff --git a/public/locales/ua/translation.json b/public/locales/ua/translation.json index 077b4e6..fc59f10 100644 --- a/public/locales/ua/translation.json +++ b/public/locales/ua/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "Показати підзадачі", "hideSubtasks": "Сховати підзадачі", "edit": "Редагувати задачу", - "delete": "Видалити задачу" + "delete": "Видалити задачу", + "sortTasks": "Сортувати завдання", + "sortBy": "Сортувати за", + "direction": "Напрямок", + "ascending": "За зростанням", + "descending": "За спаданням", + "groupBy": "Групувати за", + "groupByProject": "Проект", + "grouping": { + "none": "Без групування" + }, + "show": "Показати", + "all": "Усі", + "completedOnly": "Тільки завершені", + "notCompleted": "Незавершені", + "noProject": "Без проекту", + "unknownProject": "Невідомий проект", + "tasks": "завдання", + "showingItems": "Показано {{current}} з {{total}} елементів" }, "timeline": { "activityTimeline": "Хронологія Активності", @@ -193,7 +211,9 @@ "in_progress_desc": "Активна робота триває", "blocked_desc": "Тимчасово призупинено або застрягло", "completed_desc": "Завершено та виконано" - } + }, + "showMetrics": "Показати метрики", + "hideMetrics": "Сховати метрики" }, "projectItem": { "edit": "Редагувати", @@ -436,7 +456,7 @@ }, "project": { "projectImage": "Зображення проекту", - "uploadImageHint": "Завантажте зображення для вашого проекту (макс. 5МБ)", + "uploadImageHint": "Завантажте зображення для вашого проекту (макс. 10МБ)", "browseImage": "Обрати зображення", "name": "Назва проекту", "noNotes": "Немає приміток для цього проекту.", @@ -1138,4 +1158,4 @@ "subtasks": { "placeholder": "Додати підзадачу..." } -} +} \ No newline at end of file diff --git a/public/locales/vi/translation.json b/public/locales/vi/translation.json index b217119..fa681f4 100644 --- a/public/locales/vi/translation.json +++ b/public/locales/vi/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "Hiện các công việc con", "hideSubtasks": "Ẩn các công việc con", "edit": "Chỉnh sửa công việc", - "delete": "Xóa công việc" + "delete": "Xóa công việc", + "sortTasks": "Sắp xếp nhiệm vụ", + "sortBy": "Sắp xếp theo", + "direction": "Hướng", + "ascending": "Tăng dần", + "descending": "Giảm dần", + "groupBy": "Nhóm theo", + "groupByProject": "Dự án", + "grouping": { + "none": "Không có" + }, + "show": "Hiển thị", + "all": "Tất cả", + "completedOnly": "Chỉ đã hoàn thành", + "notCompleted": "Chưa hoàn thành", + "noProject": "Không có dự án", + "unknownProject": "Dự án không xác định", + "tasks": "nhiệm vụ", + "showingItems": "Hiển thị {{current}} trong số {{total}} mục" }, "timeline": { "activityTimeline": "Dòng Thời Gian Hoạt Động", @@ -508,7 +526,7 @@ "project": { "name": "Tên Dự Án", "projectImage": "Hình Ảnh Dự Án", - "uploadImageHint": "Tải lên một hình ảnh cho dự án của bạn (tối đa 5MB)", + "uploadImageHint": "Tải lên một hình ảnh cho dự án của bạn (tối đa 10MB)", "browseImage": "Duyệt Hình Ảnh", "noNotes": "Không có ghi chú cho dự án này.", "deleteProject": "Xóa Dự Án", @@ -765,7 +783,9 @@ "in_progress_desc": "Công việc đang diễn ra", "blocked_desc": "Tạm dừng hoặc bị mắc kẹt", "completed_desc": "Đã hoàn tất và xong" - } + }, + "showMetrics": "Hiển thị số liệu", + "hideMetrics": "Ẩn số liệu" }, "projectItem": { "edit": "Chỉnh sửa", @@ -1138,4 +1158,4 @@ "subtasks": { "placeholder": "Thêm một công việc phụ..." } -} +} \ No newline at end of file diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index ba66318..43995ff 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -122,7 +122,25 @@ "showSubtasks": "显示子任务", "hideSubtasks": "隐藏子任务", "edit": "编辑任务", - "delete": "删除任务" + "delete": "删除任务", + "sortTasks": "排序任务", + "sortBy": "排序方式", + "direction": "方向", + "ascending": "升序", + "descending": "降序", + "groupBy": "分组方式", + "groupByProject": "项目", + "grouping": { + "none": "无" + }, + "show": "显示", + "all": "全部", + "completedOnly": "仅已完成", + "notCompleted": "未完成", + "noProject": "无项目", + "unknownProject": "未知项目", + "tasks": "任务", + "showingItems": "显示 {{current}} / {{total}} 项" }, "timeline": { "activityTimeline": "活动时间线", @@ -508,7 +526,7 @@ "project": { "name": "项目名称", "projectImage": "项目图片", - "uploadImageHint": "上传项目图片(最大5MB)", + "uploadImageHint": "上传项目图片(最大10MB)", "browseImage": "浏览图片", "noNotes": "此项目没有备注。", "deleteProject": "删除项目", @@ -765,7 +783,9 @@ "in_progress_desc": "正在进行的工作", "blocked_desc": "暂时暂停或卡住", "completed_desc": "已完成并结束" - } + }, + "showMetrics": "显示指标", + "hideMetrics": "隐藏指标" }, "projectItem": { "edit": "编辑", @@ -1138,4 +1158,4 @@ "subtasks": { "placeholder": "添加子任务..." } -} +} \ No newline at end of file