diff --git a/.github/workflows/docker-image-release.yml b/.github/workflows/docker-image-release.yml deleted file mode 100644 index d3ad35c..0000000 --- a/.github/workflows/docker-image-release.yml +++ /dev/null @@ -1,52 +0,0 @@ -# name: Build and Push Docker Image - -# on: -# push: -# branches: -# - main - -# jobs: -# build: -# name: Build and Push Docker Image -# runs-on: ubuntu-latest - -# steps: -# # Checkout the code from the repository -# - name: Checkout repository -# uses: actions/checkout@v3 - -# # # Download the latest release binary from GitHub releases -# # - name: Download latest release binary -# # run: | -# # latest_release=$(curl --silent "https://api.github.com/repos/donetick/donetick/releases/latest" | jq -r '.tag_name') -# # curl -L "https://github.com/donetick/donetick/releases/download/${latest_release}/donetick_Linux_x86_64.tar.gz" -o donetick_Linux_x86_64.tar.gz -# # tar -xzf donetick_Linux_x86_64.tar.gz -# # chmod +x ./donetick - - - -# # Log in to Docker Hub -# - name: Log in to Docker Hub -# uses: docker/login-action@v2 -# with: -# username: ${{ secrets.DOCKER_USERNAME }} -# password: ${{ secrets.DOCKER_PASSWORD }} - -# # # Log in to GitHub Container Registry -# # - name: Login to GitHub Container Registry -# # uses: docker/login-action@v3.3.0 -# # with: -# # registry: ghcr.io -# # username: ${{ github.repository_owner }} -# # password: ${{ secrets.GITHUB_TOKEN }} - - -# # Build and tag Docker image -# - name: Build Docker image -# run: | -# docker build -t ${{ secrets.DOCKER_USERNAME }}/donetick:latest . - -# # Push Docker image -# - name: Push Docker image -# run: | -# docker push ${{ secrets.DOCKER_USERNAME }}/donetick:latest diff --git a/internal/chore/handler.go b/internal/chore/handler.go index c557d16..159229b 100644 --- a/internal/chore/handler.go +++ b/internal/chore/handler.go @@ -1025,7 +1025,7 @@ func (h *Handler) completeChore(c *gin.Context) { } } else { - nextDueDate, err = scheduleNextDueDate(chore, completedDate) + nextDueDate, err = scheduleNextDueDate(chore, completedDate.UTC()) if err != nil { log.Printf("Error scheduling next due date: %s", err) c.JSON(500, gin.H{ diff --git a/internal/chore/model/model.go b/internal/chore/model/model.go index c801f55..2fa652b 100644 --- a/internal/chore/model/model.go +++ b/internal/chore/model/model.go @@ -17,7 +17,7 @@ const ( FrequencyTypeMonthly FrequencyType = "monthly" FrequencyTypeYearly FrequencyType = "yearly" FrequencyTypeAdaptive FrequencyType = "adaptive" - FrequencyTypeIntervel FrequencyType = "interval" + FrequencyTypeInterval FrequencyType = "interval" FrequencyTypeDayOfTheWeek FrequencyType = "days_of_the_week" FrequencyTypeDayOfTheMonth FrequencyType = "day_of_the_month" FrequencyTypeTrigger FrequencyType = "trigger" diff --git a/internal/chore/scheduler.go b/internal/chore/scheduler.go index 4a88837..d9e0e0a 100644 --- a/internal/chore/scheduler.go +++ b/internal/chore/scheduler.go @@ -12,18 +12,11 @@ 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() @@ -45,96 +38,110 @@ func scheduleNextDueDate(chore *chModel.Chore, completedDate time.Time) (*time.T 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) { diff --git a/internal/chore/scheduler_test.go b/internal/chore/scheduler_test.go index f46c571..4ac737a 100644 --- a/internal/chore/scheduler_test.go +++ b/internal/chore/scheduler_test.go @@ -1,312 +1,310 @@ package chore import ( - "encoding/json" - "fmt" "testing" "time" chModel "donetick.com/core/internal/chore/model" ) -func TestScheduleNextDueDateBasic(t *testing.T) { - choreTime := time.Now() - freqencyMetadataBytes := `{"time":"2024-07-07T14:30:00-04:00"}` - intervalFreqencyMetadataBytes := `{"time":"2024-07-07T14:30:00-04:00", "unit": "days"}` +type scheduleTest struct { + name string + chore chModel.Chore + completedDate time.Time + want *time.Time + wantErr bool + wantErrMsg string +} - testTable := []struct { - Name string - chore *chModel.Chore - completedAt time.Time - expected time.Time - }{ - { - chore: &chModel.Chore{ - FrequencyType: chModel.FrequencyTypeDaily, - NextDueDate: &choreTime, - FrequencyMetadata: &freqencyMetadataBytes, - }, - completedAt: choreTime, - expected: choreTime.AddDate(0, 0, 1), - }, - { // Completed 1 day late - chore: &chModel.Chore{ - FrequencyType: chModel.FrequencyTypeDaily, - NextDueDate: &choreTime, - FrequencyMetadata: &freqencyMetadataBytes, - }, - completedAt: choreTime.AddDate(0, 0, 1), - expected: choreTime.AddDate(0, 0, 1), - }, - { - Name: "Rolling completed 1 day late", - chore: &chModel.Chore{ - FrequencyType: chModel.FrequencyTypeDaily, - NextDueDate: &choreTime, - FrequencyMetadata: &freqencyMetadataBytes, - IsRolling: true, - }, - completedAt: choreTime.AddDate(0, 0, 1), - expected: choreTime.AddDate(0, 0, 1+1), - }, - { - chore: &chModel.Chore{ - FrequencyType: chModel.FrequencyTypeWeekly, - NextDueDate: &choreTime, - FrequencyMetadata: &freqencyMetadataBytes, - }, - completedAt: choreTime, - expected: choreTime.AddDate(0, 0, 7), - }, - { - chore: &chModel.Chore{ - FrequencyType: chModel.FrequencyTypeMonthly, - NextDueDate: &choreTime, - FrequencyMetadata: &freqencyMetadataBytes, - }, - completedAt: choreTime, - expected: choreTime.AddDate(0, 1, 0), - }, - { - chore: &chModel.Chore{ - FrequencyType: chModel.FrequencyTypeYearly, - NextDueDate: &choreTime, - FrequencyMetadata: &freqencyMetadataBytes, - }, - completedAt: choreTime, - expected: choreTime.AddDate(1, 0, 0), - }, - { - Name: "14 days interval Rolling Completed in time", - chore: &chModel.Chore{ - FrequencyType: chModel.FrequencyTypeIntervel, - NextDueDate: &choreTime, - FrequencyMetadata: &intervalFreqencyMetadataBytes, - Frequency: 14, - IsRolling: true, - }, - completedAt: choreTime, - expected: choreTime.AddDate(0, 0, 14), - }, - { - Name: "14 days interval Rolling Completed late", - chore: &chModel.Chore{ - FrequencyType: chModel.FrequencyTypeIntervel, - NextDueDate: &choreTime, - FrequencyMetadata: &intervalFreqencyMetadataBytes, - Frequency: 14, - IsRolling: true, - }, - completedAt: choreTime.AddDate(0, 0, 1), - expected: choreTime.AddDate(0, 0, 14+1), - }, - { - Name: "14 days interval Completed in time", - chore: &chModel.Chore{ - FrequencyType: chModel.FrequencyTypeIntervel, - NextDueDate: &choreTime, - FrequencyMetadata: &intervalFreqencyMetadataBytes, - Frequency: 14, - IsRolling: false, - }, - completedAt: choreTime, - expected: truncateToDay(choreTime.AddDate(0, 0, 14).UTC()).Add(18 * time.Hour).Add(30 * time.Minute), // Note: Same Hour and Minute as Metadata time - }, - { - Name: "14 days interval Completed late", - chore: &chModel.Chore{ - FrequencyType: chModel.FrequencyTypeIntervel, - NextDueDate: &choreTime, - FrequencyMetadata: &intervalFreqencyMetadataBytes, - Frequency: 14, - IsRolling: false, - }, - completedAt: choreTime.AddDate(0, 0, 1), - expected: truncateToDay(choreTime.AddDate(0, 0, 14).UTC()).Add(18 * time.Hour).Add(30 * time.Minute), // Note: Same Hour and Minute as Metadata time - }, - - // +func TestScheduleNextDueDateBasicTests(t *testing.T) { + // location, err := time.LoadLocation("America/New_York") + location, err := time.LoadLocation("UTC") + if err != nil { + t.Fatalf("error loading location: %v", err) } - for i, tt := range testTable { - t.Run(fmt.Sprintf("%s %s %d", tt.chore.FrequencyType, tt.Name, i), func(t *testing.T) { - actual, err := scheduleNextDueDate(tt.chore, tt.completedAt) - if err != nil { - t.Errorf("Error: %v", err) - t.FailNow() + now := time.Date(2025, 1, 2, 0, 15, 0, 0, location) + tests := []scheduleTest{ + { + name: "Daily", + chore: chModel.Chore{ + FrequencyType: chModel.FrequencyTypeDaily, + FrequencyMetadata: jsonPtr(`{"time":"2024-07-07T14:30:00-04:00"}`), + }, + completedDate: now, + want: timePtr(now.AddDate(0, 0, 1)), + }, + { + name: "Daily - (IsRolling)", + chore: chModel.Chore{ + FrequencyType: chModel.FrequencyTypeDaily, + FrequencyMetadata: jsonPtr(`{"time":"2024-07-07T14:30:00-04:00"}`), + }, + completedDate: now.AddDate(0, 1, 0), + want: timePtr(now.AddDate(0, 1, 1)), + }, + + { + name: "Weekly", + chore: chModel.Chore{ + FrequencyType: chModel.FrequencyTypeWeekly, + FrequencyMetadata: jsonPtr(`{"time":"2024-07-07T14:30:00-04:00"}`), + }, + completedDate: now, + want: timePtr(now.AddDate(0, 0, 7)), + }, + { + name: "Weekly - (IsRolling)", + chore: chModel.Chore{ + FrequencyType: chModel.FrequencyTypeWeekly, + FrequencyMetadata: jsonPtr(`{"time":"2024-07-07T14:30:00-04:00"}`), + }, + completedDate: now.AddDate(1, 0, 0), + want: timePtr(now.AddDate(1, 0, 7)), + }, + { + name: "Monthly", + chore: chModel.Chore{ + FrequencyType: chModel.FrequencyTypeMonthly, + FrequencyMetadata: jsonPtr(`{"time":"2024-07-07T14:30:00-04:00"}`), + }, + completedDate: now, + want: timePtr(now.AddDate(0, 1, 0)), + }, + { + name: "Monthly - (IsRolling)", + chore: chModel.Chore{ + FrequencyType: chModel.FrequencyTypeMonthly, + FrequencyMetadata: jsonPtr(`{"time":"2024-07-07T14:30:00-04:00"}`), + }, + completedDate: now.AddDate(0, 0, 2), + want: timePtr(now.AddDate(0, 1, 2)), + }, + { + name: "Yearly", + chore: chModel.Chore{ + FrequencyType: chModel.FrequencyTypeYearly, + FrequencyMetadata: jsonPtr(`{"time":"2024-07-07T14:30:00-04:00"}`), + }, + completedDate: now, + want: timePtr(now.AddDate(1, 0, 0)), + }, + { + name: "Yearly - (IsRolling)", + chore: chModel.Chore{ + FrequencyType: chModel.FrequencyTypeYearly, + FrequencyMetadata: jsonPtr(`{"time":"2024-07-07T14:30:00-04:00"}`), + }, + completedDate: now.AddDate(0, 0, 2), + want: timePtr(now.AddDate(1, 0, 2)), + }, + } + executeTestTable(t, tests) +} + +func TestScheduleNextDueDateInterval(t *testing.T) { + // location, err := time.LoadLocation("America/New_York") + location, err := time.LoadLocation("UTC") + if err != nil { + t.Fatalf("error loading location: %v", err) + } + + now := time.Date(2025, 1, 2, 0, 15, 0, 0, location) + tests := []scheduleTest{ + { + name: "Interval - 2 Days", + chore: chModel.Chore{ + FrequencyType: chModel.FrequencyTypeInterval, + Frequency: 2, + FrequencyMetadata: jsonPtr(`{"unit": "days","time":"2024-07-07T14:30:00-04:00"}`), + }, + completedDate: now, + want: timePtr(truncateToDay(now.AddDate(0, 0, 2)).Add(14*time.Hour + 30*time.Minute)), + }, + { + name: "Interval - 4 Weeks", + chore: chModel.Chore{ + FrequencyType: chModel.FrequencyTypeInterval, + Frequency: 4, + FrequencyMetadata: jsonPtr(`{"unit": "weeks","time":"2024-07-07T14:30:00-04:00"}`), + }, + completedDate: now, + want: timePtr(truncateToDay(now.AddDate(0, 0, 4*7)).Add(14*time.Hour + 30*time.Minute)), + }, + { + name: "Interval - 3 Months", + chore: chModel.Chore{ + FrequencyType: chModel.FrequencyTypeInterval, + Frequency: 3, + FrequencyMetadata: jsonPtr(`{"unit": "months","time":"2024-07-07T14:30:00-04:00"}`), + }, + completedDate: now, + want: timePtr(truncateToDay(now.AddDate(0, 3, 0)).Add(14*time.Hour + 30*time.Minute)), + }, + { + name: "Interval - 2 Years", + chore: chModel.Chore{ + FrequencyType: chModel.FrequencyTypeInterval, + Frequency: 2, + FrequencyMetadata: jsonPtr(`{"unit": "years","time":"2024-07-07T14:30:00-04:00"}`), + }, + completedDate: now, + want: timePtr(truncateToDay(now.AddDate(2, 0, 0)).Add(14*time.Hour + 30*time.Minute)), + }, + } + executeTestTable(t, tests) +} + +func TestScheduleNextDueDateDayOfWeek(t *testing.T) { + // location, err := time.LoadLocation("America/New_York") + location, err := time.LoadLocation("UTC") + if err != nil { + t.Fatalf("error loading location: %v", err) + } + + now := time.Date(2025, 1, 2, 0, 15, 0, 0, location) + tests := []scheduleTest{ + { + name: "Days of the week - next Monday", + chore: chModel.Chore{ + FrequencyType: chModel.FrequencyTypeDayOfTheWeek, + + FrequencyMetadata: jsonPtr(`{"days": ["monday"], "time": "2025-01-20T18:00:00-05:00"}`), + }, + completedDate: now, + want: func() *time.Time { + // Calculate next Monday at 18:00 EST + nextMonday := now.AddDate(0, 0, (int(time.Monday)-int(now.Weekday())+7)%7) + nextMonday = truncateToDay(nextMonday).Add(18*time.Hour + 0*time.Minute) + return &nextMonday + }(), + }, + { + name: "Days of the week - next Monday(IsRolling)", + chore: chModel.Chore{ + FrequencyType: chModel.FrequencyTypeDayOfTheWeek, + IsRolling: true, + FrequencyMetadata: jsonPtr(`{"days": ["monday"], "time": "2025-01-20T18:00:00-05:00"}`), + }, + + completedDate: now.AddDate(0, 1, 0), + want: func() *time.Time { + // Calculate next Thursday at 18:00 EST + completedDate := now.AddDate(0, 1, 0) + nextMonday := completedDate.AddDate(0, 0, (int(time.Monday)-int(completedDate.Weekday())+7)%7) + nextMonday = truncateToDay(nextMonday).Add(18*time.Hour + 0*time.Minute) + return &nextMonday + }(), + }, + } + executeTestTable(t, tests) +} + +func TestScheduleNextDueDateDayOfMonth(t *testing.T) { + // location, err := time.LoadLocation("America/New_York") + location, err := time.LoadLocation("UTC") + if err != nil { + t.Fatalf("error loading location: %v", err) + } + + now := time.Date(2025, 1, 2, 0, 15, 0, 0, location) + tests := []scheduleTest{ + { + name: "Day of the month - 15th of January", + chore: chModel.Chore{ + FrequencyType: chModel.FrequencyTypeDayOfTheMonth, + Frequency: 15, + FrequencyMetadata: jsonPtr(`{ "unit": "days", "time": "2025-01-20T18:00:00-05:00", "days": [], "months": [ "january" ] }`), + }, + completedDate: now, + want: timePtr(time.Date(2025, 1, 15, 18, 0, 0, 0, location)), + }, + { + name: "Day of the month - 15th of January(isRolling)", + chore: chModel.Chore{ + FrequencyType: chModel.FrequencyTypeDayOfTheMonth, + Frequency: 15, + FrequencyMetadata: jsonPtr(`{ "unit": "days", "time": "2025-01-20T18:00:00-05:00", "days": [], "months": [ "january" ] }`), + }, + completedDate: now.AddDate(1, 1, 0), + want: timePtr(time.Date(2027, 1, 15, 18, 0, 0, 0, location)), + }, + } + executeTestTable(t, tests) + +} + +func TestScheduleNextDueDateErrors(t *testing.T) { + // location, err := time.LoadLocation("America/New_York") + location, err := time.LoadLocation("UTC") + if err != nil { + t.Fatalf("error loading location: %v", err) + } + + now := time.Date(2025, 1, 2, 0, 15, 0, 0, location) + tests := []scheduleTest{ + { + name: "Invalid frequency Metadata", + chore: chModel.Chore{ + FrequencyType: "invalid", + FrequencyMetadata: jsonPtr(``), + }, + completedDate: now, + wantErr: true, + wantErrMsg: "error unmarshalling frequency metadata: unexpected end of JSON input", + }, + } + executeTestTable(t, tests) +} +func TestScheduleNextDueDate(t *testing.T) { + +} + +func executeTestTable(t *testing.T, tests []scheduleTest) { + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := scheduleNextDueDate(&tt.chore, tt.completedDate) + if (err != nil) != tt.wantErr { + t.Errorf("testcase: %s", tt.name) + t.Errorf("scheduleNextDueDate() error = %v, wantErr %v", err, tt.wantErr) + return } - if actual == nil { - t.Errorf("Expected: %v, Error: Actual missing", tt.expected) - } else if actual.UTC().Format(time.RFC3339) != tt.expected.UTC().Format(time.RFC3339) { - t.Errorf("Expected: %v, Actual: %v", tt.expected, actual) + + if tt.wantErr { + if err.Error() != tt.wantErrMsg { + t.Errorf("testcase: %s", tt.name) + t.Errorf("scheduleNextDueDate() error message = %v, wantErrMsg %v", err.Error(), tt.wantErrMsg) + } + return + } + + if !equalTime(got, tt.want) { + t.Errorf("testcase: %s", tt.name) + t.Errorf("scheduleNextDueDate() = %v, want %v", got, tt.want) + } }) } } +func equalTime(t1, t2 *time.Time) bool { + if t1 == nil && t2 == nil { + return true + } + if t1 == nil || t2 == nil { + return false + } + return t1.Equal(*t2) +} + +func timePtr(t time.Time) *time.Time { + return &t +} + +func jsonPtr(s string) *string { + return &s +} func truncateToDay(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) } - -func TestScheduleNextDueDateDayOfTheWeek(t *testing.T) { - choreTime := time.Now() - - Monday := "monday" - Wednesday := "wednesday" - - timeOfChore := "2024-07-07T16:30:00-04:00" - getExpectedTime := func(choreTime time.Time, timeOfChore string) time.Time { - t, err := time.Parse(time.RFC3339, timeOfChore) - if err != nil { - return time.Time{} - } - return time.Date(choreTime.Year(), choreTime.Month(), choreTime.Day(), t.Hour(), t.Minute(), 0, 0, t.Location()) - } - nextSaturday := choreTime.AddDate(0, 0, 1) - for nextSaturday.Weekday() != time.Saturday { - nextSaturday = nextSaturday.AddDate(0, 0, 1) - } - - nextMonday := choreTime.AddDate(0, 0, 1) - for nextMonday.Weekday() != time.Monday { - nextMonday = nextMonday.AddDate(0, 0, 1) - } - - nextTuesday := choreTime.AddDate(0, 0, 1) - for nextTuesday.Weekday() != time.Tuesday { - nextTuesday = nextTuesday.AddDate(0, 0, 1) - } - - nextWednesday := choreTime.AddDate(0, 0, 1) - for nextWednesday.Weekday() != time.Wednesday { - nextWednesday = nextWednesday.AddDate(0, 0, 1) - } - - nextThursday := choreTime.AddDate(0, 0, 1) - for nextThursday.Weekday() != time.Thursday { - nextThursday = nextThursday.AddDate(0, 0, 1) - } - - testTable := []struct { - chore *chModel.Chore - frequencyMetadata *chModel.FrequencyMetadata - expected time.Time - }{ - { - chore: &chModel.Chore{ - FrequencyType: chModel.FrequencyTypeDayOfTheWeek, - NextDueDate: &nextSaturday, - }, - frequencyMetadata: &chModel.FrequencyMetadata{ - Time: timeOfChore, - Days: []*string{&Monday, &Wednesday}, - }, - - expected: getExpectedTime(nextMonday, timeOfChore), - }, - { - chore: &chModel.Chore{ - FrequencyType: chModel.FrequencyTypeDayOfTheWeek, - NextDueDate: &nextMonday, - }, - frequencyMetadata: &chModel.FrequencyMetadata{ - Time: timeOfChore, - Days: []*string{&Monday, &Wednesday}, - }, - expected: getExpectedTime(nextWednesday, timeOfChore), - }, - } - for _, tt := range testTable { - t.Run(string(tt.chore.FrequencyType), func(t *testing.T) { - bytesFrequencyMetadata, err := json.Marshal(tt.frequencyMetadata) - stringFrequencyMetadata := string(bytesFrequencyMetadata) - - if err != nil { - t.Errorf("Error: %v", err) - } - tt.chore.FrequencyMetadata = &stringFrequencyMetadata - actual, err := scheduleNextDueDate(tt.chore, choreTime) - - if err != nil { - t.Errorf("Error: %v", err) - } - if actual != nil && actual.UTC().Format(time.RFC3339) != tt.expected.UTC().Format(time.RFC3339) { - t.Errorf("Expected: %v, Actual: %v", tt.expected, actual) - } - }) - } -} -func TestScheduleAdaptiveNextDueDate(t *testing.T) { - getTimeFromDate := func(timeOfChore string) *time.Time { - t, err := time.Parse(time.RFC3339, timeOfChore) - if err != nil { - return nil - } - return &t - } - testTable := []struct { - description string - history []*chModel.ChoreHistory - chore *chModel.Chore - expected *time.Time - completeDate *time.Time - }{ - { - description: "Every Two days", - chore: &chModel.Chore{ - NextDueDate: getTimeFromDate("2024-07-13T01:30:00-00:00"), - }, - history: []*chModel.ChoreHistory{ - { - CompletedAt: getTimeFromDate("2024-07-11T01:30:00-00:00"), - }, - // { - // CompletedAt: getTimeFromDate("2024-07-09T01:30:00-00:00"), - // }, - // { - // CompletedAt: getTimeFromDate("2024-07-07T01:30:00-00:00"), - // }, - }, - expected: getTimeFromDate("2024-07-15T01:30:00-00:00"), - }, - { - description: "Every 8 days", - chore: &chModel.Chore{ - NextDueDate: getTimeFromDate("2024-07-13T01:30:00-00:00"), - }, - history: []*chModel.ChoreHistory{ - { - CompletedAt: getTimeFromDate("2024-07-05T01:30:00-00:00"), - }, - { - CompletedAt: getTimeFromDate("2024-06-27T01:30:00-00:00"), - }, - }, - expected: getTimeFromDate("2024-07-21T01:30:00-00:00"), - }, - { - description: "40 days with limit Data", - chore: &chModel.Chore{ - NextDueDate: getTimeFromDate("2024-07-13T01:30:00-00:00"), - }, - history: []*chModel.ChoreHistory{ - {CompletedAt: getTimeFromDate("2024-06-03T01:30:00-00:00")}, - }, - expected: getTimeFromDate("2024-08-22T01:30:00-00:00"), - }, - } - for _, tt := range testTable { - t.Run(tt.description, func(t *testing.T) { - expectedNextDueDate := tt.expected - - actualNextDueDate, err := scheduleAdaptiveNextDueDate(tt.chore, *tt.chore.NextDueDate, tt.history) - if err != nil { - t.Errorf("Error: %v", err) - } - - if actualNextDueDate == nil || !actualNextDueDate.Equal(*expectedNextDueDate) { - t.Errorf("Expected: %v, Actual: %v", expectedNextDueDate, actualNextDueDate) - } - }) - } -}