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
This commit is contained in:
Chris 2025-11-23 21:48:49 +02:00 committed by GitHub
parent a1b9095bce
commit 75a1e68730
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 6635 additions and 4179 deletions

View file

@ -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');
},
};

View file

@ -165,6 +165,17 @@ module.exports = (sequelize) => {
pinnedViewsOrder: [],
},
},
ui_settings: {
type: DataTypes.JSON,
allowNull: true,
defaultValue: {
project: {
details: {
showMetrics: true,
},
},
},
},
},
{
tableName: 'users',

View file

@ -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/;

View file

@ -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'] };
}

View file

@ -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;

View file

@ -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',

View file

@ -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';

View file

@ -338,11 +338,7 @@ const Layout: React.FC<LayoutProps> = ({
}
};
const mainContentMarginLeft = isUpcomingView
? 'ml-0'
: isSidebarOpen
? 'ml-72'
: 'ml-0';
const mainContentMarginLeft = isSidebarOpen ? 'ml-72' : 'ml-0';
const isLoading =
isNotesLoading ||

View file

@ -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<void>;
openTaskModal: (task: Task, inboxItemUid?: string) => void;
@ -30,7 +29,6 @@ interface InboxItemDetailProps {
const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
item,
onProcess, // eslint-disable-line @typescript-eslint/no-unused-vars
onDelete,
onUpdate,
openTaskModal,
@ -51,7 +49,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
`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<InboxItemDetailProps> = ({
}
};
// 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<InboxItemDetailProps> = ({
};
}, [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<InboxItemDetailProps> = ({
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<InboxItemDetailProps> = ({
}
}
// Skip to end of this group
i = groupEnd;
} else {
i++;
@ -140,20 +129,15 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
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<InboxItemDetailProps> = ({
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<InboxItemDetailProps> = ({
}
}
// Skip to end of this group
i = groupEnd;
} else {
i++;
@ -192,7 +173,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
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<InboxItemDetailProps> = ({
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<InboxItemDetailProps> = ({
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<InboxItemDetailProps> = ({
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<InboxItemDetailProps> = ({
i++;
}
} else {
// Keep regular tokens
cleanedTokens.push(tokens[i]);
i++;
}
@ -264,9 +235,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
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<InboxItemDetailProps> = ({
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<InboxItemDetailProps> = ({
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<InboxItemDetailProps> = ({
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<InboxItemDetailProps> = ({
}
} 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<InboxItemDetailProps> = ({
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<InboxItemDetailProps> = ({
{cleanedContent || item.content}
</button>
{/* Tags and Projects display - TaskHeader style */}
{(hashtags.length > 0 || projectRefs.length > 0) && (
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 mt-1">
{/* Projects display first */}
{projectRefs.length > 0 && (
<div className="flex items-center">
<FolderIcon className="h-3 w-3 mr-1" />
<span>
{projectRefs.map(
(projectRef, index) => {
// Find matching project
const matchingProject =
projects.find(
(project) =>
@ -526,12 +475,10 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
</div>
)}
{/* Add spacing between project and tags */}
{projectRefs.length > 0 && hashtags.length > 0 && (
<span className="mx-2"></span>
)}
{/* Tags display */}
{hashtags.length > 0 && (
<div className="flex items-center">
<TagIcon className="h-3 w-3 mr-1" />
@ -558,11 +505,9 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
)}
</div>
{/* Desktop view (md and larger) */}
<div className="hidden md:flex items-center justify-end w-1/5 space-x-1">
{loading && <div className="spinner" />}
{/* Edit Button */}
<button
onClick={() => {
if (onUpdate && item.uid !== undefined) {
@ -575,7 +520,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
<PencilIcon className="h-4 w-4" />
</button>
{/* Convert to Task Button */}
<button
onClick={handleConvertToTask}
className={`p-2 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
@ -584,7 +528,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
<ClipboardDocumentListIcon className="h-4 w-4" />
</button>
{/* Convert to Project Button */}
<button
onClick={handleConvertToProject}
className={`p-2 text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
@ -593,7 +536,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
<FolderIcon className="h-4 w-4" />
</button>
{/* Convert to Note Button */}
<button
onClick={handleConvertToNote}
className={`p-2 text-purple-600 dark:text-purple-400 hover:bg-purple-100 dark:hover:bg-purple-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
@ -602,7 +544,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
<DocumentTextIcon className="h-4 w-4" />
</button>
{/* Delete Button */}
<button
onClick={handleDelete}
className={`p-2 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
@ -612,7 +553,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
</button>
</div>
{/* Mobile 3-dot dropdown menu */}
<div className="flex md:hidden items-center justify-end w-1/5 relative">
{loading && <div className="spinner mr-2" />}
<button
@ -623,7 +563,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
e.stopPropagation();
const newOpenState = !isDropdownOpen;
// Close other dropdowns when opening this one
if (newOpenState) {
document.dispatchEvent(
new CustomEvent('closeOtherDropdowns', {
@ -639,13 +578,11 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
<EllipsisVerticalIcon className="h-5 w-5" />
</button>
{/* Dropdown Menu - Positioned Relatively */}
{isDropdownOpen && (
<div
data-dropdown-id={dropdownId}
className="absolute right-0 top-full mt-1 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-[9999] transform-gpu"
style={{
// Prevent dropdown from being cut off at the bottom of viewport
transform:
buttonRef.current &&
buttonRef.current.getBoundingClientRect()
@ -658,7 +595,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
onClick={(e) => e.stopPropagation()}
>
<div className="py-1">
{/* Edit Button */}
<button
type="button"
onClick={(e) => {
@ -685,7 +621,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
{t('common.edit', 'Edit')}
</button>
{/* Convert to Task Button */}
<button
type="button"
onClick={(e) => {
@ -698,7 +633,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
{t('inbox.createTask', 'Create Task')}
</button>
{/* Convert to Project Button */}
<button
type="button"
onClick={(e) => {
@ -711,7 +645,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
{t('inbox.createProject', 'Create Project')}
</button>
{/* Convert to Note Button */}
<button
type="button"
onClick={(e) => {
@ -724,7 +657,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
{t('inbox.createNote', 'Create Note')}
</button>
{/* Delete Button */}
<button
type="button"
onClick={(e) => {

View file

@ -32,10 +32,8 @@ const InboxItems: React.FC = () => {
const { showSuccessToast, showErrorToast } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
// Track if we've done the initial load from URL
const [hasInitialized, setHasInitialized] = useState(false);
// Access store data
const { inboxItems, isLoading, pagination } = useStore(
(state) => state.inboxStore
);
@ -50,30 +48,24 @@ const InboxItems: React.FC = () => {
isLoading: tagsLoading,
} = useStore((state) => state.tagsStore);
// Local projects state (keep main's approach)
const [projects, setProjects] = useState<Project[]>([]);
// Modal states
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isInfoExpanded, setIsInfoExpanded] = useState(false);
// Data for modals
const [taskToEdit, setTaskToEdit] = useState<Task | null>(null);
const [projectToEdit, setProjectToEdit] = useState<Project | null>(null);
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
// Track the current inbox item UID being converted (for task/project/note conversion)
const [currentConversionItemUid, setCurrentConversionItemUid] = useState<
string | null
>(null);
// Track the current inbox item being edited
const [itemToEdit, setItemToEdit] = useState<string | null>(null);
// Create stable default task object to prevent infinite re-renders
const defaultTask = useMemo(
() => ({
name: '',
@ -85,15 +77,12 @@ const InboxItems: React.FC = () => {
);
useEffect(() => {
// Read the page size from URL parameter or use default
const urlPageSize = searchParams.get('loaded');
const currentLoadedCount = urlPageSize ? parseInt(urlPageSize, 10) : 20;
// Initial data loading - load the amount specified in URL
loadInboxItemsToStore(true, currentLoadedCount);
setHasInitialized(true);
// Load projects initially
const loadInitialProjects = async () => {
try {
const projectData = await fetchProjects();
@ -105,7 +94,6 @@ const InboxItems: React.FC = () => {
};
loadInitialProjects();
// Load areas initially
const loadInitialAreas = async () => {
try {
const areasData = await fetchAreas();
@ -117,7 +105,6 @@ const InboxItems: React.FC = () => {
};
loadInitialAreas();
// Load tags initially
const loadInitialTags = async () => {
if (!tagsHasLoaded && !tagsLoading) {
try {
@ -128,23 +115,18 @@ const InboxItems: React.FC = () => {
}
};
loadInitialTags();
// Set up an event listener for force reload
const handleForceReload = () => {
// Wait a short time to ensure the backend has processed the new item
setTimeout(() => {
const currentInboxStore = useStore.getState().inboxStore;
const currentCount = currentInboxStore.inboxItems.length;
loadInboxItemsToStore(false, currentCount); // Preserve current loaded count
loadInboxItemsToStore(false, currentCount);
}, 500);
};
// Handler for the inboxItemsUpdated custom event
const handleInboxItemsUpdated = (
event: CustomEvent<{ count: number; firstItemContent: string }>
) => {
// Show toast notifications for new items
if (event.detail.count > 0) {
// Show notification for the first new item
showSuccessToast(
t(
'inbox.newTelegramItem',
@ -155,7 +137,6 @@ const InboxItems: React.FC = () => {
)
);
// If multiple new items, show a summary notification as well
if (event.detail.count > 1) {
showSuccessToast(
t(
@ -170,16 +151,12 @@ const InboxItems: React.FC = () => {
}
};
// Set up polling for new inbox items (especially from Telegram)
// This ensures real-time updates when items are added externally
// Use a reasonable interval that balances responsiveness with performance
const pollInterval = setInterval(() => {
const currentInboxStore = useStore.getState().inboxStore;
const currentCount = currentInboxStore.inboxItems.length;
loadInboxItemsToStore(false, currentCount); // Preserve current loaded count
}, 15000); // Check for new items every 15 seconds
loadInboxItemsToStore(false, currentCount);
}, 15000);
// Add event listeners
window.addEventListener('forceInboxReload', handleForceReload);
window.addEventListener(
'inboxItemsUpdated',
@ -194,27 +171,23 @@ const InboxItems: React.FC = () => {
handleInboxItemsUpdated as EventListener
);
};
}, [t, showSuccessToast]); // Include dependencies that are actually used
}, [t, showSuccessToast]);
// Update URL when inboxItems count changes (but only after initialization)
useEffect(() => {
// Don't update URL until we've done the initial load from URL
if (!hasInitialized) return;
const urlPageSize = searchParams.get('loaded');
const urlLoadedCount = urlPageSize ? parseInt(urlPageSize, 10) : 0;
// Only update URL if the count has actually changed from what's in the URL
if (inboxItems.length > 20 && inboxItems.length !== urlLoadedCount) {
setSearchParams(
{ loaded: inboxItems.length.toString() },
{ replace: true }
);
} else if (inboxItems.length <= 20 && urlLoadedCount > 0) {
// Remove the parameter if we're at the default page size
setSearchParams({}, { replace: true });
}
}, [inboxItems.length, hasInitialized]); // Track hasInitialized to prevent premature URL updates
}, [inboxItems.length, hasInitialized]);
const handleProcessItem = async (
uid: string,
@ -232,7 +205,6 @@ const InboxItems: React.FC = () => {
};
const handleUpdateItem = async (uid: string): Promise<void> => {
// When edit button is clicked, we open the InboxModal instead of doing inline editing
setItemToEdit(uid);
setIsEditModalOpen(true);
};
@ -261,20 +233,17 @@ const InboxItems: React.FC = () => {
}
};
// Modal handlers
const handleOpenTaskModal = async (task: Task, inboxItemUid?: string) => {
try {
// Load projects first before opening the modal
try {
const projectData = await fetchProjects();
// Make sure we always set an array
setProjects(Array.isArray(projectData) ? projectData : []);
} catch (error) {
console.error('Failed to load projects:', error);
showErrorToast(
t('project.loadError', 'Failed to load projects')
);
setProjects([]); // Ensure we have an empty array even on error
setProjects([]);
}
setTaskToEdit(task);
@ -294,14 +263,13 @@ const InboxItems: React.FC = () => {
inboxItemUid?: string
) => {
try {
// Load areas first before opening the modal (similar to task modal)
try {
const areasData = await fetchAreas();
setAreas(areasData);
} catch (error) {
console.error('Failed to load areas:', error);
showErrorToast(t('area.loadError', 'Failed to load areas'));
setAreas([]); // Ensure we have an empty array even on error
setAreas([]);
}
setProjectToEdit(project);
@ -320,7 +288,6 @@ const InboxItems: React.FC = () => {
note: Note | null,
inboxItemUid?: string
) => {
// Set up the note data first
if (note && note.content && isUrl(note.content.trim())) {
if (!note.tags) {
note.tags = [{ name: 'bookmark' }];
@ -335,8 +302,6 @@ const InboxItems: React.FC = () => {
setCurrentConversionItemUid(inboxItemUid);
}
// Projects are already available from the store
setIsNoteModalOpen(true);
};
@ -357,7 +322,6 @@ const InboxItems: React.FC = () => {
);
showSuccessToast(taskLink);
// Process the inbox item after successful task creation
if (currentConversionItemUid !== null) {
await handleProcessItem(currentConversionItemUid, false);
setCurrentConversionItemUid(null);
@ -375,13 +339,10 @@ const InboxItems: React.FC = () => {
await createProject(project);
showSuccessToast(t('project.createSuccess'));
// Process the inbox item after successful project creation
if (currentConversionItemUid !== null) {
await handleProcessItem(currentConversionItemUid, false);
setCurrentConversionItemUid(null);
}
// Don't set isProjectModalOpen here - the modal handles its own closing via handleClose()
} catch (error) {
console.error('Failed to create project:', error);
showErrorToast(t('project.createError'));
@ -390,31 +351,25 @@ const InboxItems: React.FC = () => {
const handleSaveNote = async (note: Note) => {
try {
// Check if the content appears to be a URL and add the bookmark tag
const noteContent = note.content || '';
const isBookmarkContent = isUrl(noteContent.trim());
// Ensure tags property exists
if (!note.tags) {
note.tags = [];
}
// Add a bookmark tag if content is a URL and doesn't already have the tag
if (
isBookmarkContent &&
!note.tags.some((tag) => tag.name === 'bookmark')
) {
// Use spread operator to create a new array with the bookmark tag added
note.tags = [...note.tags, { name: 'bookmark' }];
}
// Create the note with proper tags
await createNote(note);
showSuccessToast(
t('note.createSuccess', 'Note created successfully')
);
// Process the inbox item after successful note creation
if (currentConversionItemUid !== null) {
await handleProcessItem(currentConversionItemUid, false);
setCurrentConversionItemUid(null);
@ -457,7 +412,6 @@ const InboxItems: React.FC = () => {
return (
<div className="w-full px-2 sm:px-4 lg:px-6 pt-4 pb-8">
<div className="w-full max-w-5xl mx-auto">
{/* Title row with info button on the right */}
<div className="flex items-center mb-8 justify-between">
<div className="flex items-center">
<h1 className="text-2xl font-light">
@ -482,7 +436,6 @@ const InboxItems: React.FC = () => {
</button>
</div>
{/* Info section below title row */}
<div
className={`transition-all duration-300 ease-in-out ${
isInfoExpanded
@ -491,7 +444,6 @@ const InboxItems: React.FC = () => {
} overflow-hidden`}
>
<div className="bg-blue-50/50 dark:bg-blue-900/10 border border-blue-100 dark:border-blue-800/30 rounded-lg px-6 py-5 flex items-start gap-4">
{/* Large low-opacity info icon */}
<div className="flex-shrink-0">
<InformationCircleIcon className="h-12 w-12 text-blue-400 opacity-20" />
</div>
@ -525,7 +477,6 @@ const InboxItems: React.FC = () => {
<InboxItemDetail
key={item.uid || item.id}
item={item}
onProcess={handleProcessItem}
onDelete={handleDeleteItem}
onUpdate={handleUpdateItem}
openTaskModal={handleOpenTaskModal}
@ -536,7 +487,6 @@ const InboxItems: React.FC = () => {
))}
</div>
{/* Load more button */}
{pagination.hasMore && (
<div className="flex justify-center pt-4">
<button
@ -581,7 +531,6 @@ const InboxItems: React.FC = () => {
</div>
)}
{/* Pagination info */}
{inboxItems.length > 0 && (
<div className="text-center text-sm text-gray-500 dark:text-gray-400 pt-2">
{t(
@ -597,8 +546,6 @@ const InboxItems: React.FC = () => {
</div>
)}
{/* 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 && (
<InboxModal
isOpen={isEditModalOpen}

File diff suppressed because it is too large Load diff

View file

@ -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<InboxSelectedChipsProps> = ({
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 (
<span
key={`${tagName}-${index}`}
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-50 dark:bg-blue-900/20 rounded text-blue-600 dark:text-blue-400"
>
<Link
to={`/tag/${encodeURIComponent(tag.name)}`}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{tagName}
</Link>
<button
onClick={() => onRemoveTag(tagName)}
className="h-3 w-3 text-blue-400 hover:text-red-500 transition-colors"
title="Remove tag"
>
<XMarkIcon className="h-3 w-3" />
</button>
</span>
);
}
return (
<span
key={`${tagName}-${index}`}
className="inline-flex items-center gap-1 px-2 py-1 bg-orange-50 dark:bg-orange-900/20 rounded text-orange-500 dark:text-orange-400"
>
{tagName}
<button
onClick={() => onRemoveTag(tagName)}
className="h-3 w-3 text-orange-400 hover:text-red-500 transition-colors"
title="Remove tag"
>
<XMarkIcon className="h-3 w-3" />
</button>
</span>
);
};
const renderProjectChip = (projectName: string, index: number) => {
const project = projects.find(
(p) => p.name.toLowerCase() === projectName.toLowerCase()
);
if (project) {
return (
<span
key={`${projectName}-${index}`}
className="inline-flex items-center gap-1 px-2 py-1 bg-green-50 dark:bg-green-900/20 rounded text-green-600 dark:text-green-400"
>
<Link
to={`/projects?project=${encodeURIComponent(project.name)}`}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{projectName}
</Link>
<button
onClick={() => onRemoveProject(projectName)}
className="h-3 w-3 text-green-400 hover:text-red-500 transition-colors"
title="Remove project"
>
<XMarkIcon className="h-3 w-3" />
</button>
</span>
);
}
return (
<span
key={`${projectName}-${index}`}
className="inline-flex items-center gap-1 px-2 py-1 bg-orange-50 dark:bg-orange-900/20 rounded text-orange-500 dark:text-orange-400"
>
{projectName}
<button
onClick={() => onRemoveProject(projectName)}
className="h-3 w-3 text-orange-400 hover:text-red-500 transition-colors"
title="Remove project"
>
<XMarkIcon className="h-3 w-3" />
</button>
</span>
);
};
return (
<>
{selectedTags.length > 0 && (
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 mt-1 flex-wrap gap-1">
<TagIcon className="h-3 w-3 mr-1" />
<div className="flex flex-wrap gap-1">
{selectedTags.map((tagName, index) =>
renderTagChip(tagName, index)
)}
</div>
</div>
)}
{selectedProjects.length > 0 && (
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 mt-1 flex-wrap gap-1">
<FolderIcon className="h-3 w-3 mr-1" />
<div className="flex flex-wrap gap-1">
{selectedProjects.map((projectName, index) =>
renderProjectChip(projectName, index)
)}
</div>
</div>
)}
</>
);
};
export default InboxSelectedChips;

View file

@ -0,0 +1,49 @@
import React from 'react';
interface SuggestionsDropdownProps<T> {
isVisible: boolean;
items: T[];
position: { left: number; top: number };
selectedIndex: number;
onSelect: (item: T) => void;
renderLabel: (item: T) => React.ReactNode;
}
const SuggestionsDropdown = <T,>({
isVisible,
items,
position,
selectedIndex,
onSelect,
renderLabel,
}: SuggestionsDropdownProps<T>) => {
if (!isVisible || items.length === 0) return null;
return (
<div
className="absolute bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg z-50"
style={{
left: `${position.left}px`,
top: `${position.top + 4}px`,
minWidth: '120px',
maxWidth: '200px',
}}
>
{items.map((item, index) => (
<button
key={index}
onClick={() => onSelect(item)}
className={`w-full text-left px-3 py-2 text-sm text-gray-900 dark:text-gray-100 first:rounded-t-md last:rounded-b-md ${
selectedIndex === index
? 'bg-blue-100 dark:bg-blue-800'
: 'hover:bg-gray-100 dark:hover:bg-gray-600'
}`}
>
{renderLabel(item)}
</button>
))}
</div>
);
};
export default SuggestionsDropdown;

View file

@ -524,7 +524,7 @@ const Notes: React.FC = () => {
<div
key={note.uid}
onClick={() => 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'
}`}
>
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{previewNote?.uid === note.uid && (
<span className="absolute inset-y-0 left-0 w-1 bg-blue-400 dark:bg-blue-500 rounded-l-md pointer-events-none" />
)}
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100 truncate mb-1">
{note.title ||
t(
'notes.untitled',
@ -569,7 +572,7 @@ const Notes: React.FC = () => {
{isEditing && editingNote ? (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Editor Header - matches preview structure */}
<div className="flex items-start justify-between mb-3 flex-shrink-0 px-6 md:px-8 pt-4">
<div className="flex items-start justify-between mb-3 flex-shrink-0 px-6 md:px-8 pt-5">
<div className="flex-1">
{/* Back button for mobile */}
<button
@ -595,7 +598,7 @@ const Notes: React.FC = () => {
}
onClick={(e) => e.stopPropagation()}
placeholder="Note title..."
className="w-full text-xl md:text-2xl font-bold bg-transparent text-gray-900 dark:text-gray-100 border-none focus:outline-none focus:ring-0 p-0 mb-1"
className="w-full bg-transparent text-gray-900 dark:text-gray-100 border-none focus:outline-none focus:ring-0 pt-5 mb-4 block"
style={{
color: editingNoteColor
? shouldUseLightText(
@ -604,6 +607,11 @@ const Notes: React.FC = () => {
? '#ffffff'
: '#333333'
: undefined,
fontSize: '2rem',
lineHeight: '2rem',
fontWeight: 500,
paddingLeft: 0,
paddingRight: 0,
}}
autoFocus
/>
@ -904,7 +912,7 @@ const Notes: React.FC = () => {
) : previewNote ? (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Preview Header */}
<div className="flex items-start justify-between mb-3 flex-shrink-0 px-6 md:px-8 pt-6">
<div className="flex items-start justify-between mb-3 flex-shrink-0 px-6 md:px-8 pt-5">
<div className="flex-1">
{/* Back button for mobile */}
<button
@ -919,7 +927,7 @@ const Notes: React.FC = () => {
onClick={() =>
handleEditNote(previewNote)
}
className="text-xl md:text-2xl font-bold mb-1 cursor-pointer text-gray-900 dark:text-gray-100 transition-colors"
className="cursor-pointer text-gray-900 dark:text-gray-100 transition-colors pt-5 mb-4"
style={{
color: previewNoteColor
? shouldUseLightText(
@ -928,6 +936,9 @@ const Notes: React.FC = () => {
? '#ffffff'
: '#333333'
: undefined,
fontSize: '2rem',
lineHeight: '2rem',
fontWeight: 500,
}}
title="Click to edit"
>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,198 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
BoltIcon,
ChevronRightIcon,
ExclamationTriangleIcon,
FaceSmileIcon,
InformationCircleIcon,
LightBulbIcon,
} from '@heroicons/react/24/outline';
import type { ProfileFormData } from '../types';
interface AiTabProps {
isActive: boolean;
formData: ProfileFormData;
onToggle: (
field: keyof Pick<
ProfileFormData,
| 'task_intelligence_enabled'
| 'auto_suggest_next_actions_enabled'
| 'productivity_assistant_enabled'
| 'next_task_suggestion_enabled'
>
) => void;
}
interface ToggleRowProps {
label: string;
icon: React.ReactNode;
value: boolean;
onToggle: () => void;
}
const ToggleRow: React.FC<ToggleRowProps> = ({
label,
icon,
value,
onToggle,
}) => (
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center">
<span className="mr-2">{icon}</span>
{label}
</label>
<div
className={`relative inline-block w-12 h-6 transition-colors duration-200 ease-in-out rounded-full cursor-pointer ${
value ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
onClick={onToggle}
>
<span
className={`absolute left-0 top-0 bottom-0 m-1 w-4 h-4 transition-transform duration-200 ease-in-out transform bg-white rounded-full ${
value ? 'translate-x-6' : 'translate-x-0'
}`}
></span>
</div>
</div>
);
const AiTab: React.FC<AiTabProps> = ({ isActive, formData, onToggle }) => {
const { t } = useTranslation();
if (!isActive) return null;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
<LightBulbIcon className="w-6 h-6 mr-3 text-blue-500" />
{t(
'profile.aiProductivityFeatures',
'AI & Productivity Features'
)}
</h3>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
<BoltIcon className="w-5 h-5 mr-2 text-purple-500" />
{t('profile.taskIntelligence', 'Task Intelligence')}
</h4>
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300 flex items-start">
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
<p>
{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.'
)}
</p>
</div>
<ToggleRow
icon={<BoltIcon className="w-5 h-5 text-purple-500" />}
label={t(
'profile.enableTaskIntelligence',
'Enable Task Intelligence Assistant'
)}
value={Boolean(formData.task_intelligence_enabled)}
onToggle={() => onToggle('task_intelligence_enabled')}
/>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg mt-4">
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
<ChevronRightIcon className="w-5 h-5 mr-2 text-green-500" />
{t(
'profile.autoSuggestNextActions',
'Auto-Suggest Next Actions'
)}
</h4>
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300 flex items-start">
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
<p>
{t(
'profile.autoSuggestNextActionsDescription',
'When creating a project, automatically prompt for the very next physical action to take.'
)}
</p>
</div>
<ToggleRow
icon={
<ChevronRightIcon className="w-5 h-5 text-green-500" />
}
label={t(
'profile.enableAutoSuggestNextActions',
'Enable Next Action Prompts'
)}
value={Boolean(formData.auto_suggest_next_actions_enabled)}
onToggle={() =>
onToggle('auto_suggest_next_actions_enabled')
}
/>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg mt-4">
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
<ExclamationTriangleIcon className="w-5 h-5 mr-2 text-yellow-500" />
{t(
'profile.productivityAssistant',
'Productivity Assistant'
)}
</h4>
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300 flex items-start">
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
<p>
{t(
'profile.productivityAssistantDescription',
'Show productivity insights that help identify stalled projects, vague tasks, and workflow improvements on your Today page.'
)}
</p>
</div>
<ToggleRow
icon={
<ExclamationTriangleIcon className="w-5 h-5 text-yellow-500" />
}
label={t(
'profile.enableProductivityAssistant',
'Enable Productivity Insights'
)}
value={Boolean(formData.productivity_assistant_enabled)}
onToggle={() => onToggle('productivity_assistant_enabled')}
/>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg mt-4">
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
<FaceSmileIcon className="w-5 h-5 mr-2 text-green-500" />
{t('profile.nextTaskSuggestion', 'Next Task Suggestion')}
</h4>
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300 flex items-start">
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
<p>
{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.'
)}
</p>
</div>
<ToggleRow
icon={<FaceSmileIcon className="w-5 h-5 text-green-500" />}
label={t(
'profile.enableNextTaskSuggestion',
'Enable Next Task Suggestions'
)}
value={Boolean(formData.next_task_suggestion_enabled)}
onToggle={() => onToggle('next_task_suggestion_enabled')}
/>
</div>
</div>
);
};
export default AiTab;

View file

@ -0,0 +1,340 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
ClipboardDocumentListIcon,
KeyIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import type { ApiKeySummary } from '../../../utils/apiKeysService';
interface ApiKeysTabProps {
isActive: boolean;
apiKeys: ApiKeySummary[];
apiKeysLoading: boolean;
generatedApiToken: string | null;
newApiKeyName: string;
newApiKeyExpiration: string;
revokeInFlightId: number | null;
deleteInFlightId: number | null;
pendingDeleteId: number | null;
onCreateApiKey: () => void;
onCopyGeneratedToken: () => void;
onRevokeApiKey: (apiKeyId: number) => void;
onRequestDelete: (apiKey: ApiKeySummary) => void;
onUpdateNewName: (value: string) => void;
onUpdateNewExpiration: (value: string) => void;
getApiKeyStatus: (apiKey: ApiKeySummary) => {
label: string;
className: string;
};
formatDateTime: (value: string | null) => string;
isCreatingApiKey: boolean;
}
const ApiKeysTab: React.FC<ApiKeysTabProps> = ({
isActive,
apiKeys,
apiKeysLoading,
generatedApiToken,
newApiKeyName,
newApiKeyExpiration,
revokeInFlightId,
deleteInFlightId,
pendingDeleteId,
onCreateApiKey,
onCopyGeneratedToken,
onRevokeApiKey,
onRequestDelete,
onUpdateNewName,
onUpdateNewExpiration,
getApiKeyStatus,
formatDateTime,
isCreatingApiKey,
}) => {
const { t } = useTranslation();
if (!isActive) return null;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
<KeyIcon className="w-6 h-6 mr-3 text-indigo-500" />
{t('profile.apiKeys.title', 'API Keys')}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-6">
{t(
'profile.apiKeys.description',
'Generate personal access tokens for integrations or CLI usage. You can revoke or delete keys at any time.'
)}
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('profile.apiKeys.nameLabel', 'Key name')}
</label>
<input
type="text"
value={newApiKeyName}
onChange={(event) =>
onUpdateNewName(event.target.value)
}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
onCreateApiKey();
}
}}
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'
)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t(
'profile.apiKeys.expirationLabel',
'Expires on (optional)'
)}
</label>
<input
type="date"
value={newApiKeyExpiration}
onChange={(event) =>
onUpdateNewExpiration(event.target.value)
}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
onCreateApiKey();
}
}}
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"
/>
</div>
<div className="flex items-end">
<button
type="button"
disabled={isCreatingApiKey}
onClick={onCreateApiKey}
className={`w-full inline-flex justify-center items-center px-4 py-2 rounded-md text-white ${
isCreatingApiKey
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-500'
}`}
>
{isCreatingApiKey
? t('common.saving', 'Saving...')
: t(
'profile.apiKeys.generateButton',
'Generate key'
)}
</button>
</div>
</div>
{generatedApiToken && (
<div className="mt-4 p-4 bg-green-50 dark:bg-green-900/40 border border-green-200 dark:border-green-700 rounded-md">
<p className="text-sm text-green-900 dark:text-green-100 mb-2">
{t(
'profile.apiKeys.copyNotice',
'Copy this token now. It will not be shown again.'
)}
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<code className="flex-1 bg-white dark:bg-gray-900 rounded px-3 py-2 text-sm font-mono text-gray-800 dark:text-gray-100 overflow-x-auto">
{generatedApiToken}
</code>
<button
type="button"
onClick={onCopyGeneratedToken}
className="inline-flex items-center justify-center px-4 py-2 rounded-md bg-green-600 text-white hover:bg-green-500"
>
<ClipboardDocumentListIcon className="w-5 h-5 mr-2" />
{t('profile.apiKeys.copyButton', 'Copy key')}
</button>
</div>
</div>
)}
<div className="mt-6">
{apiKeysLoading && (
<p className="text-sm text-gray-600 dark:text-gray-300">
{t('profile.apiKeys.loading', 'Loading API keys...')}
</p>
)}
{!apiKeysLoading && apiKeys.length === 0 && (
<p className="text-sm text-gray-600 dark:text-gray-300">
{t(
'profile.apiKeys.empty',
'No API keys yet. Generate one to begin.'
)}
</p>
)}
{!apiKeysLoading && apiKeys.length > 0 && (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-600">
<thead className="bg-gray-100 dark:bg-gray-800">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t(
'profile.apiKeys.table.name',
'Name'
)}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t(
'profile.apiKeys.table.prefix',
'Prefix'
)}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t(
'profile.apiKeys.table.status',
'Status'
)}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t(
'profile.apiKeys.table.lastUsed',
'Last used'
)}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t(
'profile.apiKeys.table.expires',
'Expires'
)}
</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t(
'profile.apiKeys.table.actions',
'Actions'
)}
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{apiKeys.map((key) => {
const status = getApiKeyStatus(key);
return (
<tr key={key.id}>
<td className="px-4 py-3">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{key.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{t(
'profile.apiKeys.createdAt',
'Created {{date}}',
{
date: formatDateTime(
key.created_at
),
}
)}
</div>
</td>
<td className="px-4 py-3 text-sm font-mono text-gray-800 dark:text-gray-200">
{key.token_prefix}...
</td>
<td className="px-4 py-3 text-sm">
<span
className={status.className}
>
{status.label}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-200">
{formatDateTime(
key.last_used_at
)}
</td>
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-200">
{key.expires_at
? formatDateTime(
key.expires_at
)
: t(
'profile.apiKeys.noExpiry',
'None'
)}
</td>
<td className="px-4 py-3 text-right text-sm">
<div className="flex justify-end space-x-2">
<button
type="button"
onClick={() =>
onRevokeApiKey(
key.id
)
}
disabled={
Boolean(
key.revoked_at
) ||
revokeInFlightId ===
key.id
}
className={`inline-flex items-center px-3 py-1.5 rounded-md border text-xs font-medium ${
key.revoked_at
? 'border-gray-400 text-gray-400 cursor-not-allowed'
: 'border-yellow-600 text-yellow-700 hover:bg-yellow-50'
}`}
>
{key.revoked_at
? t(
'profile.apiKeys.revokedLabel',
'Revoked'
)
: t(
'profile.apiKeys.revokeButton',
'Revoke'
)}
</button>
<button
type="button"
onClick={() =>
onRequestDelete(key)
}
disabled={
deleteInFlightId ===
key.id ||
Boolean(
pendingDeleteId ===
key.id
)
}
className={`inline-flex items-center justify-center px-3 py-1.5 rounded-md border text-xs font-medium ${
deleteInFlightId ===
key.id
? 'border-gray-400 text-gray-400 cursor-not-allowed'
: 'border-red-600 text-red-700 hover:bg-red-50'
}`}
aria-label={t(
'profile.apiKeys.deleteAria',
'Delete API key'
)}
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
};
export default ApiKeysTab;

View file

@ -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<HTMLInputElement | HTMLSelectElement>) => 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<typeof getTimezonesByRegion>;
getRegionDisplayName: typeof getRegionDisplayName;
}
const GeneralTab: React.FC<GeneralTabProps> = ({
isActive,
formData,
onChange,
onAppearanceChange,
onLanguageChange,
onTimezoneChange,
onFirstDayChange,
avatarPreview,
onAvatarSelect,
onAvatarRemove,
timezonesByRegion,
getRegionDisplayName,
}) => {
const { t } = useTranslation();
if (!isActive) return null;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
<UserIcon className="w-6 h-6 mr-3 text-blue-500" />
{t('profile.accountSettings', 'Account & Preferences')}
</h3>
<div className="mb-8 flex flex-col items-center">
<div className="relative">
{avatarPreview || formData.avatar_image ? (
<img
src={
avatarPreview ||
getApiPath(formData.avatar_image || '')
}
alt="Avatar"
className="w-32 h-32 rounded-full object-cover border-4 border-blue-500"
/>
) : (
<div className="w-32 h-32 rounded-full border-4 border-gray-300 dark:border-gray-600 bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<UserCircleIcon className="w-20 h-20 text-gray-400 dark:text-gray-500" />
</div>
)}
<label
htmlFor="avatar-upload"
className="absolute bottom-0 right-0 bg-blue-500 hover:bg-blue-600 text-white rounded-full p-2 cursor-pointer transition-colors"
>
<PhotoIcon className="w-5 h-5" />
<input
id="avatar-upload"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
onAvatarSelect(file);
}
}}
/>
</label>
</div>
{(formData.avatar_image || avatarPreview) && (
<button
type="button"
onClick={onAvatarRemove}
className="mt-3 text-sm text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
>
{t('profile.removeAvatar', 'Remove Avatar')}
</button>
)}
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
{t(
'profile.avatarDescription',
'Upload a profile photo (max 5MB)'
)}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('profile.name', 'Name')}
</label>
<input
type="text"
name="name"
value={formData.name || ''}
onChange={onChange}
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-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder={t('profile.enterName', 'Enter your name')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('profile.surname', 'Surname')}
</label>
<input
type="text"
name="surname"
value={formData.surname || ''}
onChange={onChange}
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-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder={t(
'profile.enterSurname',
'Enter your surname'
)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('profile.appearance')}
</label>
<div className="flex rounded-md border border-gray-300 dark:border-gray-600 overflow-hidden">
<button
type="button"
onClick={() => onAppearanceChange('light')}
className={`flex-1 flex items-center justify-center px-4 py-2 text-sm font-medium transition-colors ${
formData.appearance === 'light'
? 'bg-blue-500 text-white'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
}`}
>
<SunIcon className="h-4 w-4 mr-2" />
{t('profile.lightMode', 'Light')}
</button>
<button
type="button"
onClick={() => onAppearanceChange('dark')}
className={`flex-1 flex items-center justify-center px-4 py-2 text-sm font-medium transition-colors ${
formData.appearance === 'dark'
? 'bg-blue-500 text-white'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
}`}
>
<MoonIcon className="h-4 w-4 mr-2" />
{t('profile.darkMode', 'Dark')}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('profile.language')}
</label>
<LanguageDropdown
value={formData.language || 'en'}
onChange={onLanguageChange}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('profile.timezone')}
</label>
<TimezoneDropdown
value={formData.timezone || 'UTC'}
onChange={onTimezoneChange}
timezonesByRegion={timezonesByRegion}
getRegionDisplayName={getRegionDisplayName}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('profile.firstDayOfWeek', 'First day of week')}
</label>
<FirstDayOfWeekDropdown
value={formData.first_day_of_week || 1}
onChange={onFirstDayChange}
/>
</div>
</div>
</div>
);
};
export default GeneralTab;

View file

@ -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<ProductivityTabProps> = ({
isActive,
pomodoroEnabled,
onTogglePomodoro,
}) => {
const { t } = useTranslation();
if (!isActive) return null;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
<ClockIcon className="w-6 h-6 mr-3 text-green-500" />
{t('profile.productivityFeatures', 'Productivity Features')}
</h3>
<div className="space-y-6">
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t(
'profile.enablePomodoro',
'Enable Pomodoro Timer'
)}
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t(
'profile.pomodoroDescription',
'Enable the Pomodoro timer in the navigation bar for focused work sessions.'
)}
</p>
</div>
<div
className={`relative inline-block w-12 h-6 transition-colors duration-200 ease-in-out rounded-full cursor-pointer ${
pomodoroEnabled
? 'bg-blue-500'
: 'bg-gray-300 dark:bg-gray-600'
}`}
onClick={onTogglePomodoro}
>
<span
className={`absolute left-0 top-0 bottom-0 m-1 w-4 h-4 transition-transform duration-200 ease-in-out transform bg-white rounded-full ${
pomodoroEnabled
? 'translate-x-6'
: 'translate-x-0'
}`}
></span>
</div>
</div>
</div>
</div>
);
};
export default ProductivityTab;

View file

@ -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<HTMLInputElement | HTMLSelectElement>) => void;
onToggleCurrentPassword: () => void;
onToggleNewPassword: () => void;
onToggleConfirmPassword: () => void;
}
const SecurityTab: React.FC<SecurityTabProps> = ({
isActive,
formData,
showCurrentPassword,
showNewPassword,
showConfirmPassword,
onChange,
onToggleCurrentPassword,
onToggleNewPassword,
onToggleConfirmPassword,
}) => {
const { t } = useTranslation();
if (!isActive) return null;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
<ShieldCheckIcon className="w-6 h-6 mr-3 text-red-500" />
{t('profile.security', 'Security Settings')}
</h3>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
<UserIcon className="w-5 h-5 mr-2 text-blue-500" />
{t('profile.changePassword', 'Change Password')}
</h4>
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 rounded text-blue-800 dark:text-blue-200">
<p className="text-sm">
<InformationCircleIcon className="w-4 h-4 inline mr-1" />
{t(
'profile.passwordChangeOptional',
'Leave password fields empty to update other settings without changing your password.'
)}
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('profile.currentPassword', 'Current Password')}
</label>
<div className="relative">
<input
type={showCurrentPassword ? 'text' : 'password'}
name="currentPassword"
value={formData.currentPassword || ''}
onChange={onChange}
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 pr-10 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder={t(
'profile.enterCurrentPassword',
'Enter your current password'
)}
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={onToggleCurrentPassword}
>
{showCurrentPassword ? (
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
) : (
<EyeIcon className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('profile.newPassword', 'New Password')}
</label>
<div className="relative">
<input
type={showNewPassword ? 'text' : 'password'}
name="newPassword"
value={formData.newPassword || ''}
onChange={onChange}
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 pr-10 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder={t(
'profile.enterNewPassword',
'Enter your new password'
)}
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={onToggleNewPassword}
>
{showNewPassword ? (
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
) : (
<EyeIcon className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t(
'profile.confirmPassword',
'Confirm New Password'
)}
</label>
<div className="relative">
<input
type={showConfirmPassword ? 'text' : 'password'}
name="confirmPassword"
value={formData.confirmPassword || ''}
onChange={onChange}
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 pr-10 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder={t(
'profile.confirmNewPassword',
'Confirm your new password'
)}
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={onToggleConfirmPassword}
>
{showConfirmPassword ? (
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
) : (
<EyeIcon className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{t(
'profile.passwordChangeNote',
'Password changes will be saved when you click "Save Changes" at the bottom of the form.'
)}
</div>
</div>
</div>
</div>
);
};
export default SecurityTab;

View file

@ -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<TabsNavProps> = ({ tabs, activeTab, onChange }) => (
<div className="mb-8">
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-2 sm:space-x-8 overflow-x-auto scrollbar-hide">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={`group inline-flex items-center py-2 px-1 sm:px-2 border-b-2 font-medium text-sm whitespace-nowrap ${
activeTab === tab.id
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
<span className="mr-1 sm:mr-2">{tab.icon}</span>
{tab.name}
</button>
))}
</nav>
</div>
</div>
);
export type { TabConfig };
export default TabsNav;

View file

@ -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<HTMLInputElement | HTMLSelectElement>) => void;
onSetup: () => void;
onStartPolling: () => void;
onStopPolling: () => void;
onToggleSummary: () => void;
onSelectFrequency: (frequency: string) => void;
onSendTestSummary: () => void;
formatFrequency: (frequency: string) => string;
}
const TelegramTab: React.FC<TelegramTabProps> = ({
isActive,
formData,
profile,
telegramBotInfo,
isPolling,
telegramSetupStatus,
onChange,
onSetup,
onStartPolling,
onStopPolling,
onToggleSummary,
onSelectFrequency,
onSendTestSummary,
formatFrequency,
}) => {
const { t } = useTranslation();
if (!isActive) return null;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-blue-300 dark:border-blue-700 mb-8">
<h3 className="text-xl font-semibold text-blue-700 dark:text-blue-300 mb-6 flex items-center">
<TelegramIcon className="w-6 h-6 mr-3 text-blue-500" />
{t('profile.telegramIntegration', 'Telegram Integration')}
</h3>
<div className="mb-8 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
<CogIcon className="w-5 h-5 mr-2 text-blue-500" />
{t('profile.botSetup', 'Bot Setup')}
</h4>
<div className="space-y-4">
<div className="text-sm text-gray-600 dark:text-gray-300 flex items-start">
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
<p>
{t(
'profile.telegramDescription',
'Connect your tududi account to a Telegram bot to add items to your inbox via Telegram messages.'
)}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{t(
'profile.telegramBotToken',
'Telegram Bot Token'
)}
</label>
<input
type="text"
name="telegram_bot_token"
value={formData.telegram_bot_token || ''}
onChange={onChange}
placeholder="123456789:ABCDefGhIJKlmNoPQRsTUVwxyZ"
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
{t(
'profile.telegramTokenDescription',
'Create a bot with @BotFather on Telegram and paste the token here.'
)}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{t('profile.telegramAllowedUsers', 'Allowed Users')}
</label>
<input
type="text"
name="telegram_allowed_users"
value={formData.telegram_allowed_users || ''}
onChange={onChange}
placeholder="@username1, 123456789, @username2"
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400 space-y-1">
<p>
{t(
'profile.telegramAllowedUsersDescription',
'Control who can send messages to your bot. Leave empty to allow all users.'
)}
</p>
<div className="space-y-1">
<p className="font-semibold text-gray-600 dark:text-gray-300">
{t('profile.examples', 'Examples:')}
</p>
<ul className="list-disc list-inside ml-2 space-y-0.5">
<li>
<span className="font-mono bg-gray-100 dark:bg-gray-800 px-1 rounded">
@alice, @bob
</span>
{' - '}
{t(
'profile.exampleUsernames',
'Allow specific usernames'
)}
</li>
<li>
<span className="font-mono bg-gray-100 dark:bg-gray-800 px-1 rounded">
123456789, 987654321
</span>
{' - '}
{t(
'profile.exampleUserIds',
'Allow specific user IDs'
)}
</li>
<li>
<span className="font-mono bg-gray-100 dark:bg-gray-800 px-1 rounded">
@alice, 123456789
</span>
{' - '}
{t(
'profile.exampleMixed',
'Mix usernames and user IDs'
)}
</li>
</ul>
</div>
</div>
</div>
{profile?.telegram_chat_id && (
<div className="p-2 bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-800 rounded text-green-800 dark:text-green-200">
<p className="text-sm">
{t(
'profile.telegramConnected',
'Your Telegram account is connected! Send messages to your bot to add items to your tududi inbox.'
)}
</p>
</div>
)}
{(telegramBotInfo || profile?.telegram_bot_token) && (
<div className="p-2 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 rounded text-blue-800 dark:text-blue-200">
<p className="font-medium mb-2">
{t(
'profile.botConfigured',
'Bot configured successfully!'
)}
</p>
<div className="text-sm space-y-1">
{telegramBotInfo?.first_name && (
<p>
<span className="font-semibold">
Bot Name:{' '}
</span>
{telegramBotInfo.first_name}
</p>
)}
{telegramBotInfo?.username && (
<p>
<span className="font-semibold">
{t(
'profile.botUsername',
'Bot Username:'
)}{' '}
</span>
@{telegramBotInfo.username}
</p>
)}
<div className="mt-2">
<p className="font-semibold mb-1">
{t(
'profile.pollingStatus',
'Polling Status:'
)}{' '}
</p>
<div className="flex items-center mb-2">
<div
className={`w-3 h-3 rounded-full mr-2 ${isPolling ? 'bg-green-500' : 'bg-red-500'}`}
></div>
<span>
{isPolling
? t('profile.pollingActive')
: t('profile.pollingInactive')}
</span>
</div>
<p className="text-xs mb-2">
{t(
'profile.pollingNote',
'Polling periodically checks for new messages from Telegram and adds them to your inbox.'
)}
</p>
<div className="flex flex-wrap gap-2 mt-2">
{isPolling ? (
<button
onClick={onStopPolling}
className="px-3 py-1 bg-red-600 text-white dark:bg-red-700 rounded text-sm hover:bg-red-700 dark:hover:bg-red-800"
>
{t(
'profile.stopPolling',
'Stop Polling'
)}
</button>
) : (
<button
onClick={onStartPolling}
className="px-3 py-1 bg-blue-600 text-white dark:bg-blue-700 rounded text-sm hover:bg-blue-700 dark:hover:bg-blue-800"
>
{t(
'profile.startPolling',
'Start Polling'
)}
</button>
)}
{telegramBotInfo?.chat_url && (
<a
href={telegramBotInfo.chat_url}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1 bg-green-600 text-white dark:bg-green-700 rounded text-sm hover:bg-green-700 dark:hover:bg-green-800"
>
{t(
'profile.openTelegram',
'Open in Telegram'
)}
</a>
)}
</div>
</div>
</div>
</div>
)}
<button
type="button"
onClick={onSetup}
disabled={
!formData.telegram_bot_token ||
telegramSetupStatus === 'loading'
}
className={`px-4 py-2 rounded-md ${
!formData.telegram_bot_token ||
telegramSetupStatus === 'loading'
? 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
}`}
>
{telegramSetupStatus === 'loading'
? t('profile.settingUp', 'Setting up...')
: t('profile.setupTelegram', 'Setup Telegram')}
</button>
{telegramSetupStatus === 'success' && (
<div className="mt-2 flex items-center text-green-600 dark:text-green-400">
<svg
className="w-5 h-5 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm font-medium">
{t(
'profile.botConfigured',
'Bot configured successfully!'
)}
</span>
</div>
)}
{telegramSetupStatus === 'error' && (
<div className="mt-2 flex items-center text-red-600 dark:text-red-400">
<svg
className="w-5 h-5 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm font-medium">
{t(
'profile.telegramSetupFailed',
'Setup failed. Please check your token.'
)}
</span>
</div>
)}
</div>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
<ClipboardDocumentListIcon className="w-5 h-5 mr-2 text-green-500" />
{t(
'profile.taskSummaryNotifications',
'Task Summary Notifications'
)}
</h4>
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300 flex items-start">
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
<p>
{t(
'profile.taskSummaryDescription',
'Receive regular summaries of your tasks via Telegram. This feature requires your Telegram integration to be set up.'
)}
</p>
</div>
<div className="mb-4 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t(
'profile.enableTaskSummary',
'Enable Task Summaries'
)}
</label>
<div
className={`relative inline-block w-12 h-6 transition-colors duration-200 ease-in-out rounded-full cursor-pointer ${
formData.task_summary_enabled
? 'bg-blue-500'
: 'bg-gray-300 dark:bg-gray-600'
}`}
onClick={onToggleSummary}
>
<span
className={`absolute left-0 top-0 bottom-0 m-1 w-4 h-4 transition-transform duration-200 ease-in-out transform bg-white rounded-full ${
formData.task_summary_enabled
? 'translate-x-6'
: 'translate-x-0'
}`}
></span>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('profile.summaryFrequency', 'Summary Frequency')}
</label>
<div className="flex flex-wrap gap-2">
{['1h', '2h', '4h', '8h', '12h', 'daily', 'weekly'].map(
(frequency) => (
<button
key={frequency}
type="button"
className={`px-3 py-1.5 text-sm rounded-full ${
formData.task_summary_frequency ===
frequency
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
onClick={() => onSelectFrequency(frequency)}
>
{t(
`profile.frequency.${frequency}`,
formatFrequency(frequency)
)}
</button>
)
)}
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
{t(
'profile.frequencyHelp',
'Choose how often you want to receive task summaries.'
)}
</p>
</div>
<div className="mt-4">
<button
type="button"
disabled={
!profile?.telegram_bot_token ||
!profile?.telegram_chat_id
}
className={`px-4 py-2 rounded-md ${
!profile?.telegram_bot_token ||
!profile?.telegram_chat_id
? 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
}`}
onClick={onSendTestSummary}
>
{t('profile.sendTestSummary', 'Send Test Summary')}
</button>
{(!profile?.telegram_bot_token ||
!profile?.telegram_chat_id) && (
<p className="mt-2 text-xs text-red-500">
{t(
'profile.telegramRequiredForSummaries',
'Telegram integration must be set up to use task summaries.'
)}
</p>
)}
</div>
</div>
</div>
);
};
export default TelegramTab;

View file

@ -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;
}
>;

View file

@ -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<HTMLButtonElement>;
}
const ProjectBanner: React.FC<ProjectBannerProps> = ({
project,
areas,
t,
getStateIcon,
onDeleteClick,
editButtonRef,
}) => {
const navigate = useNavigate();
return (
<div className="w-full">
<div className="mb-6 overflow-hidden relative group">
{project.image_url ? (
<img
src={project.image_url}
alt={project.name}
className="w-full h-64 object-cover"
/>
) : (
<div className="w-full h-64 bg-gradient-to-br from-blue-500 to-purple-600 dark:from-blue-600 dark:to-purple-700"></div>
)}
<div className="absolute inset-0 bg-black bg-opacity-40 flex items-center justify-center">
<div className="text-center px-4">
<h1 className="text-4xl md:text-5xl font-bold text-white drop-shadow-lg">
{project.name}
</h1>
{project.description && (
<p className="text-lg md:text-xl text-white/90 mt-2 font-light drop-shadow-md max-w-2xl mx-auto">
{project.description}
</p>
)}
</div>
</div>
<div className="absolute bottom-2 left-2 right-14 flex items-center flex-wrap gap-2">
{project.state && (
<BannerBadge>
{getStateIcon(project.state)}
<span className="text-xs text-white/90 font-medium">
{t(`projects.states.${project.state}`)}
</span>
</BannerBadge>
)}
{project.tags && project.tags.length > 0 && (
<BannerBadge>
<TagIcon className="h-3 w-3 text-white/70 flex-shrink-0 mt-0.5" />
<span className="text-xs text-white/90 font-medium">
{project.tags.map((tag, index) => (
<React.Fragment
key={tag.uid || tag.id || index}
>
<button
onClick={() => {
if (tag.uid) {
const slug = tag.name
.toLowerCase()
.replace(
/[^a-z0-9]+/g,
'-'
)
.replace(/^-|-$/g, '');
navigate(
`/tag/${tag.uid}-${slug}`
);
} else {
navigate(
`/tag/${encodeURIComponent(tag.name)}`
);
}
}}
className="hover:text-blue-200 transition-colors cursor-pointer"
>
{tag.name}
</button>
{index <
(project.tags?.length || 0) - 1 && (
<span className="text-white/60">
,{' '}
</span>
)}
</React.Fragment>
))}
</span>
</BannerBadge>
)}
{(project.area || (project as any).Area) && (
<BannerBadge>
<Squares2X2Icon className="h-3 w-3 text-white/70 flex-shrink-0 mt-0.5" />
<button
onClick={() => {
const projectArea =
project.area || (project as any).Area;
const area = areas.find(
(a) => a.id === projectArea.id
);
const areaUid = area?.uid;
if (!areaUid) return;
const areaSlug = projectArea.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
navigate(
`/projects?area=${areaUid}-${areaSlug}`
);
}}
className="text-xs text-white/90 hover:text-blue-200 transition-colors cursor-pointer font-medium"
>
{(project.area || (project as any).Area)?.name}
</button>
</BannerBadge>
)}
{project.is_shared && (
<BannerBadge>
<ShareIcon className="h-3 w-3 text-green-500 dark:text-green-400 flex-shrink-0 mt-0.5" />
<span className="text-xs text-white/90 font-medium">
{t('projects.shared', 'Shared')}
</span>
</BannerBadge>
)}
</div>
<div className="absolute bottom-2 right-2 flex space-x-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<button
ref={editButtonRef}
type="button"
className="p-2 bg-black bg-opacity-50 text-blue-400 hover:text-blue-300 hover:bg-opacity-70 rounded-full transition-all duration-200 backdrop-blur-sm"
>
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDeleteClick();
}}
className="p-2 bg-black bg-opacity-50 text-red-400 hover:text-red-300 hover:bg-opacity-70 rounded-full transition-all duration-200 backdrop-blur-sm"
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</div>
</div>
);
};
export default ProjectBanner;

File diff suppressed because it is too large Load diff

View file

@ -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> | 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<ProjectInsightsPanelProps> = ({
taskStats,
completionGradient,
nextBestAction,
getDueDescriptor,
onStartNextAction,
t,
upcomingDueTrend,
weeklyPace,
monthlyCompleted,
upcomingInsights,
eisenhower,
}) => {
const maxUpcoming = Math.max(...upcomingDueTrend.map((d) => d.count), 1);
return (
<div className="space-y-4">
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl shadow-sm p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t('projects.progress', 'Progress')}
</p>
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">
{t('projects.taskMomentum', 'Task momentum')}
</h3>
</div>
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
{taskStats.total} {t('tasks.tasks', 'tasks')}
</span>
</div>
<div className="mt-4 flex items-center gap-4">
<div className="relative w-28 h-28">
<div
className="w-full h-full rounded-full shadow-inner"
style={{
background: completionGradient,
}}
></div>
<div className="absolute inset-3 rounded-full bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 flex flex-col items-center justify-center text-center">
<span className="text-lg font-bold text-gray-900 dark:text-gray-100">
{taskStats.completionRate}%
</span>
<span className="text-[11px] text-gray-500 dark:text-gray-400">
{t('common.done', 'done')}
</span>
</div>
</div>
<div className="flex-1 space-y-3">
<div className="flex items-center justify-between text-sm text-gray-600 dark:text-gray-300">
<span>
{t('projects.activeTasks', 'Active tasks')}
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{Math.max(
taskStats.total - taskStats.completed,
0
)}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span className="w-2 h-2 rounded-full bg-red-500"></span>
<span>
{taskStats.overdue}{' '}
{t('tasks.overdue', 'overdue')},{' '}
{taskStats.dueSoon}{' '}
{t('tasks.dueSoon', 'due soon')}
</span>
</div>
<div>
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>{t('tasks.progress', 'Progress')}</span>
<span className="font-semibold text-gray-700 dark:text-gray-200">
{taskStats.completionRate}%
</span>
</div>
<div className="mt-1 h-2 rounded-full bg-gray-200 dark:bg-gray-800 overflow-hidden">
<div
className="h-full bg-blue-500 dark:bg-blue-400 transition-all duration-300 ease-in-out"
style={{
width: `${taskStats.total > 0 ? taskStats.completionRate : 0}%`,
}}
></div>
</div>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl shadow-sm p-5">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">
{t('projects.dueSchedule', 'Due schedule')}
</h3>
<span className="text-xs text-gray-500 dark:text-gray-400">
{t('projects.next14Days', 'Next 14 days')}
</span>
</div>
{upcomingDueTrend.some((d) => d.count > 0) ? (
<>
<div className="mt-3 flex flex-wrap gap-1">
{upcomingDueTrend.map((d, idx) => {
const intensity =
maxUpcoming > 0
? Math.max(
(d.count / maxUpcoming) * 0.8,
0.12
)
: 0;
return (
<div
key={idx}
className="flex flex-col items-center"
style={{
width: 'calc(100% / 7 - 4px)',
}}
>
<div
className="w-full h-10 rounded-md border border-amber-200 dark:border-amber-800 transition-all duration-300"
style={{
backgroundColor: `rgba(251, 191, 36, ${intensity})`,
}}
></div>
<span className="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{d.label}
</span>
<span className="text-[10px] text-gray-600 dark:text-gray-300">
{d.count}
</span>
</div>
);
})}
</div>
{upcomingInsights && (
<div className="mt-4 flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300 flex-wrap">
<span className="px-2 py-1 rounded-full bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-200">
{t('projects.peakDay', 'Peak')}:{' '}
{upcomingInsights.peakCount > 0
? `${upcomingInsights.peakLabel} · ${upcomingInsights.peakCount}`
: t('projects.none', 'None')}
</span>
<span className="px-2 py-1 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-200">
{t('projects.next3days', 'Next 3 days')}:{' '}
{upcomingInsights.nextThreeDays}
</span>
<span className="px-2 py-1 rounded-full bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-200">
{t('projects.nextWeek', 'Next 7 days')}:{' '}
{upcomingInsights.nextWeek}
</span>
</div>
)}
</>
) : (
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">
{t(
'projects.noUpcomingDue',
'No due dates in the next 14 days.'
)}
</p>
)}
</div>
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl shadow-sm p-4">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">
{t('projects.recentCompletion', 'Recent completion')}
</h3>
<span className="text-xs text-gray-500 dark:text-gray-400">
{t('projects.last7And30', 'Last 7 & 30 days')}
</span>
</div>
<div className="mt-4 space-y-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t('projects.weeklyPace', 'Weekly pace')}
</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{weeklyPace.lastWeek}
</p>
<p className="text-[11px] text-gray-500 dark:text-gray-400">
{t(
'projects.prevWeekCompleted',
'{{count}} prior week',
{
count: weeklyPace.prevWeek,
}
)}
</p>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<div
className={`px-2 py-1 rounded-full text-[11px] font-semibold ${
weeklyPace.delta >= 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')}
</div>
<div className="w-32 h-1.5 rounded-full bg-gray-200 dark:bg-gray-800 overflow-hidden">
<div
className="h-full bg-blue-500 dark:bg-blue-400 transition-all duration-300"
style={{
width: `${Math.min(
(weeklyPace.lastWeek /
Math.max(
Math.max(
weeklyPace.lastWeek,
weeklyPace.prevWeek
),
1
)) *
100,
100
)}%`,
}}
></div>
</div>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t(
'projects.monthlyCompletion',
'30-day completions'
)}
</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{monthlyCompleted}
</p>
<p className="text-[11px] text-gray-500 dark:text-gray-400">
{t('projects.last30Days', 'Last 30 days')}
</p>
</div>
<div className="w-32 h-1.5 rounded-full bg-gray-200 dark:bg-gray-800 overflow-hidden">
<div
className="h-full bg-indigo-500 dark:bg-indigo-400 transition-all duration-300"
style={{
width: `${Math.min(monthlyCompleted * 3, 100)}%`,
}}
></div>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl shadow-sm p-5">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">
{t('projects.eisenhower', 'Eisenhower matrix')}
</h3>
<span className="text-xs text-gray-500 dark:text-gray-400">
{t('projects.priorityVsUrgency', 'Priority vs urgency')}
</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-xs text-gray-600 dark:text-gray-300">
{[
{
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) => (
<div
key={idx}
className={`rounded-lg p-3 border border-gray-200 dark:border-gray-800 ${item.accent}`}
>
<div className="flex items-center justify-between">
<span className="font-semibold">
{item.value}
</span>
<span className="text-[11px] uppercase tracking-wide">
{item.label}
</span>
</div>
<div className="mt-2 h-1.5 rounded-full bg-white/30 dark:bg-gray-700 overflow-hidden">
<div
className="h-full bg-white/80 dark:bg-white"
style={{
width: `${Math.min(item.value * 15, 100)}%`,
}}
></div>
</div>
</div>
))}
</div>
</div>
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl shadow-sm p-5">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t('projects.nextUp', 'Next best action')}
</p>
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">
{t('projects.focusTask', 'Most impactful task')}
</h3>
</div>
{nextBestAction && (
<span className="px-2 py-1 text-[11px] rounded-full bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-200">
{getDueDescriptor(nextBestAction)}
</span>
)}
</div>
{nextBestAction ? (
<div className="mt-4 space-y-3">
<div className="flex items-start gap-3">
<div className="mt-1 w-2 h-2 rounded-full bg-blue-500" />
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{nextBestAction.name}
</p>
{nextBestAction.note && (
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{nextBestAction.note}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
{nextBestAction.priority && (
<span className="px-2 py-1 rounded-full bg-gray-100 dark:bg-gray-800">
{t('tasks.priority', 'Priority')}:{' '}
{String(nextBestAction.priority)}
</span>
)}
{nextBestAction.today && (
<span className="px-2 py-1 rounded-full bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-200">
{t('tasks.todayPlan', 'Today plan')}
</span>
)}
{(nextBestAction.status === 'in_progress' ||
nextBestAction.status === 1) && (
<span className="px-2 py-1 rounded-full bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-200">
{t('task.status.inProgress', 'In progress')}
</span>
)}
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={onStartNextAction}
disabled={
(nextBestAction.status === 'in_progress' ||
nextBestAction.status === 1) &&
nextBestAction.today
}
className={`inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium text-white rounded-md transition-colors ${
(nextBestAction.status === 'in_progress' ||
nextBestAction.status === 1) &&
nextBestAction.today
? 'bg-gray-400 dark:bg-gray-700 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{(nextBestAction.status === 'in_progress' ||
nextBestAction.status === 1) &&
nextBestAction.today
? t('tasks.inProgress', 'In progress')
: t('tasks.startNow', 'Start now')}
</button>
<span className="text-xs text-gray-500 dark:text-gray-400">
{t(
'projects.focusHint',
'Shifts this task to in progress and today'
)}
</span>
</div>
</div>
) : (
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">
{t(
'projects.noNextAction',
'All clear—no outstanding tasks.'
)}
</p>
)}
</div>
</div>
);
};
export default ProjectInsightsPanel;

View file

@ -276,16 +276,31 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
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<string | null> => {
@ -303,13 +318,30 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
});
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<ProjectModalProps> = ({
const uploadedImageUrl = await handleImageUpload();
if (uploadedImageUrl) {
imageUrl = uploadedImageUrl;
} else {
setIsSaving(false);
return;
}
}
@ -741,7 +776,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t(
'project.uploadImageHint',
'Upload an image for your project (max 5MB)'
'Upload an image for your project (max 10MB)'
)}
</p>
</div>

View file

@ -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<void>;
onDeleteNote: (note: Note) => void;
}
const ProjectNotesSection: React.FC<ProjectNotesSectionProps> = ({
project,
notes,
t,
onCreateNote,
onEditNote,
onDeleteNote,
}) => {
return (
<div className="transition-all duration-300 ease-in-out">
<div className="mb-4">
<button
type="button"
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-md bg-gray-200 text-gray-800 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 transition-colors"
onClick={() => {
if (!project?.id || !project.name) return;
onCreateNote();
}}
>
<PlusCircleIcon className="h-5 w-5" />
{t('noteCreation', 'Create New Note')}
</button>
</div>
{notes.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{notes.map((note) => (
<NoteCard
key={note.uid}
note={note}
onEdit={onEditNote}
onDelete={onDeleteNote}
showActions={true}
showProject={false}
/>
))}
</div>
) : (
<div className="text-gray-500 dark:text-gray-400">
<p>{t('project.noNotes', 'No notes for this project.')}</p>
</div>
)}
</div>
);
};
export default ProjectNotesSection;

View file

@ -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<void>;
onTaskUpdate: (task: Task) => Promise<void>;
onTaskCompletionToggle: (task: Task) => void;
onTaskDelete: (taskId: number) => void;
onToggleToday: (taskId: number, task?: Task) => Promise<void>;
allProjects: Project[];
showCompleted: boolean;
taskSearchQuery: string;
t: TFunction;
}
const ProjectTasksSection: React.FC<ProjectTasksSectionProps> = ({
project,
displayTasks,
showAutoSuggestForm,
onAddNextAction,
onDismissNextAction,
onTaskCreate,
onTaskUpdate,
onTaskCompletionToggle,
onTaskDelete,
onToggleToday,
allProjects,
showCompleted,
taskSearchQuery,
t,
}) => {
return (
<div className="xl:col-span-2 flex flex-col gap-2">
{showAutoSuggestForm && (
<div className="transition-all duration-300 ease-in-out opacity-100 transform translate-y-0">
<AutoSuggestNextActionBox
onAddAction={(actionDescription) => {
if (project?.id) {
onAddNextAction(project.id, actionDescription);
}
}}
onDismiss={onDismissNextAction}
/>
</div>
)}
<div className="transition-all duration-300 ease-in-out overflow-visible opacity-100 transform translate-y-0">
<NewTask onTaskCreate={onTaskCreate} />
</div>
<div className="transition-all duration-300 ease-in-out overflow-visible">
{displayTasks.length > 0 ? (
<div className="transition-all duration-300 ease-in-out opacity-100 transform translate-y-0 overflow-visible">
<TaskList
tasks={displayTasks}
onTaskUpdate={onTaskUpdate}
onTaskCompletionToggle={onTaskCompletionToggle}
onTaskDelete={onTaskDelete}
projects={allProjects}
hideProjectName={true}
onToggleToday={onToggleToday}
showCompletedTasks={showCompleted}
/>
</div>
) : (
<div className="transition-all duration-300 ease-in-out opacity-100 transform translate-y-0">
<p className="text-gray-500 dark:text-gray-400">
{taskSearchQuery.trim()
? t(
'tasks.noTasksAvailable',
'No tasks available.'
)
: showCompleted
? t(
'project.noCompletedTasks',
'No completed tasks.'
)
: t('project.noTasks', 'No tasks.')}
</p>
</div>
)}
</div>
</div>
);
};
export default ProjectTasksSection;

View file

@ -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<void>,
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<string, number> = {};
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<string, number> = {};
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<string, number> = {};
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,
};
};

View file

@ -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<IconSortDropdownProps> = ({
@ -26,6 +27,7 @@ const IconSortDropdown: React.FC<IconSortDropdownProps> = ({
dropdownLabel = 'Sort by',
align = 'right',
extraContent,
footerContent,
}) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@ -58,14 +60,14 @@ const IconSortDropdown: React.FC<IconSortDropdownProps> = ({
aria-label={ariaLabel}
title={title}
>
<FunnelIcon className="h-5 w-5" />
<ListBulletIcon className="h-5 w-5" />
</button>
{isOpen && (
<div
className={`absolute ${align === 'left' ? 'left-0' : 'right-0'} mt-1 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50`}
className={`absolute ${align === 'left' ? 'left-0' : 'right-0'} mt-1 w-56 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50`}
>
{dropdownLabel && (
<div className="px-3 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
{dropdownLabel}
</div>
)}
@ -93,10 +95,15 @@ const IconSortDropdown: React.FC<IconSortDropdownProps> = ({
))}
</div>
{extraContent && (
<div className="border-t border-gray-200 dark:border-gray-700 px-3 py-2">
<div className="border-t border-gray-200 dark:border-gray-700">
{extraContent}
</div>
)}
{footerContent && (
<div className="border-t border-gray-200 dark:border-gray-700">
{footerContent}
</div>
)}
</div>
)}
</div>

View file

@ -60,7 +60,7 @@ const NoteCard: React.FC<NoteCardProps> = ({
.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',

View file

@ -44,7 +44,7 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
query: 'type=today',
},
{
path: '/upcoming',
path: '/upcoming?status=active',
title: t('sidebar.upcoming', 'Upcoming'),
icon: <ClockIcon className="h-5 w-5" />,
},
@ -57,13 +57,21 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
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

View file

@ -13,6 +13,7 @@ import { GroupedTasks } from '../../utils/tasksService';
interface GroupedTaskListProps {
tasks: Task[];
groupedTasks?: GroupedTasks | null;
groupBy?: 'none' | 'project';
onTaskUpdate: (task: Task) => Promise<void>;
onTaskCompletionToggle?: (task: Task) => void;
onTaskCreate?: (task: Task) => void;
@ -32,6 +33,7 @@ interface TaskGroup {
const GroupedTaskList: React.FC<GroupedTaskListProps> = ({
tasks,
groupedTasks,
groupBy = 'none',
onTaskUpdate,
onTaskCompletionToggle,
onTaskDelete,
@ -178,6 +180,53 @@ const GroupedTaskList: React.FC<GroupedTaskListProps> = ({
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<string | number, Task[]>();
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<GroupedTaskListProps> = ({
return (
<div className="task-list-container space-y-1.5">
{/* Standalone tasks */}
{standaloneTask.map((task) => (
<div
key={task.id}
className="task-item-wrapper transition-all duration-200 ease-in-out"
>
<TaskItem
task={task}
onTaskUpdate={onTaskUpdate}
onTaskCompletionToggle={onTaskCompletionToggle}
onTaskDelete={onTaskDelete}
projects={projects}
hideProjectName={hideProjectName}
onToggleToday={onToggleToday}
/>
</div>
))}
{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 (
<div
key={String(projectId)}
className={`space-y-1.5 pb-4 mb-2 border-b border-gray-200/50 dark:border-gray-800/60 last:border-b-0 ${index > 0 ? 'pt-4' : ''}`}
>
<div className="flex items-center justify-between px-1 text-base font-semibold text-gray-900 dark:text-gray-100">
<span className="truncate">
{projectName}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{projectTasks.length}{' '}
{t('tasks.tasks', 'tasks')}
</span>
</div>
{projectTasks.map((task) => (
<div
key={task.id}
className="task-item-wrapper transition-all duration-200 ease-in-out"
>
<TaskItem
task={task}
onTaskUpdate={onTaskUpdate}
onTaskCompletionToggle={
onTaskCompletionToggle
}
onTaskDelete={onTaskDelete}
projects={projects}
hideProjectName={hideProjectName}
onToggleToday={onToggleToday}
/>
</div>
))}
</div>
);
}
)
: standaloneTask.map((task) => (
<div
key={task.id}
className="task-item-wrapper transition-all duration-200 ease-in-out"
>
<TaskItem
task={task}
onTaskUpdate={onTaskUpdate}
onTaskCompletionToggle={onTaskCompletionToggle}
onTaskDelete={onTaskDelete}
projects={projects}
hideProjectName={hideProjectName}
onToggleToday={onToggleToday}
/>
</div>
))}
{/* Grouped recurring tasks */}
{recurringGroups.map((group) => {

View file

@ -82,7 +82,7 @@ const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
return (
<div>
<div className="flex items-center justify-between py-3 px-4 border-b border-gray-200 dark:border-gray-800 rounded-lg shadow-sm bg-white dark:bg-gray-900">
<div className="flex items-center justify-between py-3.5 px-5 border-b border-gray-200 dark:border-gray-800 rounded-lg shadow-sm bg-white dark:bg-gray-900">
<span className="text-xl text-gray-500 dark:text-gray-400 mr-2">
<PlusCircleIcon className="h-5 w-5" />
</span>
@ -91,7 +91,7 @@ const NewTask: React.FC<NewTaskProps> = ({ 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',
'Προσθήκη Νέας Εργασίας'

View file

@ -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 */}
<div className="lg:col-span-3 space-y-8">
{/* Notes Section - Always Visible */}
<TaskContentSection
<TaskContentCard
content={task.note || ''}
onUpdate={handleContentUpdate}
/>
{/* Subtasks Section - Always Visible */}
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
{t('task.subtasks', 'Subtasks')}
</h4>
{isEditingSubtasks ? (
<div className="rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-blue-500 dark:border-blue-400 p-6">
<TaskSubtasksSection
parentTaskId={task.id!}
subtasks={editedSubtasks}
onSubtasksChange={setEditedSubtasks}
/>
<div className="flex items-center justify-end mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex space-x-2">
<button
onClick={handleSaveSubtasks}
className="px-4 py-2 text-sm bg-green-600 dark:bg-green-500 text-white rounded hover:bg-green-700 dark:hover:bg-green-600 transition-colors"
>
{t('common.save', 'Save')}
</button>
<button
onClick={
handleCancelSubtasksEdit
}
className="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
>
{t(
'common.cancel',
'Cancel'
)}
</button>
</div>
</div>
</div>
) : subtasks.length > 0 ? (
<div className="space-y-0.5">
{subtasks.map((subtask: Task) => (
<div
key={subtask.id}
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 transition-all duration-200 ${
subtask.status ===
'in_progress' ||
subtask.status === 1
? 'border-green-400/60 dark:border-green-500/60'
: 'border-gray-50 dark:border-gray-800'
}`}
>
<div className="px-3 py-3 flex items-center space-x-3">
<TaskPriorityIcon
priority={
subtask.priority
}
status={subtask.status}
onToggleCompletion={async () => {
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
);
}
}
<TaskSubtasksCard
task={task}
subtasks={subtasks}
isEditing={isEditingSubtasks}
editedSubtasks={editedSubtasks}
onSubtasksChange={setEditedSubtasks}
onStartEdit={handleStartSubtasksEdit}
onSave={handleSaveSubtasks}
onCancel={handleCancelSubtasksEdit}
onToggleSubtaskCompletion={
handleToggleSubtaskCompletion
}
/>
// Refresh timeline to show subtask completion activity
setTimelineRefreshKey(
(
prev
) =>
prev +
1
);
} catch (error) {
console.error(
'Error toggling subtask completion:',
error
);
}
}
}}
/>
<span
onClick={
handleStartSubtasksEdit
}
className={`text-base flex-1 truncate cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors ${
subtask.status ===
'done' ||
subtask.status ===
2 ||
subtask.status ===
'archived' ||
subtask.status === 3
? 'text-gray-500 dark:text-gray-400'
: 'text-gray-900 dark:text-gray-100'
}`}
title={t(
'task.clickToEditSubtasks',
'Click to edit subtasks'
)}
>
{subtask.name}
</span>
</div>
</div>
))}
</div>
) : (
<div
onClick={handleStartSubtasksEdit}
className="rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 hover:border-gray-200 dark:hover:border-gray-700 p-6 cursor-pointer transition-colors"
title={t(
'task.clickToEditSubtasks',
'Click to add or edit subtasks'
)}
>
<div className="flex flex-col items-center justify-center py-8 text-gray-500 dark:text-gray-400">
<ListBulletIcon className="h-12 w-12 mb-3 opacity-50" />
<span className="text-sm text-center">
{t(
'task.noSubtasksClickToAdd',
'No subtasks yet, click to add'
)}
</span>
</div>
</div>
)}
</div>
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
{t(
'task.recurringSetup',
'Recurring Setup'
)}
</h4>
<div
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 hover:border-gray-200 dark:hover:border-gray-700 p-6 space-y-4 ${
!task.recurring_parent_id &&
!isEditingRecurrence
? 'cursor-pointer'
: ''
}`}
onClick={
!task.recurring_parent_id &&
!isEditingRecurrence
? handleRecurrenceCardClick
: undefined
}
role={
!task.recurring_parent_id &&
!isEditingRecurrence
? 'button'
: undefined
}
tabIndex={
!task.recurring_parent_id &&
!isEditingRecurrence
? 0
: -1
}
onKeyDown={(e) => {
if (
!isEditingRecurrence &&
!task.recurring_parent_id &&
e.key === 'Enter'
) {
e.preventDefault();
handleRecurrenceCardClick();
}
}}
>
<TaskRecurringInstanceInfo
task={task}
parentTask={parentTask}
loadingParent={loadingParent}
/>
{isEditingRecurrence &&
!task.recurring_parent_id ? (
<div className="space-y-4">
<TaskRecurrenceSection
recurrenceType={
recurrenceForm.recurrence_type
}
recurrenceInterval={
recurrenceForm.recurrence_interval
}
recurrenceEndDate={
recurrenceForm.recurrence_end_date ||
undefined
}
recurrenceWeekday={
recurrenceForm.recurrence_weekday ||
undefined
}
recurrenceWeekdays={
recurrenceForm.recurrence_weekdays ||
[]
}
recurrenceMonthDay={
recurrenceForm.recurrence_month_day ||
undefined
}
recurrenceWeekOfMonth={
recurrenceForm.recurrence_week_of_month ||
undefined
}
completionBased={
recurrenceForm.completion_based
}
onChange={
handleRecurrenceChange
}
/>
<div className="flex justify-end space-x-2">
<button
onClick={
handleSaveRecurrence
}
className="px-4 py-2 text-sm bg-green-600 dark:bg-green-500 text-white rounded hover:bg-green-700 dark:hover:bg-green-600 transition-colors"
>
{t('common.save', 'Save')}
</button>
<button
onClick={
handleCancelRecurrenceEdit
}
className="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
>
{t(
'common.cancel',
'Cancel'
)}
</button>
</div>
</div>
) : (
<>
{(task.recurrence_type &&
task.recurrence_type !==
'none') ||
(parentTask?.recurrence_type &&
parentTask.recurrence_type !==
'none') ? (
<div className="mb-4">
<RecurrenceDisplay
recurrenceType={
task.recurring_parent_id &&
parentTask?.recurrence_type
? parentTask.recurrence_type
: task.recurrence_type
}
recurrenceInterval={
task.recurring_parent_id &&
parentTask?.recurrence_interval
? parentTask.recurrence_interval
: task.recurrence_interval
}
recurrenceWeekdays={
task.recurring_parent_id &&
parentTask?.recurrence_weekdays
? parentTask.recurrence_weekdays
: task.recurrence_weekdays
}
recurrenceEndDate={
task.recurring_parent_id &&
parentTask?.recurrence_end_date
? parentTask.recurrence_end_date
: task.recurrence_end_date
}
recurrenceMonthDay={
task.recurring_parent_id &&
parentTask?.recurrence_month_day
? parentTask.recurrence_month_day
: task.recurrence_month_day
}
recurrenceWeekOfMonth={
task.recurring_parent_id &&
parentTask?.recurrence_week_of_month
? parentTask.recurrence_week_of_month
: task.recurrence_week_of_month
}
recurrenceWeekday={
task.recurring_parent_id &&
parentTask?.recurrence_weekday
? parentTask.recurrence_weekday
: task.recurrence_weekday
}
completionBased={
task.recurring_parent_id &&
parentTask?.completion_based
? parentTask.completion_based
: task.completion_based
}
/>
</div>
) : (
<div className="text-sm text-gray-600 dark:text-gray-400 text-center">
{t(
'task.notRecurring',
'This task is not recurring yet.'
)}
</div>
)}
{((task.recurrence_type &&
task.recurrence_type !==
'none') ||
(task.recurring_parent_id &&
parentTask?.recurrence_type &&
parentTask.recurrence_type !==
'none')) && (
<div>
<div className="flex items-center mb-3">
<ClockIcon className="h-4 w-4 mr-2 text-gray-500 dark:text-gray-400" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{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
) && (
<span className="ml-2 text-xs text-blue-600 dark:text-blue-400">
(
{t(
'task.includingToday',
'including today'
)}
)
</span>
)}
</span>
</div>
{loadingIterations ? (
<div className="flex items-center justify-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
{t(
'common.loading',
'Loading...'
)}
</span>
</div>
) : nextIterations.length >
0 ? (
<div className="space-y-2">
{nextIterations.map(
(
iteration,
index
) => {
const dateInfo =
formatDateWithDayName(
iteration.date
);
return (
<div
key={
index
}
className={`flex items-center py-2 px-3 rounded transition-colors ${
dateInfo.isToday
? 'bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-200 dark:border-blue-800'
: 'bg-gray-50 dark:bg-gray-800 border border-transparent'
}`}
>
<div
className={`w-7 h-7 rounded-full flex items-center justify-center mr-3 ${
dateInfo.isToday
? 'bg-blue-600 dark:bg-blue-500'
: 'bg-blue-100 dark:bg-blue-900'
}`}
>
<span
className={`text-xs font-medium ${
dateInfo.isToday
? 'text-white'
: 'text-blue-600 dark:text-blue-400'
}`}
>
{index +
1}
</span>
</div>
<div className="flex-1">
<div
className={`text-sm font-medium ${
dateInfo.isToday
? 'text-blue-900 dark:text-blue-100'
: 'text-gray-900 dark:text-gray-100'
}`}
>
{
dateInfo.dayName
}
{dateInfo.isToday && (
<span className="ml-2 text-xs px-2 py-0.5 bg-blue-600 dark:bg-blue-500 text-white rounded-full font-semibold">
{t(
'dateIndicators.today',
'TODAY'
)}
</span>
)}
</div>
<div
className={`text-xs ${
dateInfo.isToday
? 'text-blue-700 dark:text-blue-300'
: 'text-gray-500 dark:text-gray-400'
}`}
>
{
dateInfo.formattedDate
}
</div>
</div>
</div>
);
}
)}
</div>
) : (
<div className="text-sm text-gray-500 dark:text-gray-400 py-2">
{t(
'task.noMoreIterations',
'No more iterations scheduled'
)}
</div>
)}
</div>
)}
</>
)}
</div>
</div>
<TaskRecurrenceCard
task={task}
parentTask={parentTask}
loadingParent={loadingParent}
isEditing={isEditingRecurrence}
recurrenceForm={recurrenceForm}
onStartEdit={handleStartRecurrenceEdit}
onChange={handleRecurrenceChange}
onSave={handleSaveRecurrence}
onCancel={handleCancelRecurrenceEdit}
loadingIterations={loadingIterations}
nextIterations={nextIterations}
canEdit={!task.recurring_parent_id}
/>
</div>
{/* Right Column - Metadata and Recent Activity */}
<div className="space-y-6">
{/* Project Section */}
<TaskProjectSection
<TaskProjectCard
task={task}
projects={projectsStore.projects}
onProjectSelect={handleProjectSelection}
@ -1585,7 +1126,7 @@ const TaskDetails: React.FC = () => {
/>
{/* Tags Section */}
<TaskTagsSection
<TaskTagsCard
task={task}
availableTags={tagsStore.tags}
hasLoadedTags={tagsStore.hasLoaded}
@ -1595,160 +1136,20 @@ const TaskDetails: React.FC = () => {
/>
{/* Priority Section */}
<TaskPrioritySection
<TaskPriorityCard
task={task}
onUpdate={handlePriorityUpdate}
/>
{/* Due Date Section */}
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
{t('task.dueDate', 'Due Date')}
</h4>
<div
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 hover:border-gray-200 dark:hover:border-gray-700 p-4 transition-colors ${
task.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 ? (
<div className="space-y-3">
<TaskDueDateSection
value={editedDueDate}
onChange={setEditedDueDate}
placeholder={t(
'forms.task.dueDatePlaceholder',
'Select due date'
)}
/>
<div className="flex justify-end space-x-2">
<button
onClick={handleSaveDueDate}
className="px-4 py-2 text-sm bg-green-600 dark:bg-green-500 text-white rounded hover:bg-green-700 dark:hover:bg-green-600 transition-colors"
>
{t('common.save', 'Save')}
</button>
<button
onClick={
handleCancelDueDateEdit
}
className="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
>
{t(
'common.cancel',
'Cancel'
)}
</button>
</div>
</div>
) : (
<button
type="button"
onClick={handleStartDueDateEdit}
className="flex w-full items-center justify-between text-left"
>
{task.due_date ? (
(() => {
const display =
getDueDateDisplay(
task.due_date
);
if (!display) return null;
// Check if due date is in the past and task is not completed
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;
const overdue =
dueDate < today &&
!isCompleted;
return (
<div
className={`flex items-center justify-between w-full ${
overdue
? 'text-red-600 dark:text-red-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
<div className="flex items-center space-x-2 flex-1 min-w-0">
<CalendarIcon
className={`h-4 w-4 flex-shrink-0 ${
overdue
? 'text-red-600 dark:text-red-400'
: 'text-gray-500 dark:text-gray-400'
}`}
/>
<span className="text-sm font-medium">
{
display.formattedDate
}
</span>
<span
className={`text-sm italic ${
overdue
? 'text-red-500 dark:text-red-400 font-medium'
: 'text-gray-500 dark:text-gray-400'
}`}
>
(
{
display.relativeText
}
)
</span>
</div>
{overdue && (
<ExclamationTriangleIcon className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 ml-2" />
)}
</div>
);
})()
) : (
<span className="text-sm text-gray-500 dark:text-gray-400 italic">
{t(
'task.noDueDate',
'No due date'
)}
</span>
)}
</button>
)}
</div>
</div>
<TaskDueDateCard
task={task}
isEditing={isEditingDueDate}
editedDueDate={editedDueDate}
onChangeDate={setEditedDueDate}
onStartEdit={handleStartDueDateEdit}
onSave={handleSaveDueDate}
onCancel={handleCancelDueDateEdit}
/>
{/* Recent Activity Section */}
<div>

View file

@ -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<void>;
}
const TaskContentSection: React.FC<TaskContentSectionProps> = ({
const TaskContentCard: React.FC<TaskContentCardProps> = ({
content,
onUpdate,
}) => {
@ -179,4 +179,4 @@ const TaskContentSection: React.FC<TaskContentSectionProps> = ({
);
};
export default TaskContentSection;
export default TaskContentCard;

View file

@ -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<TaskDueDateCardProps> = ({
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 (
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
{t('task.dueDate', 'Due Date')}
</h4>
<div
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 hover:border-gray-200 dark:hover:border-gray-700 p-4 transition-colors ${
task.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 ? (
<div className="space-y-3">
<TaskDueDateSection
value={editedDueDate}
onChange={onChangeDate}
placeholder={t(
'forms.task.dueDatePlaceholder',
'Select due date'
)}
/>
<div className="flex justify-end space-x-2">
<button
onClick={onSave}
className="px-4 py-2 text-sm bg-green-600 dark:bg-green-500 text-white rounded hover:bg-green-700 dark:hover:bg-green-600 transition-colors"
>
{t('common.save', 'Save')}
</button>
<button
onClick={onCancel}
className="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
>
{t('common.cancel', 'Cancel')}
</button>
</div>
</div>
) : (
<button
type="button"
onClick={onStartEdit}
className="flex w-full items-center justify-between text-left"
>
{task.due_date ? (
(() => {
const display = getDueDateDisplay(
task.due_date
);
if (!display) return null;
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;
const overdue = dueDate < today && !isCompleted;
return (
<div
className={`flex items-center justify-between w-full ${
overdue
? 'text-red-600 dark:text-red-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
<div className="flex items-center space-x-2 flex-1 min-w-0">
<CalendarIcon
className={`h-4 w-4 flex-shrink-0 ${
overdue
? 'text-red-600 dark:text-red-400'
: 'text-gray-500 dark:text-gray-400'
}`}
/>
<span className="text-sm font-medium">
{display.formattedDate}
</span>
<span
className={`text-sm italic ${
overdue
? 'text-red-500 dark:text-red-400 font-medium'
: 'text-gray-500 dark:text-gray-400'
}`}
>
({display.relativeText})
</span>
</div>
{overdue && (
<ExclamationTriangleIcon className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 ml-2" />
)}
</div>
);
})()
) : (
<span className="text-sm text-gray-500 dark:text-gray-400 italic">
{t('task.noDueDate', 'No due date')}
</span>
)}
</button>
)}
</div>
</div>
);
};
export default TaskDueDateCard;

View file

@ -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<void>;
}
const TaskPrioritySection: React.FC<TaskPrioritySectionProps> = ({
const TaskPriorityCard: React.FC<TaskPriorityCardProps> = ({
task,
onUpdate,
}) => {
@ -72,4 +72,4 @@ const TaskPrioritySection: React.FC<TaskPrioritySectionProps> = ({
);
};
export default TaskPrioritySection;
export default TaskPriorityCard;

View file

@ -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<void>;
@ -15,7 +15,7 @@ interface TaskProjectSectionProps {
getProjectLink: (project: Project) => string;
}
const TaskProjectSection: React.FC<TaskProjectSectionProps> = ({
const TaskProjectCard: React.FC<TaskProjectCardProps> = ({
task,
projects,
onProjectSelect,
@ -105,53 +105,70 @@ const TaskProjectSection: React.FC<TaskProjectSectionProps> = ({
onClearProject={handleClearProject}
/>
) : task.Project ? (
<div
onClick={() => setProjectDropdownOpen(true)}
className="bg-gray-50 dark:bg-gray-900 rounded-lg shadow-sm relative cursor-pointer hover:opacity-90 transition-opacity"
>
<div
className="flex items-center justify-center overflow-hidden rounded-t-lg relative"
style={{ height: '100px' }}
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg shadow-sm relative overflow-hidden">
<button
type="button"
onClick={() => setProjectDropdownOpen(true)}
className="group w-full text-left hover:opacity-90 transition-opacity"
>
{task.Project.image_url ? (
<img
src={task.Project.image_url}
alt={task.Project.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-blue-500 to-purple-600 dark:from-blue-600 dark:to-purple-700"></div>
)}
</div>
<div className="p-3">
<div className="flex items-center text-md font-semibold text-gray-900 dark:text-gray-100">
<span className="truncate">
{task.Project.name}
</span>
<Link
to={getProjectLink(task.Project)}
onClick={(e) => 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'
)}
>
<ArrowRightIcon className="h-4 w-4" />
<span className="sr-only">
{t(
<div
className="flex items-center justify-center overflow-hidden relative"
style={{ height: '100px' }}
>
{task.Project.image_url ? (
<img
src={task.Project.image_url}
alt={task.Project.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-blue-500 to-purple-600 dark:from-blue-600 dark:to-purple-700"></div>
)}
</div>
<div className="p-3">
<div className="flex items-center text-md font-semibold text-gray-900 dark:text-gray-100">
<span className="truncate">
{task.Project.name}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleClearProject();
}}
className="ml-auto inline-flex items-center justify-center w-6 h-6 rounded-full text-gray-500 dark:text-gray-400 bg-transparent hover:bg-gray-200 dark:hover:bg-gray-700 opacity-0 group-hover:opacity-100 transition-opacity"
title={t(
'task.clearProject',
'Remove project'
)}
>
<XMarkIcon className="h-4 w-4" />
</button>
<Link
to={getProjectLink(task.Project)}
onClick={(e) => 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'
)}
</span>
</Link>
>
<ArrowRightIcon className="h-4 w-4" />
<span className="sr-only">
{t(
'project.viewProject',
'Go to project'
)}
</span>
</Link>
</div>
</div>
</div>
</button>
</div>
) : (
<div
onClick={() => 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"
>
<span className="text-sm text-gray-500 dark:text-gray-400 italic">
{t(
@ -166,4 +183,4 @@ const TaskProjectSection: React.FC<TaskProjectSectionProps> = ({
);
};
export default TaskProjectSection;
export default TaskProjectCard;

View file

@ -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<TaskRecurrenceCardProps> = ({
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 (
<div className="flex items-center justify-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
{t('common.loading', 'Loading...')}
</span>
</div>
);
}
if (nextIterations.length === 0) {
return (
<div className="text-sm text-gray-600 dark:text-gray-400">
{t(
'task.noUpcomingOccurrences',
'No upcoming occurrences.'
)}
</div>
);
}
return (
<div className="space-y-2">
{nextIterations.map((iteration, index) => {
const dateInfo = formatDateWithDayName(iteration.date);
return (
<div
key={index}
className={`flex items-center py-2 px-3 rounded transition-colors ${
dateInfo.isToday
? 'bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-200 dark:border-blue-800'
: 'bg-gray-50 dark:bg-gray-800 border border-transparent'
}`}
>
<div className="flex-1">
<div className="text-sm font-medium text-gray-800 dark:text-gray-100">
{dateInfo.formattedDate}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{dateInfo.dayName}
</div>
</div>
</div>
);
})}
</div>
);
};
return (
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
{t('task.recurringSetup', 'Recurring Setup')}
</h4>
<div
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 hover:border-gray-200 dark:hover:border-gray-700 p-6 space-y-4 ${
canEdit && !isEditing ? 'cursor-pointer' : ''
}`}
onClick={canEdit && !isEditing ? onStartEdit : undefined}
role={canEdit && !isEditing ? 'button' : undefined}
tabIndex={canEdit && !isEditing ? 0 : -1}
onKeyDown={(e) => {
if (canEdit && !isEditing && e.key === 'Enter') {
e.preventDefault();
onStartEdit();
}
}}
>
<TaskRecurringInstanceInfo
task={task}
parentTask={parentTask}
loadingParent={loadingParent}
/>
{isEditing && canEdit ? (
<div className="space-y-4">
<TaskRecurrenceSection
recurrenceType={recurrenceForm.recurrence_type}
recurrenceInterval={
recurrenceForm.recurrence_interval
}
recurrenceEndDate={
recurrenceForm.recurrence_end_date || undefined
}
recurrenceWeekday={
recurrenceForm.recurrence_weekday || undefined
}
recurrenceWeekdays={
recurrenceForm.recurrence_weekdays || []
}
recurrenceMonthDay={
recurrenceForm.recurrence_month_day || undefined
}
recurrenceWeekOfMonth={
recurrenceForm.recurrence_week_of_month ||
undefined
}
completionBased={recurrenceForm.completion_based}
onChange={onChange}
/>
<div className="flex justify-end space-x-2">
<button
onClick={onSave}
className="px-4 py-2 text-sm bg-green-600 dark:bg-green-500 text-white rounded hover:bg-green-700 dark:hover:bg-green-600 transition-colors"
>
{t('common.save', 'Save')}
</button>
<button
onClick={onCancel}
className="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
>
{t('common.cancel', 'Cancel')}
</button>
</div>
</div>
) : (
<>
{(task.recurrence_type &&
task.recurrence_type !== 'none') ||
(parentTask?.recurrence_type &&
parentTask.recurrence_type !== 'none') ? (
<div className="mb-4">
<RecurrenceDisplay
recurrenceType={
task.recurring_parent_id &&
parentTask?.recurrence_type
? parentTask.recurrence_type
: task.recurrence_type
}
recurrenceInterval={
task.recurring_parent_id &&
parentTask?.recurrence_interval
? parentTask.recurrence_interval
: task.recurrence_interval
}
recurrenceWeekdays={
task.recurring_parent_id &&
parentTask?.recurrence_weekdays
? parentTask.recurrence_weekdays
: task.recurrence_weekdays
}
recurrenceEndDate={
task.recurring_parent_id &&
parentTask?.recurrence_end_date
? parentTask.recurrence_end_date
: task.recurrence_end_date
}
recurrenceMonthDay={
task.recurring_parent_id &&
parentTask?.recurrence_month_day
? parentTask.recurrence_month_day
: task.recurrence_month_day
}
recurrenceWeekOfMonth={
task.recurring_parent_id &&
parentTask?.recurrence_week_of_month
? parentTask.recurrence_week_of_month
: task.recurrence_week_of_month
}
recurrenceWeekday={
task.recurring_parent_id &&
parentTask?.recurrence_weekday
? parentTask.recurrence_weekday
: task.recurrence_weekday
}
completionBased={
task.recurring_parent_id &&
parentTask?.completion_based
? parentTask.completion_based
: task.completion_based
}
/>
</div>
) : (
<div className="text-sm text-gray-600 dark:text-gray-400 text-center">
{t(
'task.notRecurring',
'This task is not recurring yet.'
)}
</div>
)}
{((task.recurrence_type &&
task.recurrence_type !== 'none') ||
(task.recurring_parent_id &&
parentTask?.recurrence_type &&
parentTask.recurrence_type !== 'none')) && (
<div>
<div className="flex items-center mb-3">
<ClockIcon className="h-4 w-4 mr-2 text-gray-500 dark:text-gray-400" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{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
) && (
<span className="ml-2 text-xs text-blue-600 dark:text-blue-400">
(
{t(
'task.includingToday',
'including today'
)}
)
</span>
)}
</span>
</div>
{renderNextIterations()}
</div>
)}
</>
)}
</div>
</div>
);
};
export default TaskRecurrenceCard;

View file

@ -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<void>;
}
const TaskSubtasksCard: React.FC<TaskSubtasksCardProps> = ({
task,
subtasks,
isEditing,
editedSubtasks,
onSubtasksChange,
onStartEdit,
onSave,
onCancel,
onToggleSubtaskCompletion,
}) => {
const { t } = useTranslation();
return (
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
{t('task.subtasks', 'Subtasks')}
</h4>
{isEditing ? (
<div className="rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-blue-500 dark:border-blue-400 p-6">
<TaskSubtasksSection
parentTaskId={task.id!}
subtasks={editedSubtasks}
onSubtasksChange={onSubtasksChange}
/>
<div className="flex items-center justify-end mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex space-x-2">
<button
onClick={onSave}
className="px-4 py-2 text-sm bg-green-600 dark:bg-green-500 text-white rounded hover:bg-green-700 dark:hover:bg-green-600 transition-colors"
>
{t('common.save', 'Save')}
</button>
<button
onClick={onCancel}
className="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
>
{t('common.cancel', 'Cancel')}
</button>
</div>
</div>
</div>
) : subtasks.length > 0 ? (
<div className="space-y-0.5">
{subtasks.map((subtask: Task) => (
<div
key={subtask.id}
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 transition-all duration-200 ${
subtask.status === 'in_progress' ||
subtask.status === 1
? 'border-green-400/60 dark:border-green-500/60'
: 'border-gray-50 dark:border-gray-800'
}`}
>
<div className="px-3 py-3 flex items-center space-x-3">
<TaskPriorityIcon
priority={subtask.priority}
status={subtask.status}
onToggleCompletion={() =>
onToggleSubtaskCompletion(subtask)
}
/>
<span
onClick={onStartEdit}
className={`text-base flex-1 truncate cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors ${
subtask.status === 'done' ||
subtask.status === 2 ||
subtask.status === 'archived' ||
subtask.status === 3
? 'text-gray-500 dark:text-gray-400'
: 'text-gray-900 dark:text-gray-100'
}`}
title={t(
'task.clickToEditSubtasks',
'Click to edit subtasks'
)}
>
{subtask.name}
</span>
</div>
</div>
))}
</div>
) : (
<div
onClick={onStartEdit}
className="rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 hover:border-gray-200 dark:hover:border-gray-700 p-6 cursor-pointer transition-colors"
title={t(
'task.clickToEditSubtasks',
'Click to add or edit subtasks'
)}
>
<div className="flex flex-col items-center justify-center py-8 text-gray-500 dark:text-gray-400">
<ListBulletIcon className="h-12 w-12 mb-3 opacity-50" />
<span className="text-sm text-center">
{t(
'task.noSubtasksClickToAdd',
'No subtasks yet, click to add'
)}
</span>
</div>
</div>
)}
</div>
);
};
export default TaskSubtasksCard;

View file

@ -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<TaskTagsSectionProps> = ({
const TaskTagsCard: React.FC<TaskTagsCardProps> = ({
task,
availableTags,
hasLoadedTags,
@ -131,4 +131,4 @@ const TaskTagsSection: React.FC<TaskTagsSectionProps> = ({
);
};
export default TaskTagsSection;
export default TaskTagsCard;

View file

@ -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';

View file

@ -66,7 +66,8 @@ const SubtasksDisplay: React.FC<SubtasksDisplayProps> = ({
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<TaskItemProps> = ({
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) {

View file

@ -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<HTMLDivElement>(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 (
<div
className={`w-full pt-4 pb-8 ${isUpcomingView ? '' : 'px-2 sm:px-4 lg:px-6'}`}
className={`w-full pt-4 pb-8 ${isUpcomingView ? 'pl-2 sm:pl-4' : 'px-2 sm:px-4 lg:px-6'}`}
>
<div
className={`w-full ${isUpcomingView ? '' : 'max-w-5xl mx-auto'}`}
@ -560,57 +653,244 @@ const Tasks: React.FC = () => {
ariaLabel={t('tasks.sortTasks', 'Sort tasks')}
title={t('tasks.sortTasks', 'Sort tasks')}
dropdownLabel={t('tasks.sortBy', 'Sort by')}
extraContent={
<button
type="button"
onClick={() => setShowCompleted((v) => !v)}
className="w-full flex items-center justify-between text-sm text-gray-700 dark:text-gray-300"
aria-pressed={showCompleted}
aria-label={
showCompleted
? t(
'tasks.hideCompleted',
'Hide completed tasks'
)
: t(
'tasks.showCompleted',
'Show completed tasks'
)
}
title={
showCompleted
? t(
'tasks.hideCompleted',
'Hide completed tasks'
)
: t(
'tasks.showCompleted',
'Show completed tasks'
)
}
>
<span>
{t(
'tasks.showCompleted',
'Show completed'
)}
</span>
<span
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
showCompleted
? 'bg-blue-600'
: 'bg-gray-200 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
showCompleted
? 'translate-x-4'
: 'translate-x-0.5'
}`}
/>
</span>
</button>
align="right"
footerContent={
!isUpcomingView && (
<div className="space-y-3">
<div>
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
{t('tasks.groupBy', 'Group by')}
</div>
<div className="py-1">
{['none', 'project'].map(
(val) => (
<button
key={val}
onClick={() => {
setGroupBy(
val as
| 'none'
| 'project'
);
localStorage.setItem(
'tasks_group_by',
val
);
}}
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
groupBy === val
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<span>
{val ===
'project'
? t(
'tasks.groupByProject',
'Project'
)
: t(
'tasks.grouping.none',
'None'
)}
</span>
{groupBy ===
val && (
<CheckIcon className="h-4 w-4" />
)}
</button>
)
)}
</div>
</div>
<div>
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
{t('tasks.show', 'Show')}
</div>
<div className="py-1 space-y-1">
{[
{
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 (
<button
key={opt.key}
type="button"
onClick={() => {
if (
opt.key ===
'completed'
) {
const params =
new URLSearchParams(
location.search
);
params.set(
'status',
'completed'
);
navigate(
{
pathname:
location.pathname,
search: `?${params.toString()}`,
},
{
replace: true,
}
);
} else if (
opt.key ===
'all'
) {
const params =
new URLSearchParams(
location.search
);
params.delete(
'status'
);
navigate(
{
pathname:
location.pathname,
search: `?${params.toString()}`,
},
{
replace: true,
}
);
} else {
// active (not completed)
const params =
new URLSearchParams(
location.search
);
params.set(
'status',
'active'
);
navigate(
{
pathname:
location.pathname,
search: `?${params.toString()}`,
},
{
replace: true,
}
);
}
}}
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
isActive
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<span>
{opt.label}
</span>
{isActive && (
<CheckIcon className="h-4 w-4" />
)}
</button>
);
})}
</div>
</div>
<div>
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
{t(
'tasks.direction',
'Direction'
)}
</div>
<div className="py-1">
{[
{
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 (
<button
key={dir.key}
onClick={() => {
const [field] =
orderBy.split(
':'
);
const newOrderBy = `${field}:${dir.key}`;
handleSortChange(
newOrderBy
);
}}
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
isActive
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<span>
{dir.label}
</span>
{isActive && (
<CheckIcon className="h-4 w-4" />
)}
</button>
);
})}
</div>
</div>
</div>
)
}
/>
</div>
@ -670,7 +950,7 @@ const Tasks: React.FC = () => {
<>
{/* New Task Form */}
{isNewTaskAllowed() && (
<div className="mb-1.5">
<div className="mb-6">
<NewTask
onTaskCreate={async (taskName: string) =>
await handleTaskCreate({
@ -690,6 +970,7 @@ const Tasks: React.FC = () => {
<GroupedTaskList
tasks={displayTasks}
groupedTasks={groupedTasks}
groupBy="none"
onTaskCreate={handleTaskCreate}
onTaskUpdate={handleTaskUpdate}
onTaskCompletionToggle={
@ -702,6 +983,23 @@ const Tasks: React.FC = () => {
showCompletedTasks={showCompleted}
searchQuery={taskSearchQuery}
/>
) : groupBy === 'project' ? (
<GroupedTaskList
tasks={displayTasks}
groupedTasks={null}
groupBy="project"
onTaskCreate={handleTaskCreate}
onTaskUpdate={handleTaskUpdate}
onTaskCompletionToggle={
handleTaskCompletionToggle
}
onTaskDelete={handleTaskDelete}
projects={projects}
hideProjectName={false}
onToggleToday={handleToggleToday}
showCompletedTasks={showCompleted}
searchQuery={taskSearchQuery}
/>
) : (
<TaskList
tasks={displayTasks}
@ -716,13 +1014,13 @@ const Tasks: React.FC = () => {
showCompletedTasks={showCompleted}
/>
)}
{/* Load more button */}
{hasMore && (
<div className="flex justify-center pt-4">
{/* Load more button - hide in upcoming view */}
{!isUpcomingView && hasMore && (
<div className="flex justify-center pt-4 gap-3">
<button
onClick={loadMore}
onClick={() => loadMore(false)}
disabled={isLoadingMore}
className="inline-flex items-center px-6 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isLoadingMore ? (
<>
@ -761,11 +1059,18 @@ const Tasks: React.FC = () => {
</>
)}
</button>
<button
onClick={() => loadMore(true)}
disabled={isLoadingMore}
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{t('common.showAll', 'Show All')}
</button>
</div>
)}
{/* Pagination info */}
{tasks.length > 0 && (
{/* Pagination info - hide in upcoming view */}
{!isUpcomingView && tasks.length > 0 && (
<div className="text-center text-sm text-gray-500 dark:text-gray-400 pt-2 pb-4">
{t(
'tasks.showingItems',

View file

@ -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<string>('');
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const [showCompleted, setShowCompleted] = useState(false);
const [taskStatusFilter, setTaskStatusFilter] = useState<
'all' | 'active' | 'completed'
>('active');
const [orderBy, setOrderBy] = useState<string>('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={
<button
type="button"
onClick={() => setShowCompleted((v) => !v)}
className="w-full flex items-center justify-between text-sm text-gray-700 dark:text-gray-300"
aria-pressed={showCompleted}
aria-label={
showCompleted
? t(
'views.hideCompleted',
'Hide completed tasks'
)
: t(
'views.showCompleted',
'Show completed tasks'
)
}
title={
showCompleted
? t(
'views.hideCompleted',
'Hide completed tasks'
)
: t(
'views.showCompleted',
'Show completed tasks'
)
}
>
<span>
{t(
'common.showCompleted',
'Show completed'
)}
</span>
<span
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
showCompleted
? 'bg-blue-600'
: 'bg-gray-200 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
showCompleted
? 'translate-x-4'
: 'translate-x-0.5'
}`}
/>
</span>
</button>
footerContent={
<div className="space-y-3">
<div>
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
{t('tasks.show', 'Show')}
</div>
<div className="py-1 space-y-1">
{[
{
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 (
<button
key={opt.key}
type="button"
onClick={() =>
setTaskStatusFilter(
opt.key as
| 'all'
| 'active'
| 'completed'
)
}
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
isActive
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<span>{opt.label}</span>
{isActive && (
<CheckIcon className="h-4 w-4" />
)}
</button>
);
})}
</div>
</div>
<div>
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
{t('tasks.direction', 'Direction')}
</div>
<div className="py-1">
{[
{
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 (
<button
key={dir.key}
onClick={() => {
const [field] =
orderBy.split(
':'
);
setOrderBy(
`${field}:${dir.key}`
);
}}
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
isActive
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<span>{dir.label}</span>
{isActive && (
<CheckIcon className="h-4 w-4" />
)}
</button>
);
})}
</div>
</div>
</div>
}
/>
<div className="relative" ref={criteriaDropdownRef}>
@ -781,7 +851,7 @@ const ViewDetail: React.FC = () => {
projects={projects}
hideProjectName={false}
onToggleToday={handleToggleToday}
showCompletedTasks={showCompleted}
showCompletedTasks={taskStatusFilter !== 'active'}
/>
{/* Load more button */}
{hasMore && (

View file

@ -77,7 +77,7 @@ export const createTask = async (taskData: Task): Promise<Task> => {
export const updateTask = async (
taskId: number,
taskData: Task
taskData: Partial<Task>
): Promise<Task> => {
const response = await fetch(getApiPath(`task/${taskId}`), {
method: 'PATCH',
@ -94,18 +94,12 @@ export const toggleTaskCompletion = async (
taskId: number,
currentTask?: Task
): Promise<Task> => {
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<void> => {

View file

@ -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": "أضف مهمة فرعية..."
}
}
}

View file

@ -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": "Добавете подзадача..."
}
}
}

View file

@ -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..."
}
}
}

View file

@ -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..."
}
}
}

View file

@ -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": "Προσθέστε μια υποεργασία..."
}
}
}

View file

@ -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",

View file

@ -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..."
}
}
}

View file

@ -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ä..."
}
}
}

View file

@ -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..."
}
}
}

View file

@ -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..."
}
}
}

View file

@ -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..."
}
}
}

View file

@ -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": "サブタスクを追加..."
}
}
}

View file

@ -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": "하위 작업 추가..."
}
}
}

View file

@ -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..."
}
}
}

View file

@ -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..."
}
}
}

View file

@ -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..."
}
}
}

View file

@ -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..."
}
}
}

View file

@ -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..."
}
}
}

View file

@ -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": "Добавить подзадачу..."
}
}
}

View file

@ -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..."
}
}
}

View file

@ -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..."
}
}
}

View file

@ -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..."
}
}
}

View file

@ -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": "Додати підзадачу..."
}
}
}

View file

@ -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ụ..."
}
}
}

View file

@ -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": "添加子任务..."
}
}
}