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

@@ -1 +1 @@
3.2.0 3.3.0

View File

@@ -209,6 +209,7 @@ type Task struct {
RewardMessage *string `json:"reward_message,omitempty"` RewardMessage *string `json:"reward_message,omitempty"`
ProgressionBase *float64 `json:"progression_base,omitempty"` ProgressionBase *float64 `json:"progression_base,omitempty"`
RepetitionPeriod *string `json:"repetition_period,omitempty"` RepetitionPeriod *string `json:"repetition_period,omitempty"`
RepetitionDate *string `json:"repetition_date,omitempty"`
// Дополнительные поля для списка задач (без omitempty чтобы всегда передавались) // Дополнительные поля для списка задач (без omitempty чтобы всегда передавались)
ProjectNames []string `json:"project_names"` ProjectNames []string `json:"project_names"`
SubtasksCount int `json:"subtasks_count"` SubtasksCount int `json:"subtasks_count"`
@@ -253,6 +254,7 @@ type TaskRequest struct {
ProgressionBase *float64 `json:"progression_base,omitempty"` ProgressionBase *float64 `json:"progression_base,omitempty"`
RewardMessage *string `json:"reward_message,omitempty"` RewardMessage *string `json:"reward_message,omitempty"`
RepetitionPeriod *string `json:"repetition_period,omitempty"` RepetitionPeriod *string `json:"repetition_period,omitempty"`
RepetitionDate *string `json:"repetition_date,omitempty"`
Rewards []RewardRequest `json:"rewards,omitempty"` Rewards []RewardRequest `json:"rewards,omitempty"`
Subtasks []SubtaskRequest `json:"subtasks,omitempty"` Subtasks []SubtaskRequest `json:"subtasks,omitempty"`
} }
@@ -266,6 +268,111 @@ type PostponeTaskRequest struct {
NextShowAt *string `json:"next_show_at"` NextShowAt *string `json:"next_show_at"`
} }
// ============================================
// Helper functions for repetition_date
// ============================================
// calculateNextShowAtFromRepetitionDate calculates the next occurrence date based on repetition_date pattern
// Formats:
// - "N week" - Nth day of week (1=Monday, 7=Sunday)
// - "N month" - Nth day of month (1-31)
// - "MM-DD year" - specific date each year
func calculateNextShowAtFromRepetitionDate(repetitionDate string, fromDate time.Time) *time.Time {
if repetitionDate == "" {
return nil
}
parts := strings.Fields(strings.TrimSpace(repetitionDate))
if len(parts) < 2 {
return nil
}
value := parts[0]
unit := strings.ToLower(parts[1])
// Start from tomorrow at midnight
nextDate := time.Date(fromDate.Year(), fromDate.Month(), fromDate.Day(), 0, 0, 0, 0, fromDate.Location())
nextDate = nextDate.AddDate(0, 0, 1)
switch unit {
case "week":
// N-th day of week (1=Monday, 7=Sunday)
dayOfWeek, err := strconv.Atoi(value)
if err != nil || dayOfWeek < 1 || dayOfWeek > 7 {
return nil
}
// Go: Sunday=0, Monday=1, ..., Saturday=6
// Our format: Monday=1, ..., Sunday=7
// Convert our format to Go format
targetGoDay := dayOfWeek % 7 // Monday(1)->1, Sunday(7)->0
currentGoDay := int(nextDate.Weekday())
daysUntil := (targetGoDay - currentGoDay + 7) % 7
if daysUntil == 0 {
daysUntil = 7 // If same day, go to next week
}
nextDate = nextDate.AddDate(0, 0, daysUntil)
case "month":
// N-th day of month
dayOfMonth, err := strconv.Atoi(value)
if err != nil || dayOfMonth < 1 || dayOfMonth > 31 {
return nil
}
// Find the next occurrence of this day
for i := 0; i < 12; i++ { // Check up to 12 months ahead
// Get the last day of the current month
year, month, _ := nextDate.Date()
lastDayOfMonth := time.Date(year, month+1, 0, 0, 0, 0, 0, nextDate.Location()).Day()
// Use the actual day (capped at last day of month if needed)
actualDay := dayOfMonth
if actualDay > lastDayOfMonth {
actualDay = lastDayOfMonth
}
candidateDate := time.Date(year, month, actualDay, 0, 0, 0, 0, nextDate.Location())
// If this date is in the future (after fromDate), use it
if candidateDate.After(fromDate) {
nextDate = candidateDate
break
}
// Otherwise, try next month
nextDate = time.Date(year, month+1, 1, 0, 0, 0, 0, nextDate.Location())
}
case "year":
// MM-DD format (e.g., "02-01" for February 1st)
dateParts := strings.Split(value, "-")
if len(dateParts) != 2 {
return nil
}
month, err1 := strconv.Atoi(dateParts[0])
day, err2 := strconv.Atoi(dateParts[1])
if err1 != nil || err2 != nil || month < 1 || month > 12 || day < 1 || day > 31 {
return nil
}
// Find the next occurrence of this date
year := nextDate.Year()
candidateDate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, nextDate.Location())
// If this year's date has passed, use next year
if !candidateDate.After(fromDate) {
candidateDate = time.Date(year+1, time.Month(month), day, 0, 0, 0, 0, nextDate.Location())
}
nextDate = candidateDate
default:
return nil
}
return &nextDate
}
// ============================================ // ============================================
// Auth types // Auth types
// ============================================ // ============================================
@@ -2926,6 +3033,11 @@ func (a *App) initPlayLifeDB() error {
log.Printf("Warning: Failed to apply migration 017 (add next_show_at): %v", err) log.Printf("Warning: Failed to apply migration 017 (add next_show_at): %v", err)
} }
// Apply migration 018: Add repetition_date to tasks
if _, err := a.DB.Exec("ALTER TABLE tasks ADD COLUMN IF NOT EXISTS repetition_date TEXT"); err != nil {
log.Printf("Warning: Failed to apply migration 018 (add repetition_date): %v", err)
}
// Создаем таблицу reward_configs // Создаем таблицу reward_configs
createRewardConfigsTable := ` createRewardConfigsTable := `
CREATE TABLE IF NOT EXISTS reward_configs ( CREATE TABLE IF NOT EXISTS reward_configs (
@@ -6273,6 +6385,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
t.last_completed_at, t.last_completed_at,
t.next_show_at, t.next_show_at,
t.repetition_period::text, t.repetition_period::text,
t.repetition_date,
t.progression_base, t.progression_base,
COALESCE(( COALESCE((
SELECT COUNT(*) SELECT COUNT(*)
@@ -6315,6 +6428,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
var lastCompletedAt sql.NullString var lastCompletedAt sql.NullString
var nextShowAt sql.NullString var nextShowAt sql.NullString
var repetitionPeriod sql.NullString var repetitionPeriod sql.NullString
var repetitionDate sql.NullString
var progressionBase sql.NullFloat64 var progressionBase sql.NullFloat64
var projectNames pq.StringArray var projectNames pq.StringArray
var subtaskProjectNames pq.StringArray var subtaskProjectNames pq.StringArray
@@ -6326,6 +6440,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
&lastCompletedAt, &lastCompletedAt,
&nextShowAt, &nextShowAt,
&repetitionPeriod, &repetitionPeriod,
&repetitionDate,
&progressionBase, &progressionBase,
&task.SubtasksCount, &task.SubtasksCount,
&projectNames, &projectNames,
@@ -6345,6 +6460,9 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
if repetitionPeriod.Valid { if repetitionPeriod.Valid {
task.RepetitionPeriod = &repetitionPeriod.String task.RepetitionPeriod = &repetitionPeriod.String
} }
if repetitionDate.Valid {
task.RepetitionDate = &repetitionDate.String
}
if progressionBase.Valid { if progressionBase.Valid {
task.HasProgression = true task.HasProgression = true
task.ProgressionBase = &progressionBase.Float64 task.ProgressionBase = &progressionBase.Float64
@@ -6406,19 +6524,22 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
var lastCompletedAt sql.NullString var lastCompletedAt sql.NullString
var nextShowAt sql.NullString var nextShowAt sql.NullString
var repetitionPeriod sql.NullString var repetitionPeriod sql.NullString
var repetitionDate sql.NullString
// Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL // Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL
var repetitionPeriodStr string var repetitionPeriodStr string
var repetitionDateStr string
err = a.DB.QueryRow(` err = a.DB.QueryRow(`
SELECT id, name, completed, last_completed_at, next_show_at, reward_message, progression_base, SELECT id, name, completed, last_completed_at, next_show_at, reward_message, progression_base,
CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END as repetition_period CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END as repetition_period,
COALESCE(repetition_date, '') as repetition_date
FROM tasks FROM tasks
WHERE id = $1 AND user_id = $2 AND deleted = FALSE WHERE id = $1 AND user_id = $2 AND deleted = FALSE
`, taskID, userID).Scan( `, taskID, userID).Scan(
&task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr,
) )
log.Printf("Scanned repetition_period for task %d: String='%s'", taskID, repetitionPeriodStr) log.Printf("Scanned repetition_period for task %d: String='%s', repetition_date='%s'", taskID, repetitionPeriodStr, repetitionDateStr)
// Преобразуем в sql.NullString для совместимости // Преобразуем в sql.NullString для совместимости
if repetitionPeriodStr != "" { if repetitionPeriodStr != "" {
@@ -6426,6 +6547,11 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
} else { } else {
repetitionPeriod = sql.NullString{Valid: false} repetitionPeriod = sql.NullString{Valid: false}
} }
if repetitionDateStr != "" {
repetitionDate = sql.NullString{String: repetitionDateStr, Valid: true}
} else {
repetitionDate = sql.NullString{Valid: false}
}
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
sendErrorWithCORS(w, "Task not found", http.StatusNotFound) sendErrorWithCORS(w, "Task not found", http.StatusNotFound)
@@ -6455,6 +6581,10 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
} else { } else {
log.Printf("Task %d has no repetition_period (Valid: %v, String: '%s')", task.ID, repetitionPeriod.Valid, repetitionPeriod.String) log.Printf("Task %d has no repetition_period (Valid: %v, String: '%s')", task.ID, repetitionPeriod.Valid, repetitionPeriod.String)
} }
if repetitionDate.Valid && repetitionDate.String != "" {
task.RepetitionDate = &repetitionDate.String
log.Printf("Task %d has repetition_date: %s", task.ID, repetitionDate.String)
}
// Получаем награды основной задачи // Получаем награды основной задачи
rewards := make([]Reward, 0) rewards := make([]Reward, 0)
@@ -6645,6 +6775,7 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
var rewardMessage sql.NullString var rewardMessage sql.NullString
var progressionBase sql.NullFloat64 var progressionBase sql.NullFloat64
var repetitionPeriod sql.NullString var repetitionPeriod sql.NullString
var repetitionDate sql.NullString
if req.RewardMessage != nil { if req.RewardMessage != nil {
rewardMessage = sql.NullString{String: *req.RewardMessage, Valid: true} rewardMessage = sql.NullString{String: *req.RewardMessage, Valid: true}
} }
@@ -6657,6 +6788,10 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
} else { } else {
log.Printf("Creating task without repetition_period (req.RepetitionPeriod: %v)", req.RepetitionPeriod) log.Printf("Creating task without repetition_period (req.RepetitionPeriod: %v)", req.RepetitionPeriod)
} }
if req.RepetitionDate != nil && strings.TrimSpace(*req.RepetitionDate) != "" {
repetitionDate = sql.NullString{String: strings.TrimSpace(*req.RepetitionDate), Valid: true}
log.Printf("Creating task with repetition_date: %s", repetitionDate.String)
}
// Используем CAST для преобразования строки в INTERVAL // Используем CAST для преобразования строки в INTERVAL
var repetitionPeriodValue interface{} var repetitionPeriodValue interface{}
@@ -6671,15 +6806,33 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
var insertArgs []interface{} var insertArgs []interface{}
if repetitionPeriod.Valid { if repetitionPeriod.Valid {
insertSQL = ` insertSQL = `
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, completed, deleted) INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted)
VALUES ($1, $2, $3, $4, $5::INTERVAL, 0, FALSE) VALUES ($1, $2, $3, $4, $5::INTERVAL, NULL, 0, FALSE)
RETURNING id RETURNING id
` `
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriodValue} insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriodValue}
} else if repetitionDate.Valid {
// Вычисляем next_show_at для задачи с repetition_date
nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now())
if nextShowAt != nil {
insertSQL = `
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted)
VALUES ($1, $2, $3, $4, NULL, $5, $6, 0, FALSE)
RETURNING id
`
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt}
} else {
insertSQL = `
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted)
VALUES ($1, $2, $3, $4, NULL, $5, 0, FALSE)
RETURNING id
`
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String}
}
} else { } else {
insertSQL = ` insertSQL = `
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, completed, deleted) INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted)
VALUES ($1, $2, $3, $4, NULL, 0, FALSE) VALUES ($1, $2, $3, $4, NULL, NULL, 0, FALSE)
RETURNING id RETURNING id
` `
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase} insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase}
@@ -6781,13 +6934,14 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
var createdTask Task var createdTask Task
var lastCompletedAt sql.NullString var lastCompletedAt sql.NullString
var createdRepetitionPeriod sql.NullString var createdRepetitionPeriod sql.NullString
var createdRepetitionDate sql.NullString
err = a.DB.QueryRow(` err = a.DB.QueryRow(`
SELECT id, name, completed, last_completed_at, reward_message, progression_base, repetition_period::text SELECT id, name, completed, last_completed_at, reward_message, progression_base, repetition_period::text, repetition_date
FROM tasks FROM tasks
WHERE id = $1 WHERE id = $1
`, taskID).Scan( `, taskID).Scan(
&createdTask.ID, &createdTask.Name, &createdTask.Completed, &createdTask.ID, &createdTask.Name, &createdTask.Completed,
&lastCompletedAt, &rewardMessage, &progressionBase, &createdRepetitionPeriod, &lastCompletedAt, &rewardMessage, &progressionBase, &createdRepetitionPeriod, &createdRepetitionDate,
) )
if err != nil { if err != nil {
@@ -6808,6 +6962,9 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
if createdRepetitionPeriod.Valid { if createdRepetitionPeriod.Valid {
createdTask.RepetitionPeriod = &createdRepetitionPeriod.String createdTask.RepetitionPeriod = &createdRepetitionPeriod.String
} }
if createdRepetitionDate.Valid {
createdTask.RepetitionDate = &createdRepetitionDate.String
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
@@ -6883,6 +7040,7 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
var rewardMessage sql.NullString var rewardMessage sql.NullString
var progressionBase sql.NullFloat64 var progressionBase sql.NullFloat64
var repetitionPeriod sql.NullString var repetitionPeriod sql.NullString
var repetitionDate sql.NullString
if req.RewardMessage != nil { if req.RewardMessage != nil {
rewardMessage = sql.NullString{String: *req.RewardMessage, Valid: true} rewardMessage = sql.NullString{String: *req.RewardMessage, Valid: true}
} }
@@ -6895,6 +7053,10 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
} else { } else {
log.Printf("Updating task %d without repetition_period (req.RepetitionPeriod: %v)", taskID, req.RepetitionPeriod) log.Printf("Updating task %d without repetition_period (req.RepetitionPeriod: %v)", taskID, req.RepetitionPeriod)
} }
if req.RepetitionDate != nil && strings.TrimSpace(*req.RepetitionDate) != "" {
repetitionDate = sql.NullString{String: strings.TrimSpace(*req.RepetitionDate), Valid: true}
log.Printf("Updating task %d with repetition_date: %s", taskID, repetitionDate.String)
}
// Используем условный SQL для обработки NULL значений // Используем условный SQL для обработки NULL значений
var updateSQL string var updateSQL string
@@ -6902,14 +7064,32 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
if repetitionPeriod.Valid { if repetitionPeriod.Valid {
updateSQL = ` updateSQL = `
UPDATE tasks UPDATE tasks
SET name = $1, reward_message = $2, progression_base = $3, repetition_period = $4::INTERVAL SET name = $1, reward_message = $2, progression_base = $3, repetition_period = $4::INTERVAL, repetition_date = NULL, next_show_at = NULL
WHERE id = $5 WHERE id = $5
` `
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriod.String, taskID} updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriod.String, taskID}
} else if repetitionDate.Valid {
// Вычисляем next_show_at для задачи с repetition_date
nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now())
if nextShowAt != nil {
updateSQL = `
UPDATE tasks
SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = $4, next_show_at = $5
WHERE id = $6
`
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, taskID}
} else {
updateSQL = `
UPDATE tasks
SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = $4
WHERE id = $5
`
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, taskID}
}
} else { } else {
updateSQL = ` updateSQL = `
UPDATE tasks UPDATE tasks
SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = NULL, next_show_at = NULL
WHERE id = $4 WHERE id = $4
` `
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, taskID} updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, taskID}
@@ -7111,13 +7291,14 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
var updatedTask Task var updatedTask Task
var lastCompletedAt sql.NullString var lastCompletedAt sql.NullString
var updatedRepetitionPeriod sql.NullString var updatedRepetitionPeriod sql.NullString
var updatedRepetitionDate sql.NullString
err = a.DB.QueryRow(` err = a.DB.QueryRow(`
SELECT id, name, completed, last_completed_at, reward_message, progression_base, repetition_period::text SELECT id, name, completed, last_completed_at, reward_message, progression_base, repetition_period::text, repetition_date
FROM tasks FROM tasks
WHERE id = $1 WHERE id = $1
`, taskID).Scan( `, taskID).Scan(
&updatedTask.ID, &updatedTask.Name, &updatedTask.Completed, &updatedTask.ID, &updatedTask.Name, &updatedTask.Completed,
&lastCompletedAt, &rewardMessage, &progressionBase, &updatedRepetitionPeriod, &lastCompletedAt, &rewardMessage, &progressionBase, &updatedRepetitionPeriod, &updatedRepetitionDate,
) )
if err != nil { if err != nil {
@@ -7138,6 +7319,9 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
if updatedRepetitionPeriod.Valid { if updatedRepetitionPeriod.Valid {
updatedTask.RepetitionPeriod = &updatedRepetitionPeriod.String updatedTask.RepetitionPeriod = &updatedRepetitionPeriod.String
} }
if updatedRepetitionDate.Valid {
updatedTask.RepetitionDate = &updatedRepetitionDate.String
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(updatedTask) json.NewEncoder(w).Encode(updatedTask)
@@ -7227,13 +7411,14 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) {
var rewardMessage sql.NullString var rewardMessage sql.NullString
var progressionBase sql.NullFloat64 var progressionBase sql.NullFloat64
var repetitionPeriod sql.NullString var repetitionPeriod sql.NullString
var repetitionDate sql.NullString
var ownerID int var ownerID int
err = a.DB.QueryRow(` err = a.DB.QueryRow(`
SELECT id, name, reward_message, progression_base, repetition_period, user_id SELECT id, name, reward_message, progression_base, repetition_period, repetition_date, user_id
FROM tasks FROM tasks
WHERE id = $1 AND deleted = FALSE WHERE id = $1 AND deleted = FALSE
`, taskID).Scan(&task.ID, &task.Name, &rewardMessage, &progressionBase, &repetitionPeriod, &ownerID) `, taskID).Scan(&task.ID, &task.Name, &rewardMessage, &progressionBase, &repetitionPeriod, &repetitionDate, &ownerID)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
sendErrorWithCORS(w, "Task not found", http.StatusNotFound) sendErrorWithCORS(w, "Task not found", http.StatusNotFound)
@@ -7469,9 +7654,27 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) {
} }
// Обновляем completed и last_completed_at для основной задачи // Обновляем completed и last_completed_at для основной задачи
// Если repetition_period не установлен, помечаем задачу как удаленную // Если repetition_date установлен, вычисляем next_show_at
// Если repetition_period не установлен и repetition_date не установлен, помечаем задачу как удаленную
// Если repetition_period = "0 day" (или любое значение с 0), не обновляем last_completed_at // Если repetition_period = "0 day" (или любое значение с 0), не обновляем last_completed_at
if repetitionPeriod.Valid { if repetitionDate.Valid && repetitionDate.String != "" {
// Есть repetition_date - вычисляем следующую дату показа
nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now())
if nextShowAt != nil {
_, err = a.DB.Exec(`
UPDATE tasks
SET completed = completed + 1, last_completed_at = NOW(), next_show_at = $2
WHERE id = $1
`, taskID, nextShowAt)
} else {
// Если не удалось вычислить дату, обновляем как обычно
_, err = a.DB.Exec(`
UPDATE tasks
SET completed = completed + 1, last_completed_at = NOW(), next_show_at = NULL
WHERE id = $1
`, taskID)
}
} else if repetitionPeriod.Valid {
// Проверяем, является ли период нулевым (начинается с "0 ") // Проверяем, является ли период нулевым (начинается с "0 ")
periodStr := strings.TrimSpace(repetitionPeriod.String) periodStr := strings.TrimSpace(repetitionPeriod.String)
isZeroPeriod := strings.HasPrefix(periodStr, "0 ") || periodStr == "0" isZeroPeriod := strings.HasPrefix(periodStr, "0 ") || periodStr == "0"

View File

@@ -0,0 +1,16 @@
-- Migration: Add repetition_date field to tasks table
-- This script adds the repetition_date field for pattern-based recurring tasks
-- Format examples: "2 week" (2nd day of week), "15 month" (15th day of month), "02-01 year" (Feb 1st)
-- ============================================
-- Add repetition_date column
-- ============================================
ALTER TABLE tasks
ADD COLUMN IF NOT EXISTS repetition_date TEXT;
-- ============================================
-- Comments for documentation
-- ============================================
COMMENT ON COLUMN tasks.repetition_date IS 'Pattern-based repetition: "N week" (day of week 1-7), "N month" (day of month 1-31), "MM-DD year" (specific date). Mutually exclusive with repetition_period.';

View File

@@ -1,6 +1,6 @@
{ {
"name": "play-life-web", "name": "play-life-web",
"version": "3.2.0", "version": "3.3.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

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

View File

@@ -90,14 +90,109 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
onNavigate?.('task-form', { taskId: undefined }) onNavigate?.('task-form', { taskId: undefined })
} }
// Функция для вычисления следующей даты по repetition_date
const calculateNextDateFromRepetitionDate = (repetitionDateStr) => {
if (!repetitionDateStr) return null
const parts = repetitionDateStr.trim().split(/\s+/)
if (parts.length < 2) return null
const value = parts[0]
const unit = parts[1].toLowerCase()
const now = new Date()
now.setHours(0, 0, 0, 0)
switch (unit) {
case 'week': {
// N-й день недели (1=понедельник, 7=воскресенье)
const dayOfWeek = parseInt(value, 10)
if (isNaN(dayOfWeek) || dayOfWeek < 1 || dayOfWeek > 7) return null
// JavaScript: 0=воскресенье, 1=понедельник... 6=суббота
// Наш формат: 1=понедельник... 7=воскресенье
// Конвертируем: наш 1 (Пн) -> JS 1, наш 7 (Вс) -> JS 0
const targetJsDay = dayOfWeek === 7 ? 0 : dayOfWeek
const currentJsDay = now.getDay()
// Вычисляем дни до следующего вхождения (включая сегодня, если ещё не прошло)
let daysUntil = (targetJsDay - currentJsDay + 7) % 7
// Если сегодня тот же день, берём следующую неделю
if (daysUntil === 0) daysUntil = 7
const nextDate = new Date(now)
nextDate.setDate(now.getDate() + daysUntil)
return nextDate
}
case 'month': {
// N-й день месяца
const dayOfMonth = parseInt(value, 10)
if (isNaN(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) return null
// Ищем ближайшую дату с этим днём
let searchDate = new Date(now)
for (let i = 0; i < 12; i++) {
const year = searchDate.getFullYear()
const month = searchDate.getMonth()
const lastDayOfMonth = new Date(year, month + 1, 0).getDate()
const actualDay = Math.min(dayOfMonth, lastDayOfMonth)
const candidateDate = new Date(year, month, actualDay)
if (candidateDate > now) {
return candidateDate
}
// Переходим к следующему месяцу
searchDate = new Date(year, month + 1, 1)
}
return null
}
case 'year': {
// MM-DD формат
const dateParts = value.split('-')
if (dateParts.length !== 2) return null
const monthNum = parseInt(dateParts[0], 10)
const day = parseInt(dateParts[1], 10)
if (isNaN(monthNum) || isNaN(day) || monthNum < 1 || monthNum > 12 || day < 1 || day > 31) return null
let year = now.getFullYear()
let candidateDate = new Date(year, monthNum - 1, day)
if (candidateDate <= now) {
candidateDate = new Date(year + 1, monthNum - 1, day)
}
return candidateDate
}
default:
return null
}
}
// Форматирование даты в YYYY-MM-DD (локальное время, без смещения в UTC)
const formatDateToLocal = (date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const handlePostponeClick = (task, e) => { const handlePostponeClick = (task, e) => {
e.stopPropagation() e.stopPropagation()
setSelectedTaskForPostpone(task) setSelectedTaskForPostpone(task)
// Устанавливаем дату по умолчанию - завтра
const tomorrow = new Date() // Устанавливаем дату по умолчанию
tomorrow.setDate(tomorrow.getDate() + 1) let defaultDate
tomorrow.setHours(0, 0, 0, 0) if (task.repetition_date) {
setPostponeDate(tomorrow.toISOString().split('T')[0]) // Для задач с repetition_date - вычисляем следующую подходящую дату
const nextDate = calculateNextDateFromRepetitionDate(task.repetition_date)
if (nextDate) {
defaultDate = nextDate
}
}
if (!defaultDate) {
// Без repetition_date или если не удалось вычислить - завтра
defaultDate = new Date()
defaultDate.setDate(defaultDate.getDate() + 1)
}
defaultDate.setHours(0, 0, 0, 0)
setPostponeDate(formatDateToLocal(defaultDate))
} }
const handlePostponeSubmit = async () => { const handlePostponeSubmit = async () => {
@@ -267,6 +362,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
return result return result
} }
// Группируем задачи по проектам // Группируем задачи по проектам
const groupedTasks = useMemo(() => { const groupedTasks = useMemo(() => {
const today = new Date() const today = new Date()
@@ -287,7 +383,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
let isInfinite = false let isInfinite = false
// Если next_show_at установлен, задача всегда в выполненных (если дата в будущем) // Если next_show_at установлен, задача всегда в выполненных (если дата в будущем)
// даже если она бесконечная // даже если она бесконечная (next_show_at приоритетнее всего)
if (task.next_show_at) { if (task.next_show_at) {
const nextShowDate = new Date(task.next_show_at) const nextShowDate = new Date(task.next_show_at)
nextShowDate.setHours(0, 0, 0, 0) nextShowDate.setHours(0, 0, 0, 0)
@@ -324,7 +420,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
} }
} }
} else { } else {
// Если repetition_period == null, используем старую логику // Если нет ни repetition_period, ни repetition_date, используем старую логику
if (task.last_completed_at) { if (task.last_completed_at) {
const completedDate = new Date(task.last_completed_at) const completedDate = new Date(task.last_completed_at)
completedDate.setHours(0, 0, 0, 0) completedDate.setHours(0, 0, 0, 0)