diff --git a/internal/handler/api_handler.go b/internal/handler/api_handler.go index cec11bf..884632b 100644 --- a/internal/handler/api_handler.go +++ b/internal/handler/api_handler.go @@ -239,6 +239,7 @@ type CreateAssignmentInput struct { Subject string `json:"subject"` Priority string `json:"priority"` DueDate string `json:"due_date" binding:"required"` + SoftDueDate string `json:"soft_due_date"` ReminderEnabled bool `json:"reminder_enabled"` ReminderAt string `json:"reminder_at"` @@ -352,7 +353,17 @@ func (h *APIHandler) CreateAssignment(c *gin.Context) { return } - assignment, err := h.assignmentService.Create(userID, input.Title, input.Description, input.Subject, input.Priority, dueDate, input.ReminderEnabled, reminderAt, urgentReminder) + var softDueDate *time.Time + if input.SoftDueDate != "" { + parsedSoft, err := parseDateString(input.SoftDueDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid soft_due_date format"}) + return + } + softDueDate = &parsedSoft + } + + assignment, err := h.assignmentService.Create(userID, input.Title, input.Description, input.Subject, input.Priority, dueDate, softDueDate, input.ReminderEnabled, reminderAt, urgentReminder) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create assignment"}) return @@ -367,6 +378,7 @@ type UpdateAssignmentInput struct { Subject string `json:"subject"` Priority string `json:"priority"` DueDate string `json:"due_date"` + SoftDueDate string `json:"soft_due_date"` ReminderEnabled *bool `json:"reminder_enabled"` ReminderAt string `json:"reminder_at"` UrgentReminderEnabled *bool `json:"urgent_reminder_enabled"` @@ -448,7 +460,17 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) { urgentReminderEnabled = *input.UrgentReminderEnabled } - assignment, err := h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled) + softDueDate := existing.SoftDueDate + if input.SoftDueDate != "" { + parsedSoft, err := parseDateString(input.SoftDueDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid soft_due_date format"}) + return + } + softDueDate = &parsedSoft + } + + assignment, err := h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate, softDueDate, reminderEnabled, reminderAt, urgentReminderEnabled) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update assignment"}) return diff --git a/internal/handler/assignment_handler.go b/internal/handler/assignment_handler.go index dd2ad4c..37c7ef3 100644 --- a/internal/handler/assignment_handler.go +++ b/internal/handler/assignment_handler.go @@ -111,14 +111,16 @@ func (h *AssignmentHandler) New(c *gin.Context) { now := time.Now() tomorrow := now.AddDate(0, 0, 1) defaultDue := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 23, 59, 0, 0, now.Location()) + defaultSoftDue := defaultDue.Add(-48 * time.Hour) RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{ - "title": "課題登録", - "isAdmin": role == "admin", - "userName": name, - "currentWeekday": int(now.Weekday()), - "currentDay": now.Day(), - "defaultDueDate": defaultDue.Format("2006-01-02T15:04"), + "title": "課題登録", + "isAdmin": role == "admin", + "userName": name, + "currentWeekday": int(now.Weekday()), + "currentDay": now.Day(), + "defaultDueDate": defaultDue.Format("2006-01-02T15:04"), + "defaultSoftDueDate": defaultSoftDue.Format("2006-01-02T15:04"), }) } @@ -178,6 +180,16 @@ func (h *AssignmentHandler) Create(c *gin.Context) { dueDate = dueDate.Add(23*time.Hour + 59*time.Minute) } + var softDueDate *time.Time + if softDueDateStr := c.PostForm("soft_due_date"); softDueDateStr != "" { + if parsed, err := time.ParseInLocation("2006-01-02T15:04", softDueDateStr, time.Local); err == nil { + softDueDate = &parsed + } else if parsed, err := time.ParseInLocation("2006-01-02", softDueDateStr, time.Local); err == nil { + p := parsed.Add(23*time.Hour + 59*time.Minute) + softDueDate = &p + } + } + recurrenceType := c.PostForm("recurrence_type") if recurrenceType != "" && recurrenceType != "none" { @@ -265,7 +277,7 @@ func (h *AssignmentHandler) Create(c *gin.Context) { return } } else { - assignment, err := h.assignmentService.Create(userID, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled) + assignment, err := h.assignmentService.Create(userID, title, description, subject, priority, dueDate, softDueDate, reminderEnabled, reminderAt, urgentReminderEnabled) if err != nil { role, _ := c.Get(middleware.UserRoleKey) name, _ := c.Get(middleware.UserNameKey) @@ -352,7 +364,17 @@ func (h *AssignmentHandler) Update(c *gin.Context) { dueDate = dueDate.Add(23*time.Hour + 59*time.Minute) } - _, err = h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled) + var softDueDate *time.Time + if softDueDateStr := c.PostForm("soft_due_date"); softDueDateStr != "" { + if parsed, err := time.ParseInLocation("2006-01-02T15:04", softDueDateStr, time.Local); err == nil { + softDueDate = &parsed + } else if parsed, err := time.ParseInLocation("2006-01-02", softDueDateStr, time.Local); err == nil { + p := parsed.Add(23*time.Hour + 59*time.Minute) + softDueDate = &p + } + } + + _, err = h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate, softDueDate, reminderEnabled, reminderAt, urgentReminderEnabled) if err != nil { c.Redirect(http.StatusFound, "/assignments") return @@ -501,10 +523,9 @@ func (h *AssignmentHandler) ExportCSV(c *gin.Context) { c.Header("Content-Disposition", "attachment; filename=\""+filename+"\"") w := csv.NewWriter(c.Writer) - // UTF-8 BOM for Excel compatibility c.Writer.Write([]byte("\xef\xbb\xbf")) - headers := []string{"ID", "タイトル", "科目", "説明", "重要度", "提出期限", "完了", "完了日時", "登録日時"} + headers := []string{"ID", "タイトル", "科目", "説明", "重要度", "提出期限", "自分の期限", "完了", "完了日時", "登録日時"} w.Write(headers) priorityLabel := map[string]string{"low": "低", "medium": "中", "high": "高"} @@ -517,6 +538,10 @@ func (h *AssignmentHandler) ExportCSV(c *gin.Context) { if a.CompletedAt != nil { completedAt = a.CompletedAt.Format("2006/01/02 15:04") } + softDueDateStr := "" + if a.SoftDueDate != nil { + softDueDateStr = a.SoftDueDate.Format("2006/01/02 15:04") + } label := priorityLabel[a.Priority] if label == "" { label = a.Priority @@ -528,6 +553,7 @@ func (h *AssignmentHandler) ExportCSV(c *gin.Context) { a.Description, label, a.DueDate.Format("2006/01/02 15:04"), + softDueDateStr, completed, completedAt, a.CreatedAt.Format("2006/01/02 15:04"), diff --git a/internal/models/assignment.go b/internal/models/assignment.go index 57f8d77..6600a79 100644 --- a/internal/models/assignment.go +++ b/internal/models/assignment.go @@ -14,6 +14,7 @@ type Assignment struct { Subject string `json:"subject"` Priority string `gorm:"not null;default:medium" json:"priority"` DueDate time.Time `gorm:"not null" json:"due_date"` + SoftDueDate *time.Time `json:"soft_due_date,omitempty"` IsCompleted bool `gorm:"default:false" json:"is_completed"` IsArchived bool `gorm:"default:false;index" json:"is_archived"` CompletedAt *time.Time `json:"completed_at,omitempty"` @@ -23,7 +24,6 @@ type Assignment struct { 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:"-"` @@ -34,6 +34,13 @@ type Assignment struct { User *User `gorm:"foreignKey:UserID" json:"user,omitempty"` } +func (a *Assignment) GetEffectiveSoftDueDate() time.Time { + if a.SoftDueDate != nil { + return *a.SoftDueDate + } + return a.DueDate.Add(-48 * time.Hour) +} + func (a *Assignment) IsOverdue() bool { return !a.IsCompleted && time.Now().After(a.DueDate) } diff --git a/internal/router/router.go b/internal/router/router.go index dd8f249..78a47f4 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -25,11 +25,27 @@ func getFuncMap() template.FuncMap { "formatDate": func(t time.Time) string { return t.Format("2006/01/02") }, - "formatDateTime": func(t time.Time) string { - return t.Format("2006/01/02 15:04") + "formatDateTime": func(t interface{}) string { + switch v := t.(type) { + case time.Time: + return v.Format("2006/01/02 15:04") + case *time.Time: + if v != nil { + return v.Format("2006/01/02 15:04") + } + } + return "" }, - "formatDateInput": func(t time.Time) string { - return t.Format("2006-01-02T15:04") + "formatDateInput": func(t interface{}) string { + switch v := t.(type) { + case time.Time: + return v.Format("2006-01-02T15:04") + case *time.Time: + if v != nil { + return v.Format("2006-01-02T15:04") + } + } + return "" }, "isOverdue": func(t time.Time, completed bool) bool { return !completed && time.Now().After(t) diff --git a/internal/service/assignment_service.go b/internal/service/assignment_service.go index 7f86424..2042c95 100644 --- a/internal/service/assignment_service.go +++ b/internal/service/assignment_service.go @@ -31,7 +31,7 @@ func NewAssignmentService() *AssignmentService { } } -func (s *AssignmentService) Create(userID uint, title, description, subject, priority string, dueDate time.Time, reminderEnabled bool, reminderAt *time.Time, urgentReminderEnabled bool) (*models.Assignment, error) { +func (s *AssignmentService) Create(userID uint, title, description, subject, priority string, dueDate time.Time, softDueDate *time.Time, reminderEnabled bool, reminderAt *time.Time, urgentReminderEnabled bool) (*models.Assignment, error) { if priority == "" { priority = "medium" } @@ -42,6 +42,7 @@ func (s *AssignmentService) Create(userID uint, title, description, subject, pri Subject: subject, Priority: priority, DueDate: dueDate, + SoftDueDate: softDueDate, IsCompleted: false, ReminderEnabled: reminderEnabled, ReminderAt: reminderAt, @@ -195,7 +196,7 @@ func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filt }, nil } -func (s *AssignmentService) Update(userID, assignmentID uint, title, description, subject, priority string, dueDate time.Time, reminderEnabled bool, reminderAt *time.Time, urgentReminderEnabled bool) (*models.Assignment, error) { +func (s *AssignmentService) Update(userID, assignmentID uint, title, description, subject, priority string, dueDate time.Time, softDueDate *time.Time, reminderEnabled bool, reminderAt *time.Time, urgentReminderEnabled bool) (*models.Assignment, error) { assignment, err := s.GetByID(userID, assignmentID) if err != nil { return nil, err @@ -206,6 +207,7 @@ func (s *AssignmentService) Update(userID, assignmentID uint, title, description assignment.Subject = subject assignment.Priority = priority assignment.DueDate = dueDate + assignment.SoftDueDate = softDueDate assignment.ReminderEnabled = reminderEnabled assignment.ReminderAt = reminderAt assignment.UrgentReminderEnabled = urgentReminderEnabled diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index 46599ea..e74d3d0 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -170,10 +170,11 @@ func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models return err } - timeRemaining := time.Until(assignment.DueDate) + softDue := assignment.GetEffectiveSoftDueDate() + timeRemaining := time.Until(softDue) var timeStr string if timeRemaining < 0 { - timeStr = "期限切れ!" + timeStr = "自分の期限切れ!" } else if timeRemaining < time.Hour { timeStr = fmt.Sprintf("あと%d分", int(timeRemaining.Minutes())) } else { @@ -191,12 +192,13 @@ func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models } message := fmt.Sprintf( - "%s 督促通知!\n\n【%s】\n科目: %s\n期限: %s (%s)\n\n完了したらアプリで完了ボタンを押してください!", + "%s 督促通知!\n\n【%s】\n科目: %s\n自分の期限: %s (%s)\nガチ期限: %s\n\n完了したらアプリで完了ボタンを押してください!", priorityEmoji, assignment.Title, assignment.Subject, - assignment.DueDate.Format("2006/01/02 15:04"), + softDue.Format("2006/01/02 15:04"), timeStr, + assignment.DueDate.Format("2006/01/02 15:04"), ) var errors []string @@ -268,9 +270,10 @@ func (s *NotificationService) ProcessUrgentReminders() { } for _, assignment := range assignments { - timeUntilDue := assignment.DueDate.Sub(now) + softDue := assignment.GetEffectiveSoftDueDate() + timeUntilSoftDue := softDue.Sub(now) - if timeUntilDue > urgentStartTime { + if timeUntilSoftDue > urgentStartTime { continue } diff --git a/internal/service/recurring_assignment_service.go b/internal/service/recurring_assignment_service.go index 4d9556e..6705280 100644 --- a/internal/service/recurring_assignment_service.go +++ b/internal/service/recurring_assignment_service.go @@ -244,26 +244,27 @@ func (s *RecurringAssignmentService) UpdateAssignmentWithBehavior( assignment *models.Assignment, title, description, subject, priority string, dueDate time.Time, + softDueDate *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) + return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, softDueDate, 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) + return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, softDueDate, reminderEnabled, reminderAt, urgentReminderEnabled) } switch editBehavior { case models.EditBehaviorThisOnly: - return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled) + return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, softDueDate, reminderEnabled, reminderAt, urgentReminderEnabled) case models.EditBehaviorThisAndFuture: - if err := s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled); err != nil { + if err := s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, softDueDate, reminderEnabled, reminderAt, urgentReminderEnabled); err != nil { return err } recurring.Title = title @@ -288,7 +289,7 @@ func (s *RecurringAssignmentService) UpdateAssignmentWithBehavior( return s.updateAllPendingAssignments(recurring.ID, title, description, subject, priority, urgentReminderEnabled) default: - return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled) + return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, softDueDate, reminderEnabled, reminderAt, urgentReminderEnabled) } } @@ -296,6 +297,7 @@ func (s *RecurringAssignmentService) updateSingleAssignment( assignment *models.Assignment, title, description, subject, priority string, dueDate time.Time, + softDueDate *time.Time, reminderEnabled bool, reminderAt *time.Time, urgentReminderEnabled bool, @@ -305,6 +307,7 @@ func (s *RecurringAssignmentService) updateSingleAssignment( assignment.Subject = subject assignment.Priority = priority assignment.DueDate = dueDate + assignment.SoftDueDate = softDueDate assignment.ReminderEnabled = reminderEnabled assignment.ReminderAt = reminderAt assignment.UrgentReminderEnabled = urgentReminderEnabled @@ -471,6 +474,8 @@ func (s *RecurringAssignmentService) generateAssignment(recurring *models.Recurr reminderAt = &t } + softDue := dueDate.Add(-48 * time.Hour) + assignment := &models.Assignment{ UserID: recurring.UserID, Title: recurring.Title, @@ -478,6 +483,7 @@ func (s *RecurringAssignmentService) generateAssignment(recurring *models.Recurr Subject: recurring.Subject, Priority: recurring.Priority, DueDate: dueDate, + SoftDueDate: &softDue, ReminderEnabled: recurring.ReminderEnabled, ReminderAt: reminderAt, UrgentReminderEnabled: recurring.UrgentReminderEnabled, diff --git a/web/templates/assignments/edit.html b/web/templates/assignments/edit.html index 229bdb0..1a8aacf 100644 --- a/web/templates/assignments/edit.html +++ b/web/templates/assignments/edit.html @@ -28,9 +28,14 @@
- +
+
+ + +
鬼督促はこの期限を基準に通知します。未指定の場合は提出期限の2日前になります。
+
diff --git a/web/templates/assignments/index.html b/web/templates/assignments/index.html index 687e403..4b92a96 100644 --- a/web/templates/assignments/index.html +++ b/web/templates/assignments/index.html @@ -129,6 +129,7 @@
{{.DueDate.Format "2006/01/02 15:04"}}
+ {{if .SoftDueDate}}
{{.SoftDueDate.Format "01/02 15:04"}}
{{end}} {{if not .IsCompleted}} diff --git a/web/templates/assignments/new.html b/web/templates/assignments/new.html index 3a0e5ac..1183545 100644 --- a/web/templates/assignments/new.html +++ b/web/templates/assignments/new.html @@ -28,9 +28,14 @@
- +
+
+ + +
鬼督促はこの期限を基準に通知します。
+
@@ -156,6 +161,9 @@
{{end}} diff --git a/web/templates/pages/dashboard.html b/web/templates/pages/dashboard.html index 83e1d2d..1d39b3c 100644 --- a/web/templates/pages/dashboard.html +++ b/web/templates/pages/dashboard.html @@ -115,6 +115,7 @@ {{if eq .Priority "high"}}(重要度:高){{end}} {{.Title}}
{{formatDateTime .DueDate}} + {{if .SoftDueDate}}
自分の期限: {{formatDateTime .SoftDueDate}}{{end}}
@@ -140,6 +141,7 @@ {{if eq .Priority "high"}}(重要度:高){{end}} {{.Title}}
{{formatDateTime .DueDate}} + {{if .SoftDueDate}}
自分の期限: {{formatDateTime .SoftDueDate}}{{end}} @@ -165,6 +167,7 @@ {{if eq .Priority "high"}}(重要度:高){{end}} {{.Title}}
{{formatDateTime .DueDate}} + {{if .SoftDueDate}}
自分の期限: {{formatDateTime .SoftDueDate}}{{end}}