aboutsummaryrefslogtreecommitdiffstats
path: root/internal/chore/scheduler.go
blob: 54134475bd4b3f2b6b4999211571856c44cfd1eb (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
package chore

import (
	"encoding/json"
	"fmt"
	"math/rand"
	"strings"
	"time"

	chModel "donetick.com/core/internal/chore/model"
)

func scheduleNextDueDate(chore *chModel.Chore, completedDate time.Time) (*time.Time, error) {
	// if Chore is rolling then the next due date calculated from the completed date, otherwise it's calculated from the due date
	var nextDueDate time.Time
	var baseDate time.Time
	var frequencyMetadata chModel.FrequencyMetadata
	err := json.Unmarshal([]byte(*chore.FrequencyMetadata), &frequencyMetadata)
	if err != nil {
		return nil, fmt.Errorf("error unmarshalling frequency metadata")
	}
	if chore.FrequencyType == "once" {
		return nil, nil
	}

	if chore.NextDueDate != nil {
		// no due date set, use the current date

		baseDate = chore.NextDueDate.UTC()
	} else {
		baseDate = completedDate.UTC()
	}
	if chore.FrequencyType == "day_of_the_month" || chore.FrequencyType == "days_of_the_week" || chore.FrequencyType == "interval" {
		// time in frequency metadata stored as RFC3339 format like  `2024-07-07T13:27:00-04:00`
		// parse it to time.Time:
		t, err := time.Parse(time.RFC3339, frequencyMetadata.Time)
		if err != nil {
			return nil, fmt.Errorf("error parsing time in frequency metadata")
		}
		// set the time to the time in the frequency metadata:
		baseDate = time.Date(baseDate.Year(), baseDate.Month(), baseDate.Day(), t.Hour(), t.Minute(), 0, 0, t.Location())

	}
	if chore.IsRolling && chore.NextDueDate.Before(completedDate) {
		// we need to check if chore due date is before the completed date to handle this senario:
		// if user trying to complete chore due in future (multiple time for insance) due date will be calculated
		//  from the last completed date and due date change only in seconds.
		// this make sure that the due date is always in future if the chore is rolling

		baseDate = completedDate.UTC()
	}

	if chore.FrequencyType == "daily" {
		nextDueDate = baseDate.AddDate(0, 0, 1)
	} else if chore.FrequencyType == "weekly" {
		nextDueDate = baseDate.AddDate(0, 0, 7)
	} else if chore.FrequencyType == "monthly" {
		nextDueDate = baseDate.AddDate(0, 1, 0)
	} else if chore.FrequencyType == "yearly" {
		nextDueDate = baseDate.AddDate(1, 0, 0)
	} else if chore.FrequencyType == "adaptive" {

		// TODO: calculate next due date based on the history of the chore
		// calculate the difference between the due date and now in days:
		diff := completedDate.UTC().Sub(chore.NextDueDate.UTC())
		nextDueDate = completedDate.UTC().Add(diff)
	} else if chore.FrequencyType == "once" {
		// if the chore is a one-time chore, then the next due date is nil
	} else if chore.FrequencyType == "interval" {
		// calculate the difference between the due date and now in days:
		if *frequencyMetadata.Unit == "hours" {
			nextDueDate = baseDate.UTC().Add(time.Hour * time.Duration(chore.Frequency))
		} else if *frequencyMetadata.Unit == "days" {
			nextDueDate = baseDate.UTC().AddDate(0, 0, chore.Frequency)
		} else if *frequencyMetadata.Unit == "weeks" {
			nextDueDate = baseDate.UTC().AddDate(0, 0, chore.Frequency*7)
		} else if *frequencyMetadata.Unit == "months" {
			nextDueDate = baseDate.UTC().AddDate(0, chore.Frequency, 0)
		} else if *frequencyMetadata.Unit == "years" {
			nextDueDate = baseDate.UTC().AddDate(chore.Frequency, 0, 0)
		} else {

			return nil, fmt.Errorf("invalid frequency unit, cannot calculate next due date")
		}
	} else if chore.FrequencyType == "days_of_the_week" {
		// TODO : this logic is bad, need to be refactored and be better.
		// coding at night is almost  always bad idea.
		// calculate the difference between the due date and now in days:
		var frequencyMetadata chModel.FrequencyMetadata
		err := json.Unmarshal([]byte(*chore.FrequencyMetadata), &frequencyMetadata)
		if err != nil {

			return nil, fmt.Errorf("error unmarshalling frequency metadata")
		}
		//we can only assign to days of the week that part of the frequency metadata.days
		//it's array of days of the week, for example ["monday", "tuesday", "wednesday"]

		// we need to find the next day of the week in the frequency metadata.days that we can schedule
		// if this the last or there is only one. will use same otherwise find the next one:

		// find the index of the chore day in the frequency metadata.days
		// loop for next 7 days from the base, if the day in the frequency metadata.days then we can schedule it:
		for i := 1; i <= 7; i++ {
			nextDueDate = baseDate.AddDate(0, 0, i)
			nextDay := strings.ToLower(nextDueDate.Weekday().String())
			for _, day := range frequencyMetadata.Days {
				if strings.ToLower(*day) == nextDay {
					nextDate := nextDueDate.UTC()
					return &nextDate, nil
				}
			}
		}
	} else if chore.FrequencyType == "day_of_the_month" {
		var frequencyMetadata chModel.FrequencyMetadata
		err := json.Unmarshal([]byte(*chore.FrequencyMetadata), &frequencyMetadata)
		if err != nil {

			return nil, fmt.Errorf("error unmarshalling frequency metadata")
		}

		for i := 1; i <= 12; i++ {
			nextDueDate = baseDate.AddDate(0, i, 0)
			// set the date to the first day of the month:
			nextDueDate = time.Date(nextDueDate.Year(), nextDueDate.Month(), chore.Frequency, nextDueDate.Hour(), nextDueDate.Minute(), 0, 0, nextDueDate.Location())
			nextMonth := strings.ToLower(nextDueDate.Month().String())
			for _, month := range frequencyMetadata.Months {
				if *month == nextMonth {
					nextDate := nextDueDate.UTC()
					return &nextDate, nil
				}
			}
		}
	} else if chore.FrequencyType == "no_repeat" {
		return nil, nil
	} else if chore.FrequencyType == "trigger" {
		// if the chore is a trigger chore, then the next due date is nil
		return nil, nil
	} else {
		return nil, fmt.Errorf("invalid frequency type, cannot calculate next due date")
	}
	return &nextDueDate, nil

}

func scheduleAdaptiveNextDueDate(chore *chModel.Chore, completedDate time.Time, history []*chModel.ChoreHistory) (*time.Time, error) {
	//  will generate due date base on history and the different between the completed date and the due date
	// the more recent the higher weight
	if len(history) <= 1 {
		if chore.NextDueDate != nil {
			diff := completedDate.UTC().Sub(chore.NextDueDate.UTC())
			nextDueDate := completedDate.UTC().Add(diff)
			return &nextDueDate, nil
		}
		return nil, nil
	}
	var weight float64
	var totalWeight float64
	var nextDueDate time.Time
	for i := 0; i < len(history)-1; i++ {
		delay := history[i].CompletedAt.UTC().Sub(history[i+1].CompletedAt.UTC()).Seconds()
		weight = delay * float64(len(history)-i)
		totalWeight += weight
	}
	// calculate the average delay
	averageDelay := totalWeight / float64(len(history)-1)
	// calculate the difference between the completed date and the due date
	nextDueDate = completedDate.UTC().Add(time.Duration(averageDelay) * time.Second)

	return &nextDueDate, nil
}

func RemoveAssigneeAndReassign(chore *chModel.Chore, userID int) {
	for i, assignee := range chore.Assignees {
		if assignee.UserID == userID {
			chore.Assignees = append(chore.Assignees[:i], chore.Assignees[i+1:]...)
			break
		}
	}
	if len(chore.Assignees) == 0 {
		chore.AssignedTo = chore.CreatedBy
	} else {
		chore.AssignedTo = chore.Assignees[rand.Intn(len(chore.Assignees))].UserID
	}
	chore.UpdatedAt = time.Now()
}