Add repetition_date support for tasks (v3.3.0)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 42s

- Add repetition_date field to tasks table (migration 018)
- Support pattern-based repetition: day of week, day of month, specific date
- Add 'Через'/'Каждое' mode selector in task form
- Auto-calculate next_show_at from repetition_date on create/complete
- Show calculated next date in postpone dialog for repetition_date tasks
- Update version to 3.3.0
This commit is contained in:
poignatov
2026-01-06 16:41:54 +03:00
parent 508355dcb3
commit b41f6e7cdc
6 changed files with 473 additions and 74 deletions

View File

@@ -12,6 +12,7 @@ function TaskForm({ onNavigate, taskId }) {
const [rewardMessage, setRewardMessage] = useState('')
const [repetitionPeriodValue, setRepetitionPeriodValue] = useState('')
const [repetitionPeriodType, setRepetitionPeriodType] = useState('day')
const [repetitionMode, setRepetitionMode] = useState('after') // 'after' = Через, 'each' = Каждое
const [rewards, setRewards] = useState([])
const [subtasks, setSubtasks] = useState([])
const [projects, setProjects] = useState([])
@@ -44,6 +45,7 @@ function TaskForm({ onNavigate, taskId }) {
setProgressionBase('')
setRepetitionPeriodValue('')
setRepetitionPeriodType('day')
setRepetitionMode('after')
setRewards([])
setSubtasks([])
setError('')
@@ -76,8 +78,30 @@ function TaskForm({ onNavigate, taskId }) {
setRewardMessage(data.task.reward_message || '')
setProgressionBase(data.task.progression_base ? String(data.task.progression_base) : '')
// Парсим repetition_period если он есть
if (data.task.repetition_period) {
// Парсим repetition_date если он есть (приоритет над repetition_period)
if (data.task.repetition_date) {
const dateStr = data.task.repetition_date.trim()
console.log('Parsing repetition_date:', dateStr) // Отладка
// Формат: "N unit" где unit = week, month, year
// или "MM-DD year" для конкретной даты в году
const match = dateStr.match(/^(\d+(?:-\d+)?)\s+(week|month|year)/i)
if (match) {
const value = match[1]
const unit = match[2].toLowerCase()
setRepetitionPeriodValue(value)
setRepetitionPeriodType(unit)
setRepetitionMode('each')
} else {
console.log('Failed to parse repetition_date:', dateStr)
setRepetitionPeriodValue('')
setRepetitionPeriodType('week')
setRepetitionMode('each')
}
} else if (data.task.repetition_period) {
// Парсим repetition_period если он есть
setRepetitionMode('after')
const periodStr = data.task.repetition_period.trim()
console.log('Parsing repetition_period:', periodStr, 'Full task data:', data.task) // Отладка
@@ -199,9 +223,10 @@ function TaskForm({ onNavigate, taskId }) {
console.log('Successfully parsed repetition_period - value will be set') // Отладка
}
} else {
console.log('No repetition_period in task data') // Отладка
console.log('No repetition_period or repetition_date in task data') // Отладка
setRepetitionPeriodValue('')
setRepetitionPeriodType('day')
setRepetitionMode('after')
}
// Загружаем rewards
@@ -384,25 +409,37 @@ function TaskForm({ onNavigate, taskId }) {
}
try {
// Преобразуем период повторения в строку INTERVAL для PostgreSQL
// Преобразуем период повторения в строку INTERVAL для PostgreSQL или repetition_date
let repetitionPeriod = null
let repetitionDate = null
if (repetitionPeriodValue && repetitionPeriodValue.trim() !== '') {
const value = parseInt(repetitionPeriodValue.trim(), 10)
if (!isNaN(value) && value >= 0) {
const typeMap = {
'minute': 'minute',
'hour': 'hour',
'day': 'day',
'week': 'week',
'month': 'month',
'year': 'year'
const valueStr = repetitionPeriodValue.trim()
if (repetitionMode === 'each') {
// Режим "Каждое" - сохраняем как repetition_date
// Формат: "N unit" где unit = week, month, year
repetitionDate = `${valueStr} ${repetitionPeriodType}`
console.log('Sending repetition_date:', repetitionDate)
} else {
// Режим "Через" - сохраняем как repetition_period (INTERVAL)
const value = parseInt(valueStr, 10)
if (!isNaN(value) && value >= 0) {
const typeMap = {
'minute': 'minute',
'hour': 'hour',
'day': 'day',
'week': 'week',
'month': 'month',
'year': 'year'
}
const unit = typeMap[repetitionPeriodType] || 'day'
repetitionPeriod = `${value} ${unit}`
console.log('Sending repetition_period:', repetitionPeriod, 'from value:', repetitionPeriodValue, 'type:', repetitionPeriodType)
}
const unit = typeMap[repetitionPeriodType] || 'day'
repetitionPeriod = `${value} ${unit}`
console.log('Sending repetition_period:', repetitionPeriod, 'from value:', repetitionPeriodValue, 'type:', repetitionPeriodType)
}
} else {
console.log('No repetition_period to send (value:', repetitionPeriodValue, 'type:', repetitionPeriodType, ')')
console.log('No repetition to send (value:', repetitionPeriodValue, 'type:', repetitionPeriodType, 'mode:', repetitionMode, ')')
}
const payload = {
@@ -410,6 +447,7 @@ function TaskForm({ onNavigate, taskId }) {
reward_message: rewardMessage.trim() || null,
progression_base: progressionBase ? parseFloat(progressionBase) : null,
repetition_period: repetitionPeriod,
repetition_date: repetitionDate,
rewards: rewards.map(r => ({
position: r.position,
project_name: r.project_name.trim(),
@@ -545,37 +583,83 @@ function TaskForm({ onNavigate, taskId }) {
</div>
<div className="form-group">
<label htmlFor="repetition_period">Период повторения</label>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
id="repetition_period"
type="number"
min="0"
value={repetitionPeriodValue}
onChange={(e) => setRepetitionPeriodValue(e.target.value)}
placeholder="Число"
className="form-input"
style={{ flex: '1' }}
/>
{repetitionPeriodValue && repetitionPeriodValue.trim() !== '' && parseInt(repetitionPeriodValue.trim(), 10) !== 0 && (
<select
value={repetitionPeriodType}
onChange={(e) => setRepetitionPeriodType(e.target.value)}
className="form-input"
style={{ width: '120px' }}
>
<option value="minute">Минута</option>
<option value="hour">Час</option>
<option value="day">День</option>
<option value="week">Неделя</option>
<option value="month">Месяц</option>
<option value="year">Год</option>
</select>
)}
</div>
<small style={{ color: '#666', fontSize: '0.9em' }}>
Оставьте пустым, если задача не повторяется. Введите 0, если задача никогда не переносится в выполненные.
</small>
<label htmlFor="repetition_period">Повторения</label>
{(() => {
const hasValidValue = repetitionPeriodValue && repetitionPeriodValue.trim() !== '' && parseInt(repetitionPeriodValue.trim(), 10) !== 0
const isEachMode = hasValidValue && repetitionMode === 'each'
const isYearType = isEachMode && repetitionPeriodType === 'year'
return (
<>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
{hasValidValue && (
<select
value={repetitionMode}
onChange={(e) => {
setRepetitionMode(e.target.value)
// При переключении режима устанавливаем подходящий тип
if (e.target.value === 'each') {
// Для режима "Каждое" только week, month, year
if (!['week', 'month', 'year'].includes(repetitionPeriodType)) {
setRepetitionPeriodType('week')
}
}
}}
className="form-input"
style={{ width: '100px' }}
>
<option value="after">Через</option>
<option value="each">Каждое</option>
</select>
)}
<input
id="repetition_period"
type={isYearType ? 'text' : 'number'}
min="0"
value={repetitionPeriodValue}
onChange={(e) => setRepetitionPeriodValue(e.target.value)}
placeholder={isYearType ? 'ММ-ДД' : 'Число'}
className="form-input"
style={{ flex: '1' }}
/>
{hasValidValue && (
<select
value={repetitionPeriodType}
onChange={(e) => setRepetitionPeriodType(e.target.value)}
className="form-input"
style={{ width: '120px' }}
>
{repetitionMode === 'after' ? (
<>
<option value="minute">Минута</option>
<option value="hour">Час</option>
<option value="day">День</option>
<option value="week">Неделя</option>
<option value="month">Месяц</option>
<option value="year">Год</option>
</>
) : (
<>
<option value="week">Неделя</option>
<option value="month">Месяц</option>
<option value="year">Год</option>
</>
)}
</select>
)}
</div>
<small style={{ color: '#666', fontSize: '0.9em' }}>
{isEachMode ? (
repetitionPeriodType === 'week' ? 'Номер дня недели (1-7, где 1 = понедельник)' :
repetitionPeriodType === 'month' ? 'Номер дня месяца (1-31)' :
'Дата в формате ММ-ДД (например, 02-01 для 1 февраля)'
) : (
'Оставьте пустым, если задача не повторяется. Введите 0, если задача никогда не переносится в выполненные.'
)}
</small>
</>
)
})()}
</div>
<div className="form-group">