diff options
Diffstat (limited to 'internal')
-rw-r--r-- | internal/chore/handler.go | 2 | ||||
-rw-r--r-- | internal/chore/model/model.go | 18 | ||||
-rw-r--r-- | internal/chore/scheduler.go | 30 | ||||
-rw-r--r-- | internal/chore/scheduler_test.go | 229 | ||||
-rw-r--r-- | internal/user/handler.go | 27 | ||||
-rw-r--r-- | internal/user/model/model.go | 24 | ||||
-rw-r--r-- | internal/user/repo/repository.go | 4 |
7 files changed, 317 insertions, 17 deletions
diff --git a/internal/chore/handler.go b/internal/chore/handler.go index 16c80ef..01490cc 100644 --- a/internal/chore/handler.go +++ b/internal/chore/handler.go @@ -33,7 +33,7 @@ type ThingTrigger struct { type ChoreReq struct { Name string `json:"name" binding:"required"` - FrequencyType string `json:"frequencyType"` + FrequencyType chModel.FrequencyType `json:"frequencyType"` ID int `json:"id"` DueDate string `json:"dueDate"` Assignees []chModel.ChoreAssignees `json:"assignees"` diff --git a/internal/chore/model/model.go b/internal/chore/model/model.go index 38d8354..b2633b3 100644 --- a/internal/chore/model/model.go +++ b/internal/chore/model/model.go @@ -6,10 +6,26 @@ import ( tModel "donetick.com/core/internal/thing/model" ) +type FrequencyType string + +const ( + FrequancyTypeOnce FrequencyType = "once" + FrequancyTypeDaily FrequencyType = "daily" + FrequancyTypeWeekly FrequencyType = "weekly" + FrequancyTypeMonthly FrequencyType = "monthly" + FrequancyTypeYearly FrequencyType = "yearly" + FrequancyTypeAdaptive FrequencyType = "adaptive" + FrequancyTypeIntervel FrequencyType = "interval" + FrequancyTypeDayOfTheWeek FrequencyType = "days_of_the_week" + FrequancyTypeDayOfTheMonth FrequencyType = "day_of_the_month" + FrequancyTypeTrigger FrequencyType = "trigger" + FrequancyTypeNoRepeat FrequencyType = "no_repeat" +) + type Chore struct { ID int `json:"id" gorm:"primary_key"` Name string `json:"name" gorm:"column:name"` // Chore description - FrequencyType string `json:"frequencyType" gorm:"column:frequency_type"` // "daily", "weekly", "monthly", "yearly", "adaptive",or "custom" + FrequencyType FrequencyType `json:"frequencyType" gorm:"column:frequency_type"` // "daily", "weekly", "monthly", "yearly", "adaptive",or "custom" Frequency int `json:"frequency" gorm:"column:frequency"` // Number of days, weeks, months, or years between chores FrequencyMetadata *string `json:"frequencyMetadata" gorm:"column:frequency_meta"` // Additional frequency information NextDueDate *time.Time `json:"nextDueDate" gorm:"column:next_due_date;index"` // When the chore is due diff --git a/internal/chore/scheduler.go b/internal/chore/scheduler.go index 5413447..cec120d 100644 --- a/internal/chore/scheduler.go +++ b/internal/chore/scheduler.go @@ -3,6 +3,7 @@ package chore import ( "encoding/json" "fmt" + "math" "math/rand" "strings" "time" @@ -141,11 +142,15 @@ func scheduleNextDueDate(chore *chModel.Chore, completedDate time.Time) (*time.T 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 { + + history = append([]*chModel.ChoreHistory{ + { + CompletedAt: &completedDate, + }, + }, history...) + + if len(history) < 2 { if chore.NextDueDate != nil { diff := completedDate.UTC().Sub(chore.NextDueDate.UTC()) nextDueDate := completedDate.UTC().Add(diff) @@ -153,22 +158,23 @@ func scheduleAdaptiveNextDueDate(chore *chModel.Chore, completedDate time.Time, } return nil, nil } - var weight float64 + + var totalDelay float64 var totalWeight float64 - var nextDueDate time.Time + decayFactor := 0.5 // Adjust this value to control the decay rate + 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) + weight := math.Pow(decayFactor, float64(i)) + totalDelay += delay * weight 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) + + averageDelay := totalDelay / totalWeight + 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 { diff --git a/internal/chore/scheduler_test.go b/internal/chore/scheduler_test.go new file mode 100644 index 0000000..21d064a --- /dev/null +++ b/internal/chore/scheduler_test.go @@ -0,0 +1,229 @@ +package chore + +import ( + "encoding/json" + "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"}` + + testTable := []struct { + chore *chModel.Chore + expected time.Time + }{ + { + chore: &chModel.Chore{ + FrequencyType: chModel.FrequancyTypeDaily, + NextDueDate: &choreTime, + FrequencyMetadata: &freqencyMetadataBytes, + }, + expected: choreTime.AddDate(0, 0, 1), + }, + { + chore: &chModel.Chore{ + FrequencyType: chModel.FrequancyTypeWeekly, + NextDueDate: &choreTime, + FrequencyMetadata: &freqencyMetadataBytes, + }, + expected: choreTime.AddDate(0, 0, 7), + }, + { + chore: &chModel.Chore{ + FrequencyType: chModel.FrequancyTypeMonthly, + NextDueDate: &choreTime, + FrequencyMetadata: &freqencyMetadataBytes, + }, + expected: choreTime.AddDate(0, 1, 0), + }, + { + chore: &chModel.Chore{ + FrequencyType: chModel.FrequancyTypeYearly, + NextDueDate: &choreTime, + FrequencyMetadata: &freqencyMetadataBytes, + }, + expected: choreTime.AddDate(1, 0, 0), + }, + + // + } + for _, tt := range testTable { + t.Run(string(tt.chore.FrequencyType), func(t *testing.T) { + + 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 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.FrequancyTypeDayOfTheWeek, + NextDueDate: &nextSaturday, + }, + frequencyMetadata: &chModel.FrequencyMetadata{ + Time: timeOfChore, + Days: []*string{&Monday, &Wednesday}, + }, + + expected: getExpectedTime(nextMonday, timeOfChore), + }, + { + chore: &chModel.Chore{ + FrequencyType: chModel.FrequancyTypeDayOfTheWeek, + 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) + } + }) + } +} diff --git a/internal/user/handler.go b/internal/user/handler.go index 15e881f..ff885f3 100644 --- a/internal/user/handler.go +++ b/internal/user/handler.go @@ -486,6 +486,32 @@ func (h *Handler) DeleteUserToken(c *gin.Context) { c.JSON(http.StatusOK, gin.H{}) } +func (h *Handler) UpdateNotificationTarget(c *gin.Context) { + currentUser, ok := auth.CurrentUser(c) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get current user"}) + return + } + + type Request struct { + Type uModel.UserNotificationType `json:"type" binding:"required"` + Token string `json:"token" binding:"required"` + } + + var req Request + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) + return + } + + err := h.userRepo.UpdateNotificationTarget(c, currentUser.ID, req.Token, req.Type) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update notification target"}) + return + } + + c.JSON(http.StatusOK, gin.H{}) +} func Routes(router *gin.Engine, h *Handler, auth *jwt.GinJWTMiddleware, limiter *limiter.Limiter) { userRoutes := router.Group("users") @@ -497,6 +523,7 @@ func Routes(router *gin.Engine, h *Handler, auth *jwt.GinJWTMiddleware, limiter userRoutes.POST("/tokens", h.CreateLongLivedToken) userRoutes.GET("/tokens", h.GetAllUserToken) userRoutes.DELETE("/tokens/:id", h.DeleteUserToken) + userRoutes.PUT("/targets", h.UpdateNotificationTarget) } authRoutes := router.Group("auth") diff --git a/internal/user/model/model.go b/internal/user/model/model.go index 4874ac1..4cfb38b 100644 --- a/internal/user/model/model.go +++ b/internal/user/model/model.go @@ -16,9 +16,10 @@ type User struct { UpdatedAt time.Time `json:"updated_at" gorm:"column:updated_at"` // Updated at Disabled bool `json:"disabled" gorm:"column:disabled"` // Disabled // Email string `json:"email" gorm:"column:email"` // Email - CustomerID *string `gorm:"column:customer_id;<-:false"` // read one column - Subscription *string `json:"subscription" gorm:"column:subscription;<-:false"` // read one column - Expiration *string `json:"expiration" gorm:"column:expiration;<-:false"` // read one column + CustomerID *string `gorm:"column:customer_id;<-:false"` // read only column + Subscription *string `json:"subscription" gorm:"column:subscription;<-:false"` // read only column + Expiration *string `json:"expiration" gorm:"column:expiration;<-:false"` // read only column + UserNotificationTargets []UserNotificationTarget `json:"-" gorm:"foreignKey:UserID;references:ID"` } type UserPasswordReset struct { @@ -36,3 +37,20 @@ type APIToken struct { Token string `json:"token" gorm:"column:token;index"` // Index on token CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"` } + +type UserNotificationTarget struct { + ID int `json:"id" gorm:"primary_key"` // Unique identifier + UserID int `json:"userId" gorm:"column:user_id;index"` // Index on userID + Type UserNotificationType `json:"type" gorm:"column:type"` // Type + TargetID string `json:"targetId" gorm:"column:target_id"` // Target ID + CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"` +} + +type UserNotificationType int8 + +const ( + _ UserNotificationType = iota + Android + IOS + Telegram +) diff --git a/internal/user/repo/repository.go b/internal/user/repo/repository.go index 76ddd54..7dde6fb 100644 --- a/internal/user/repo/repository.go +++ b/internal/user/repo/repository.go @@ -158,3 +158,7 @@ func (r *UserRepository) GetAllUserTokens(c context.Context, userID int) ([]*uMo func (r *UserRepository) DeleteAPIToken(c context.Context, userID int, tokenID string) error { return r.db.WithContext(c).Where("id = ? AND user_id = ?", tokenID, userID).Delete(&uModel.APIToken{}).Error } + +func (r *UserRepository) UpdateNotificationTarget(c context.Context, userID int, targetID string, targetType uModel.UserNotificationType) error { + return r.db.WithContext(c).Model(&uModel.UserNotificationTarget{}).Where("user_id = ? AND type = ?", userID, targetType).Update("target_id", targetID).Error +} |