diff --git a/backend/migrations/20251119000001-add-recurrence-weekdays.js b/backend/migrations/20251119000001-add-recurrence-weekdays.js new file mode 100644 index 0000000..55c066d --- /dev/null +++ b/backend/migrations/20251119000001-add-recurrence-weekdays.js @@ -0,0 +1,26 @@ +'use strict'; + +const { + safeAddColumns, + safeRemoveColumn, +} = require('../utils/migration-utils'); + +module.exports = { + up: async (queryInterface, Sequelize) => { + await safeAddColumns(queryInterface, 'tasks', [ + { + name: 'recurrence_weekdays', + definition: { + type: Sequelize.TEXT, + allowNull: true, + comment: + 'JSON array of weekday numbers for weekly recurrence (e.g., "[1,3,5]" for Mon, Wed, Fri)', + }, + }, + ]); + }, + + down: async (queryInterface, Sequelize) => { + await safeRemoveColumn(queryInterface, 'tasks', 'recurrence_weekdays'); + }, +}; diff --git a/backend/models/task.js b/backend/models/task.js index 4f28edf..8a6060f 100644 --- a/backend/models/task.js +++ b/backend/models/task.js @@ -86,6 +86,20 @@ module.exports = (sequelize) => { max: 6, }, }, + recurrence_weekdays: { + type: DataTypes.TEXT, + allowNull: true, + get() { + const rawValue = this.getDataValue('recurrence_weekdays'); + return rawValue ? JSON.parse(rawValue) : null; + }, + set(value) { + this.setDataValue( + 'recurrence_weekdays', + value ? JSON.stringify(value) : null + ); + }, + }, recurrence_month_day: { type: DataTypes.INTEGER, allowNull: true, diff --git a/backend/routes/tasks/operations/recurring.js b/backend/routes/tasks/operations/recurring.js index ca0895a..9d6ea21 100644 --- a/backend/routes/tasks/operations/recurring.js +++ b/backend/routes/tasks/operations/recurring.js @@ -66,31 +66,71 @@ async function calculateNextIterations(task, startFromDate, userTimezone) { startDate.setUTCHours(0, 0, 0, 0); let nextDate = new Date(startDate); + let includesToday = false; - if (task.recurrence_type === 'daily') { - nextDate.setDate(nextDate.getDate() + (task.recurrence_interval || 1)); - } else if (task.recurrence_type === 'weekly') { - const interval = task.recurrence_interval || 1; - if ( + // Check if today matches the recurrence pattern + if (task.recurrence_type === 'weekly') { + // Check if today matches any of the weekdays + if (task.recurrence_weekdays) { + // Note: Sequelize getter already parses JSON, so it's already an array + const weekdays = Array.isArray(task.recurrence_weekdays) + ? task.recurrence_weekdays + : JSON.parse(task.recurrence_weekdays); + const todayWeekday = nextDate.getDay(); + console.log('Weekly recurrence check:', { + weekdays, + todayWeekday, + includes: weekdays.includes(todayWeekday), + }); + includesToday = weekdays.includes(todayWeekday); + } else if ( task.recurrence_weekday !== null && task.recurrence_weekday !== undefined ) { - const currentWeekday = nextDate.getDay(); - const daysUntilTarget = - (task.recurrence_weekday - currentWeekday + 7) % 7; - if (daysUntilTarget === 0) { - nextDate.setDate(nextDate.getDate() + interval * 7); - } else { - nextDate.setDate(nextDate.getDate() + daysUntilTarget); - } - } else { - nextDate.setDate(nextDate.getDate() + interval * 7); + const todayWeekday = nextDate.getDay(); + includesToday = task.recurrence_weekday === todayWeekday; } - } else { - nextDate = calculateNextDueDate(task, startDate); + } else if (task.recurrence_type === 'daily') { + // For daily recurrence, today is always included + includesToday = true; } - for (let i = 0; i < 5 && nextDate; i++) { + console.log('calculateNextIterations:', { + startDate: startDate.toISOString(), + includesToday, + recurrence_type: task.recurrence_type, + recurrence_weekdays: task.recurrence_weekdays, + }); + + // If today doesn't match, calculate the next occurrence + if (!includesToday) { + if (task.recurrence_type === 'daily') { + nextDate.setDate( + nextDate.getDate() + (task.recurrence_interval || 1) + ); + } else if (task.recurrence_type === 'weekly') { + const interval = task.recurrence_interval || 1; + if ( + task.recurrence_weekday !== null && + task.recurrence_weekday !== undefined + ) { + const currentWeekday = nextDate.getDay(); + const daysUntilTarget = + (task.recurrence_weekday - currentWeekday + 7) % 7; + if (daysUntilTarget === 0) { + nextDate.setDate(nextDate.getDate() + interval * 7); + } else { + nextDate.setDate(nextDate.getDate() + daysUntilTarget); + } + } else { + nextDate.setDate(nextDate.getDate() + interval * 7); + } + } else { + nextDate = calculateNextDueDate(task, startDate); + } + } + + for (let i = 0; i < 6 && nextDate; i++) { if (task.recurrence_end_date) { const endDate = new Date(task.recurrence_end_date); if (nextDate > endDate) { @@ -113,9 +153,39 @@ async function calculateNextIterations(task, startFromDate, userTimezone) { ); } else if (task.recurrence_type === 'weekly') { nextDate = new Date(nextDate); - nextDate.setDate( - nextDate.getDate() + (task.recurrence_interval || 1) * 7 - ); + + // Handle multiple weekdays + if (task.recurrence_weekdays) { + // Sequelize getter already parses JSON, so it's already an array + const weekdays = Array.isArray(task.recurrence_weekdays) + ? task.recurrence_weekdays + : JSON.parse(task.recurrence_weekdays); + const currentWeekday = nextDate.getDay(); + + // Find next matching weekday + let found = false; + for (let daysAhead = 1; daysAhead <= 7; daysAhead++) { + const testDate = new Date(nextDate); + testDate.setDate(testDate.getDate() + daysAhead); + const testWeekday = testDate.getDay(); + + if (weekdays.includes(testWeekday)) { + nextDate = testDate; + found = true; + break; + } + } + + if (!found) { + // Fallback: add 7 days + nextDate.setDate(nextDate.getDate() + 7); + } + } else { + // Old behavior for single weekday + nextDate.setDate( + nextDate.getDate() + (task.recurrence_interval || 1) * 7 + ); + } } else { nextDate = calculateNextDueDate(task, nextDate); } diff --git a/frontend/components/Task/RecurrenceDisplay.tsx b/frontend/components/Task/RecurrenceDisplay.tsx new file mode 100644 index 0000000..0528603 --- /dev/null +++ b/frontend/components/Task/RecurrenceDisplay.tsx @@ -0,0 +1,220 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RecurrenceType } from '../../entities/Task'; +import { getFirstDayOfWeek } from '../../utils/profileService'; +import { ArrowPathIcon, CalendarIcon } from '@heroicons/react/24/outline'; + +interface RecurrenceDisplayProps { + recurrenceType: RecurrenceType; + recurrenceInterval?: number; + recurrenceWeekdays?: number[]; + recurrenceEndDate?: string; + recurrenceMonthDay?: number; + recurrenceWeekOfMonth?: number; + recurrenceWeekday?: number; + completionBased?: boolean; + compact?: boolean; +} + +const RecurrenceDisplay: React.FC = ({ + recurrenceType, + recurrenceInterval = 1, + recurrenceWeekdays, + recurrenceEndDate, + recurrenceMonthDay, + // recurrenceWeekOfMonth and recurrenceWeekday kept for future use + completionBased = false, + compact = false, +}) => { + const { t } = useTranslation(); + const [firstDayOfWeek, setFirstDayOfWeek] = useState(null); + + useEffect(() => { + const loadFirstDayOfWeek = async () => { + try { + const day = await getFirstDayOfWeek(); + setFirstDayOfWeek(day); + } catch (error) { + console.error('Error loading first day of week:', error); + setFirstDayOfWeek(1); // Default to Monday on error + } + }; + loadFirstDayOfWeek(); + }, []); + + const allWeekdays = useMemo( + () => [ + { + value: 0, + short: t('weekdays.sunday', 'Sun'), + full: t('weekdaysFull.sunday', 'Sunday'), + }, + { + value: 1, + short: t('weekdays.monday', 'Mon'), + full: t('weekdaysFull.monday', 'Monday'), + }, + { + value: 2, + short: t('weekdays.tuesday', 'Tue'), + full: t('weekdaysFull.tuesday', 'Tuesday'), + }, + { + value: 3, + short: t('weekdays.wednesday', 'Wed'), + full: t('weekdaysFull.wednesday', 'Wednesday'), + }, + { + value: 4, + short: t('weekdays.thursday', 'Thu'), + full: t('weekdaysFull.thursday', 'Thursday'), + }, + { + value: 5, + short: t('weekdays.friday', 'Fri'), + full: t('weekdaysFull.friday', 'Friday'), + }, + { + value: 6, + short: t('weekdays.saturday', 'Sat'), + full: t('weekdaysFull.saturday', 'Saturday'), + }, + ], + [t] + ); + + const orderedWeekdays = useMemo(() => { + if (firstDayOfWeek === null) return allWeekdays; + return [ + ...allWeekdays.slice(firstDayOfWeek), + ...allWeekdays.slice(0, firstDayOfWeek), + ]; + }, [allWeekdays, firstDayOfWeek]); + + const formatRecurrenceText = () => { + switch (recurrenceType) { + case 'daily': + return recurrenceInterval > 1 + ? t( + 'recurrence.everyNDays', + `Every ${recurrenceInterval} days`, + { count: recurrenceInterval } + ) + : t('recurrence.daily', 'Daily'); + case 'weekly': + return recurrenceInterval > 1 + ? t( + 'recurrence.everyNWeeks', + `Every ${recurrenceInterval} weeks`, + { count: recurrenceInterval } + ) + : t('recurrence.weekly', 'Weekly'); + case 'monthly': + return recurrenceInterval > 1 + ? t( + 'recurrence.everyNMonths', + `Every ${recurrenceInterval} months`, + { count: recurrenceInterval } + ) + : t('recurrence.monthly', 'Monthly'); + case 'monthly_weekday': + return t('recurrence.monthlyWeekday', 'Monthly on weekday'); + case 'monthly_last_day': + return t('recurrence.monthlyLastDay', 'Monthly on last day'); + default: + return t('recurrence.recurring', 'Recurring'); + } + }; + + const formatEndDate = (dateString: string) => { + try { + const date = new Date(dateString); + return date.toLocaleDateString(); + } catch { + return dateString; + } + }; + + if (recurrenceType === 'none' || !recurrenceType) { + return null; + } + + console.log('RecurrenceDisplay rendering:', { + recurrenceType, + recurrenceWeekdays, + recurrenceInterval, + }); + + return ( +
+ {/* Main recurrence info */} +
+ + + {formatRecurrenceText()} + + {completionBased && ( + + {t('recurrence.completionBased', 'After completion')} + + )} +
+ + {/* Weekday display for weekly recurrence */} + {recurrenceType === 'weekly' && + recurrenceWeekdays && + recurrenceWeekdays.length > 0 && ( +
+
+ {t('forms.task.labels.repeatOn', 'Repeat on')}: +
+
+ {orderedWeekdays.map((weekday) => { + const isSelected = recurrenceWeekdays.includes( + weekday.value + ); + return ( +
+ {weekday.short} +
+ ); + })} +
+
+ )} + + {/* Month day display for monthly recurrence */} + {recurrenceType === 'monthly' && recurrenceMonthDay && ( +
+ {t('recurrence.onDay', 'On day')} {recurrenceMonthDay} +
+ )} + + {/* End date display */} + {recurrenceEndDate && ( +
+ + + {t('recurrence.until', 'Until')}{' '} + {formatEndDate(recurrenceEndDate)} + +
+ )} +
+ ); +}; + +export default RecurrenceDisplay; diff --git a/frontend/components/Task/RecurrenceInput.tsx b/frontend/components/Task/RecurrenceInput.tsx deleted file mode 100644 index ab15a5e..0000000 --- a/frontend/components/Task/RecurrenceInput.tsx +++ /dev/null @@ -1,526 +0,0 @@ -import React, { useState } from 'react'; -import { RecurrenceType } from '../../entities/Task'; -import { useTranslation } from 'react-i18next'; -import RecurrenceSelectDropdown from '../Shared/RecurrenceSelectDropdown'; -import NumberSelectDropdown from '../Shared/NumberSelectDropdown'; -import ToggleSwitch from '../Shared/ToggleSwitch'; -import DatePicker from '../Shared/DatePicker'; - -interface RecurrenceInputProps { - recurrenceType: RecurrenceType; - recurrenceInterval: number; - recurrenceEndDate?: string; - recurrenceWeekday?: number; - recurrenceMonthDay?: number; - recurrenceWeekOfMonth?: number; - completionBased: boolean; - onChange: (field: string, value: any) => void; - disabled?: boolean; - isChildTask?: boolean; - parentTaskLoading?: boolean; - onEditParent?: () => void; - onParentRecurrenceChange?: (field: string, value: any) => void; -} - -const RecurrenceInput: React.FC = ({ - recurrenceType, - recurrenceInterval, - recurrenceEndDate, - recurrenceWeekday, - recurrenceMonthDay, - recurrenceWeekOfMonth, - completionBased, - onChange, - disabled = false, - isChildTask = false, - parentTaskLoading = false, - onEditParent, // eslint-disable-line @typescript-eslint/no-unused-vars - onParentRecurrenceChange, -}) => { - // Helper function to convert ISO date string to YYYY-MM-DD format for DatePicker - const formatDateForPicker = (dateString?: string) => { - if (!dateString) return ''; - try { - const date = new Date(dateString); - if (isNaN(date.getTime())) return ''; - return date.toISOString().split('T')[0]; // Returns YYYY-MM-DD - } catch { - return dateString; // Return as-is if it's already in the correct format - } - }; - const { t } = useTranslation(); - const [editingParentRecurrence, setEditingParentRecurrence] = - useState(false); - - const weekdays = [ - { value: 0, label: t('weekdays.sunday', 'Sunday') }, - { value: 1, label: t('weekdays.monday', 'Monday') }, - { value: 2, label: t('weekdays.tuesday', 'Tuesday') }, - { value: 3, label: t('weekdays.wednesday', 'Wednesday') }, - { value: 4, label: t('weekdays.thursday', 'Thursday') }, - { value: 5, label: t('weekdays.friday', 'Friday') }, - { value: 6, label: t('weekdays.saturday', 'Saturday') }, - ]; - - const weekOfMonthOptions = [ - { value: 1, label: t('recurrence.firstWeek', 'First') }, - { value: 2, label: t('recurrence.secondWeek', 'Second') }, - { value: 3, label: t('recurrence.thirdWeek', 'Third') }, - { value: 4, label: t('recurrence.fourthWeek', 'Fourth') }, - { value: 5, label: t('recurrence.lastWeek', 'Last') }, - ]; - - const recurrenceTypeOptions = [ - { value: 'none', label: t('recurrence.none', 'No repeat') }, - { value: 'daily', label: t('recurrence.daily', 'Daily') }, - { value: 'weekly', label: t('recurrence.weekly', 'Weekly') }, - { value: 'monthly', label: t('recurrence.monthly', 'Monthly') }, - { - value: 'monthly_weekday', - label: t('recurrence.monthlyWeekday', 'Monthly on weekday'), - }, - { - value: 'monthly_last_day', - label: t('recurrence.monthlyLastDay', 'Monthly on last day'), - }, - ]; - - const renderRecurrenceTypeSelect = ( - customOnChange?: (field: string, value: any) => void, - isDisabled?: boolean - ) => ( -
- - - (customOnChange || onChange)( - 'recurrence_type', - value as RecurrenceType - ) - } - options={recurrenceTypeOptions} - disabled={isDisabled} - /> -
- ); - - const renderIntervalInput = ( - customOnChange?: (field: string, value: any) => void, - isDisabled?: boolean - ) => { - // Determine max value based on recurrence type - const getMaxValue = () => { - if (recurrenceType === 'daily') return 30; - if (recurrenceType === 'weekly') return 52; // Max 52 weeks (1 year) - if ( - recurrenceType === 'monthly' || - recurrenceType === 'monthly_weekday' || - recurrenceType === 'monthly_last_day' - ) - return 24; // Max 24 months (2 years) - return 99; - }; - - return ( -
- -
-
- - (customOnChange || onChange)( - 'recurrence_interval', - value - ) - } - min={1} - max={getMaxValue()} - disabled={isDisabled} - /> -
- - {recurrenceType === 'daily' && - t('recurrence.days', 'days')} - {recurrenceType === 'weekly' && - t('recurrence.weeks', 'weeks')} - {(recurrenceType === 'monthly' || - recurrenceType === 'monthly_weekday' || - recurrenceType === 'monthly_last_day') && - t('recurrence.months', 'months')} - -
-
- ); - }; - - const renderWeekdaySelect = ( - customOnChange?: (field: string, value: any) => void, - isDisabled?: boolean - ) => { - const weekdayOptions = [ - { value: '', label: t('recurrence.anyDay', 'Any day') }, - ...weekdays, - ]; - - return ( -
- - - (customOnChange || onChange)( - 'recurrence_weekday', - value !== '' ? parseInt(value as string) : null - ) - } - options={weekdayOptions} - disabled={isDisabled} - /> -
- ); - }; - - const renderMonthDayInput = ( - customOnChange?: (field: string, value: any) => void, - isDisabled?: boolean - ) => ( -
- - - (customOnChange || onChange)( - 'recurrence_month_day', - e.target.value ? parseInt(e.target.value) : null - ) - } - placeholder={t( - 'recurrence.monthDayPlaceholder', - 'Leave empty for current day' - )} - className="block w-full border border-gray-300 dark:border-gray-900 rounded-md focus:outline-none shadow-sm px-2 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100" - disabled={isDisabled} - /> -
- ); - - const renderMonthlyWeekdayInputs = () => ( -
-
- - - onChange( - 'recurrence_week_of_month', - parseInt(value as string) - ) - } - options={weekOfMonthOptions} - /> -
-
- - - onChange( - 'recurrence_weekday', - parseInt(value as string) - ) - } - options={weekdays} - /> -
-
- ); - - const renderEndDateInput = ( - customOnChange?: (field: string, value: any) => void, - isDisabled?: boolean - ) => ( -
- - - (customOnChange || onChange)( - 'recurrence_end_date', - value || null - ) - } - placeholder={t( - 'forms.task.endDatePlaceholder', - 'Select end date' - )} - disabled={isDisabled} - /> -
- ); - - const renderCompletionBasedToggle = () => ( -
- onChange('completion_based', checked)} - label={t( - 'forms.task.labels.completionBased', - 'Repeat after completion' - )} - description={t( - 'forms.task.completionBasedHelp', - 'If checked, the next task will be created based on completion date instead of due date' - )} - /> -
- ); - - // Show message for child tasks - if (isChildTask && parentTaskLoading) { - return ( -
-

- {t('forms.task.recurrenceSettings', 'Recurrence Settings')} -

-
- Loading parent task recurrence settings... -
-
- ); - } - - if (isChildTask) { - return ( -
-

- {t('forms.task.recurrenceSettings', 'Recurrence Settings')} -

-
-
- Recurring Task Instance -

- This task was generated from a recurring task. The - recurrence settings shown below are inherited from - the original task and cannot be edited here. -

- {onParentRecurrenceChange && ( - - )} -
-
-
- {editingParentRecurrence && ( -
-
- ⚠️ You are editing the parent task's - recurrence settings. Changes will affect all - future instances of this recurring task. -
-
- )} - {recurrenceType === 'none' ? ( - renderRecurrenceTypeSelect( - editingParentRecurrence - ? onParentRecurrenceChange - : undefined, - !editingParentRecurrence - ) - ) : ( - <> - {renderRecurrenceTypeSelect( - editingParentRecurrence - ? onParentRecurrenceChange - : undefined, - !editingParentRecurrence - )} - {renderIntervalInput( - editingParentRecurrence - ? onParentRecurrenceChange - : undefined, - !editingParentRecurrence - )} - {(recurrenceType === 'weekly' || - recurrenceType === 'monthly_weekday') && - renderWeekdaySelect( - editingParentRecurrence - ? onParentRecurrenceChange - : undefined, - !editingParentRecurrence - )} - {recurrenceType === 'monthly' && - renderMonthDayInput( - editingParentRecurrence - ? onParentRecurrenceChange - : undefined, - !editingParentRecurrence - )} - {recurrenceType === 'monthly_weekday' && - renderMonthlyWeekdayInputs()} - {renderEndDateInput( - editingParentRecurrence - ? onParentRecurrenceChange - : undefined, - !editingParentRecurrence - )} - {renderCompletionBasedToggle()} - - )} -
-
- ); - } - - if (recurrenceType === 'none') { - return
{renderRecurrenceTypeSelect()}
; - } - - return ( -
-

- {t('forms.task.recurrenceSettings', 'Recurrence Settings')} -

- - {/* Main recurrence settings in one row */} -
-
- - - onChange('recurrence_type', value as RecurrenceType) - } - options={recurrenceTypeOptions} - disabled={disabled} - /> -
- - {(recurrenceType === 'daily' || - recurrenceType === 'weekly' || - recurrenceType === 'monthly' || - recurrenceType === 'monthly_weekday' || - recurrenceType === 'monthly_last_day') && ( -
- -
-
- - onChange('recurrence_interval', value) - } - min={1} - max={ - recurrenceType === 'daily' - ? 30 - : recurrenceType === 'weekly' - ? 52 - : recurrenceType === 'monthly' || - recurrenceType === - 'monthly_weekday' || - recurrenceType === - 'monthly_last_day' - ? 24 - : 99 - } - disabled={disabled} - /> -
- - {recurrenceType === 'daily' && - t('recurrence.days', 'days')} - {recurrenceType === 'weekly' && - t('recurrence.weeks', 'weeks')} - {(recurrenceType === 'monthly' || - recurrenceType === 'monthly_weekday' || - recurrenceType === 'monthly_last_day') && - t('recurrence.months', 'months')} - -
-
- )} - -
- - - onChange('recurrence_end_date', value || null) - } - placeholder={t( - 'forms.task.endDatePlaceholder', - 'Select end date' - )} - disabled={disabled} - /> -
-
- - {/* Additional settings for specific recurrence types */} - {recurrenceType === 'weekly' && renderWeekdaySelect()} - - {recurrenceType === 'monthly' && renderMonthDayInput()} - - {recurrenceType === 'monthly_weekday' && - renderMonthlyWeekdayInputs()} - - {renderCompletionBasedToggle()} -
- ); -}; - -export default RecurrenceInput; diff --git a/frontend/components/Task/TaskDetails.tsx b/frontend/components/Task/TaskDetails.tsx index b73569c..8ebc9f3 100644 --- a/frontend/components/Task/TaskDetails.tsx +++ b/frontend/components/Task/TaskDetails.tsx @@ -15,6 +15,7 @@ import { } from '@heroicons/react/24/outline'; import ConfirmDialog from '../Shared/ConfirmDialog'; import TaskModal from './TaskModal'; +import RecurrenceDisplay from './RecurrenceDisplay'; import { Task } from '../../entities/Task'; import { Project } from '../../entities/Project'; import { @@ -136,6 +137,25 @@ const TaskDetails: React.FC = () => { }); }; + 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(undefined, { weekday: 'long' }); + const formattedDate = date.toLocaleDateString(undefined, { + day: 'numeric', + month: 'long', + }); + + return { + dayName, + formattedDate, + fullText: `${dayName}, ${formattedDate}`, + isToday, + }; + }; + const formatRecurrence = (recurrenceType: string) => { switch (recurrenceType) { case 'daily': @@ -615,7 +635,8 @@ const TaskDetails: React.FC = () => { task.today_move_count > 1 ? t( 'task.overdueMultipleDays', - `This task has been postponed ${task.today_move_count} times.` + `This task has been postponed {{count}} times.`, + { count: task.today_move_count } ) : t( 'task.overdueYesterday', @@ -864,71 +885,56 @@ const TaskDetails: React.FC = () => { parentTask.recurrence_type !== 'none') ? (
-
- - - {formatRecurrence( - task.recurring_parent_id && - parentTask?.recurrence_type - ? parentTask.recurrence_type - : task.recurrence_type - )} - - {((task.recurring_parent_id && - parentTask?.recurrence_interval && - parentTask.recurrence_interval > - 1) || - (!task.recurring_parent_id && - task.recurrence_interval && - task.recurrence_interval > - 1)) && ( - - ( - {t( - 'recurrence.every', - 'Every' - )}{' '} - {task.recurring_parent_id && - parentTask?.recurrence_interval - ? parentTask.recurrence_interval - : task.recurrence_interval} - ) - - )} -
- {((task.recurring_parent_id && - parentTask?.recurrence_end_date) || - (!task.recurring_parent_id && - task.recurrence_end_date)) && ( -
- - - {t( - 'recurrence.endsOn', - 'Ends on' - )}{' '} - {formatDueDate( - task.recurring_parent_id && - parentTask?.recurrence_end_date - ? parentTask.recurrence_end_date - : task.recurrence_end_date! - )} - -
- )} - {((task.recurring_parent_id && - parentTask?.completion_based) || - (!task.recurring_parent_id && - task.completion_based)) && ( -
- - {t( - 'recurrence.completionBased', - 'Completion-based' - )} - -
- )} +
) : null} @@ -946,12 +952,30 @@ const TaskDetails: React.FC = () => { {task.recurring_parent_id ? t( 'task.nextOccurrencesAfterThis', - 'Next 5 Occurrences After This' + 'Next Occurrences After This' ) : t( 'task.nextOccurrences', - 'Next 5 Occurrences' + 'Next Occurrences' )} + {!loadingIterations && + nextIterations.length > + 0 && + nextIterations.some( + (iter) => + formatDateWithDayName( + iter.date + ).isToday + ) && ( + + ( + {t( + 'task.includingToday', + 'including today' + )} + ) + + )} @@ -972,24 +996,75 @@ const TaskDetails: React.FC = () => { ( iteration, index - ) => ( -
-
- - {index + - 1} - + ) => { + const dateInfo = + formatDateWithDayName( + iteration.date + ); + return ( +
+
+ + {index + + 1} + +
+
+
+ { + dateInfo.dayName + } + {dateInfo.isToday && ( + + {t( + 'dateIndicators.today', + 'TODAY' + )} + + )} +
+
+ { + dateInfo.formattedDate + } +
+
- - {formatDueDate( - iteration.date - )} - -
- ) + ); + } )}
) : ( diff --git a/frontend/components/Task/TaskForm/TaskDueDateSection.tsx b/frontend/components/Task/TaskForm/TaskDueDateSection.tsx new file mode 100644 index 0000000..884302a --- /dev/null +++ b/frontend/components/Task/TaskForm/TaskDueDateSection.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import DatePicker from '../../Shared/DatePicker'; + +interface TaskDueDateSectionProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; +} + +const TaskDueDateSection: React.FC = ({ + value, + onChange, + placeholder = 'Select due date', +}) => { + return ( +
+ +
+ ); +}; + +export default TaskDueDateSection; diff --git a/frontend/components/Task/TaskForm/TaskModalActions.tsx b/frontend/components/Task/TaskForm/TaskModalActions.tsx new file mode 100644 index 0000000..b154368 --- /dev/null +++ b/frontend/components/Task/TaskForm/TaskModalActions.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { TrashIcon } from '@heroicons/react/24/outline'; +import { useTranslation } from 'react-i18next'; + +interface TaskModalActionsProps { + taskId?: number; + isSaving: boolean; + onDelete: () => void; + onCancel: () => void; + onSave: () => void; +} + +const TaskModalActions: React.FC = ({ + taskId, + isSaving, + onDelete, + onCancel, + onSave, +}) => { + const { t } = useTranslation(); + return ( +
+ {/* Left side: Delete and Cancel */} +
+ {taskId && ( + + )} + +
+ + {/* Right side: Save */} + +
+ ); +}; + +export default TaskModalActions; diff --git a/frontend/components/Task/TaskForm/TaskPrioritySection.tsx b/frontend/components/Task/TaskForm/TaskPrioritySection.tsx new file mode 100644 index 0000000..a85567e --- /dev/null +++ b/frontend/components/Task/TaskForm/TaskPrioritySection.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { PriorityType } from '../../../entities/Task'; +import PriorityDropdown from '../../Shared/PriorityDropdown'; + +interface TaskPrioritySectionProps { + value: PriorityType; + onChange: (value: PriorityType) => void; +} + +const TaskPrioritySection: React.FC = ({ + value, + onChange, +}) => { + return ; +}; + +export default TaskPrioritySection; diff --git a/frontend/components/Task/TaskForm/TaskRecurrenceSection.tsx b/frontend/components/Task/TaskForm/TaskRecurrenceSection.tsx index c5e9372..8d10e45 100644 --- a/frontend/components/Task/TaskForm/TaskRecurrenceSection.tsx +++ b/frontend/components/Task/TaskForm/TaskRecurrenceSection.tsx @@ -1,70 +1,517 @@ -import React from 'react'; -import { Task } from '../../../entities/Task'; -import RecurrenceInput from '../RecurrenceInput'; +import React, { useState } from 'react'; +import { RecurrenceType } from '../../../entities/Task'; +import { useTranslation } from 'react-i18next'; +import RecurrenceSelectDropdown from '../../Shared/RecurrenceSelectDropdown'; +import NumberSelectDropdown from '../../Shared/NumberSelectDropdown'; +import ToggleSwitch from '../../Shared/ToggleSwitch'; +import DatePicker from '../../Shared/DatePicker'; +import WeekdaySelector from './WeekdaySelector'; interface TaskRecurrenceSectionProps { - formData: Task; - parentTask: Task | null; - parentTaskLoading: boolean; - onRecurrenceChange: (field: string, value: any) => void; + recurrenceType: RecurrenceType; + recurrenceInterval: number; + recurrenceEndDate?: string; + recurrenceWeekday?: number; + recurrenceWeekdays?: number[]; + recurrenceMonthDay?: number; + recurrenceWeekOfMonth?: number; + completionBased: boolean; + onChange: (field: string, value: any) => void; + disabled?: boolean; + isChildTask?: boolean; + parentTaskLoading?: boolean; onEditParent?: () => void; onParentRecurrenceChange?: (field: string, value: any) => void; } const TaskRecurrenceSection: React.FC = ({ - formData, - parentTask, - parentTaskLoading, - onRecurrenceChange, - onEditParent, + recurrenceType, + recurrenceInterval, + recurrenceEndDate, + recurrenceWeekday, + recurrenceWeekdays, + recurrenceMonthDay, + recurrenceWeekOfMonth, + completionBased, + onChange, + disabled = false, + isChildTask = false, + parentTaskLoading = false, + onEditParent, // eslint-disable-line @typescript-eslint/no-unused-vars onParentRecurrenceChange, }) => { + // Helper function to convert ISO date string to YYYY-MM-DD format for DatePicker + const formatDateForPicker = (dateString?: string) => { + if (!dateString) return ''; + try { + const date = new Date(dateString); + if (isNaN(date.getTime())) return ''; + return date.toISOString().split('T')[0]; // Returns YYYY-MM-DD + } catch { + return dateString; // Return as-is if it's already in the correct format + } + }; + const { t } = useTranslation(); + const [editingParentRecurrence, setEditingParentRecurrence] = + useState(false); + + const weekdays = [ + { value: 0, label: t('weekdays.sunday', 'Sunday') }, + { value: 1, label: t('weekdays.monday', 'Monday') }, + { value: 2, label: t('weekdays.tuesday', 'Tuesday') }, + { value: 3, label: t('weekdays.wednesday', 'Wednesday') }, + { value: 4, label: t('weekdays.thursday', 'Thursday') }, + { value: 5, label: t('weekdays.friday', 'Friday') }, + { value: 6, label: t('weekdays.saturday', 'Saturday') }, + ]; + + const weekOfMonthOptions = [ + { value: 1, label: t('recurrence.firstWeek', 'First') }, + { value: 2, label: t('recurrence.secondWeek', 'Second') }, + { value: 3, label: t('recurrence.thirdWeek', 'Third') }, + { value: 4, label: t('recurrence.fourthWeek', 'Fourth') }, + { value: 5, label: t('recurrence.lastWeek', 'Last') }, + ]; + + const recurrenceTypeOptions = [ + { value: 'none', label: t('recurrence.none', 'No repeat') }, + { value: 'daily', label: t('recurrence.daily', 'Daily') }, + { value: 'weekly', label: t('recurrence.weekly', 'Weekly') }, + { value: 'monthly', label: t('recurrence.monthly', 'Monthly') }, + { + value: 'monthly_weekday', + label: t('recurrence.monthlyWeekday', 'Monthly on weekday'), + }, + { + value: 'monthly_last_day', + label: t('recurrence.monthlyLastDay', 'Monthly on last day'), + }, + ]; + + const renderRecurrenceTypeSelect = ( + customOnChange?: (field: string, value: any) => void, + isDisabled?: boolean + ) => ( +
+ + + (customOnChange || onChange)( + 'recurrence_type', + value as RecurrenceType + ) + } + options={recurrenceTypeOptions} + disabled={isDisabled} + /> +
+ ); + + const renderIntervalInput = ( + customOnChange?: (field: string, value: any) => void, + isDisabled?: boolean + ) => { + // Determine max value based on recurrence type + const getMaxValue = () => { + if (recurrenceType === 'daily') return 30; + if (recurrenceType === 'weekly') return 52; // Max 52 weeks (1 year) + if ( + recurrenceType === 'monthly' || + recurrenceType === 'monthly_weekday' || + recurrenceType === 'monthly_last_day' + ) + return 24; // Max 24 months (2 years) + return 99; + }; + + return ( +
+ +
+
+ + (customOnChange || onChange)( + 'recurrence_interval', + value + ) + } + min={1} + max={getMaxValue()} + disabled={isDisabled} + /> +
+ + {recurrenceType === 'daily' && + t('recurrence.days', 'days')} + {recurrenceType === 'weekly' && + t('recurrence.weeks', 'weeks')} + {(recurrenceType === 'monthly' || + recurrenceType === 'monthly_weekday' || + recurrenceType === 'monthly_last_day') && + t('recurrence.months', 'months')} + +
+
+ ); + }; + + const renderWeekdaySelect = ( + customOnChange?: (field: string, value: any) => void, + isDisabled?: boolean + ) => { + return ( +
+ + (customOnChange || onChange)( + 'recurrence_weekdays', + days + ) + } + disabled={isDisabled} + /> +
+ ); + }; + + const renderMonthDayInput = ( + customOnChange?: (field: string, value: any) => void, + isDisabled?: boolean + ) => ( +
+ + + (customOnChange || onChange)( + 'recurrence_month_day', + e.target.value ? parseInt(e.target.value) : null + ) + } + placeholder={t( + 'recurrence.monthDayPlaceholder', + 'Leave empty for current day' + )} + className="block w-full border border-gray-300 dark:border-gray-900 rounded-md focus:outline-none shadow-sm px-2 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100" + disabled={isDisabled} + /> +
+ ); + + const renderMonthlyWeekdayInputs = () => ( +
+
+ + + onChange( + 'recurrence_week_of_month', + parseInt(value as string) + ) + } + options={weekOfMonthOptions} + /> +
+
+ + + onChange( + 'recurrence_weekday', + parseInt(value as string) + ) + } + options={weekdays} + /> +
+
+ ); + + const renderEndDateInput = ( + customOnChange?: (field: string, value: any) => void, + isDisabled?: boolean + ) => ( +
+ + + (customOnChange || onChange)( + 'recurrence_end_date', + value || null + ) + } + placeholder={t( + 'forms.task.endDatePlaceholder', + 'Select end date' + )} + disabled={isDisabled} + /> +
+ ); + + const renderCompletionBasedToggle = () => ( +
+ onChange('completion_based', checked)} + label={t( + 'forms.task.labels.completionBased', + 'Repeat after completion' + )} + description={t( + 'forms.task.completionBasedHelp', + 'If checked, the next task will be created based on completion date instead of due date' + )} + /> +
+ ); + + // Show message for child tasks + if (isChildTask && parentTaskLoading) { + return ( +
+

+ {t('forms.task.recurrenceSettings', 'Recurrence Settings')} +

+
+ Loading parent task recurrence settings... +
+
+ ); + } + + if (isChildTask) { + return ( +
+

+ {t('forms.task.recurrenceSettings', 'Recurrence Settings')} +

+
+
+ Recurring Task Instance +

+ This task was generated from a recurring task. The + recurrence settings shown below are inherited from + the original task and cannot be edited here. +

+ {onParentRecurrenceChange && ( + + )} +
+
+
+ {editingParentRecurrence && ( +
+
+ ⚠️ You are editing the parent task's + recurrence settings. Changes will affect all + future instances of this recurring task. +
+
+ )} + {recurrenceType === 'none' ? ( + renderRecurrenceTypeSelect( + editingParentRecurrence + ? onParentRecurrenceChange + : undefined, + !editingParentRecurrence + ) + ) : ( + <> + {renderRecurrenceTypeSelect( + editingParentRecurrence + ? onParentRecurrenceChange + : undefined, + !editingParentRecurrence + )} + {renderIntervalInput( + editingParentRecurrence + ? onParentRecurrenceChange + : undefined, + !editingParentRecurrence + )} + {(recurrenceType === 'weekly' || + recurrenceType === 'monthly_weekday') && + renderWeekdaySelect( + editingParentRecurrence + ? onParentRecurrenceChange + : undefined, + !editingParentRecurrence + )} + {recurrenceType === 'monthly' && + renderMonthDayInput( + editingParentRecurrence + ? onParentRecurrenceChange + : undefined, + !editingParentRecurrence + )} + {recurrenceType === 'monthly_weekday' && + renderMonthlyWeekdayInputs()} + {renderEndDateInput( + editingParentRecurrence + ? onParentRecurrenceChange + : undefined, + !editingParentRecurrence + )} + {renderCompletionBasedToggle()} + + )} +
+
+ ); + } + + if (recurrenceType === 'none') { + return
{renderRecurrenceTypeSelect()}
; + } + return ( - +
+

+ {t('forms.task.recurrenceSettings', 'Recurrence Settings')} +

+ + {/* Main recurrence settings in one row */} +
+
+ + + onChange('recurrence_type', value as RecurrenceType) + } + options={recurrenceTypeOptions} + disabled={disabled} + /> +
+ + {(recurrenceType === 'daily' || + recurrenceType === 'weekly' || + recurrenceType === 'monthly' || + recurrenceType === 'monthly_weekday' || + recurrenceType === 'monthly_last_day') && ( +
+ +
+
+ + onChange('recurrence_interval', value) + } + min={1} + max={ + recurrenceType === 'daily' + ? 30 + : recurrenceType === 'weekly' + ? 52 + : recurrenceType === 'monthly' || + recurrenceType === + 'monthly_weekday' || + recurrenceType === + 'monthly_last_day' + ? 24 + : 99 + } + disabled={disabled} + /> +
+ + {recurrenceType === 'daily' && + t('recurrence.days', 'days')} + {recurrenceType === 'weekly' && + t('recurrence.weeks', 'weeks')} + {(recurrenceType === 'monthly' || + recurrenceType === 'monthly_weekday' || + recurrenceType === 'monthly_last_day') && + t('recurrence.months', 'months')} + +
+
+ )} + +
+ + + onChange('recurrence_end_date', value || null) + } + placeholder={t( + 'forms.task.endDatePlaceholder', + 'Select end date' + )} + disabled={disabled} + /> +
+
+ + {/* Additional settings for specific recurrence types */} + {recurrenceType === 'weekly' && renderWeekdaySelect()} + + {recurrenceType === 'monthly' && renderMonthDayInput()} + + {recurrenceType === 'monthly_weekday' && + renderMonthlyWeekdayInputs()} + + {renderCompletionBasedToggle()} +
); }; diff --git a/frontend/components/Task/TaskForm/TaskSectionToggle.tsx b/frontend/components/Task/TaskForm/TaskSectionToggle.tsx new file mode 100644 index 0000000..2b44f37 --- /dev/null +++ b/frontend/components/Task/TaskForm/TaskSectionToggle.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { + TagIcon, + FolderIcon, + ArrowPathIcon, + ListBulletIcon, + ExclamationTriangleIcon, + CalendarIcon, +} from '@heroicons/react/24/outline'; +import { Task } from '../../../entities/Task'; +import { useTranslation } from 'react-i18next'; + +interface TaskSectionToggleProps { + expandedSections: { + tags: boolean; + project: boolean; + priority: boolean; + dueDate: boolean; + recurrence: boolean; + subtasks: boolean; + }; + onToggleSection: ( + section: keyof TaskSectionToggleProps['expandedSections'] + ) => void; + formData: Task; + subtasksCount: number; +} + +const TaskSectionToggle: React.FC = ({ + expandedSections, + onToggleSection, + formData, + subtasksCount, +}) => { + const { t } = useTranslation(); + const toggleButtons = [ + { + key: 'tags' as const, + icon: TagIcon, + title: t('forms.task.labels.tags', 'Tags'), + hasValue: formData.tags && formData.tags.length > 0, + }, + { + key: 'project' as const, + icon: FolderIcon, + title: t('forms.task.labels.project', 'Project'), + hasValue: !!formData.project_id, + }, + { + key: 'priority' as const, + icon: ExclamationTriangleIcon, + title: t('forms.task.labels.priority', 'Priority'), + hasValue: formData.priority != null, + }, + { + key: 'dueDate' as const, + icon: CalendarIcon, + title: t('forms.task.labels.dueDate', 'Due Date'), + hasValue: !!formData.due_date, + }, + { + key: 'recurrence' as const, + icon: ArrowPathIcon, + title: t('forms.task.recurrence', 'Recurrence'), + hasValue: + (formData.recurrence_type && + formData.recurrence_type !== 'none') || + !!formData.recurring_parent_uid, + }, + { + key: 'subtasks' as const, + icon: ListBulletIcon, + title: t('forms.task.subtasks', 'Subtasks'), + hasValue: subtasksCount > 0, + }, + ]; + + return ( +
+
+
+ {toggleButtons.map( + ({ key, icon: Icon, title, hasValue }) => ( + + ) + )} +
+
+
+ ); +}; + +export default TaskSectionToggle; diff --git a/frontend/components/Task/TaskForm/WeekdaySelector.tsx b/frontend/components/Task/TaskForm/WeekdaySelector.tsx new file mode 100644 index 0000000..9d7b6a3 --- /dev/null +++ b/frontend/components/Task/TaskForm/WeekdaySelector.tsx @@ -0,0 +1,144 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getFirstDayOfWeek } from '../../../utils/profileService'; + +interface WeekdaySelectorProps { + selectedDays: number[]; + onChange: (days: number[]) => void; + disabled?: boolean; +} + +const WeekdaySelector: React.FC = ({ + selectedDays = [], + onChange, + disabled = false, +}) => { + const { t } = useTranslation(); + const [firstDayOfWeek, setFirstDayOfWeek] = useState(null); + + useEffect(() => { + const loadFirstDayOfWeek = async () => { + try { + const day = await getFirstDayOfWeek(); + console.log('Loaded first day of week from profile:', day); + setFirstDayOfWeek(day); + } catch (error) { + console.error('Error loading first day of week:', error); + setFirstDayOfWeek(1); // Default to Monday on error + } + }; + loadFirstDayOfWeek(); + }, []); + + // All weekdays with their short names - use useMemo to recalculate when translations change + const allWeekdays = useMemo( + () => [ + { + value: 0, + short: t('weekdays.sunday', 'Sun'), + full: t('weekdaysFull.sunday', 'Sunday'), + }, + { + value: 1, + short: t('weekdays.monday', 'Mon'), + full: t('weekdaysFull.monday', 'Monday'), + }, + { + value: 2, + short: t('weekdays.tuesday', 'Tue'), + full: t('weekdaysFull.tuesday', 'Tuesday'), + }, + { + value: 3, + short: t('weekdays.wednesday', 'Wed'), + full: t('weekdaysFull.wednesday', 'Wednesday'), + }, + { + value: 4, + short: t('weekdays.thursday', 'Thu'), + full: t('weekdaysFull.thursday', 'Thursday'), + }, + { + value: 5, + short: t('weekdays.friday', 'Fri'), + full: t('weekdaysFull.friday', 'Friday'), + }, + { + value: 6, + short: t('weekdays.saturday', 'Sat'), + full: t('weekdaysFull.saturday', 'Saturday'), + }, + ], + [t] + ); + + // Reorder weekdays based on first day of week - use useMemo to recalculate when firstDayOfWeek changes + const orderedWeekdays = useMemo(() => { + if (firstDayOfWeek === null) return allWeekdays; // Return default order while loading + console.log('Reordering weekdays with firstDayOfWeek:', firstDayOfWeek); + const ordered = [ + ...allWeekdays.slice(firstDayOfWeek), + ...allWeekdays.slice(0, firstDayOfWeek), + ]; + console.log( + 'Ordered weekdays:', + ordered.map((w) => w.short).join(', ') + ); + return ordered; + }, [allWeekdays, firstDayOfWeek]); + + const toggleDay = (day: number) => { + if (disabled) return; + + const newSelectedDays = selectedDays.includes(day) + ? selectedDays.filter((d) => d !== day) + : [...selectedDays, day].sort((a, b) => a - b); + + onChange(newSelectedDays); + }; + + return ( +
+ +
+ {orderedWeekdays.map((weekday) => { + const isSelected = selectedDays.includes(weekday.value); + return ( + + ); + })} +
+ {selectedDays.length === 0 && !disabled && ( +

+ {t( + 'forms.task.selectAtLeastOneDay', + 'Please select at least one day' + )} +

+ )} +
+ ); +}; + +export default WeekdaySelector; diff --git a/frontend/components/Task/TaskModal.tsx b/frontend/components/Task/TaskModal.tsx index 9a37afb..0650cd5 100644 --- a/frontend/components/Task/TaskModal.tsx +++ b/frontend/components/Task/TaskModal.tsx @@ -13,16 +13,6 @@ import { } from '../../utils/taskIntelligenceService'; import { useTranslation } from 'react-i18next'; import { getTaskIntelligenceEnabled } from '../../utils/profileService'; -import { - TagIcon, - FolderIcon, - ArrowPathIcon, - TrashIcon, - ListBulletIcon, - ExclamationTriangleIcon, - CalendarIcon, -} from '@heroicons/react/24/outline'; - // Import form sections import TaskTitleSection from './TaskForm/TaskTitleSection'; import TaskContentSection from './TaskForm/TaskContentSection'; @@ -30,8 +20,10 @@ import TaskTagsSection from './TaskForm/TaskTagsSection'; import TaskProjectSection from './TaskForm/TaskProjectSection'; import TaskRecurrenceSection from './TaskForm/TaskRecurrenceSection'; import TaskSubtasksSection from './TaskForm/TaskSubtasksSection'; -import PriorityDropdown from '../Shared/PriorityDropdown'; -import DatePicker from '../Shared/DatePicker'; +import TaskPrioritySection from './TaskForm/TaskPrioritySection'; +import TaskDueDateSection from './TaskForm/TaskDueDateSection'; +import TaskSectionToggle from './TaskForm/TaskSectionToggle'; +import TaskModalActions from './TaskForm/TaskModalActions'; interface TaskModalProps { isOpen: boolean; @@ -704,7 +696,7 @@ const TaskModal: React.FC = ({ 'Priority' )} - = ({ 'Due Date' )} -
- { - const event = - { - target: { - name: 'due_date', - value, - }, - } as React.ChangeEvent; - handleChange( - event - ); - }} - placeholder={t( - 'forms.task.dueDatePlaceholder', - 'Select due date' - )} - /> -
+ { + const event = { + target: { + name: 'due_date', + value, + }, + } as React.ChangeEvent; + handleChange( + event + ); + }} + placeholder={t( + 'forms.task.dueDatePlaceholder', + 'Select due date' + )} + /> )} @@ -771,16 +760,77 @@ const TaskModal: React.FC = ({ )} = ({ {/* Section Icons - Above border, split layout */} -
-
- {/* Left side: Section icons */} -
- {/* Tags Toggle */} - - - {/* Project Toggle */} - - - {/* Priority Toggle */} - - - {/* Due Date Toggle */} - - - {/* Recurrence Toggle */} - - - {/* Subtasks Toggle */} - -
-
-
+ {/* Action Buttons - Below border with custom layout */} -
- {/* Left side: Delete and Cancel */} -
- {task.id && ( - - )} - -
- - {/* Right side: Save */} - -
+ diff --git a/frontend/components/Task/TaskTimeline.tsx b/frontend/components/Task/TaskTimeline.tsx index 135d90d..3ca47f4 100644 --- a/frontend/components/Task/TaskTimeline.tsx +++ b/frontend/components/Task/TaskTimeline.tsx @@ -115,6 +115,31 @@ const TaskTimeline: React.FC = ({ taskUid, refreshKey }) => { } return t('timeline.events.recurrenceEndDateChanged'); } + case 'recurrence_type_changed': { + const oldType = old_value?.recurrence_type; + const newType = new_value?.recurrence_type; + if (oldType !== undefined && newType !== undefined) { + const formatRecurrenceType = (type: string) => { + const typeMap: Record = { + none: t('recurrence.none', 'None'), + daily: t('recurrence.daily', 'Daily'), + weekly: t('recurrence.weekly', 'Weekly'), + monthly: t('recurrence.monthly', 'Monthly'), + monthly_weekday: t( + 'recurrence.monthlyWeekday', + 'Monthly (weekday)' + ), + monthly_last_day: t( + 'recurrence.monthlyLastDay', + 'Monthly (last day)' + ), + }; + return typeMap[type] || type; + }; + return `${t('timeline.events.recurrenceType')}: ${formatRecurrenceType(oldType)} → ${formatRecurrenceType(newType)}`; + } + return t('timeline.events.recurrenceTypeChanged'); + } case 'name_changed': return t('timeline.events.nameUpdated'); case 'description_changed': diff --git a/frontend/entities/Task.ts b/frontend/entities/Task.ts index 76d38b0..1f5dea7 100644 --- a/frontend/entities/Task.ts +++ b/frontend/entities/Task.ts @@ -21,6 +21,7 @@ export interface Task { recurrence_interval?: number; recurrence_end_date?: string; recurrence_weekday?: number; + recurrence_weekdays?: number[]; // Array of weekday numbers for weekly recurrence recurrence_month_day?: number; recurrence_week_of_month?: number; completion_based?: boolean; diff --git a/frontend/entities/TaskEvent.ts b/frontend/entities/TaskEvent.ts index 8d363a5..6c31146 100644 --- a/frontend/entities/TaskEvent.ts +++ b/frontend/entities/TaskEvent.ts @@ -8,6 +8,7 @@ export interface TaskEvent { | 'priority_changed' | 'due_date_changed' | 'recurrence_end_date_changed' + | 'recurrence_type_changed' | 'project_changed' | 'name_changed' | 'description_changed' diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 534f8e1..125c841 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -156,6 +156,8 @@ "priority": "Priority", "dueDate": "Due date", "recurrenceEndDate": "Recurrence end date", + "recurrenceType": "Recurrence type", + "recurrenceTypeChanged": "Recurrence type changed", "none": "None" } },