From df4d0dd320a171d3b4c9b888abb2b951b4901568 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 13 Apr 2021 14:10:33 +0200 Subject: [PATCH] Add repeat mode --- pkg/migration/20210413131057.go | 43 +++++++++ pkg/models/tasks.go | 159 ++++++++++++++++++++------------ pkg/models/tasks_test.go | 95 +++++++++++++++++++ 3 files changed, 236 insertions(+), 61 deletions(-) create mode 100644 pkg/migration/20210413131057.go diff --git a/pkg/migration/20210413131057.go b/pkg/migration/20210413131057.go new file mode 100644 index 000000000..d5df2f162 --- /dev/null +++ b/pkg/migration/20210413131057.go @@ -0,0 +1,43 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2021 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package migration + +import ( + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +type tasks20210413131057 struct { + RepeatMode int `xorm:"not null default 0" json:"repeat_mode"` +} + +func (tasks20210413131057) TableName() string { + return "tasks" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20210413131057", + Description: "Add repeat mode column to tasks", + Migrate: func(tx *xorm.Engine) error { + return tx.Sync2(tasks20210413131057{}) + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 9c81d5bed..542554bef 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -36,6 +36,13 @@ import ( "xorm.io/xorm/schemas" ) +type TaskRepeatMode int + +const ( + TaskRepeatModeDefault TaskRepeatMode = iota + TaskRepeatModeMonth +) + // Task represents an task in a todolist type Task struct { // The unique, numeric id of this task. @@ -59,6 +66,8 @@ type Task struct { RepeatAfter int64 `xorm:"bigint INDEX null" json:"repeat_after"` // If specified, a repeating task will repeat from the current date rather than the last set date. RepeatFromCurrentDate bool `xorm:"null" json:"repeat_from_current_date"` + + RepeatMode TaskRepeatMode `xorm:"not null default 0" json:"repeat_mode"` // The task priority. Can be anything you want, it is possible to sort by this later. Priority int64 `xorm:"bigint null" json:"priority"` // When this task starts. @@ -1071,87 +1080,115 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { return updateListLastUpdated(s, &List{ID: t.ListID}) } +func addOneMonthToDate(d time.Time) time.Time { + return time.Date(d.Year(), d.Month()+1, d.Day(), d.Hour(), d.Minute(), d.Second(), d.Nanosecond(), config.GetTimeZone()) +} + // This helper function updates the reminders, doneAt, start and end dates of the *old* task // and saves the new values in the newTask object. // We make a few assumtions here: // 1. Everything in oldTask is the truth - we figure out if we update anything at all if oldTask.RepeatAfter has a value > 0 // 2. Because of 1., this functions should not be used to update values other than Done in the same go func updateDone(oldTask *Task, newTask *Task) { - if !oldTask.Done && newTask.Done && oldTask.RepeatAfter > 0 { - - repeatDuration := time.Duration(oldTask.RepeatAfter) * time.Second + if !oldTask.Done && newTask.Done { // Current time in an extra variable to base all calculations on the same time now := time.Now() - // assuming we'll merge the new task over the old task - if !oldTask.DueDate.IsZero() { - if oldTask.RepeatFromCurrentDate { - newTask.DueDate = now.Add(repeatDuration) - } else { - // Always add one instance of the repeating interval to catch cases where a due date is already in the future - // but not the repeating interval - newTask.DueDate = oldTask.DueDate.Add(repeatDuration) - // Add the repeating interval until the new due date is in the future - for !newTask.DueDate.After(now) { - newTask.DueDate = newTask.DueDate.Add(repeatDuration) + if oldTask.RepeatMode == TaskRepeatModeMonth { + if !oldTask.DueDate.IsZero() { + newTask.DueDate = addOneMonthToDate(oldTask.DueDate) + } + + newTask.Reminders = oldTask.Reminders + if len(oldTask.Reminders) > 0 { + for in, r := range oldTask.Reminders { + newTask.Reminders[in] = addOneMonthToDate(r) } } - } - newTask.Reminders = oldTask.Reminders - // When repeating from the current date, all reminders should keep their difference to each other. - // To make this easier, we sort them first because we can then rely on the fact the first is the smallest - if len(oldTask.Reminders) > 0 { - if oldTask.RepeatFromCurrentDate { - sort.Slice(oldTask.Reminders, func(i, j int) bool { - return oldTask.Reminders[i].Unix() < oldTask.Reminders[j].Unix() - }) - first := oldTask.Reminders[0] - for in, r := range oldTask.Reminders { - diff := r.Sub(first) - newTask.Reminders[in] = now.Add(repeatDuration + diff) - } - } else { - for in, r := range oldTask.Reminders { - newTask.Reminders[in] = r.Add(repeatDuration) - for !newTask.Reminders[in].After(now) { - newTask.Reminders[in] = newTask.Reminders[in].Add(repeatDuration) - } - } - } - } - - // If a task has a start and end date, the end date should keep the difference to the start date when setting them as new - if oldTask.RepeatFromCurrentDate && !oldTask.StartDate.IsZero() && !oldTask.EndDate.IsZero() { - diff := oldTask.EndDate.Sub(oldTask.StartDate) - newTask.StartDate = now.Add(repeatDuration) - newTask.EndDate = now.Add(repeatDuration + diff) - } else { if !oldTask.StartDate.IsZero() { - if oldTask.RepeatFromCurrentDate { - newTask.StartDate = now.Add(repeatDuration) - } else { - newTask.StartDate = oldTask.StartDate.Add(repeatDuration) - for !newTask.StartDate.After(now) { - newTask.StartDate = newTask.StartDate.Add(repeatDuration) - } - } + newTask.StartDate = addOneMonthToDate(oldTask.StartDate) } if !oldTask.EndDate.IsZero() { - if oldTask.RepeatFromCurrentDate { - newTask.EndDate = now.Add(repeatDuration) - } else { - newTask.EndDate = oldTask.EndDate.Add(repeatDuration) - for !newTask.EndDate.After(now) { - newTask.EndDate = newTask.EndDate.Add(repeatDuration) - } - } + newTask.EndDate = addOneMonthToDate(oldTask.EndDate) } } - newTask.Done = false + if oldTask.RepeatAfter > 0 { + + repeatDuration := time.Duration(oldTask.RepeatAfter) * time.Second + + // assuming we'll merge the new task over the old task + if !oldTask.DueDate.IsZero() { + if oldTask.RepeatFromCurrentDate { + newTask.DueDate = now.Add(repeatDuration) + } else { + // Always add one instance of the repeating interval to catch cases where a due date is already in the future + // but not the repeating interval + newTask.DueDate = oldTask.DueDate.Add(repeatDuration) + // Add the repeating interval until the new due date is in the future + for !newTask.DueDate.After(now) { + newTask.DueDate = newTask.DueDate.Add(repeatDuration) + } + } + } + + newTask.Reminders = oldTask.Reminders + // When repeating from the current date, all reminders should keep their difference to each other. + // To make this easier, we sort them first because we can then rely on the fact the first is the smallest + if len(oldTask.Reminders) > 0 { + if oldTask.RepeatFromCurrentDate { + sort.Slice(oldTask.Reminders, func(i, j int) bool { + return oldTask.Reminders[i].Unix() < oldTask.Reminders[j].Unix() + }) + first := oldTask.Reminders[0] + for in, r := range oldTask.Reminders { + diff := r.Sub(first) + newTask.Reminders[in] = now.Add(repeatDuration + diff) + } + } else { + for in, r := range oldTask.Reminders { + newTask.Reminders[in] = r.Add(repeatDuration) + for !newTask.Reminders[in].After(now) { + newTask.Reminders[in] = newTask.Reminders[in].Add(repeatDuration) + } + } + } + } + + // If a task has a start and end date, the end date should keep the difference to the start date when setting them as new + if oldTask.RepeatFromCurrentDate && !oldTask.StartDate.IsZero() && !oldTask.EndDate.IsZero() { + diff := oldTask.EndDate.Sub(oldTask.StartDate) + newTask.StartDate = now.Add(repeatDuration) + newTask.EndDate = now.Add(repeatDuration + diff) + } else { + if !oldTask.StartDate.IsZero() { + if oldTask.RepeatFromCurrentDate { + newTask.StartDate = now.Add(repeatDuration) + } else { + newTask.StartDate = oldTask.StartDate.Add(repeatDuration) + for !newTask.StartDate.After(now) { + newTask.StartDate = newTask.StartDate.Add(repeatDuration) + } + } + } + + if !oldTask.EndDate.IsZero() { + if oldTask.RepeatFromCurrentDate { + newTask.EndDate = now.Add(repeatDuration) + } else { + newTask.EndDate = oldTask.EndDate.Add(repeatDuration) + for !newTask.EndDate.After(now) { + newTask.EndDate = newTask.EndDate.Add(repeatDuration) + } + } + } + } + + newTask.Done = false + } } // Update the "done at" timestamp diff --git a/pkg/models/tasks_test.go b/pkg/models/tasks_test.go index ff1e692d1..e8ff0303f 100644 --- a/pkg/models/tasks_test.go +++ b/pkg/models/tasks_test.go @@ -541,6 +541,101 @@ func TestUpdateDone(t *testing.T) { assert.Equal(t, time.Now().Add(diff+time.Duration(oldTask.RepeatAfter)*time.Second).Unix(), newTask.EndDate.Unix()) }) }) + t.Run("repeat each month", func(t *testing.T) { + t.Run("due date", func(t *testing.T) { + oldTask := &Task{ + Done: false, + RepeatMode: TaskRepeatModeMonth, + DueDate: time.Unix(1550000000, 0), + } + newTask := &Task{ + Done: true, + } + oldDueDate := oldTask.DueDate + + updateDone(oldTask, newTask) + + assert.True(t, newTask.DueDate.After(oldDueDate)) + assert.NotEqual(t, oldDueDate.Month(), newTask.DueDate.Month()) + }) + t.Run("reminders", func(t *testing.T) { + oldTask := &Task{ + Done: false, + RepeatMode: TaskRepeatModeMonth, + Reminders: []time.Time{ + time.Unix(1550000000, 0), + time.Unix(1555000000, 0), + }, + } + newTask := &Task{ + Done: true, + } + oldReminders := make([]time.Time, len(oldTask.Reminders)) + copy(oldReminders, oldTask.Reminders) + + updateDone(oldTask, newTask) + + assert.Len(t, newTask.Reminders, len(oldReminders)) + for i, r := range newTask.Reminders { + assert.True(t, r.After(oldReminders[i])) + assert.NotEqual(t, oldReminders[i].Month(), r.Month()) + } + }) + t.Run("start date", func(t *testing.T) { + oldTask := &Task{ + Done: false, + RepeatMode: TaskRepeatModeMonth, + StartDate: time.Unix(1550000000, 0), + } + newTask := &Task{ + Done: true, + } + oldStartDate := oldTask.StartDate + + updateDone(oldTask, newTask) + + assert.True(t, newTask.StartDate.After(oldStartDate)) + assert.NotEqual(t, oldStartDate.Month(), newTask.StartDate.Month()) + }) + t.Run("end date", func(t *testing.T) { + oldTask := &Task{ + Done: false, + RepeatMode: TaskRepeatModeMonth, + EndDate: time.Unix(1560000000, 0), + } + newTask := &Task{ + Done: true, + } + oldEndDate := oldTask.EndDate + + updateDone(oldTask, newTask) + + assert.True(t, newTask.EndDate.After(oldEndDate)) + assert.NotEqual(t, oldEndDate.Month(), newTask.EndDate.Month()) + }) + t.Run("start and end date", func(t *testing.T) { + oldTask := &Task{ + Done: false, + RepeatMode: TaskRepeatModeMonth, + StartDate: time.Unix(1550000000, 0), + EndDate: time.Unix(1560000000, 0), + } + newTask := &Task{ + Done: true, + } + oldStartDate := oldTask.StartDate + oldEndDate := oldTask.EndDate + oldDiff := oldTask.EndDate.Sub(oldTask.StartDate) + + updateDone(oldTask, newTask) + + assert.True(t, newTask.StartDate.After(oldStartDate)) + assert.NotEqual(t, oldStartDate.Month(), newTask.StartDate.Month()) + assert.True(t, newTask.EndDate.After(oldEndDate)) + assert.NotEqual(t, oldEndDate.Month(), newTask.EndDate.Month()) + assert.Equal(t, oldDiff, newTask.EndDate.Sub(newTask.StartDate)) + }) + }) }) }