Fix redesign recurring tasks (#582)

* Break down TaskModal

* Fix an issue with counting past times

* Add daily reccurence on week

* fixup! Add daily reccurence on week

* fixup! fixup! Add daily reccurence on week

* Improve recurring widget on task page

* fixup! Improve recurring widget on task page
This commit is contained in:
Chris 2025-11-19 17:03:26 +02:00 committed by GitHub
parent c3bf5f5522
commit eb10ea8355
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1534 additions and 940 deletions

View file

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

View file

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

View file

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

View file

@ -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<RecurrenceDisplayProps> = ({
recurrenceType,
recurrenceInterval = 1,
recurrenceWeekdays,
recurrenceEndDate,
recurrenceMonthDay,
// recurrenceWeekOfMonth and recurrenceWeekday kept for future use
completionBased = false,
compact = false,
}) => {
const { t } = useTranslation();
const [firstDayOfWeek, setFirstDayOfWeek] = useState<number | null>(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 (
<div className={`${compact ? 'space-y-2' : 'space-y-3'}`}>
{/* Main recurrence info */}
<div className="flex items-center">
<ArrowPathIcon className="h-4 w-4 mr-2 text-blue-600 dark:text-blue-400" />
<span
className={`${compact ? 'text-sm' : 'text-base'} font-medium text-gray-900 dark:text-gray-100`}
>
{formatRecurrenceText()}
</span>
{completionBased && (
<span className="ml-2 text-xs px-2 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded">
{t('recurrence.completionBased', 'After completion')}
</span>
)}
</div>
{/* Weekday display for weekly recurrence */}
{recurrenceType === 'weekly' &&
recurrenceWeekdays &&
recurrenceWeekdays.length > 0 && (
<div className="ml-7">
<div className="text-xs text-gray-600 dark:text-gray-400 mb-1">
{t('forms.task.labels.repeatOn', 'Repeat on')}:
</div>
<div className="flex gap-1 flex-wrap">
{orderedWeekdays.map((weekday) => {
const isSelected = recurrenceWeekdays.includes(
weekday.value
);
return (
<div
key={weekday.value}
className={`
w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium
${
isSelected
? 'bg-blue-600 text-white dark:bg-blue-500'
: 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-600'
}
`}
title={weekday.full}
>
{weekday.short}
</div>
);
})}
</div>
</div>
)}
{/* Month day display for monthly recurrence */}
{recurrenceType === 'monthly' && recurrenceMonthDay && (
<div className="ml-7 text-sm text-gray-600 dark:text-gray-400">
{t('recurrence.onDay', 'On day')} {recurrenceMonthDay}
</div>
)}
{/* End date display */}
{recurrenceEndDate && (
<div className="ml-7 flex items-center text-sm text-gray-600 dark:text-gray-400">
<CalendarIcon className="h-4 w-4 mr-1" />
<span>
{t('recurrence.until', 'Until')}{' '}
{formatEndDate(recurrenceEndDate)}
</span>
</div>
)}
</div>
);
};
export default RecurrenceDisplay;

View file

@ -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<RecurrenceInputProps> = ({
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
) => (
<div className="mb-4">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.recurrenceType', 'Repeat')}
</label>
<RecurrenceSelectDropdown
value={recurrenceType}
onChange={(value) =>
(customOnChange || onChange)(
'recurrence_type',
value as RecurrenceType
)
}
options={recurrenceTypeOptions}
disabled={isDisabled}
/>
</div>
);
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 (
<div className="mb-4">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.recurrenceInterval', 'Every')}
</label>
<div className="flex items-center space-x-2">
<div className="w-20">
<NumberSelectDropdown
value={recurrenceInterval || 1}
onChange={(value) =>
(customOnChange || onChange)(
'recurrence_interval',
value
)
}
min={1}
max={getMaxValue()}
disabled={isDisabled}
/>
</div>
<span className="text-sm text-gray-600 dark:text-gray-400">
{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')}
</span>
</div>
</div>
);
};
const renderWeekdaySelect = (
customOnChange?: (field: string, value: any) => void,
isDisabled?: boolean
) => {
const weekdayOptions = [
{ value: '', label: t('recurrence.anyDay', 'Any day') },
...weekdays,
];
return (
<div className="mb-4">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.weekday', 'On day')}
</label>
<RecurrenceSelectDropdown
value={
recurrenceWeekday !== undefined ? recurrenceWeekday : ''
}
onChange={(value) =>
(customOnChange || onChange)(
'recurrence_weekday',
value !== '' ? parseInt(value as string) : null
)
}
options={weekdayOptions}
disabled={isDisabled}
/>
</div>
);
};
const renderMonthDayInput = (
customOnChange?: (field: string, value: any) => void,
isDisabled?: boolean
) => (
<div className="mb-4">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.monthDay', 'Day of month')}
</label>
<input
type="number"
min="1"
max="31"
value={recurrenceMonthDay || ''}
onChange={(e) =>
(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}
/>
</div>
);
const renderMonthlyWeekdayInputs = () => (
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.weekOfMonth', 'Week of month')}
</label>
<RecurrenceSelectDropdown
value={recurrenceWeekOfMonth || 1}
onChange={(value) =>
onChange(
'recurrence_week_of_month',
parseInt(value as string)
)
}
options={weekOfMonthOptions}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.weekday', 'Weekday')}
</label>
<RecurrenceSelectDropdown
value={recurrenceWeekday || 1}
onChange={(value) =>
onChange(
'recurrence_weekday',
parseInt(value as string)
)
}
options={weekdays}
/>
</div>
</div>
);
const renderEndDateInput = (
customOnChange?: (field: string, value: any) => void,
isDisabled?: boolean
) => (
<div className="mb-4">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t(
'forms.task.labels.recurrenceEndDate',
'End date (optional)'
)}
</label>
<DatePicker
value={formatDateForPicker(recurrenceEndDate)}
onChange={(value) =>
(customOnChange || onChange)(
'recurrence_end_date',
value || null
)
}
placeholder={t(
'forms.task.endDatePlaceholder',
'Select end date'
)}
disabled={isDisabled}
/>
</div>
);
const renderCompletionBasedToggle = () => (
<div className="mb-4">
<ToggleSwitch
checked={completionBased}
onChange={(checked) => 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'
)}
/>
</div>
);
// Show message for child tasks
if (isChildTask && parentTaskLoading) {
return (
<div className="pb-3 border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-4">
{t('forms.task.recurrenceSettings', 'Recurrence Settings')}
</h3>
<div className="text-sm text-gray-600 dark:text-gray-400">
Loading parent task recurrence settings...
</div>
</div>
);
}
if (isChildTask) {
return (
<div className="pb-3">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-4">
{t('forms.task.recurrenceSettings', 'Recurrence Settings')}
</h3>
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-md p-3 mb-4">
<div className="text-sm text-blue-800 dark:text-blue-200">
<strong>Recurring Task Instance</strong>
<p className="mt-1">
This task was generated from a recurring task. The
recurrence settings shown below are inherited from
the original task and cannot be edited here.
</p>
{onParentRecurrenceChange && (
<button
type="button"
onClick={() =>
setEditingParentRecurrence(
!editingParentRecurrence
)
}
className={`mt-2 inline-flex items-center px-3 py-1 border text-xs font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${
editingParentRecurrence
? 'border-red-300 dark:border-red-600 text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/50 hover:bg-red-100 dark:hover:bg-red-800/50'
: 'border-blue-300 dark:border-blue-600 text-blue-700 dark:text-blue-300 bg-white dark:bg-blue-900/50 hover:bg-blue-50 dark:hover:bg-blue-800/50'
}`}
>
{editingParentRecurrence
? 'Cancel Edit'
: 'Edit Parent Recurrence'}
</button>
)}
</div>
</div>
<div
className={
editingParentRecurrence
? ''
: 'opacity-60 pointer-events-none'
}
>
{editingParentRecurrence && (
<div className="mb-4 p-2 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 rounded-md">
<div className="text-xs text-yellow-800 dark:text-yellow-200">
You are editing the parent task&apos;s
recurrence settings. Changes will affect all
future instances of this recurring task.
</div>
</div>
)}
{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()}
</>
)}
</div>
</div>
);
}
if (recurrenceType === 'none') {
return <div className="pb-3">{renderRecurrenceTypeSelect()}</div>;
}
return (
<div className="pb-3">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-4">
{t('forms.task.recurrenceSettings', 'Recurrence Settings')}
</h3>
{/* Main recurrence settings in one row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.recurrenceType', 'Repeat')}
</label>
<RecurrenceSelectDropdown
value={recurrenceType}
onChange={(value) =>
onChange('recurrence_type', value as RecurrenceType)
}
options={recurrenceTypeOptions}
disabled={disabled}
/>
</div>
{(recurrenceType === 'daily' ||
recurrenceType === 'weekly' ||
recurrenceType === 'monthly' ||
recurrenceType === 'monthly_weekday' ||
recurrenceType === 'monthly_last_day') && (
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.recurrenceInterval', 'Every')}
</label>
<div className="flex items-center space-x-2">
<div className="w-20">
<NumberSelectDropdown
value={recurrenceInterval || 1}
onChange={(value) =>
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}
/>
</div>
<span className="text-sm text-gray-600 dark:text-gray-400">
{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')}
</span>
</div>
</div>
)}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t(
'forms.task.labels.recurrenceEndDate',
'End date (optional)'
)}
</label>
<DatePicker
value={formatDateForPicker(recurrenceEndDate)}
onChange={(value) =>
onChange('recurrence_end_date', value || null)
}
placeholder={t(
'forms.task.endDatePlaceholder',
'Select end date'
)}
disabled={disabled}
/>
</div>
</div>
{/* Additional settings for specific recurrence types */}
{recurrenceType === 'weekly' && renderWeekdaySelect()}
{recurrenceType === 'monthly' && renderMonthDayInput()}
{recurrenceType === 'monthly_weekday' &&
renderMonthlyWeekdayInputs()}
{renderCompletionBasedToggle()}
</div>
);
};
export default RecurrenceInput;

View file

@ -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') ? (
<div className="mb-4">
<div className="flex items-center mb-2">
<ArrowPathIcon 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">
{formatRecurrence(
task.recurring_parent_id &&
parentTask?.recurrence_type
? parentTask.recurrence_type
: task.recurrence_type
)}
</span>
{((task.recurring_parent_id &&
parentTask?.recurrence_interval &&
parentTask.recurrence_interval >
1) ||
(!task.recurring_parent_id &&
task.recurrence_interval &&
task.recurrence_interval >
1)) && (
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
(
{t(
'recurrence.every',
'Every'
)}{' '}
{task.recurring_parent_id &&
parentTask?.recurrence_interval
? parentTask.recurrence_interval
: task.recurrence_interval}
)
</span>
)}
</div>
{((task.recurring_parent_id &&
parentTask?.recurrence_end_date) ||
(!task.recurring_parent_id &&
task.recurrence_end_date)) && (
<div className="flex items-center text-sm">
<CalendarIcon className="h-4 w-4 mr-2 text-gray-500 dark:text-gray-400" />
<span className="text-gray-500 dark:text-gray-400">
{t(
'recurrence.endsOn',
'Ends on'
)}{' '}
{formatDueDate(
task.recurring_parent_id &&
parentTask?.recurrence_end_date
? parentTask.recurrence_end_date
: task.recurrence_end_date!
)}
</span>
</div>
)}
{((task.recurring_parent_id &&
parentTask?.completion_based) ||
(!task.recurring_parent_id &&
task.completion_based)) && (
<div className="flex items-center mt-2">
<span className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200">
{t(
'recurrence.completionBased',
'Completion-based'
)}
</span>
</div>
)}
<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>
) : 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
) && (
<span className="ml-2 text-xs text-blue-600 dark:text-blue-400">
(
{t(
'task.includingToday',
'including today'
)}
)
</span>
)}
</span>
</div>
@ -972,24 +996,75 @@ const TaskDetails: React.FC = () => {
(
iteration,
index
) => (
<div
key={index}
className="flex items-center py-1 px-2 rounded bg-gray-50 dark:bg-gray-800"
>
<div className="w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center mr-3">
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">
{index +
1}
</span>
) => {
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>
<span className="text-sm text-gray-700 dark:text-gray-300">
{formatDueDate(
iteration.date
)}
</span>
</div>
)
);
}
)}
</div>
) : (

View file

@ -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<TaskDueDateSectionProps> = ({
value,
onChange,
placeholder = 'Select due date',
}) => {
return (
<div className="overflow-visible">
<DatePicker
value={value || ''}
onChange={onChange}
placeholder={placeholder}
/>
</div>
);
};
export default TaskDueDateSection;

View file

@ -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<TaskModalActionsProps> = ({
taskId,
isSaving,
onDelete,
onCancel,
onSave,
}) => {
const { t } = useTranslation();
return (
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between sm:rounded-bl-lg">
{/* Left side: Delete and Cancel */}
<div className="flex items-center space-x-3">
{taskId && (
<button
type="button"
onClick={onDelete}
className="p-2 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none transition duration-150 ease-in-out"
title={t('common.delete', 'Delete')}
>
<TrashIcon className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={onCancel}
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 focus:outline-none transition duration-150 ease-in-out text-sm"
>
{t('common.cancel', 'Cancel')}
</button>
</div>
{/* Right side: Save */}
<button
type="button"
onClick={onSave}
disabled={isSaving}
className={`px-4 py-2 rounded-md focus:outline-none transition duration-150 ease-in-out text-sm ${
isSaving
? 'bg-blue-400 text-white cursor-not-allowed dark:bg-blue-400'
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
}`}
data-testid="task-save-button"
>
{isSaving ? (
<span className="flex items-center">
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{t('common.saving', 'Saving...')}
</span>
) : (
t('common.save', 'Save')
)}
</button>
</div>
);
};
export default TaskModalActions;

View file

@ -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<TaskPrioritySectionProps> = ({
value,
onChange,
}) => {
return <PriorityDropdown value={value} onChange={onChange} />;
};
export default TaskPrioritySection;

View file

@ -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<TaskRecurrenceSectionProps> = ({
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
) => (
<div className="mb-4">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.recurrenceType', 'Repeat')}
</label>
<RecurrenceSelectDropdown
value={recurrenceType}
onChange={(value) =>
(customOnChange || onChange)(
'recurrence_type',
value as RecurrenceType
)
}
options={recurrenceTypeOptions}
disabled={isDisabled}
/>
</div>
);
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 (
<div className="mb-4">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.recurrenceInterval', 'Every')}
</label>
<div className="flex items-center space-x-2">
<div className="w-20">
<NumberSelectDropdown
value={recurrenceInterval || 1}
onChange={(value) =>
(customOnChange || onChange)(
'recurrence_interval',
value
)
}
min={1}
max={getMaxValue()}
disabled={isDisabled}
/>
</div>
<span className="text-sm text-gray-600 dark:text-gray-400">
{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')}
</span>
</div>
</div>
);
};
const renderWeekdaySelect = (
customOnChange?: (field: string, value: any) => void,
isDisabled?: boolean
) => {
return (
<div className="mb-4">
<WeekdaySelector
selectedDays={recurrenceWeekdays || []}
onChange={(days) =>
(customOnChange || onChange)(
'recurrence_weekdays',
days
)
}
disabled={isDisabled}
/>
</div>
);
};
const renderMonthDayInput = (
customOnChange?: (field: string, value: any) => void,
isDisabled?: boolean
) => (
<div className="mb-4">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.monthDay', 'Day of month')}
</label>
<input
type="number"
min="1"
max="31"
value={recurrenceMonthDay || ''}
onChange={(e) =>
(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}
/>
</div>
);
const renderMonthlyWeekdayInputs = () => (
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.weekOfMonth', 'Week of month')}
</label>
<RecurrenceSelectDropdown
value={recurrenceWeekOfMonth || 1}
onChange={(value) =>
onChange(
'recurrence_week_of_month',
parseInt(value as string)
)
}
options={weekOfMonthOptions}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.weekday', 'Weekday')}
</label>
<RecurrenceSelectDropdown
value={recurrenceWeekday || 1}
onChange={(value) =>
onChange(
'recurrence_weekday',
parseInt(value as string)
)
}
options={weekdays}
/>
</div>
</div>
);
const renderEndDateInput = (
customOnChange?: (field: string, value: any) => void,
isDisabled?: boolean
) => (
<div className="mb-4">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t(
'forms.task.labels.recurrenceEndDate',
'End date (optional)'
)}
</label>
<DatePicker
value={formatDateForPicker(recurrenceEndDate)}
onChange={(value) =>
(customOnChange || onChange)(
'recurrence_end_date',
value || null
)
}
placeholder={t(
'forms.task.endDatePlaceholder',
'Select end date'
)}
disabled={isDisabled}
/>
</div>
);
const renderCompletionBasedToggle = () => (
<div className="mb-4">
<ToggleSwitch
checked={completionBased}
onChange={(checked) => 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'
)}
/>
</div>
);
// Show message for child tasks
if (isChildTask && parentTaskLoading) {
return (
<div className="pb-3 border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-4">
{t('forms.task.recurrenceSettings', 'Recurrence Settings')}
</h3>
<div className="text-sm text-gray-600 dark:text-gray-400">
Loading parent task recurrence settings...
</div>
</div>
);
}
if (isChildTask) {
return (
<div className="pb-3">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-4">
{t('forms.task.recurrenceSettings', 'Recurrence Settings')}
</h3>
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-md p-3 mb-4">
<div className="text-sm text-blue-800 dark:text-blue-200">
<strong>Recurring Task Instance</strong>
<p className="mt-1">
This task was generated from a recurring task. The
recurrence settings shown below are inherited from
the original task and cannot be edited here.
</p>
{onParentRecurrenceChange && (
<button
type="button"
onClick={() =>
setEditingParentRecurrence(
!editingParentRecurrence
)
}
className={`mt-2 inline-flex items-center px-3 py-1 border text-xs font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${
editingParentRecurrence
? 'border-red-300 dark:border-red-600 text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/50 hover:bg-red-100 dark:hover:bg-red-800/50'
: 'border-blue-300 dark:border-blue-600 text-blue-700 dark:text-blue-300 bg-white dark:bg-blue-900/50 hover:bg-blue-50 dark:hover:bg-blue-800/50'
}`}
>
{editingParentRecurrence
? 'Cancel Edit'
: 'Edit Parent Recurrence'}
</button>
)}
</div>
</div>
<div
className={
editingParentRecurrence
? ''
: 'opacity-60 pointer-events-none'
}
>
{editingParentRecurrence && (
<div className="mb-4 p-2 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 rounded-md">
<div className="text-xs text-yellow-800 dark:text-yellow-200">
You are editing the parent task&apos;s
recurrence settings. Changes will affect all
future instances of this recurring task.
</div>
</div>
)}
{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()}
</>
)}
</div>
</div>
);
}
if (recurrenceType === 'none') {
return <div className="pb-3">{renderRecurrenceTypeSelect()}</div>;
}
return (
<RecurrenceInput
recurrenceType={
parentTask
? parentTask.recurrence_type || 'none'
: formData.recurrence_type || 'none'
}
recurrenceInterval={
parentTask
? parentTask.recurrence_interval || 1
: formData.recurrence_interval || 1
}
recurrenceEndDate={
parentTask
? parentTask.recurrence_end_date
: formData.recurrence_end_date
}
recurrenceWeekday={
parentTask
? parentTask.recurrence_weekday
: formData.recurrence_weekday
}
recurrenceMonthDay={
parentTask
? parentTask.recurrence_month_day
: formData.recurrence_month_day
}
recurrenceWeekOfMonth={
parentTask
? parentTask.recurrence_week_of_month
: formData.recurrence_week_of_month
}
completionBased={
parentTask
? parentTask.completion_based || false
: formData.completion_based || false
}
onChange={onRecurrenceChange}
disabled={!!parentTask}
isChildTask={!!parentTask}
parentTaskLoading={parentTaskLoading}
onEditParent={parentTask ? onEditParent : undefined}
onParentRecurrenceChange={
parentTask ? onParentRecurrenceChange : undefined
}
/>
<div className="pb-3">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-4">
{t('forms.task.recurrenceSettings', 'Recurrence Settings')}
</h3>
{/* Main recurrence settings in one row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.recurrenceType', 'Repeat')}
</label>
<RecurrenceSelectDropdown
value={recurrenceType}
onChange={(value) =>
onChange('recurrence_type', value as RecurrenceType)
}
options={recurrenceTypeOptions}
disabled={disabled}
/>
</div>
{(recurrenceType === 'daily' ||
recurrenceType === 'weekly' ||
recurrenceType === 'monthly' ||
recurrenceType === 'monthly_weekday' ||
recurrenceType === 'monthly_last_day') && (
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.recurrenceInterval', 'Every')}
</label>
<div className="flex items-center space-x-2">
<div className="w-20">
<NumberSelectDropdown
value={recurrenceInterval || 1}
onChange={(value) =>
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}
/>
</div>
<span className="text-sm text-gray-600 dark:text-gray-400">
{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')}
</span>
</div>
</div>
)}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t(
'forms.task.labels.recurrenceEndDate',
'End date (optional)'
)}
</label>
<DatePicker
value={formatDateForPicker(recurrenceEndDate)}
onChange={(value) =>
onChange('recurrence_end_date', value || null)
}
placeholder={t(
'forms.task.endDatePlaceholder',
'Select end date'
)}
disabled={disabled}
/>
</div>
</div>
{/* Additional settings for specific recurrence types */}
{recurrenceType === 'weekly' && renderWeekdaySelect()}
{recurrenceType === 'monthly' && renderMonthDayInput()}
{recurrenceType === 'monthly_weekday' &&
renderMonthlyWeekdayInputs()}
{renderCompletionBasedToggle()}
</div>
);
};

View file

@ -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<TaskSectionToggleProps> = ({
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 (
<div className="flex-shrink-0 bg-white dark:bg-gray-800 px-3 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-1">
{toggleButtons.map(
({ key, icon: Icon, title, hasValue }) => (
<button
key={key}
onClick={() => onToggleSection(key)}
className={`relative p-2 rounded-full transition-colors ${
expandedSections[key]
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={title}
>
<Icon className="h-5 w-5" />
{hasValue && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)}
</button>
)
)}
</div>
</div>
</div>
);
};
export default TaskSectionToggle;

View file

@ -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<WeekdaySelectorProps> = ({
selectedDays = [],
onChange,
disabled = false,
}) => {
const { t } = useTranslation();
const [firstDayOfWeek, setFirstDayOfWeek] = useState<number | null>(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 (
<div className="space-y-2">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
{t('forms.task.labels.repeatOn', 'Repeat on')}
</label>
<div className="flex gap-2 flex-wrap">
{orderedWeekdays.map((weekday) => {
const isSelected = selectedDays.includes(weekday.value);
return (
<button
key={weekday.value}
type="button"
onClick={() => toggleDay(weekday.value)}
disabled={disabled}
title={weekday.full}
className={`
w-10 h-10 rounded-full font-medium text-sm
transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
${
isSelected
? 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
>
{weekday.short}
</button>
);
})}
</div>
{selectedDays.length === 0 && !disabled && (
<p className="text-xs text-amber-600 dark:text-amber-400">
{t(
'forms.task.selectAtLeastOneDay',
'Please select at least one day'
)}
</p>
)}
</div>
);
};
export default WeekdaySelector;

View file

@ -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<TaskModalProps> = ({
'Priority'
)}
</h3>
<PriorityDropdown
<TaskPrioritySection
value={getPriorityString(
formData.priority
)}
@ -733,32 +725,29 @@ const TaskModal: React.FC<TaskModalProps> = ({
'Due Date'
)}
</h3>
<div className="overflow-visible">
<DatePicker
value={
formData.due_date ||
''
}
onChange={(
value
) => {
const event =
{
target: {
name: 'due_date',
value,
},
} as React.ChangeEvent<HTMLInputElement>;
handleChange(
event
);
}}
placeholder={t(
'forms.task.dueDatePlaceholder',
'Select due date'
)}
/>
</div>
<TaskDueDateSection
value={
formData.due_date ||
''
}
onChange={(
value
) => {
const event = {
target: {
name: 'due_date',
value,
},
} as React.ChangeEvent<HTMLInputElement>;
handleChange(
event
);
}}
placeholder={t(
'forms.task.dueDatePlaceholder',
'Select due date'
)}
/>
</div>
)}
@ -771,16 +760,77 @@ const TaskModal: React.FC<TaskModalProps> = ({
)}
</h3>
<TaskRecurrenceSection
formData={formData}
parentTask={
parentTask
recurrenceType={
(
parentTask ||
formData
)
.recurrence_type ||
'none'
}
recurrenceInterval={
(
parentTask ||
formData
)
.recurrence_interval ||
1
}
recurrenceEndDate={
(
parentTask ||
formData
)
.recurrence_end_date
}
recurrenceWeekday={
(
parentTask ||
formData
)
.recurrence_weekday
}
recurrenceWeekdays={
(
parentTask ||
formData
)
.recurrence_weekdays
}
recurrenceMonthDay={
(
parentTask ||
formData
)
.recurrence_month_day
}
recurrenceWeekOfMonth={
(
parentTask ||
formData
)
.recurrence_week_of_month
}
completionBased={
(
parentTask ||
formData
)
.completion_based ||
false
}
onChange={
handleRecurrenceChange
}
disabled={
!!parentTask
}
isChildTask={
!!parentTask
}
parentTaskLoading={
parentTaskLoading
}
onRecurrenceChange={
handleRecurrenceChange
}
onEditParent={
parentTask
? handleEditParent
@ -840,214 +890,21 @@ const TaskModal: React.FC<TaskModalProps> = ({
</div>
{/* Section Icons - Above border, split layout */}
<div className="flex-shrink-0 bg-white dark:bg-gray-800 px-3 py-2">
<div className="flex items-center justify-between">
{/* Left side: Section icons */}
<div className="flex items-center space-x-1">
{/* Tags Toggle */}
<button
onClick={() =>
toggleSection('tags')
}
className={`relative p-2 rounded-full transition-colors ${
expandedSections.tags
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={t(
'forms.task.labels.tags',
'Tags'
)}
>
<TagIcon className="h-5 w-5" />
{formData.tags &&
formData.tags.length >
0 && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)}
</button>
{/* Project Toggle */}
<button
onClick={() =>
toggleSection('project')
}
className={`relative p-2 rounded-full transition-colors ${
expandedSections.project
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={t(
'forms.task.labels.project',
'Project'
)}
>
<FolderIcon className="h-5 w-5" />
{formData.project_id && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)}
</button>
{/* Priority Toggle */}
<button
onClick={() =>
toggleSection('priority')
}
className={`relative p-2 rounded-full transition-colors ${
expandedSections.priority
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={t(
'forms.task.labels.priority',
'Priority'
)}
>
<ExclamationTriangleIcon className="h-5 w-5" />
{formData.priority != null && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)}
</button>
{/* Due Date Toggle */}
<button
onClick={() =>
toggleSection('dueDate')
}
className={`relative p-2 rounded-full transition-colors ${
expandedSections.dueDate
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={t(
'forms.task.labels.dueDate',
'Due Date'
)}
>
<CalendarIcon className="h-5 w-5" />
{formData.due_date && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)}
</button>
{/* Recurrence Toggle */}
<button
onClick={() =>
toggleSection('recurrence')
}
className={`relative p-2 rounded-full transition-colors ${
expandedSections.recurrence
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={t(
'forms.task.recurrence',
'Recurrence'
)}
>
<ArrowPathIcon className="h-5 w-5" />
{((formData.recurrence_type &&
formData.recurrence_type !==
'none') ||
formData.recurring_parent_uid) && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)}
</button>
{/* Subtasks Toggle */}
<button
onClick={() =>
toggleSection('subtasks')
}
className={`relative p-2 rounded-full transition-colors ${
expandedSections.subtasks
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={t(
'forms.task.subtasks',
'Subtasks'
)}
>
<ListBulletIcon className="h-5 w-5" />
{subtasks.length > 0 && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)}
</button>
</div>
</div>
</div>
<TaskSectionToggle
expandedSections={expandedSections}
onToggleSection={toggleSection}
formData={formData}
subtasksCount={subtasks.length}
/>
{/* Action Buttons - Below border with custom layout */}
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between sm:rounded-bl-lg">
{/* Left side: Delete and Cancel */}
<div className="flex items-center space-x-3">
{task.id && (
<button
type="button"
onClick={handleDeleteClick}
className="p-2 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none transition duration-150 ease-in-out"
title={t(
'common.delete',
'Delete'
)}
>
<TrashIcon className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={handleClose}
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 focus:outline-none transition duration-150 ease-in-out text-sm"
>
{t('common.cancel', 'Cancel')}
</button>
</div>
{/* Right side: Save */}
<button
type="button"
onClick={handleSubmit}
disabled={isSaving}
className={`px-4 py-2 rounded-md focus:outline-none transition duration-150 ease-in-out text-sm ${
isSaving
? 'bg-blue-400 text-white cursor-not-allowed dark:bg-blue-400'
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
}`}
data-testid="task-save-button"
>
{isSaving ? (
<span className="flex items-center">
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{t(
'common.saving',
'Saving...'
)}
</span>
) : (
t('common.save', 'Save')
)}
</button>
</div>
<TaskModalActions
taskId={task.id}
isSaving={isSaving}
onDelete={handleDeleteClick}
onCancel={handleClose}
onSave={handleSubmit}
/>
</div>
</div>
</div>

View file

@ -115,6 +115,31 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ 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<string, string> = {
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':

View file

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

View file

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

View file

@ -156,6 +156,8 @@
"priority": "Priority",
"dueDate": "Due date",
"recurrenceEndDate": "Recurrence end date",
"recurrenceType": "Recurrence type",
"recurrenceTypeChanged": "Recurrence type changed",
"none": "None"
}
},