diff --git a/internal/database/database.go b/internal/database/database.go index 111bc12..05f83a3 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -62,19 +62,13 @@ func Connect(dbConfig config.DatabaseConfig, debug bool) error { return err } - // Set connection pool settings sqlDB, err := db.DB() if err != nil { return err } - // SetMaxIdleConns sets the maximum number of connections in the idle connection pool. sqlDB.SetMaxIdleConns(10) - - // SetMaxOpenConns sets the maximum number of open connections to the database. sqlDB.SetMaxOpenConns(100) - - // SetConnMaxLifetime sets the maximum amount of time a connection may be reused. sqlDB.SetConnMaxLifetime(time.Hour) DB = db @@ -85,6 +79,7 @@ func Migrate() error { return DB.AutoMigrate( &models.User{}, &models.Assignment{}, + &models.RecurringAssignment{}, &models.APIKey{}, &models.UserNotificationSettings{}, ) diff --git a/internal/handler/assignment_handler.go b/internal/handler/assignment_handler.go index 08bc1d3..07015ec 100644 --- a/internal/handler/assignment_handler.go +++ b/internal/handler/assignment_handler.go @@ -119,7 +119,6 @@ func (h *AssignmentHandler) Create(c *gin.Context) { priority := c.PostForm("priority") dueDateStr := c.PostForm("due_date") - // Parse reminder settings reminderEnabled := c.PostForm("reminder_enabled") == "on" reminderAtStr := c.PostForm("reminder_at") var reminderAt *time.Time @@ -151,21 +150,101 @@ func (h *AssignmentHandler) Create(c *gin.Context) { dueDate = dueDate.Add(23*time.Hour + 59*time.Minute) } - _, err = h.assignmentService.Create(userID, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled) - if err != nil { - role, _ := c.Get(middleware.UserRoleKey) - name, _ := c.Get(middleware.UserNameKey) - RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{ - "title": "課題登録", - "error": "課題の登録に失敗しました", - "formTitle": title, - "description": description, - "subject": subject, - "priority": priority, - "isAdmin": role == "admin", - "userName": name, - }) - return + recurrenceType := c.PostForm("recurrence_type") + if recurrenceType != "" && recurrenceType != "none" { + + recurrenceInterval := 1 + if v, err := strconv.Atoi(c.PostForm("recurrence_interval")); err == nil && v > 0 { + recurrenceInterval = v + } + + var recurrenceWeekday *int + if wd := c.PostForm("recurrence_weekday"); wd != "" { + if v, err := strconv.Atoi(wd); err == nil && v >= 0 && v <= 6 { + recurrenceWeekday = &v + } + } + + var recurrenceDay *int + if d := c.PostForm("recurrence_day"); d != "" { + if v, err := strconv.Atoi(d); err == nil && v >= 1 && v <= 31 { + recurrenceDay = &v + } + } + + endType := c.PostForm("end_type") + if endType == "" { + endType = models.EndTypeNever + } + + var endCount *int + if ec := c.PostForm("end_count"); ec != "" { + if v, err := strconv.Atoi(ec); err == nil && v > 0 { + endCount = &v + } + } + + var endDate *time.Time + if ed := c.PostForm("end_date"); ed != "" { + if v, err := time.ParseInLocation("2006-01-02", ed, time.Local); err == nil { + endDate = &v + } + } + + dueTime := dueDate.Format("15:04") + + recurringService := service.NewRecurringAssignmentService() + input := service.CreateRecurringInput{ + Title: title, + Description: description, + Subject: subject, + Priority: priority, + RecurrenceType: recurrenceType, + RecurrenceInterval: recurrenceInterval, + RecurrenceWeekday: recurrenceWeekday, + RecurrenceDay: recurrenceDay, + DueTime: dueTime, + EndType: endType, + EndCount: endCount, + EndDate: endDate, + ReminderEnabled: reminderEnabled, + UrgentReminderEnabled: urgentReminderEnabled, + FirstDueDate: dueDate, + } + + _, err = recurringService.Create(userID, input) + if err != nil { + role, _ := c.Get(middleware.UserRoleKey) + name, _ := c.Get(middleware.UserNameKey) + RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{ + "title": "課題登録", + "error": "繰り返し課題の登録に失敗しました: " + err.Error(), + "formTitle": title, + "description": description, + "subject": subject, + "priority": priority, + "isAdmin": role == "admin", + "userName": name, + }) + return + } + } else { + _, err = h.assignmentService.Create(userID, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled) + if err != nil { + role, _ := c.Get(middleware.UserRoleKey) + name, _ := c.Get(middleware.UserNameKey) + RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{ + "title": "課題登録", + "error": "課題の登録に失敗しました", + "formTitle": title, + "description": description, + "subject": subject, + "priority": priority, + "isAdmin": role == "admin", + "userName": name, + }) + return + } } c.Redirect(http.StatusFound, "/assignments") @@ -202,7 +281,6 @@ func (h *AssignmentHandler) Update(c *gin.Context) { priority := c.PostForm("priority") dueDateStr := c.PostForm("due_date") - // Parse reminder settings reminderEnabled := c.PostForm("reminder_enabled") == "on" reminderAtStr := c.PostForm("reminder_at") var reminderAt *time.Time @@ -259,7 +337,6 @@ func (h *AssignmentHandler) Statistics(c *gin.Context) { role, _ := c.Get(middleware.UserRoleKey) name, _ := c.Get(middleware.UserNameKey) - // Parse filter parameters filter := service.StatisticsFilter{ Subject: c.Query("subject"), IncludeArchived: c.Query("include_archived") == "true", @@ -268,7 +345,6 @@ func (h *AssignmentHandler) Statistics(c *gin.Context) { fromStr := c.Query("from") toStr := c.Query("to") - // Parse from date if fromStr != "" { fromDate, err := time.ParseInLocation("2006-01-02", fromStr, time.Local) if err == nil { @@ -276,7 +352,6 @@ func (h *AssignmentHandler) Statistics(c *gin.Context) { } } - // Parse to date if toStr != "" { toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local) if err == nil { @@ -293,11 +368,9 @@ func (h *AssignmentHandler) Statistics(c *gin.Context) { return } - // Get available subjects for filter dropdown (exclude archived) subjects, _ := h.assignmentService.GetSubjectsWithArchived(userID, false) archivedSubjects, _ := h.assignmentService.GetArchivedSubjects(userID) - // Create a map for quick lookup of archived subjects archivedMap := make(map[string]bool) for _, s := range archivedSubjects { archivedMap[s] = true @@ -338,5 +411,3 @@ func (h *AssignmentHandler) UnarchiveSubject(c *gin.Context) { c.Redirect(http.StatusFound, "/statistics?include_archived=true") } - - diff --git a/internal/models/assignment.go b/internal/models/assignment.go index 2a9e081..57f8d77 100644 --- a/internal/models/assignment.go +++ b/internal/models/assignment.go @@ -7,26 +7,29 @@ import ( ) type Assignment struct { - ID uint `gorm:"primarykey" json:"id"` - UserID uint `gorm:"not null;index" json:"user_id"` - Title string `gorm:"not null" json:"title"` - Description string `json:"description"` - Subject string `json:"subject"` - Priority string `gorm:"not null;default:medium" json:"priority"` // low, medium, high - DueDate time.Time `gorm:"not null" json:"due_date"` - IsCompleted bool `gorm:"default:false" json:"is_completed"` - IsArchived bool `gorm:"default:false;index" json:"is_archived"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - // Reminder notification settings (one-time) - ReminderEnabled bool `gorm:"default:false" json:"reminder_enabled"` - ReminderAt *time.Time `json:"reminder_at,omitempty"` - ReminderSent bool `gorm:"default:false;index" json:"reminder_sent"` - // Urgent reminder settings (repeating until completed) - UrgentReminderEnabled bool `gorm:"default:true" json:"urgent_reminder_enabled"` - LastUrgentReminderSent *time.Time `json:"last_urgent_reminder_sent,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + ID uint `gorm:"primarykey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + Title string `gorm:"not null" json:"title"` + Description string `json:"description"` + Subject string `json:"subject"` + Priority string `gorm:"not null;default:medium" json:"priority"` + DueDate time.Time `gorm:"not null" json:"due_date"` + IsCompleted bool `gorm:"default:false" json:"is_completed"` + IsArchived bool `gorm:"default:false;index" json:"is_archived"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + ReminderEnabled bool `gorm:"default:false" json:"reminder_enabled"` + ReminderAt *time.Time `json:"reminder_at,omitempty"` + ReminderSent bool `gorm:"default:false;index" json:"reminder_sent"` + UrgentReminderEnabled bool `gorm:"default:true" json:"urgent_reminder_enabled"` + LastUrgentReminderSent *time.Time `json:"last_urgent_reminder_sent,omitempty"` + + // Recurring assignment reference + RecurringAssignmentID *uint `gorm:"index" json:"recurring_assignment_id,omitempty"` + RecurringAssignment *RecurringAssignment `gorm:"foreignKey:RecurringAssignmentID" json:"-"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` User *User `gorm:"foreignKey:UserID" json:"user,omitempty"` } diff --git a/internal/models/notification_settings.go b/internal/models/notification_settings.go index fe9d1ad..fb90382 100644 --- a/internal/models/notification_settings.go +++ b/internal/models/notification_settings.go @@ -6,14 +6,13 @@ import ( "gorm.io/gorm" ) -// UserNotificationSettings stores user's notification preferences type UserNotificationSettings struct { ID uint `gorm:"primarykey" json:"id"` UserID uint `gorm:"uniqueIndex;not null" json:"user_id"` TelegramEnabled bool `gorm:"default:false" json:"telegram_enabled"` TelegramChatID string `json:"telegram_chat_id"` LineEnabled bool `gorm:"default:false" json:"line_enabled"` - LineNotifyToken string `json:"-"` // Hide token from JSON + LineNotifyToken string `json:"-"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` diff --git a/internal/models/recurring_assignment.go b/internal/models/recurring_assignment.go new file mode 100644 index 0000000..b24599a --- /dev/null +++ b/internal/models/recurring_assignment.go @@ -0,0 +1,102 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +const ( + RecurrenceNone = "none" + RecurrenceDaily = "daily" + RecurrenceWeekly = "weekly" + RecurrenceMonthly = "monthly" +) + +const ( + EndTypeNever = "never" + EndTypeCount = "count" + EndTypeDate = "date" +) + +const ( + EditBehaviorThisOnly = "this_only" + EditBehaviorThisAndFuture = "this_and_future" + EditBehaviorAll = "all" +) + +type RecurringAssignment struct { + ID uint `gorm:"primarykey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + Title string `gorm:"not null" json:"title"` + Description string `json:"description"` + Subject string `json:"subject"` + Priority string `gorm:"not null;default:medium" json:"priority"` + + RecurrenceType string `gorm:"not null;default:none" json:"recurrence_type"` + RecurrenceInterval int `gorm:"not null;default:1" json:"recurrence_interval"` + RecurrenceWeekday *int `json:"recurrence_weekday,omitempty"` + RecurrenceDay *int `json:"recurrence_day,omitempty"` + DueTime string `gorm:"not null" json:"due_time"` + + EndType string `gorm:"not null;default:never" json:"end_type"` + EndCount *int `json:"end_count,omitempty"` + EndDate *time.Time `json:"end_date,omitempty"` + GeneratedCount int `gorm:"default:0" json:"generated_count"` + EditBehavior string `gorm:"not null;default:this_only" json:"edit_behavior"` + + ReminderEnabled bool `gorm:"default:false" json:"reminder_enabled"` + ReminderOffset *int `json:"reminder_offset,omitempty"` + UrgentReminderEnabled bool `gorm:"default:true" json:"urgent_reminder_enabled"` + IsActive bool `gorm:"default:true" json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + User *User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Assignments []Assignment `gorm:"foreignKey:RecurringAssignmentID" json:"assignments,omitempty"` +} + +func (r *RecurringAssignment) ShouldGenerateNext() bool { + if !r.IsActive || r.RecurrenceType == RecurrenceNone { + return false + } + + switch r.EndType { + case EndTypeCount: + if r.EndCount != nil && r.GeneratedCount >= *r.EndCount { + return false + } + case EndTypeDate: + if r.EndDate != nil && time.Now().After(*r.EndDate) { + return false + } + } + + return true +} + +func (r *RecurringAssignment) CalculateNextDueDate(lastDueDate time.Time) time.Time { + var nextDate time.Time + + switch r.RecurrenceType { + case RecurrenceDaily: + nextDate = lastDueDate.AddDate(0, 0, r.RecurrenceInterval) + case RecurrenceWeekly: + nextDate = lastDueDate.AddDate(0, 0, 7*r.RecurrenceInterval) + case RecurrenceMonthly: + nextDate = lastDueDate.AddDate(0, r.RecurrenceInterval, 0) + if r.RecurrenceDay != nil { + day := *r.RecurrenceDay + lastDayOfMonth := time.Date(nextDate.Year(), nextDate.Month()+1, 0, 0, 0, 0, 0, nextDate.Location()).Day() + if day > lastDayOfMonth { + day = lastDayOfMonth + } + nextDate = time.Date(nextDate.Year(), nextDate.Month(), day, nextDate.Hour(), nextDate.Minute(), 0, 0, nextDate.Location()) + } + default: + return lastDueDate + } + + return nextDate +} diff --git a/internal/repository/assignment_repository.go b/internal/repository/assignment_repository.go index 01bcb4b..2092649 100644 --- a/internal/repository/assignment_repository.go +++ b/internal/repository/assignment_repository.go @@ -187,7 +187,6 @@ func (r *AssignmentRepository) CountOverdueByUserID(userID uint) (int64, error) return count, err } -// StatisticsFilter holds filter parameters for statistics queries type StatisticsFilter struct { Subject string From *time.Time @@ -195,7 +194,6 @@ type StatisticsFilter struct { IncludeArchived bool } -// AssignmentStatistics holds statistics data type AssignmentStatistics struct { Total int64 Completed int64 @@ -205,7 +203,6 @@ type AssignmentStatistics struct { OnTimeCompletionRate float64 } -// SubjectStatistics holds statistics for a specific subject type SubjectStatistics struct { Subject string Total int64 @@ -216,64 +213,48 @@ type SubjectStatistics struct { OnTimeCompletionRate float64 } -// GetStatistics returns statistics for a user with optional filters func (r *AssignmentRepository) GetStatistics(userID uint, filter StatisticsFilter) (*AssignmentStatistics, error) { now := time.Now() stats := &AssignmentStatistics{} - - // Base query baseQuery := r.db.Model(&models.Assignment{}).Where("user_id = ?", userID) - // Apply subject filter if filter.Subject != "" { baseQuery = baseQuery.Where("subject = ?", filter.Subject) } - // Apply date range filter (by created_at) if filter.From != nil { baseQuery = baseQuery.Where("created_at >= ?", *filter.From) } if filter.To != nil { - // Add 1 day to include the entire "to" date toEnd := filter.To.AddDate(0, 0, 1) baseQuery = baseQuery.Where("created_at < ?", toEnd) } - - // Apply archived filter if !filter.IncludeArchived { baseQuery = baseQuery.Where("is_archived = ?", false) } - // Total count if err := baseQuery.Count(&stats.Total).Error; err != nil { return nil, err } - - // Completed count completedQuery := baseQuery.Session(&gorm.Session{}) if err := completedQuery.Where("is_completed = ?", true).Count(&stats.Completed).Error; err != nil { return nil, err } - // Pending count pendingQuery := baseQuery.Session(&gorm.Session{}) if err := pendingQuery.Where("is_completed = ?", false).Count(&stats.Pending).Error; err != nil { return nil, err } - - // Overdue count overdueQuery := baseQuery.Session(&gorm.Session{}) if err := overdueQuery.Where("is_completed = ? AND due_date < ?", false, now).Count(&stats.Overdue).Error; err != nil { return nil, err } - // Completed on time count (completed_at <= due_date) onTimeQuery := baseQuery.Session(&gorm.Session{}) if err := onTimeQuery.Where("is_completed = ? AND completed_at <= due_date", true).Count(&stats.CompletedOnTime).Error; err != nil { return nil, err } - // Calculate on-time completion rate if stats.Completed > 0 { stats.OnTimeCompletionRate = float64(stats.CompletedOnTime) / float64(stats.Completed) * 100 } @@ -281,7 +262,6 @@ func (r *AssignmentRepository) GetStatistics(userID uint, filter StatisticsFilte return stats, nil } -// GetStatisticsBySubjects returns statistics grouped by subject func (r *AssignmentRepository) GetStatisticsBySubjects(userID uint, filter StatisticsFilter) ([]SubjectStatistics, error) { now := time.Now() subjects, err := r.GetSubjectsByUserID(userID) @@ -301,7 +281,6 @@ func (r *AssignmentRepository) GetStatisticsBySubjects(userID uint, filter Stati return nil, err } - // Count overdue for this subject overdueQuery := r.db.Model(&models.Assignment{}). Where("user_id = ? AND subject = ? AND is_completed = ? AND due_date < ?", userID, subject, false, now) if filter.From != nil { @@ -328,21 +307,18 @@ func (r *AssignmentRepository) GetStatisticsBySubjects(userID uint, filter Stati return results, nil } -// ArchiveBySubject archives all assignments for a subject func (r *AssignmentRepository) ArchiveBySubject(userID uint, subject string) error { return r.db.Model(&models.Assignment{}). Where("user_id = ? AND subject = ?", userID, subject). Update("is_archived", true).Error } -// UnarchiveBySubject unarchives all assignments for a subject func (r *AssignmentRepository) UnarchiveBySubject(userID uint, subject string) error { return r.db.Model(&models.Assignment{}). Where("user_id = ? AND subject = ?", userID, subject). Update("is_archived", false).Error } -// GetArchivedSubjects returns a list of archived subjects for a user func (r *AssignmentRepository) GetArchivedSubjects(userID uint) ([]string, error) { var subjects []string err := r.db.Model(&models.Assignment{}). @@ -352,7 +328,6 @@ func (r *AssignmentRepository) GetArchivedSubjects(userID uint) ([]string, error return subjects, err } -// GetSubjectsByUserIDWithArchived returns subjects optionally including archived func (r *AssignmentRepository) GetSubjectsByUserIDWithArchived(userID uint, includeArchived bool) ([]string, error) { var subjects []string query := r.db.Model(&models.Assignment{}). diff --git a/internal/repository/recurring_assignment_repository.go b/internal/repository/recurring_assignment_repository.go new file mode 100644 index 0000000..c94509f --- /dev/null +++ b/internal/repository/recurring_assignment_repository.go @@ -0,0 +1,123 @@ +package repository + +import ( + "time" + + "homework-manager/internal/database" + "homework-manager/internal/models" + + "gorm.io/gorm" +) + +type RecurringAssignmentRepository struct { + db *gorm.DB +} + +func NewRecurringAssignmentRepository() *RecurringAssignmentRepository { + return &RecurringAssignmentRepository{db: database.GetDB()} +} + +func (r *RecurringAssignmentRepository) Create(recurring *models.RecurringAssignment) error { + return r.db.Create(recurring).Error +} + +func (r *RecurringAssignmentRepository) FindByID(id uint) (*models.RecurringAssignment, error) { + var recurring models.RecurringAssignment + err := r.db.First(&recurring, id).Error + if err != nil { + return nil, err + } + return &recurring, nil +} + +func (r *RecurringAssignmentRepository) FindByUserID(userID uint) ([]models.RecurringAssignment, error) { + var recurrings []models.RecurringAssignment + err := r.db.Where("user_id = ?", userID).Order("created_at DESC").Find(&recurrings).Error + return recurrings, err +} + +func (r *RecurringAssignmentRepository) FindActiveByUserID(userID uint) ([]models.RecurringAssignment, error) { + var recurrings []models.RecurringAssignment + err := r.db.Where("user_id = ? AND is_active = ?", userID, true).Order("created_at DESC").Find(&recurrings).Error + return recurrings, err +} + +func (r *RecurringAssignmentRepository) Update(recurring *models.RecurringAssignment) error { + return r.db.Save(recurring).Error +} + +func (r *RecurringAssignmentRepository) Delete(id uint) error { + return r.db.Delete(&models.RecurringAssignment{}, id).Error +} + +func (r *RecurringAssignmentRepository) FindDueForGeneration() ([]models.RecurringAssignment, error) { + var recurrings []models.RecurringAssignment + + err := r.db.Where("is_active = ? AND recurrence_type != ?", true, models.RecurrenceNone). + Find(&recurrings).Error + + if err != nil { + return nil, err + } + + var result []models.RecurringAssignment + now := time.Now() + for _, rec := range recurrings { + shouldGenerate := true + + switch rec.EndType { + case models.EndTypeCount: + if rec.EndCount != nil && rec.GeneratedCount >= *rec.EndCount { + shouldGenerate = false + } + case models.EndTypeDate: + if rec.EndDate != nil && now.After(*rec.EndDate) { + shouldGenerate = false + } + } + + if shouldGenerate { + result = append(result, rec) + } + } + + return result, nil +} + +func (r *RecurringAssignmentRepository) GetLatestAssignmentByRecurringID(recurringID uint) (*models.Assignment, error) { + var assignment models.Assignment + err := r.db.Where("recurring_assignment_id = ?", recurringID). + Order("due_date DESC"). + First(&assignment).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &assignment, nil +} + +func (r *RecurringAssignmentRepository) GetAssignmentsByRecurringID(recurringID uint) ([]models.Assignment, error) { + var assignments []models.Assignment + err := r.db.Where("recurring_assignment_id = ?", recurringID). + Order("due_date ASC"). + Find(&assignments).Error + return assignments, err +} + +func (r *RecurringAssignmentRepository) GetFutureAssignmentsByRecurringID(recurringID uint, fromDate time.Time) ([]models.Assignment, error) { + var assignments []models.Assignment + err := r.db.Where("recurring_assignment_id = ? AND due_date >= ?", recurringID, fromDate). + Order("due_date ASC"). + Find(&assignments).Error + return assignments, err +} + +func (r *RecurringAssignmentRepository) CountPendingByRecurringID(recurringID uint) (int64, error) { + var count int64 + err := r.db.Model(&models.Assignment{}). + Where("recurring_assignment_id = ? AND is_completed = ?", recurringID, false). + Count(&count).Error + return count, err +} diff --git a/internal/router/router.go b/internal/router/router.go index a4d0d94..65abe63 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -45,6 +45,9 @@ func getFuncMap() template.FuncMap { "multiplyFloat": func(a float64, b float64) float64 { return a * b }, + "recurringLabel": service.GetRecurrenceTypeLabel, + "endTypeLabel": service.GetEndTypeLabel, + "recurringSummary": service.FormatRecurringSummary, } } @@ -59,6 +62,8 @@ func loadTemplates() (*template.Template, error) { pattern string prefix string }{ + {"web/templates/auth/*.html", ""}, + {"web/templates/pages/*.html", ""}, {"web/templates/auth/*.html", ""}, {"web/templates/pages/*.html", ""}, {"web/templates/assignments/*.html", "assignments/"}, @@ -175,7 +180,6 @@ func Setup(cfg *config.Config) *gin.Engine { apiKeyService := service.NewAPIKeyService() notificationService := service.NewNotificationService(cfg.Notification.TelegramBotToken) - // Start notification reminder scheduler notificationService.StartReminderScheduler() authHandler := handler.NewAuthHandler() diff --git a/internal/service/assignment_service.go b/internal/service/assignment_service.go index 350faa9..ac2b0b4 100644 --- a/internal/service/assignment_service.go +++ b/internal/service/assignment_service.go @@ -209,7 +209,6 @@ func (s *AssignmentService) Update(userID, assignmentID uint, title, description assignment.ReminderEnabled = reminderEnabled assignment.ReminderAt = reminderAt assignment.UrgentReminderEnabled = urgentReminderEnabled - // Reset reminder sent flag if reminder settings changed if reminderEnabled && reminderAt != nil { assignment.ReminderSent = false } @@ -279,7 +278,6 @@ func (s *AssignmentService) GetDashboardStats(userID uint) (*DashboardStats, err }, nil } -// StatisticsFilter holds filter parameters for statistics type StatisticsFilter struct { Subject string From *time.Time @@ -287,7 +285,6 @@ type StatisticsFilter struct { IncludeArchived bool } -// SubjectStats holds statistics for a subject type SubjectStats struct { Subject string `json:"subject"` Total int64 `json:"total"` @@ -298,7 +295,6 @@ type SubjectStats struct { IsArchived bool `json:"is_archived,omitempty"` } -// StatisticsSummary holds overall statistics type StatisticsSummary struct { TotalAssignments int64 `json:"total_assignments"` CompletedAssignments int64 `json:"completed_assignments"` @@ -309,7 +305,6 @@ type StatisticsSummary struct { Subjects []SubjectStats `json:"subjects,omitempty"` } -// FilterInfo shows applied filters in response type FilterInfo struct { Subject *string `json:"subject"` From *string `json:"from"` @@ -317,9 +312,7 @@ type FilterInfo struct { IncludeArchived bool `json:"include_archived"` } -// GetStatistics returns statistics for a user with optional filters func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter) (*StatisticsSummary, error) { - // Convert filter to repository filter repoFilter := repository.StatisticsFilter{ Subject: filter.Subject, From: filter.From, @@ -327,7 +320,6 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter) IncludeArchived: filter.IncludeArchived, } - // Get overall statistics stats, err := s.assignmentRepo.GetStatistics(userID, repoFilter) if err != nil { return nil, err @@ -341,7 +333,6 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter) OnTimeCompletionRate: stats.OnTimeCompletionRate, } - // Build filter info filterInfo := &FilterInfo{} hasFilter := false if filter.Subject != "" { @@ -366,7 +357,6 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter) summary.Filter = filterInfo } - // If no specific subject filter, get per-subject statistics if filter.Subject == "" { subjectStats, err := s.assignmentRepo.GetStatisticsBySubjects(userID, repoFilter) if err != nil { @@ -388,22 +378,17 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter) return summary, nil } -// ArchiveSubject archives all assignments for a subject func (s *AssignmentService) ArchiveSubject(userID uint, subject string) error { return s.assignmentRepo.ArchiveBySubject(userID, subject) } - -// UnarchiveSubject unarchives all assignments for a subject func (s *AssignmentService) UnarchiveSubject(userID uint, subject string) error { return s.assignmentRepo.UnarchiveBySubject(userID, subject) } -// GetSubjectsWithArchived returns subjects optionally including archived func (s *AssignmentService) GetSubjectsWithArchived(userID uint, includeArchived bool) ([]string, error) { return s.assignmentRepo.GetSubjectsByUserIDWithArchived(userID, includeArchived) } -// GetArchivedSubjects returns archived subjects only func (s *AssignmentService) GetArchivedSubjects(userID uint) ([]string, error) { return s.assignmentRepo.GetArchivedSubjects(userID) } diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index fc2968a..3744fab 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -14,24 +14,21 @@ import ( "homework-manager/internal/models" ) -// NotificationService handles Telegram and LINE notifications type NotificationService struct { telegramBotToken string } -// NewNotificationService creates a new notification service func NewNotificationService(telegramBotToken string) *NotificationService { return &NotificationService{ telegramBotToken: telegramBotToken, } } -// GetUserSettings retrieves notification settings for a user + func (s *NotificationService) GetUserSettings(userID uint) (*models.UserNotificationSettings, error) { var settings models.UserNotificationSettings result := database.GetDB().Where("user_id = ?", userID).First(&settings) if result.Error != nil { - // If not found, return a new empty settings object if result.RowsAffected == 0 { return &models.UserNotificationSettings{ UserID: userID, @@ -42,7 +39,6 @@ func (s *NotificationService) GetUserSettings(userID uint) (*models.UserNotifica return &settings, nil } -// UpdateUserSettings updates notification settings for a user func (s *NotificationService) UpdateUserSettings(userID uint, settings *models.UserNotificationSettings) error { settings.UserID = userID @@ -50,16 +46,12 @@ func (s *NotificationService) UpdateUserSettings(userID uint, settings *models.U result := database.GetDB().Where("user_id = ?", userID).First(&existing) if result.RowsAffected == 0 { - // Create new return database.GetDB().Create(settings).Error } - // Update existing settings.ID = existing.ID return database.GetDB().Save(settings).Error } - -// SendTelegramNotification sends a message via Telegram Bot API func (s *NotificationService) SendTelegramNotification(chatID, message string) error { if s.telegramBotToken == "" { return fmt.Errorf("telegram bot token is not configured") @@ -94,7 +86,6 @@ func (s *NotificationService) SendTelegramNotification(chatID, message string) e return nil } -// SendLineNotification sends a message via LINE Notify API func (s *NotificationService) SendLineNotification(token, message string) error { if token == "" { return fmt.Errorf("LINE Notify token is empty") @@ -127,7 +118,6 @@ func (s *NotificationService) SendLineNotification(token, message string) error return nil } -// SendAssignmentReminder sends a reminder notification for an assignment func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *models.Assignment) error { settings, err := s.GetUserSettings(userID) if err != nil { @@ -144,14 +134,12 @@ func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *mo var errors []string - // Send to Telegram if enabled if settings.TelegramEnabled && settings.TelegramChatID != "" { if err := s.SendTelegramNotification(settings.TelegramChatID, message); err != nil { errors = append(errors, fmt.Sprintf("Telegram: %v", err)) } } - // Send to LINE if enabled if settings.LineEnabled && settings.LineNotifyToken != "" { if err := s.SendLineNotification(settings.LineNotifyToken, message); err != nil { errors = append(errors, fmt.Sprintf("LINE: %v", err)) @@ -165,7 +153,6 @@ func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *mo return nil } -// SendUrgentReminder sends an urgent reminder notification for an assignment func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models.Assignment) error { settings, err := s.GetUserSettings(userID) if err != nil { @@ -203,14 +190,12 @@ func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models var errors []string - // Send to Telegram if enabled if settings.TelegramEnabled && settings.TelegramChatID != "" { if err := s.SendTelegramNotification(settings.TelegramChatID, message); err != nil { errors = append(errors, fmt.Sprintf("Telegram: %v", err)) } } - // Send to LINE if enabled if settings.LineEnabled && settings.LineNotifyToken != "" { if err := s.SendLineNotification(settings.LineNotifyToken, message); err != nil { errors = append(errors, fmt.Sprintf("LINE: %v", err)) @@ -224,8 +209,6 @@ func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models return nil } -// getUrgentReminderInterval returns the reminder interval based on priority -// high=10min, medium=30min, low=60min func getUrgentReminderInterval(priority string) time.Duration { switch priority { case "high": @@ -239,7 +222,6 @@ func getUrgentReminderInterval(priority string) time.Duration { } } -// ProcessPendingReminders checks and sends pending one-time reminders func (s *NotificationService) ProcessPendingReminders() { now := time.Now() @@ -260,17 +242,14 @@ func (s *NotificationService) ProcessPendingReminders() { continue } - // Mark as sent database.GetDB().Model(&assignment).Update("reminder_sent", true) log.Printf("Sent reminder for assignment %d to user %d", assignment.ID, assignment.UserID) } } -// ProcessUrgentReminders checks and sends urgent (repeating) reminders -// Starts 3 hours before deadline, repeats at interval based on priority func (s *NotificationService) ProcessUrgentReminders() { now := time.Now() - urgentStartTime := 3 * time.Hour // Start 3 hours before deadline + urgentStartTime := 3 * time.Hour var assignments []models.Assignment result := database.GetDB().Where( @@ -286,12 +265,10 @@ func (s *NotificationService) ProcessUrgentReminders() { for _, assignment := range assignments { timeUntilDue := assignment.DueDate.Sub(now) - // Only send if within 3 hours of deadline if timeUntilDue > urgentStartTime { continue } - // Check if enough time has passed since last urgent reminder interval := getUrgentReminderInterval(assignment.Priority) if assignment.LastUrgentReminderSent != nil { @@ -301,20 +278,17 @@ func (s *NotificationService) ProcessUrgentReminders() { } } - // Send urgent reminder if err := s.SendUrgentReminder(assignment.UserID, &assignment); err != nil { log.Printf("Error sending urgent reminder for assignment %d: %v", assignment.ID, err) continue } - // Update last sent time database.GetDB().Model(&assignment).Update("last_urgent_reminder_sent", now) log.Printf("Sent urgent reminder for assignment %d (priority: %s) to user %d", assignment.ID, assignment.Priority, assignment.UserID) } } -// StartReminderScheduler starts a background goroutine to process reminders func (s *NotificationService) StartReminderScheduler() { go func() { ticker := time.NewTicker(1 * time.Minute) diff --git a/internal/service/recurring_assignment_service.go b/internal/service/recurring_assignment_service.go new file mode 100644 index 0000000..b3c7ab9 --- /dev/null +++ b/internal/service/recurring_assignment_service.go @@ -0,0 +1,467 @@ +package service + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + "homework-manager/internal/models" + "homework-manager/internal/repository" +) + +var ( + ErrRecurringAssignmentNotFound = errors.New("recurring assignment not found") + ErrRecurringUnauthorized = errors.New("unauthorized") + ErrInvalidRecurrenceType = errors.New("invalid recurrence type") + ErrInvalidEndType = errors.New("invalid end type") +) + +type RecurringAssignmentService struct { + recurringRepo *repository.RecurringAssignmentRepository + assignmentRepo *repository.AssignmentRepository +} + +func NewRecurringAssignmentService() *RecurringAssignmentService { + return &RecurringAssignmentService{ + recurringRepo: repository.NewRecurringAssignmentRepository(), + assignmentRepo: repository.NewAssignmentRepository(), + } +} + +type CreateRecurringInput struct { + Title string + Description string + Subject string + Priority string + RecurrenceType string + RecurrenceInterval int + RecurrenceWeekday *int + RecurrenceDay *int + DueTime string + EndType string + EndCount *int + EndDate *time.Time + EditBehavior string + ReminderEnabled bool + ReminderOffset *int + UrgentReminderEnabled bool + FirstDueDate time.Time +} + +func (s *RecurringAssignmentService) Create(userID uint, input CreateRecurringInput) (*models.RecurringAssignment, error) { + if !isValidRecurrenceType(input.RecurrenceType) { + return nil, ErrInvalidRecurrenceType + } + + if !isValidEndType(input.EndType) { + return nil, ErrInvalidEndType + } + + if input.RecurrenceInterval < 1 { + input.RecurrenceInterval = 1 + } + if input.EditBehavior == "" { + input.EditBehavior = models.EditBehaviorThisOnly + } + + recurring := &models.RecurringAssignment{ + UserID: userID, + Title: input.Title, + Description: input.Description, + Subject: input.Subject, + Priority: input.Priority, + RecurrenceType: input.RecurrenceType, + RecurrenceInterval: input.RecurrenceInterval, + RecurrenceWeekday: input.RecurrenceWeekday, + RecurrenceDay: input.RecurrenceDay, + DueTime: input.DueTime, + EndType: input.EndType, + EndCount: input.EndCount, + EndDate: input.EndDate, + EditBehavior: input.EditBehavior, + ReminderEnabled: input.ReminderEnabled, + ReminderOffset: input.ReminderOffset, + UrgentReminderEnabled: input.UrgentReminderEnabled, + IsActive: true, + GeneratedCount: 0, + } + + if err := s.recurringRepo.Create(recurring); err != nil { + return nil, err + } + + if err := s.generateAssignment(recurring, input.FirstDueDate); err != nil { + return nil, err + } + + return recurring, nil +} + +func (s *RecurringAssignmentService) GetByID(userID, recurringID uint) (*models.RecurringAssignment, error) { + recurring, err := s.recurringRepo.FindByID(recurringID) + if err != nil { + return nil, ErrRecurringAssignmentNotFound + } + + if recurring.UserID != userID { + return nil, ErrRecurringUnauthorized + } + + return recurring, nil +} + +func (s *RecurringAssignmentService) GetAllByUser(userID uint) ([]models.RecurringAssignment, error) { + return s.recurringRepo.FindByUserID(userID) +} + +func (s *RecurringAssignmentService) GetActiveByUser(userID uint) ([]models.RecurringAssignment, error) { + return s.recurringRepo.FindActiveByUserID(userID) +} + +type UpdateRecurringInput struct { + Title string + Description string + Subject string + Priority string + DueTime string + EditBehavior string + ReminderEnabled bool + ReminderOffset *int + UrgentReminderEnabled bool +} + +func (s *RecurringAssignmentService) Update(userID, recurringID uint, input UpdateRecurringInput) (*models.RecurringAssignment, error) { + recurring, err := s.GetByID(userID, recurringID) + if err != nil { + return nil, err + } + + recurring.Title = input.Title + recurring.Description = input.Description + recurring.Subject = input.Subject + recurring.Priority = input.Priority + if input.DueTime != "" { + recurring.DueTime = input.DueTime + } + if input.EditBehavior != "" { + recurring.EditBehavior = input.EditBehavior + } + recurring.ReminderEnabled = input.ReminderEnabled + recurring.ReminderOffset = input.ReminderOffset + recurring.UrgentReminderEnabled = input.UrgentReminderEnabled + + if err := s.recurringRepo.Update(recurring); err != nil { + return nil, err + } + + return recurring, nil +} + +func (s *RecurringAssignmentService) UpdateAssignmentWithBehavior( + userID uint, + assignment *models.Assignment, + title, description, subject, priority string, + dueDate time.Time, + reminderEnabled bool, + reminderAt *time.Time, + urgentReminderEnabled bool, + editBehavior string, +) error { + if assignment.RecurringAssignmentID == nil { + return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled) + } + + recurring, err := s.GetByID(userID, *assignment.RecurringAssignmentID) + if err != nil { + return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled) + } + + switch editBehavior { + case models.EditBehaviorThisOnly: + return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled) + + case models.EditBehaviorThisAndFuture: + if err := s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled); err != nil { + return err + } + recurring.Title = title + recurring.Description = description + recurring.Subject = subject + recurring.Priority = priority + recurring.UrgentReminderEnabled = urgentReminderEnabled + if err := s.recurringRepo.Update(recurring); err != nil { + return err + } + return s.updateFutureAssignments(recurring.ID, assignment.DueDate, title, description, subject, priority, urgentReminderEnabled) + + case models.EditBehaviorAll: + recurring.Title = title + recurring.Description = description + recurring.Subject = subject + recurring.Priority = priority + recurring.UrgentReminderEnabled = urgentReminderEnabled + if err := s.recurringRepo.Update(recurring); err != nil { + return err + } + return s.updateAllPendingAssignments(recurring.ID, title, description, subject, priority, urgentReminderEnabled) + + default: + return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled) + } +} + +func (s *RecurringAssignmentService) updateSingleAssignment( + assignment *models.Assignment, + title, description, subject, priority string, + dueDate time.Time, + reminderEnabled bool, + reminderAt *time.Time, + urgentReminderEnabled bool, +) error { + assignment.Title = title + assignment.Description = description + assignment.Subject = subject + assignment.Priority = priority + assignment.DueDate = dueDate + assignment.ReminderEnabled = reminderEnabled + assignment.ReminderAt = reminderAt + assignment.UrgentReminderEnabled = urgentReminderEnabled + return s.assignmentRepo.Update(assignment) +} + +func (s *RecurringAssignmentService) updateFutureAssignments( + recurringID uint, + fromDate time.Time, + title, description, subject, priority string, + urgentReminderEnabled bool, +) error { + assignments, err := s.recurringRepo.GetFutureAssignmentsByRecurringID(recurringID, fromDate) + if err != nil { + return err + } + + for _, a := range assignments { + if a.IsCompleted { + continue + } + a.Title = title + a.Description = description + a.Subject = subject + a.Priority = priority + a.UrgentReminderEnabled = urgentReminderEnabled + if err := s.assignmentRepo.Update(&a); err != nil { + return err + } + } + return nil +} + +func (s *RecurringAssignmentService) updateAllPendingAssignments( + recurringID uint, + title, description, subject, priority string, + urgentReminderEnabled bool, +) error { + assignments, err := s.recurringRepo.GetAssignmentsByRecurringID(recurringID) + if err != nil { + return err + } + + for _, a := range assignments { + if a.IsCompleted { + continue + } + a.Title = title + a.Description = description + a.Subject = subject + a.Priority = priority + a.UrgentReminderEnabled = urgentReminderEnabled + if err := s.assignmentRepo.Update(&a); err != nil { + return err + } + } + return nil +} + +func (s *RecurringAssignmentService) Delete(userID, recurringID uint, deleteFutureAssignments bool) error { + recurring, err := s.GetByID(userID, recurringID) + if err != nil { + return err + } + + if deleteFutureAssignments { + assignments, err := s.recurringRepo.GetFutureAssignmentsByRecurringID(recurringID, time.Now()) + if err != nil { + return err + } + for _, a := range assignments { + if !a.IsCompleted { + s.assignmentRepo.Delete(a.ID) + } + } + } + + return s.recurringRepo.Delete(recurring.ID) +} + +func (s *RecurringAssignmentService) GenerateNextAssignments() error { + recurrings, err := s.recurringRepo.FindDueForGeneration() + if err != nil { + return err + } + + for _, recurring := range recurrings { + pendingCount, err := s.recurringRepo.CountPendingByRecurringID(recurring.ID) + if err != nil { + continue + } + + if pendingCount == 0 { + latest, err := s.recurringRepo.GetLatestAssignmentByRecurringID(recurring.ID) + if err != nil { + continue + } + + var nextDueDate time.Time + if latest != nil { + nextDueDate = recurring.CalculateNextDueDate(latest.DueDate) + } else { + nextDueDate = time.Now() + } + + if nextDueDate.After(time.Now()) { + s.generateAssignment(&recurring, nextDueDate) + } + } + } + + return nil +} + +func (s *RecurringAssignmentService) generateAssignment(recurring *models.RecurringAssignment, dueDate time.Time) error { + if recurring.DueTime != "" { + parts := strings.Split(recurring.DueTime, ":") + if len(parts) == 2 { + hour, _ := strconv.Atoi(parts[0]) + minute, _ := strconv.Atoi(parts[1]) + dueDate = time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), hour, minute, 0, 0, dueDate.Location()) + } + } + + var reminderAt *time.Time + if recurring.ReminderEnabled && recurring.ReminderOffset != nil { + t := dueDate.Add(-time.Duration(*recurring.ReminderOffset) * time.Minute) + reminderAt = &t + } + + assignment := &models.Assignment{ + UserID: userID(recurring.UserID), + Title: recurring.Title, + Description: recurring.Description, + Subject: recurring.Subject, + Priority: recurring.Priority, + DueDate: dueDate, + ReminderEnabled: recurring.ReminderEnabled, + ReminderAt: reminderAt, + UrgentReminderEnabled: recurring.UrgentReminderEnabled, + RecurringAssignmentID: &recurring.ID, + } + + if err := s.assignmentRepo.Create(assignment); err != nil { + return err + } + + recurring.GeneratedCount++ + return s.recurringRepo.Update(recurring) +} + +func userID(id uint) uint { + return id +} + +func isValidRecurrenceType(t string) bool { + switch t { + case models.RecurrenceNone, models.RecurrenceDaily, models.RecurrenceWeekly, models.RecurrenceMonthly: + return true + } + return false +} + +func isValidEndType(t string) bool { + switch t { + case models.EndTypeNever, models.EndTypeCount, models.EndTypeDate: + return true + } + return false +} + +func GetRecurrenceTypeLabel(t string) string { + switch t { + case models.RecurrenceDaily: + return "毎日" + case models.RecurrenceWeekly: + return "毎週" + case models.RecurrenceMonthly: + return "毎月" + default: + return "なし" + } +} + +func GetEndTypeLabel(t string) string { + switch t { + case models.EndTypeCount: + return "回数指定" + case models.EndTypeDate: + return "終了日指定" + default: + return "無期限" + } +} + +func FormatRecurringSummary(recurring *models.RecurringAssignment) string { + if recurring.RecurrenceType == models.RecurrenceNone { + return "" + } + + var parts []string + + typeLabel := GetRecurrenceTypeLabel(recurring.RecurrenceType) + if recurring.RecurrenceInterval > 1 { + switch recurring.RecurrenceType { + case models.RecurrenceDaily: + parts = append(parts, fmt.Sprintf("%d日ごと", recurring.RecurrenceInterval)) + case models.RecurrenceWeekly: + parts = append(parts, fmt.Sprintf("%d週間ごと", recurring.RecurrenceInterval)) + case models.RecurrenceMonthly: + parts = append(parts, fmt.Sprintf("%dヶ月ごと", recurring.RecurrenceInterval)) + } + } else { + parts = append(parts, typeLabel) + } + + if recurring.RecurrenceType == models.RecurrenceWeekly && recurring.RecurrenceWeekday != nil { + weekdays := []string{"日", "月", "火", "水", "木", "金", "土"} + if *recurring.RecurrenceWeekday >= 0 && *recurring.RecurrenceWeekday < 7 { + parts = append(parts, fmt.Sprintf("(%s曜日)", weekdays[*recurring.RecurrenceWeekday])) + } + } + + if recurring.RecurrenceType == models.RecurrenceMonthly && recurring.RecurrenceDay != nil { + parts = append(parts, fmt.Sprintf("(%d日)", *recurring.RecurrenceDay)) + } + + switch recurring.EndType { + case models.EndTypeCount: + if recurring.EndCount != nil { + parts = append(parts, fmt.Sprintf("/ %d回まで", *recurring.EndCount)) + } + case models.EndTypeDate: + if recurring.EndDate != nil { + parts = append(parts, fmt.Sprintf("/ %sまで", recurring.EndDate.Format("2006/01/02"))) + } + } + + return strings.Join(parts, " ") +} diff --git a/web/templates/assignments/edit.html b/web/templates/assignments/edit.html index 6e1c997..ca4f02a 100644 --- a/web/templates/assignments/edit.html +++ b/web/templates/assignments/edit.html @@ -56,13 +56,12 @@ 重要度により間隔が変わります:大=10分、中=30分、小=1時間