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

@@ -209,6 +209,7 @@ type Task struct {
RewardMessage *string `json:"reward_message,omitempty"`
ProgressionBase *float64 `json:"progression_base,omitempty"`
RepetitionPeriod *string `json:"repetition_period,omitempty"`
RepetitionDate *string `json:"repetition_date,omitempty"`
// Дополнительные поля для списка задач (без omitempty чтобы всегда передавались)
ProjectNames []string `json:"project_names"`
SubtasksCount int `json:"subtasks_count"`
@@ -253,6 +254,7 @@ type TaskRequest struct {
ProgressionBase *float64 `json:"progression_base,omitempty"`
RewardMessage *string `json:"reward_message,omitempty"`
RepetitionPeriod *string `json:"repetition_period,omitempty"`
RepetitionDate *string `json:"repetition_date,omitempty"`
Rewards []RewardRequest `json:"rewards,omitempty"`
Subtasks []SubtaskRequest `json:"subtasks,omitempty"`
}
@@ -266,6 +268,111 @@ type PostponeTaskRequest struct {
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
// ============================================
@@ -2926,6 +3033,11 @@ func (a *App) initPlayLifeDB() error {
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
createRewardConfigsTable := `
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.next_show_at,
t.repetition_period::text,
t.repetition_date,
t.progression_base,
COALESCE((
SELECT COUNT(*)
@@ -6315,6 +6428,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
var lastCompletedAt sql.NullString
var nextShowAt sql.NullString
var repetitionPeriod sql.NullString
var repetitionDate sql.NullString
var progressionBase sql.NullFloat64
var projectNames pq.StringArray
var subtaskProjectNames pq.StringArray
@@ -6326,6 +6440,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
&lastCompletedAt,
&nextShowAt,
&repetitionPeriod,
&repetitionDate,
&progressionBase,
&task.SubtasksCount,
&projectNames,
@@ -6345,6 +6460,9 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
if repetitionPeriod.Valid {
task.RepetitionPeriod = &repetitionPeriod.String
}
if repetitionDate.Valid {
task.RepetitionDate = &repetitionDate.String
}
if progressionBase.Valid {
task.HasProgression = true
task.ProgressionBase = &progressionBase.Float64
@@ -6406,19 +6524,22 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
var lastCompletedAt sql.NullString
var nextShowAt sql.NullString
var repetitionPeriod sql.NullString
var repetitionDate sql.NullString
// Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL
var repetitionPeriodStr string
var repetitionDateStr string
err = a.DB.QueryRow(`
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
WHERE id = $1 AND user_id = $2 AND deleted = FALSE
`, 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 для совместимости
if repetitionPeriodStr != "" {
@@ -6426,6 +6547,11 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
} else {
repetitionPeriod = sql.NullString{Valid: false}
}
if repetitionDateStr != "" {
repetitionDate = sql.NullString{String: repetitionDateStr, Valid: true}
} else {
repetitionDate = sql.NullString{Valid: false}
}
if err == sql.ErrNoRows {
sendErrorWithCORS(w, "Task not found", http.StatusNotFound)
@@ -6455,6 +6581,10 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
} else {
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)
@@ -6645,6 +6775,7 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
var rewardMessage sql.NullString
var progressionBase sql.NullFloat64
var repetitionPeriod sql.NullString
var repetitionDate sql.NullString
if req.RewardMessage != nil {
rewardMessage = sql.NullString{String: *req.RewardMessage, Valid: true}
}
@@ -6657,6 +6788,10 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
} else {
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
var repetitionPeriodValue interface{}
@@ -6671,15 +6806,33 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
var insertArgs []interface{}
if repetitionPeriod.Valid {
insertSQL = `
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, completed, deleted)
VALUES ($1, $2, $3, $4, $5::INTERVAL, 0, FALSE)
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted)
VALUES ($1, $2, $3, $4, $5::INTERVAL, NULL, 0, FALSE)
RETURNING id
`
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 {
insertSQL = `
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, completed, deleted)
VALUES ($1, $2, $3, $4, NULL, 0, FALSE)
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted)
VALUES ($1, $2, $3, $4, NULL, NULL, 0, FALSE)
RETURNING id
`
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 lastCompletedAt sql.NullString
var createdRepetitionPeriod sql.NullString
var createdRepetitionDate sql.NullString
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
WHERE id = $1
`, taskID).Scan(
&createdTask.ID, &createdTask.Name, &createdTask.Completed,
&lastCompletedAt, &rewardMessage, &progressionBase, &createdRepetitionPeriod,
&lastCompletedAt, &rewardMessage, &progressionBase, &createdRepetitionPeriod, &createdRepetitionDate,
)
if err != nil {
@@ -6808,6 +6962,9 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
if createdRepetitionPeriod.Valid {
createdTask.RepetitionPeriod = &createdRepetitionPeriod.String
}
if createdRepetitionDate.Valid {
createdTask.RepetitionDate = &createdRepetitionDate.String
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
@@ -6883,6 +7040,7 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
var rewardMessage sql.NullString
var progressionBase sql.NullFloat64
var repetitionPeriod sql.NullString
var repetitionDate sql.NullString
if req.RewardMessage != nil {
rewardMessage = sql.NullString{String: *req.RewardMessage, Valid: true}
}
@@ -6895,6 +7053,10 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
} else {
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 значений
var updateSQL string
@@ -6902,14 +7064,32 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
if repetitionPeriod.Valid {
updateSQL = `
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
`
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 {
updateSQL = `
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
`
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 lastCompletedAt sql.NullString
var updatedRepetitionPeriod sql.NullString
var updatedRepetitionDate sql.NullString
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
WHERE id = $1
`, taskID).Scan(
&updatedTask.ID, &updatedTask.Name, &updatedTask.Completed,
&lastCompletedAt, &rewardMessage, &progressionBase, &updatedRepetitionPeriod,
&lastCompletedAt, &rewardMessage, &progressionBase, &updatedRepetitionPeriod, &updatedRepetitionDate,
)
if err != nil {
@@ -7138,6 +7319,9 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
if updatedRepetitionPeriod.Valid {
updatedTask.RepetitionPeriod = &updatedRepetitionPeriod.String
}
if updatedRepetitionDate.Valid {
updatedTask.RepetitionDate = &updatedRepetitionDate.String
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(updatedTask)
@@ -7227,13 +7411,14 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) {
var rewardMessage sql.NullString
var progressionBase sql.NullFloat64
var repetitionPeriod sql.NullString
var repetitionDate sql.NullString
var ownerID int
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
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 {
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 для основной задачи
// Если repetition_period не установлен, помечаем задачу как удаленную
// Если repetition_date установлен, вычисляем next_show_at
// Если repetition_period не установлен и repetition_date не установлен, помечаем задачу как удаленную
// Если 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 ")
periodStr := strings.TrimSpace(repetitionPeriod.String)
isZeroPeriod := strings.HasPrefix(periodStr, "0 ") || periodStr == "0"