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
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:
@@ -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, 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, $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, 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
|
||||
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, 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"
|
||||
|
||||
16
play-life-backend/migrations/018_add_repetition_date.sql
Normal file
16
play-life-backend/migrations/018_add_repetition_date.sql
Normal 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.';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "play-life-web",
|
||||
"version": "3.2.0",
|
||||
"version": "3.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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_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 если он есть
|
||||
if (data.task.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,10 +409,21 @@ 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)
|
||||
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',
|
||||
@@ -401,8 +437,9 @@ function TaskForm({ onNavigate, taskId }) {
|
||||
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>
|
||||
<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="number"
|
||||
type={isYearType ? 'text' : 'number'}
|
||||
min="0"
|
||||
value={repetitionPeriodValue}
|
||||
onChange={(e) => setRepetitionPeriodValue(e.target.value)}
|
||||
placeholder="Число"
|
||||
placeholder={isYearType ? 'ММ-ДД' : 'Число'}
|
||||
className="form-input"
|
||||
style={{ flex: '1' }}
|
||||
/>
|
||||
{repetitionPeriodValue && repetitionPeriodValue.trim() !== '' && parseInt(repetitionPeriodValue.trim(), 10) !== 0 && (
|
||||
{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' }}>
|
||||
Оставьте пустым, если задача не повторяется. Введите 0, если задача никогда не переносится в выполненные.
|
||||
{isEachMode ? (
|
||||
repetitionPeriodType === 'week' ? 'Номер дня недели (1-7, где 1 = понедельник)' :
|
||||
repetitionPeriodType === 'month' ? 'Номер дня месяца (1-31)' :
|
||||
'Дата в формате ММ-ДД (например, 02-01 для 1 февраля)'
|
||||
) : (
|
||||
'Оставьте пустым, если задача не повторяется. Введите 0, если задача никогда не переносится в выполненные.'
|
||||
)}
|
||||
</small>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
|
||||
@@ -90,14 +90,109 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
||||
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) => {
|
||||
e.stopPropagation()
|
||||
setSelectedTaskForPostpone(task)
|
||||
// Устанавливаем дату по умолчанию - завтра
|
||||
const tomorrow = new Date()
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
tomorrow.setHours(0, 0, 0, 0)
|
||||
setPostponeDate(tomorrow.toISOString().split('T')[0])
|
||||
|
||||
// Устанавливаем дату по умолчанию
|
||||
let defaultDate
|
||||
if (task.repetition_date) {
|
||||
// Для задач с 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 () => {
|
||||
@@ -267,6 +362,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
// Группируем задачи по проектам
|
||||
const groupedTasks = useMemo(() => {
|
||||
const today = new Date()
|
||||
@@ -287,7 +383,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
||||
let isInfinite = false
|
||||
|
||||
// Если next_show_at установлен, задача всегда в выполненных (если дата в будущем)
|
||||
// даже если она бесконечная
|
||||
// даже если она бесконечная (next_show_at приоритетнее всего)
|
||||
if (task.next_show_at) {
|
||||
const nextShowDate = new Date(task.next_show_at)
|
||||
nextShowDate.setHours(0, 0, 0, 0)
|
||||
@@ -324,7 +420,7 @@ function TaskList({ onNavigate, data, loading, backgroundLoading, onRefresh }) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Если repetition_period == null, используем старую логику
|
||||
// Если нет ни repetition_period, ни repetition_date, используем старую логику
|
||||
if (task.last_completed_at) {
|
||||
const completedDate = new Date(task.last_completed_at)
|
||||
completedDate.setHours(0, 0, 0, 0)
|
||||
|
||||
Reference in New Issue
Block a user