Fix timezone handling in completeChore and correct frequency type constants

Add Tests for scheduler
Build on PR and run tests
This commit is contained in:
Mo Tarbin 2025-01-20 21:17:36 -05:00
parent 3e0b68bbff
commit 2145299638
5 changed files with 385 additions and 361 deletions

View file

@ -12,129 +12,123 @@ import (
)
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" {
if chore.FrequencyType == "once" || chore.FrequencyType == "no_repeat" || chore.FrequencyType == "trigger" {
return nil, nil
}
var baseDate time.Time
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 {
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 {
frequencyMetadata := chModel.FrequencyMetadata{}
err := json.Unmarshal([]byte(*chore.FrequencyMetadata), &frequencyMetadata)
if err != nil {
return nil, fmt.Errorf("error unmarshalling frequency metadata: %w", err)
}
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)
// Handle time-based frequencies, ensure time is in the future
if chore.FrequencyType == "day_of_the_month" || chore.FrequencyType == "days_of_the_week" || chore.FrequencyType == "interval" {
t, err := time.Parse(time.RFC3339, frequencyMetadata.Time)
if err != nil {
return nil, fmt.Errorf("error unmarshalling frequency metadata")
return nil, fmt.Errorf("error parsing time in frequency metadata: %w", err)
}
//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:
baseDate = time.Date(baseDate.Year(), baseDate.Month(), baseDate.Day(), t.Hour(), t.Minute(), t.Second(), 0, time.UTC)
// 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:
// If the time is in the past today, move it to tomorrow
if baseDate.Before(completedDate) {
baseDate = baseDate.AddDate(0, 0, 1)
}
}
switch chore.FrequencyType {
case "daily":
baseDate = baseDate.AddDate(0, 0, 1)
case "weekly":
baseDate = baseDate.AddDate(0, 0, 7)
case "monthly":
baseDate = baseDate.AddDate(0, 1, 0)
case "yearly":
baseDate = baseDate.AddDate(1, 0, 0)
case "adaptive":
// TODO: Implement a more sophisticated adaptive logic
diff := completedDate.UTC().Sub(chore.NextDueDate.UTC())
baseDate = completedDate.UTC().Add(diff)
case "interval":
switch *frequencyMetadata.Unit {
case "hours":
baseDate = baseDate.Add(time.Duration(chore.Frequency) * time.Hour)
case "days":
baseDate = baseDate.AddDate(0, 0, chore.Frequency)
case "weeks":
baseDate = baseDate.AddDate(0, 0, chore.Frequency*7)
case "months":
baseDate = baseDate.AddDate(0, chore.Frequency, 0)
case "years":
baseDate = baseDate.AddDate(chore.Frequency, 0, 0)
default:
return nil, fmt.Errorf("invalid frequency unit: %s", *frequencyMetadata.Unit)
}
case "days_of_the_week":
if len(frequencyMetadata.Days) == 0 {
return nil, fmt.Errorf("days_of_the_week requires at least one day")
}
// Find the next valid day of the week
for i := 1; i <= 7; i++ {
nextDueDate = baseDate.AddDate(0, 0, 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
return &nextDueDate, 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")
return nil, fmt.Errorf("no matching day of the week found")
case "day_of_the_month":
if len(frequencyMetadata.Months) == 0 {
return nil, fmt.Errorf("day_of_the_month requires at least one month")
}
// Ensure the day of the month is valid
if chore.Frequency <= 0 || chore.Frequency > 31 {
return nil, fmt.Errorf("invalid day of the month: %d", chore.Frequency)
}
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())
// Find the next valid day of the month, considering the year
currentMonth := int(baseDate.Month())
for i := 0; i < 12; i++ { // Start from 0 to check the current month first
nextDueDate := baseDate.AddDate(0, i, 0)
nextMonth := (currentMonth + i) % 12 // Use modulo to cycle through months
if nextMonth == 0 {
nextMonth = 12 // Adjust for December
}
// Ensure the target day exists in the month (e.g., Feb 30th is invalid)
lastDayOfMonth := time.Date(nextDueDate.Year(), time.Month(nextMonth+1), 0, 0, 0, 0, 0, time.UTC).Day()
targetDay := chore.Frequency
if targetDay > lastDayOfMonth {
targetDay = lastDayOfMonth
}
nextDueDate = time.Date(nextDueDate.Year(), time.Month(nextMonth), targetDay, nextDueDate.Hour(), nextDueDate.Minute(), 0, 0, time.UTC)
for _, month := range frequencyMetadata.Months {
if *month == nextMonth {
nextDate := nextDueDate.UTC()
return &nextDate, nil
if strings.ToLower(*month) == strings.ToLower(time.Month(nextMonth).String()) {
return &nextDueDate, 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 nil, fmt.Errorf("no matching month found")
default:
return nil, fmt.Errorf("invalid frequency type: %s", chore.FrequencyType)
}
return &nextDueDate, nil
return &baseDate, nil
}
func scheduleAdaptiveNextDueDate(chore *chModel.Chore, completedDate time.Time, history []*chModel.ChoreHistory) (*time.Time, error) {