Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30ba9510a6 | |||
| 041556786d | |||
| c87e71d1ac | |||
| f0712bddfe | |||
| 920928746e |
@@ -151,8 +151,8 @@ REST API認証用のAPIキーを管理するモデル。
|
|||||||
|
|
||||||
| 機能 | 説明 |
|
| 機能 | 説明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| ダッシュボード | 課題の統計情報、本日期限の課題、期限切れ課題、今週期限の課題を表示 |
|
| ダッシュボード | 課題の統計情報、本日期限の課題、期限切れ課題、今週期限の課題を表示。各統計カードをクリックすると対応するフィルタで課題一覧に遷移 |
|
||||||
| 課題一覧 | フィルタ付き(未完了/完了済み/期限切れ)で課題を一覧表示 |
|
| 課題一覧 | フィルタ付き(未完了/今日が期限/今週が期限/完了済み/期限切れ)で課題を一覧表示 |
|
||||||
| 課題登録 | タイトル、説明、教科、重要度、提出期限、通知設定を入力して新規登録 |
|
| 課題登録 | タイトル、説明、教科、重要度、提出期限、通知設定を入力して新規登録 |
|
||||||
| 課題編集 | 既存の課題情報を編集 |
|
| 課題編集 | 既存の課題情報を編集 |
|
||||||
| 課題削除 | 課題を論理削除 |
|
| 課題削除 | 課題を論理削除 |
|
||||||
|
|||||||
@@ -62,19 +62,13 @@ func Connect(dbConfig config.DatabaseConfig, debug bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set connection pool settings
|
|
||||||
sqlDB, err := db.DB()
|
sqlDB, err := db.DB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMaxIdleConns sets the maximum number of connections in the idle connection pool.
|
|
||||||
sqlDB.SetMaxIdleConns(10)
|
sqlDB.SetMaxIdleConns(10)
|
||||||
|
|
||||||
// SetMaxOpenConns sets the maximum number of open connections to the database.
|
|
||||||
sqlDB.SetMaxOpenConns(100)
|
sqlDB.SetMaxOpenConns(100)
|
||||||
|
|
||||||
// SetConnMaxLifetime sets the maximum amount of time a connection may be reused.
|
|
||||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||||
|
|
||||||
DB = db
|
DB = db
|
||||||
@@ -85,6 +79,7 @@ func Migrate() error {
|
|||||||
return DB.AutoMigrate(
|
return DB.AutoMigrate(
|
||||||
&models.User{},
|
&models.User{},
|
||||||
&models.Assignment{},
|
&models.Assignment{},
|
||||||
|
&models.RecurringAssignment{},
|
||||||
&models.APIKey{},
|
&models.APIKey{},
|
||||||
&models.UserNotificationSettings{},
|
&models.UserNotificationSettings{},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AssignmentHandler struct {
|
type AssignmentHandler struct {
|
||||||
assignmentService *service.AssignmentService
|
assignmentService *service.AssignmentService
|
||||||
|
notificationService *service.NotificationService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAssignmentHandler() *AssignmentHandler {
|
func NewAssignmentHandler(notificationService *service.NotificationService) *AssignmentHandler {
|
||||||
return &AssignmentHandler{
|
return &AssignmentHandler{
|
||||||
assignmentService: service.NewAssignmentService(),
|
assignmentService: service.NewAssignmentService(),
|
||||||
|
notificationService: notificationService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +121,6 @@ func (h *AssignmentHandler) Create(c *gin.Context) {
|
|||||||
priority := c.PostForm("priority")
|
priority := c.PostForm("priority")
|
||||||
dueDateStr := c.PostForm("due_date")
|
dueDateStr := c.PostForm("due_date")
|
||||||
|
|
||||||
// Parse reminder settings
|
|
||||||
reminderEnabled := c.PostForm("reminder_enabled") == "on"
|
reminderEnabled := c.PostForm("reminder_enabled") == "on"
|
||||||
reminderAtStr := c.PostForm("reminder_at")
|
reminderAtStr := c.PostForm("reminder_at")
|
||||||
var reminderAt *time.Time
|
var reminderAt *time.Time
|
||||||
@@ -151,21 +152,105 @@ func (h *AssignmentHandler) Create(c *gin.Context) {
|
|||||||
dueDate = dueDate.Add(23*time.Hour + 59*time.Minute)
|
dueDate = dueDate.Add(23*time.Hour + 59*time.Minute)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = h.assignmentService.Create(userID, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
recurrenceType := c.PostForm("recurrence_type")
|
||||||
if err != nil {
|
if recurrenceType != "" && recurrenceType != "none" {
|
||||||
role, _ := c.Get(middleware.UserRoleKey)
|
|
||||||
name, _ := c.Get(middleware.UserNameKey)
|
recurrenceInterval := 1
|
||||||
RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{
|
if v, err := strconv.Atoi(c.PostForm("recurrence_interval")); err == nil && v > 0 {
|
||||||
"title": "課題登録",
|
recurrenceInterval = v
|
||||||
"error": "課題の登録に失敗しました",
|
}
|
||||||
"formTitle": title,
|
|
||||||
"description": description,
|
var recurrenceWeekday *int
|
||||||
"subject": subject,
|
if wd := c.PostForm("recurrence_weekday"); wd != "" {
|
||||||
"priority": priority,
|
if v, err := strconv.Atoi(wd); err == nil && v >= 0 && v <= 6 {
|
||||||
"isAdmin": role == "admin",
|
recurrenceWeekday = &v
|
||||||
"userName": name,
|
}
|
||||||
})
|
}
|
||||||
return
|
|
||||||
|
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 {
|
||||||
|
assignment, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.notificationService != nil {
|
||||||
|
go h.notificationService.SendAssignmentCreatedNotification(userID, assignment)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Redirect(http.StatusFound, "/assignments")
|
c.Redirect(http.StatusFound, "/assignments")
|
||||||
@@ -202,7 +287,6 @@ func (h *AssignmentHandler) Update(c *gin.Context) {
|
|||||||
priority := c.PostForm("priority")
|
priority := c.PostForm("priority")
|
||||||
dueDateStr := c.PostForm("due_date")
|
dueDateStr := c.PostForm("due_date")
|
||||||
|
|
||||||
// Parse reminder settings
|
|
||||||
reminderEnabled := c.PostForm("reminder_enabled") == "on"
|
reminderEnabled := c.PostForm("reminder_enabled") == "on"
|
||||||
reminderAtStr := c.PostForm("reminder_at")
|
reminderAtStr := c.PostForm("reminder_at")
|
||||||
var reminderAt *time.Time
|
var reminderAt *time.Time
|
||||||
@@ -259,7 +343,6 @@ func (h *AssignmentHandler) Statistics(c *gin.Context) {
|
|||||||
role, _ := c.Get(middleware.UserRoleKey)
|
role, _ := c.Get(middleware.UserRoleKey)
|
||||||
name, _ := c.Get(middleware.UserNameKey)
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
|
|
||||||
// Parse filter parameters
|
|
||||||
filter := service.StatisticsFilter{
|
filter := service.StatisticsFilter{
|
||||||
Subject: c.Query("subject"),
|
Subject: c.Query("subject"),
|
||||||
IncludeArchived: c.Query("include_archived") == "true",
|
IncludeArchived: c.Query("include_archived") == "true",
|
||||||
@@ -268,7 +351,6 @@ func (h *AssignmentHandler) Statistics(c *gin.Context) {
|
|||||||
fromStr := c.Query("from")
|
fromStr := c.Query("from")
|
||||||
toStr := c.Query("to")
|
toStr := c.Query("to")
|
||||||
|
|
||||||
// Parse from date
|
|
||||||
if fromStr != "" {
|
if fromStr != "" {
|
||||||
fromDate, err := time.ParseInLocation("2006-01-02", fromStr, time.Local)
|
fromDate, err := time.ParseInLocation("2006-01-02", fromStr, time.Local)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -276,7 +358,6 @@ func (h *AssignmentHandler) Statistics(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse to date
|
|
||||||
if toStr != "" {
|
if toStr != "" {
|
||||||
toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local)
|
toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -293,11 +374,9 @@ func (h *AssignmentHandler) Statistics(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get available subjects for filter dropdown (exclude archived)
|
|
||||||
subjects, _ := h.assignmentService.GetSubjectsWithArchived(userID, false)
|
subjects, _ := h.assignmentService.GetSubjectsWithArchived(userID, false)
|
||||||
archivedSubjects, _ := h.assignmentService.GetArchivedSubjects(userID)
|
archivedSubjects, _ := h.assignmentService.GetArchivedSubjects(userID)
|
||||||
|
|
||||||
// Create a map for quick lookup of archived subjects
|
|
||||||
archivedMap := make(map[string]bool)
|
archivedMap := make(map[string]bool)
|
||||||
for _, s := range archivedSubjects {
|
for _, s := range archivedSubjects {
|
||||||
archivedMap[s] = true
|
archivedMap[s] = true
|
||||||
@@ -338,5 +417,3 @@ func (h *AssignmentHandler) UnarchiveSubject(c *gin.Context) {
|
|||||||
|
|
||||||
c.Redirect(http.StatusFound, "/statistics?include_archived=true")
|
c.Redirect(http.StatusFound, "/statistics?include_archived=true")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ func (h *ProfileHandler) UpdateNotificationSettings(c *gin.Context) {
|
|||||||
TelegramChatID: c.PostForm("telegram_chat_id"),
|
TelegramChatID: c.PostForm("telegram_chat_id"),
|
||||||
LineEnabled: c.PostForm("line_enabled") == "on",
|
LineEnabled: c.PostForm("line_enabled") == "on",
|
||||||
LineNotifyToken: c.PostForm("line_token"),
|
LineNotifyToken: c.PostForm("line_token"),
|
||||||
|
NotifyOnCreate: c.PostForm("notify_on_create") == "on",
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.notificationService.UpdateUserSettings(userID, settings)
|
err := h.notificationService.UpdateUserSettings(userID, settings)
|
||||||
@@ -172,4 +173,3 @@ func (h *ProfileHandler) UpdateNotificationSettings(c *gin.Context) {
|
|||||||
"notifySettings": notifySettings,
|
"notifySettings": notifySettings,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,26 +7,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Assignment struct {
|
type Assignment struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||||
Title string `gorm:"not null" json:"title"`
|
Title string `gorm:"not null" json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Priority string `gorm:"not null;default:medium" json:"priority"` // low, medium, high
|
Priority string `gorm:"not null;default:medium" json:"priority"`
|
||||||
DueDate time.Time `gorm:"not null" json:"due_date"`
|
DueDate time.Time `gorm:"not null" json:"due_date"`
|
||||||
IsCompleted bool `gorm:"default:false" json:"is_completed"`
|
IsCompleted bool `gorm:"default:false" json:"is_completed"`
|
||||||
IsArchived bool `gorm:"default:false;index" json:"is_archived"`
|
IsArchived bool `gorm:"default:false;index" json:"is_archived"`
|
||||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
// Reminder notification settings (one-time)
|
ReminderEnabled bool `gorm:"default:false" json:"reminder_enabled"`
|
||||||
ReminderEnabled bool `gorm:"default:false" json:"reminder_enabled"`
|
ReminderAt *time.Time `json:"reminder_at,omitempty"`
|
||||||
ReminderAt *time.Time `json:"reminder_at,omitempty"`
|
ReminderSent bool `gorm:"default:false;index" json:"reminder_sent"`
|
||||||
ReminderSent bool `gorm:"default:false;index" json:"reminder_sent"`
|
UrgentReminderEnabled bool `gorm:"default:true" json:"urgent_reminder_enabled"`
|
||||||
// Urgent reminder settings (repeating until completed)
|
LastUrgentReminderSent *time.Time `json:"last_urgent_reminder_sent,omitempty"`
|
||||||
UrgentReminderEnabled bool `gorm:"default:true" json:"urgent_reminder_enabled"`
|
|
||||||
LastUrgentReminderSent *time.Time `json:"last_urgent_reminder_sent,omitempty"`
|
// Recurring assignment reference
|
||||||
CreatedAt time.Time `json:"created_at"`
|
RecurringAssignmentID *uint `gorm:"index" json:"recurring_assignment_id,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
RecurringAssignment *RecurringAssignment `gorm:"foreignKey:RecurringAssignmentID" json:"-"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" 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"`
|
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserNotificationSettings stores user's notification preferences
|
|
||||||
type UserNotificationSettings struct {
|
type UserNotificationSettings struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
|
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
|
||||||
TelegramEnabled bool `gorm:"default:false" json:"telegram_enabled"`
|
TelegramEnabled bool `gorm:"default:false" json:"telegram_enabled"`
|
||||||
TelegramChatID string `json:"telegram_chat_id"`
|
TelegramChatID string `json:"telegram_chat_id"`
|
||||||
LineEnabled bool `gorm:"default:false" json:"line_enabled"`
|
LineEnabled bool `gorm:"default:false" json:"line_enabled"`
|
||||||
LineNotifyToken string `json:"-"` // Hide token from JSON
|
LineNotifyToken string `json:"-"`
|
||||||
|
NotifyOnCreate bool `gorm:"default:true" json:"notify_on_create"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|||||||
102
internal/models/recurring_assignment.go
Normal file
102
internal/models/recurring_assignment.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -148,11 +148,19 @@ func (r *AssignmentRepository) Search(userID uint, queryStr, priority, filter st
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
|
endOfDay := startOfDay.AddDate(0, 0, 1)
|
||||||
|
weekLater := startOfDay.AddDate(0, 0, 7)
|
||||||
|
|
||||||
switch filter {
|
switch filter {
|
||||||
case "completed":
|
case "completed":
|
||||||
dbQuery = dbQuery.Where("is_completed = ?", true)
|
dbQuery = dbQuery.Where("is_completed = ?", true)
|
||||||
case "overdue":
|
case "overdue":
|
||||||
dbQuery = dbQuery.Where("is_completed = ? AND due_date < ?", false, now)
|
dbQuery = dbQuery.Where("is_completed = ? AND due_date < ?", false, now)
|
||||||
|
case "due_today":
|
||||||
|
dbQuery = dbQuery.Where("is_completed = ? AND due_date >= ? AND due_date < ?", false, startOfDay, endOfDay)
|
||||||
|
case "due_this_week":
|
||||||
|
dbQuery = dbQuery.Where("is_completed = ? AND due_date >= ? AND due_date < ?", false, startOfDay, weekLater)
|
||||||
default: // pending
|
default: // pending
|
||||||
dbQuery = dbQuery.Where("is_completed = ?", false)
|
dbQuery = dbQuery.Where("is_completed = ?", false)
|
||||||
}
|
}
|
||||||
@@ -187,7 +195,6 @@ func (r *AssignmentRepository) CountOverdueByUserID(userID uint) (int64, error)
|
|||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatisticsFilter holds filter parameters for statistics queries
|
|
||||||
type StatisticsFilter struct {
|
type StatisticsFilter struct {
|
||||||
Subject string
|
Subject string
|
||||||
From *time.Time
|
From *time.Time
|
||||||
@@ -195,17 +202,15 @@ type StatisticsFilter struct {
|
|||||||
IncludeArchived bool
|
IncludeArchived bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// AssignmentStatistics holds statistics data
|
|
||||||
type AssignmentStatistics struct {
|
type AssignmentStatistics struct {
|
||||||
Total int64
|
Total int64
|
||||||
Completed int64
|
Completed int64
|
||||||
Pending int64
|
Pending int64
|
||||||
Overdue int64
|
Overdue int64
|
||||||
CompletedOnTime int64
|
CompletedOnTime int64
|
||||||
OnTimeCompletionRate float64
|
OnTimeCompletionRate float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubjectStatistics holds statistics for a specific subject
|
|
||||||
type SubjectStatistics struct {
|
type SubjectStatistics struct {
|
||||||
Subject string
|
Subject string
|
||||||
Total int64
|
Total int64
|
||||||
@@ -216,64 +221,48 @@ type SubjectStatistics struct {
|
|||||||
OnTimeCompletionRate float64
|
OnTimeCompletionRate float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatistics returns statistics for a user with optional filters
|
|
||||||
func (r *AssignmentRepository) GetStatistics(userID uint, filter StatisticsFilter) (*AssignmentStatistics, error) {
|
func (r *AssignmentRepository) GetStatistics(userID uint, filter StatisticsFilter) (*AssignmentStatistics, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
stats := &AssignmentStatistics{}
|
stats := &AssignmentStatistics{}
|
||||||
|
|
||||||
// Base query
|
|
||||||
baseQuery := r.db.Model(&models.Assignment{}).Where("user_id = ?", userID)
|
baseQuery := r.db.Model(&models.Assignment{}).Where("user_id = ?", userID)
|
||||||
|
|
||||||
// Apply subject filter
|
|
||||||
if filter.Subject != "" {
|
if filter.Subject != "" {
|
||||||
baseQuery = baseQuery.Where("subject = ?", filter.Subject)
|
baseQuery = baseQuery.Where("subject = ?", filter.Subject)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply date range filter (by created_at)
|
|
||||||
if filter.From != nil {
|
if filter.From != nil {
|
||||||
baseQuery = baseQuery.Where("created_at >= ?", *filter.From)
|
baseQuery = baseQuery.Where("created_at >= ?", *filter.From)
|
||||||
}
|
}
|
||||||
if filter.To != nil {
|
if filter.To != nil {
|
||||||
// Add 1 day to include the entire "to" date
|
|
||||||
toEnd := filter.To.AddDate(0, 0, 1)
|
toEnd := filter.To.AddDate(0, 0, 1)
|
||||||
baseQuery = baseQuery.Where("created_at < ?", toEnd)
|
baseQuery = baseQuery.Where("created_at < ?", toEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply archived filter
|
|
||||||
if !filter.IncludeArchived {
|
if !filter.IncludeArchived {
|
||||||
baseQuery = baseQuery.Where("is_archived = ?", false)
|
baseQuery = baseQuery.Where("is_archived = ?", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Total count
|
|
||||||
if err := baseQuery.Count(&stats.Total).Error; err != nil {
|
if err := baseQuery.Count(&stats.Total).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Completed count
|
|
||||||
completedQuery := baseQuery.Session(&gorm.Session{})
|
completedQuery := baseQuery.Session(&gorm.Session{})
|
||||||
if err := completedQuery.Where("is_completed = ?", true).Count(&stats.Completed).Error; err != nil {
|
if err := completedQuery.Where("is_completed = ?", true).Count(&stats.Completed).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pending count
|
|
||||||
pendingQuery := baseQuery.Session(&gorm.Session{})
|
pendingQuery := baseQuery.Session(&gorm.Session{})
|
||||||
if err := pendingQuery.Where("is_completed = ?", false).Count(&stats.Pending).Error; err != nil {
|
if err := pendingQuery.Where("is_completed = ?", false).Count(&stats.Pending).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overdue count
|
|
||||||
overdueQuery := baseQuery.Session(&gorm.Session{})
|
overdueQuery := baseQuery.Session(&gorm.Session{})
|
||||||
if err := overdueQuery.Where("is_completed = ? AND due_date < ?", false, now).Count(&stats.Overdue).Error; err != nil {
|
if err := overdueQuery.Where("is_completed = ? AND due_date < ?", false, now).Count(&stats.Overdue).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Completed on time count (completed_at <= due_date)
|
|
||||||
onTimeQuery := baseQuery.Session(&gorm.Session{})
|
onTimeQuery := baseQuery.Session(&gorm.Session{})
|
||||||
if err := onTimeQuery.Where("is_completed = ? AND completed_at <= due_date", true).Count(&stats.CompletedOnTime).Error; err != nil {
|
if err := onTimeQuery.Where("is_completed = ? AND completed_at <= due_date", true).Count(&stats.CompletedOnTime).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate on-time completion rate
|
|
||||||
if stats.Completed > 0 {
|
if stats.Completed > 0 {
|
||||||
stats.OnTimeCompletionRate = float64(stats.CompletedOnTime) / float64(stats.Completed) * 100
|
stats.OnTimeCompletionRate = float64(stats.CompletedOnTime) / float64(stats.Completed) * 100
|
||||||
}
|
}
|
||||||
@@ -281,7 +270,6 @@ func (r *AssignmentRepository) GetStatistics(userID uint, filter StatisticsFilte
|
|||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatisticsBySubjects returns statistics grouped by subject
|
|
||||||
func (r *AssignmentRepository) GetStatisticsBySubjects(userID uint, filter StatisticsFilter) ([]SubjectStatistics, error) {
|
func (r *AssignmentRepository) GetStatisticsBySubjects(userID uint, filter StatisticsFilter) ([]SubjectStatistics, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
subjects, err := r.GetSubjectsByUserID(userID)
|
subjects, err := r.GetSubjectsByUserID(userID)
|
||||||
@@ -301,7 +289,6 @@ func (r *AssignmentRepository) GetStatisticsBySubjects(userID uint, filter Stati
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count overdue for this subject
|
|
||||||
overdueQuery := r.db.Model(&models.Assignment{}).
|
overdueQuery := r.db.Model(&models.Assignment{}).
|
||||||
Where("user_id = ? AND subject = ? AND is_completed = ? AND due_date < ?", userID, subject, false, now)
|
Where("user_id = ? AND subject = ? AND is_completed = ? AND due_date < ?", userID, subject, false, now)
|
||||||
if filter.From != nil {
|
if filter.From != nil {
|
||||||
@@ -328,21 +315,18 @@ func (r *AssignmentRepository) GetStatisticsBySubjects(userID uint, filter Stati
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveBySubject archives all assignments for a subject
|
|
||||||
func (r *AssignmentRepository) ArchiveBySubject(userID uint, subject string) error {
|
func (r *AssignmentRepository) ArchiveBySubject(userID uint, subject string) error {
|
||||||
return r.db.Model(&models.Assignment{}).
|
return r.db.Model(&models.Assignment{}).
|
||||||
Where("user_id = ? AND subject = ?", userID, subject).
|
Where("user_id = ? AND subject = ?", userID, subject).
|
||||||
Update("is_archived", true).Error
|
Update("is_archived", true).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnarchiveBySubject unarchives all assignments for a subject
|
|
||||||
func (r *AssignmentRepository) UnarchiveBySubject(userID uint, subject string) error {
|
func (r *AssignmentRepository) UnarchiveBySubject(userID uint, subject string) error {
|
||||||
return r.db.Model(&models.Assignment{}).
|
return r.db.Model(&models.Assignment{}).
|
||||||
Where("user_id = ? AND subject = ?", userID, subject).
|
Where("user_id = ? AND subject = ?", userID, subject).
|
||||||
Update("is_archived", false).Error
|
Update("is_archived", false).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetArchivedSubjects returns a list of archived subjects for a user
|
|
||||||
func (r *AssignmentRepository) GetArchivedSubjects(userID uint) ([]string, error) {
|
func (r *AssignmentRepository) GetArchivedSubjects(userID uint) ([]string, error) {
|
||||||
var subjects []string
|
var subjects []string
|
||||||
err := r.db.Model(&models.Assignment{}).
|
err := r.db.Model(&models.Assignment{}).
|
||||||
@@ -352,7 +336,6 @@ func (r *AssignmentRepository) GetArchivedSubjects(userID uint) ([]string, error
|
|||||||
return subjects, err
|
return subjects, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSubjectsByUserIDWithArchived returns subjects optionally including archived
|
|
||||||
func (r *AssignmentRepository) GetSubjectsByUserIDWithArchived(userID uint, includeArchived bool) ([]string, error) {
|
func (r *AssignmentRepository) GetSubjectsByUserIDWithArchived(userID uint, includeArchived bool) ([]string, error) {
|
||||||
var subjects []string
|
var subjects []string
|
||||||
query := r.db.Model(&models.Assignment{}).
|
query := r.db.Model(&models.Assignment{}).
|
||||||
@@ -363,4 +346,3 @@ func (r *AssignmentRepository) GetSubjectsByUserIDWithArchived(userID uint, incl
|
|||||||
err := query.Distinct("subject").Pluck("subject", &subjects).Error
|
err := query.Distinct("subject").Pluck("subject", &subjects).Error
|
||||||
return subjects, err
|
return subjects, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
123
internal/repository/recurring_assignment_repository.go
Normal file
123
internal/repository/recurring_assignment_repository.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -45,6 +45,9 @@ func getFuncMap() template.FuncMap {
|
|||||||
"multiplyFloat": func(a float64, b float64) float64 {
|
"multiplyFloat": func(a float64, b float64) float64 {
|
||||||
return a * b
|
return a * b
|
||||||
},
|
},
|
||||||
|
"recurringLabel": service.GetRecurrenceTypeLabel,
|
||||||
|
"endTypeLabel": service.GetEndTypeLabel,
|
||||||
|
"recurringSummary": service.FormatRecurringSummary,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +62,8 @@ func loadTemplates() (*template.Template, error) {
|
|||||||
pattern string
|
pattern string
|
||||||
prefix string
|
prefix string
|
||||||
}{
|
}{
|
||||||
|
{"web/templates/auth/*.html", ""},
|
||||||
|
{"web/templates/pages/*.html", ""},
|
||||||
{"web/templates/auth/*.html", ""},
|
{"web/templates/auth/*.html", ""},
|
||||||
{"web/templates/pages/*.html", ""},
|
{"web/templates/pages/*.html", ""},
|
||||||
{"web/templates/assignments/*.html", "assignments/"},
|
{"web/templates/assignments/*.html", "assignments/"},
|
||||||
@@ -175,11 +180,10 @@ func Setup(cfg *config.Config) *gin.Engine {
|
|||||||
apiKeyService := service.NewAPIKeyService()
|
apiKeyService := service.NewAPIKeyService()
|
||||||
notificationService := service.NewNotificationService(cfg.Notification.TelegramBotToken)
|
notificationService := service.NewNotificationService(cfg.Notification.TelegramBotToken)
|
||||||
|
|
||||||
// Start notification reminder scheduler
|
|
||||||
notificationService.StartReminderScheduler()
|
notificationService.StartReminderScheduler()
|
||||||
|
|
||||||
authHandler := handler.NewAuthHandler()
|
authHandler := handler.NewAuthHandler()
|
||||||
assignmentHandler := handler.NewAssignmentHandler()
|
assignmentHandler := handler.NewAssignmentHandler(notificationService)
|
||||||
adminHandler := handler.NewAdminHandler()
|
adminHandler := handler.NewAdminHandler()
|
||||||
profileHandler := handler.NewProfileHandler(notificationService)
|
profileHandler := handler.NewProfileHandler(notificationService)
|
||||||
apiHandler := handler.NewAPIHandler()
|
apiHandler := handler.NewAPIHandler()
|
||||||
|
|||||||
@@ -209,7 +209,6 @@ func (s *AssignmentService) Update(userID, assignmentID uint, title, description
|
|||||||
assignment.ReminderEnabled = reminderEnabled
|
assignment.ReminderEnabled = reminderEnabled
|
||||||
assignment.ReminderAt = reminderAt
|
assignment.ReminderAt = reminderAt
|
||||||
assignment.UrgentReminderEnabled = urgentReminderEnabled
|
assignment.UrgentReminderEnabled = urgentReminderEnabled
|
||||||
// Reset reminder sent flag if reminder settings changed
|
|
||||||
if reminderEnabled && reminderAt != nil {
|
if reminderEnabled && reminderAt != nil {
|
||||||
assignment.ReminderSent = false
|
assignment.ReminderSent = false
|
||||||
}
|
}
|
||||||
@@ -279,7 +278,6 @@ func (s *AssignmentService) GetDashboardStats(userID uint) (*DashboardStats, err
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatisticsFilter holds filter parameters for statistics
|
|
||||||
type StatisticsFilter struct {
|
type StatisticsFilter struct {
|
||||||
Subject string
|
Subject string
|
||||||
From *time.Time
|
From *time.Time
|
||||||
@@ -287,7 +285,6 @@ type StatisticsFilter struct {
|
|||||||
IncludeArchived bool
|
IncludeArchived bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubjectStats holds statistics for a subject
|
|
||||||
type SubjectStats struct {
|
type SubjectStats struct {
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
@@ -298,7 +295,6 @@ type SubjectStats struct {
|
|||||||
IsArchived bool `json:"is_archived,omitempty"`
|
IsArchived bool `json:"is_archived,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatisticsSummary holds overall statistics
|
|
||||||
type StatisticsSummary struct {
|
type StatisticsSummary struct {
|
||||||
TotalAssignments int64 `json:"total_assignments"`
|
TotalAssignments int64 `json:"total_assignments"`
|
||||||
CompletedAssignments int64 `json:"completed_assignments"`
|
CompletedAssignments int64 `json:"completed_assignments"`
|
||||||
@@ -309,7 +305,6 @@ type StatisticsSummary struct {
|
|||||||
Subjects []SubjectStats `json:"subjects,omitempty"`
|
Subjects []SubjectStats `json:"subjects,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FilterInfo shows applied filters in response
|
|
||||||
type FilterInfo struct {
|
type FilterInfo struct {
|
||||||
Subject *string `json:"subject"`
|
Subject *string `json:"subject"`
|
||||||
From *string `json:"from"`
|
From *string `json:"from"`
|
||||||
@@ -317,9 +312,7 @@ type FilterInfo struct {
|
|||||||
IncludeArchived bool `json:"include_archived"`
|
IncludeArchived bool `json:"include_archived"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatistics returns statistics for a user with optional filters
|
|
||||||
func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter) (*StatisticsSummary, error) {
|
func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter) (*StatisticsSummary, error) {
|
||||||
// Convert filter to repository filter
|
|
||||||
repoFilter := repository.StatisticsFilter{
|
repoFilter := repository.StatisticsFilter{
|
||||||
Subject: filter.Subject,
|
Subject: filter.Subject,
|
||||||
From: filter.From,
|
From: filter.From,
|
||||||
@@ -327,7 +320,6 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter)
|
|||||||
IncludeArchived: filter.IncludeArchived,
|
IncludeArchived: filter.IncludeArchived,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get overall statistics
|
|
||||||
stats, err := s.assignmentRepo.GetStatistics(userID, repoFilter)
|
stats, err := s.assignmentRepo.GetStatistics(userID, repoFilter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -341,7 +333,6 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter)
|
|||||||
OnTimeCompletionRate: stats.OnTimeCompletionRate,
|
OnTimeCompletionRate: stats.OnTimeCompletionRate,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build filter info
|
|
||||||
filterInfo := &FilterInfo{}
|
filterInfo := &FilterInfo{}
|
||||||
hasFilter := false
|
hasFilter := false
|
||||||
if filter.Subject != "" {
|
if filter.Subject != "" {
|
||||||
@@ -366,7 +357,6 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter)
|
|||||||
summary.Filter = filterInfo
|
summary.Filter = filterInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no specific subject filter, get per-subject statistics
|
|
||||||
if filter.Subject == "" {
|
if filter.Subject == "" {
|
||||||
subjectStats, err := s.assignmentRepo.GetStatisticsBySubjects(userID, repoFilter)
|
subjectStats, err := s.assignmentRepo.GetStatisticsBySubjects(userID, repoFilter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -388,22 +378,17 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter)
|
|||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveSubject archives all assignments for a subject
|
|
||||||
func (s *AssignmentService) ArchiveSubject(userID uint, subject string) error {
|
func (s *AssignmentService) ArchiveSubject(userID uint, subject string) error {
|
||||||
return s.assignmentRepo.ArchiveBySubject(userID, subject)
|
return s.assignmentRepo.ArchiveBySubject(userID, subject)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnarchiveSubject unarchives all assignments for a subject
|
|
||||||
func (s *AssignmentService) UnarchiveSubject(userID uint, subject string) error {
|
func (s *AssignmentService) UnarchiveSubject(userID uint, subject string) error {
|
||||||
return s.assignmentRepo.UnarchiveBySubject(userID, subject)
|
return s.assignmentRepo.UnarchiveBySubject(userID, subject)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSubjectsWithArchived returns subjects optionally including archived
|
|
||||||
func (s *AssignmentService) GetSubjectsWithArchived(userID uint, includeArchived bool) ([]string, error) {
|
func (s *AssignmentService) GetSubjectsWithArchived(userID uint, includeArchived bool) ([]string, error) {
|
||||||
return s.assignmentRepo.GetSubjectsByUserIDWithArchived(userID, includeArchived)
|
return s.assignmentRepo.GetSubjectsByUserIDWithArchived(userID, includeArchived)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetArchivedSubjects returns archived subjects only
|
|
||||||
func (s *AssignmentService) GetArchivedSubjects(userID uint) ([]string, error) {
|
func (s *AssignmentService) GetArchivedSubjects(userID uint) ([]string, error) {
|
||||||
return s.assignmentRepo.GetArchivedSubjects(userID)
|
return s.assignmentRepo.GetArchivedSubjects(userID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,24 +14,20 @@ import (
|
|||||||
"homework-manager/internal/models"
|
"homework-manager/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NotificationService handles Telegram and LINE notifications
|
|
||||||
type NotificationService struct {
|
type NotificationService struct {
|
||||||
telegramBotToken string
|
telegramBotToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewNotificationService creates a new notification service
|
|
||||||
func NewNotificationService(telegramBotToken string) *NotificationService {
|
func NewNotificationService(telegramBotToken string) *NotificationService {
|
||||||
return &NotificationService{
|
return &NotificationService{
|
||||||
telegramBotToken: telegramBotToken,
|
telegramBotToken: telegramBotToken,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserSettings retrieves notification settings for a user
|
|
||||||
func (s *NotificationService) GetUserSettings(userID uint) (*models.UserNotificationSettings, error) {
|
func (s *NotificationService) GetUserSettings(userID uint) (*models.UserNotificationSettings, error) {
|
||||||
var settings models.UserNotificationSettings
|
var settings models.UserNotificationSettings
|
||||||
result := database.GetDB().Where("user_id = ?", userID).First(&settings)
|
result := database.GetDB().Where("user_id = ?", userID).First(&settings)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
// If not found, return a new empty settings object
|
|
||||||
if result.RowsAffected == 0 {
|
if result.RowsAffected == 0 {
|
||||||
return &models.UserNotificationSettings{
|
return &models.UserNotificationSettings{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
@@ -42,24 +38,19 @@ func (s *NotificationService) GetUserSettings(userID uint) (*models.UserNotifica
|
|||||||
return &settings, nil
|
return &settings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateUserSettings updates notification settings for a user
|
|
||||||
func (s *NotificationService) UpdateUserSettings(userID uint, settings *models.UserNotificationSettings) error {
|
func (s *NotificationService) UpdateUserSettings(userID uint, settings *models.UserNotificationSettings) error {
|
||||||
settings.UserID = userID
|
settings.UserID = userID
|
||||||
|
|
||||||
var existing models.UserNotificationSettings
|
var existing models.UserNotificationSettings
|
||||||
result := database.GetDB().Where("user_id = ?", userID).First(&existing)
|
result := database.GetDB().Where("user_id = ?", userID).First(&existing)
|
||||||
|
|
||||||
if result.RowsAffected == 0 {
|
if result.RowsAffected == 0 {
|
||||||
// Create new
|
|
||||||
return database.GetDB().Create(settings).Error
|
return database.GetDB().Create(settings).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update existing
|
|
||||||
settings.ID = existing.ID
|
settings.ID = existing.ID
|
||||||
return database.GetDB().Save(settings).Error
|
return database.GetDB().Save(settings).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendTelegramNotification sends a message via Telegram Bot API
|
|
||||||
func (s *NotificationService) SendTelegramNotification(chatID, message string) error {
|
func (s *NotificationService) SendTelegramNotification(chatID, message string) error {
|
||||||
if s.telegramBotToken == "" {
|
if s.telegramBotToken == "" {
|
||||||
return fmt.Errorf("telegram bot token is not configured")
|
return fmt.Errorf("telegram bot token is not configured")
|
||||||
@@ -69,65 +60,63 @@ func (s *NotificationService) SendTelegramNotification(chatID, message string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", s.telegramBotToken)
|
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", s.telegramBotToken)
|
||||||
|
|
||||||
payload := map[string]string{
|
payload := map[string]string{
|
||||||
"chat_id": chatID,
|
"chat_id": chatID,
|
||||||
"text": message,
|
"text": message,
|
||||||
"parse_mode": "HTML",
|
"parse_mode": "HTML",
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonPayload, err := json.Marshal(payload)
|
jsonPayload, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonPayload))
|
resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonPayload))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("telegram API returned status %d", resp.StatusCode)
|
return fmt.Errorf("telegram API returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendLineNotification sends a message via LINE Notify API
|
|
||||||
func (s *NotificationService) SendLineNotification(token, message string) error {
|
func (s *NotificationService) SendLineNotification(token, message string) error {
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return fmt.Errorf("LINE Notify token is empty")
|
return fmt.Errorf("LINE Notify token is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
apiURL := "https://notify-api.line.me/api/notify"
|
apiURL := "https://notify-api.line.me/api/notify"
|
||||||
|
|
||||||
data := url.Values{}
|
data := url.Values{}
|
||||||
data.Set("message", message)
|
data.Set("message", message)
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", apiURL, strings.NewReader(data.Encode()))
|
req, err := http.NewRequest("POST", apiURL, strings.NewReader(data.Encode()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("LINE Notify API returned status %d", resp.StatusCode)
|
return fmt.Errorf("LINE Notify API returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendAssignmentReminder sends a reminder notification for an assignment
|
|
||||||
func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *models.Assignment) error {
|
func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *models.Assignment) error {
|
||||||
settings, err := s.GetUserSettings(userID)
|
settings, err := s.GetUserSettings(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -144,14 +133,12 @@ func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *mo
|
|||||||
|
|
||||||
var errors []string
|
var errors []string
|
||||||
|
|
||||||
// Send to Telegram if enabled
|
|
||||||
if settings.TelegramEnabled && settings.TelegramChatID != "" {
|
if settings.TelegramEnabled && settings.TelegramChatID != "" {
|
||||||
if err := s.SendTelegramNotification(settings.TelegramChatID, message); err != nil {
|
if err := s.SendTelegramNotification(settings.TelegramChatID, message); err != nil {
|
||||||
errors = append(errors, fmt.Sprintf("Telegram: %v", err))
|
errors = append(errors, fmt.Sprintf("Telegram: %v", err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send to LINE if enabled
|
|
||||||
if settings.LineEnabled && settings.LineNotifyToken != "" {
|
if settings.LineEnabled && settings.LineNotifyToken != "" {
|
||||||
if err := s.SendLineNotification(settings.LineNotifyToken, message); err != nil {
|
if err := s.SendLineNotification(settings.LineNotifyToken, message); err != nil {
|
||||||
errors = append(errors, fmt.Sprintf("LINE: %v", err))
|
errors = append(errors, fmt.Sprintf("LINE: %v", err))
|
||||||
@@ -165,7 +152,63 @@ func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *mo
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendUrgentReminder sends an urgent reminder notification for an assignment
|
func (s *NotificationService) SendAssignmentCreatedNotification(userID uint, assignment *models.Assignment) error {
|
||||||
|
settings, err := s.GetUserSettings(userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !settings.NotifyOnCreate {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !settings.TelegramEnabled && !settings.LineEnabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
message := fmt.Sprintf(
|
||||||
|
"新しい課題が追加されました\n\n【%s】\n科目: %s\n優先度: %s\n期限: %s\n\n%s",
|
||||||
|
assignment.Title,
|
||||||
|
assignment.Subject,
|
||||||
|
getPriorityLabel(assignment.Priority),
|
||||||
|
assignment.DueDate.Format("2006/01/02 15:04"),
|
||||||
|
assignment.Description,
|
||||||
|
)
|
||||||
|
|
||||||
|
var errors []string
|
||||||
|
|
||||||
|
if settings.TelegramEnabled && settings.TelegramChatID != "" {
|
||||||
|
if err := s.SendTelegramNotification(settings.TelegramChatID, message); err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("Telegram: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.LineEnabled && settings.LineNotifyToken != "" {
|
||||||
|
if err := s.SendLineNotification(settings.LineNotifyToken, message); err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("LINE: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errors) > 0 {
|
||||||
|
return fmt.Errorf("notification errors: %s", strings.Join(errors, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPriorityLabel(priority string) string {
|
||||||
|
switch priority {
|
||||||
|
case "high":
|
||||||
|
return "大"
|
||||||
|
case "medium":
|
||||||
|
return "中"
|
||||||
|
case "low":
|
||||||
|
return "小"
|
||||||
|
default:
|
||||||
|
return priority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models.Assignment) error {
|
func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models.Assignment) error {
|
||||||
settings, err := s.GetUserSettings(userID)
|
settings, err := s.GetUserSettings(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -203,14 +246,12 @@ func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models
|
|||||||
|
|
||||||
var errors []string
|
var errors []string
|
||||||
|
|
||||||
// Send to Telegram if enabled
|
|
||||||
if settings.TelegramEnabled && settings.TelegramChatID != "" {
|
if settings.TelegramEnabled && settings.TelegramChatID != "" {
|
||||||
if err := s.SendTelegramNotification(settings.TelegramChatID, message); err != nil {
|
if err := s.SendTelegramNotification(settings.TelegramChatID, message); err != nil {
|
||||||
errors = append(errors, fmt.Sprintf("Telegram: %v", err))
|
errors = append(errors, fmt.Sprintf("Telegram: %v", err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send to LINE if enabled
|
|
||||||
if settings.LineEnabled && settings.LineNotifyToken != "" {
|
if settings.LineEnabled && settings.LineNotifyToken != "" {
|
||||||
if err := s.SendLineNotification(settings.LineNotifyToken, message); err != nil {
|
if err := s.SendLineNotification(settings.LineNotifyToken, message); err != nil {
|
||||||
errors = append(errors, fmt.Sprintf("LINE: %v", err))
|
errors = append(errors, fmt.Sprintf("LINE: %v", err))
|
||||||
@@ -224,8 +265,6 @@ func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getUrgentReminderInterval returns the reminder interval based on priority
|
|
||||||
// high=10min, medium=30min, low=60min
|
|
||||||
func getUrgentReminderInterval(priority string) time.Duration {
|
func getUrgentReminderInterval(priority string) time.Duration {
|
||||||
switch priority {
|
switch priority {
|
||||||
case "high":
|
case "high":
|
||||||
@@ -239,87 +278,78 @@ func getUrgentReminderInterval(priority string) time.Duration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessPendingReminders checks and sends pending one-time reminders
|
|
||||||
func (s *NotificationService) ProcessPendingReminders() {
|
func (s *NotificationService) ProcessPendingReminders() {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
var assignments []models.Assignment
|
var assignments []models.Assignment
|
||||||
result := database.GetDB().Where(
|
result := database.GetDB().Where(
|
||||||
"reminder_enabled = ? AND reminder_sent = ? AND reminder_at <= ? AND is_completed = ?",
|
"reminder_enabled = ? AND reminder_sent = ? AND reminder_at <= ? AND is_completed = ?",
|
||||||
true, false, now, false,
|
true, false, now, false,
|
||||||
).Find(&assignments)
|
).Find(&assignments)
|
||||||
|
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
log.Printf("Error fetching pending reminders: %v", result.Error)
|
log.Printf("Error fetching pending reminders: %v", result.Error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, assignment := range assignments {
|
for _, assignment := range assignments {
|
||||||
if err := s.SendAssignmentReminder(assignment.UserID, &assignment); err != nil {
|
if err := s.SendAssignmentReminder(assignment.UserID, &assignment); err != nil {
|
||||||
log.Printf("Error sending reminder for assignment %d: %v", assignment.ID, err)
|
log.Printf("Error sending reminder for assignment %d: %v", assignment.ID, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark as sent
|
|
||||||
database.GetDB().Model(&assignment).Update("reminder_sent", true)
|
database.GetDB().Model(&assignment).Update("reminder_sent", true)
|
||||||
log.Printf("Sent reminder for assignment %d to user %d", assignment.ID, assignment.UserID)
|
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() {
|
func (s *NotificationService) ProcessUrgentReminders() {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
urgentStartTime := 3 * time.Hour // Start 3 hours before deadline
|
urgentStartTime := 3 * time.Hour
|
||||||
|
|
||||||
var assignments []models.Assignment
|
var assignments []models.Assignment
|
||||||
result := database.GetDB().Where(
|
result := database.GetDB().Where(
|
||||||
"urgent_reminder_enabled = ? AND is_completed = ? AND due_date > ?",
|
"urgent_reminder_enabled = ? AND is_completed = ? AND due_date > ?",
|
||||||
true, false, now,
|
true, false, now,
|
||||||
).Find(&assignments)
|
).Find(&assignments)
|
||||||
|
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
log.Printf("Error fetching urgent reminders: %v", result.Error)
|
log.Printf("Error fetching urgent reminders: %v", result.Error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, assignment := range assignments {
|
for _, assignment := range assignments {
|
||||||
timeUntilDue := assignment.DueDate.Sub(now)
|
timeUntilDue := assignment.DueDate.Sub(now)
|
||||||
|
|
||||||
// Only send if within 3 hours of deadline
|
|
||||||
if timeUntilDue > urgentStartTime {
|
if timeUntilDue > urgentStartTime {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if enough time has passed since last urgent reminder
|
|
||||||
interval := getUrgentReminderInterval(assignment.Priority)
|
interval := getUrgentReminderInterval(assignment.Priority)
|
||||||
|
|
||||||
if assignment.LastUrgentReminderSent != nil {
|
if assignment.LastUrgentReminderSent != nil {
|
||||||
timeSinceLastReminder := now.Sub(*assignment.LastUrgentReminderSent)
|
timeSinceLastReminder := now.Sub(*assignment.LastUrgentReminderSent)
|
||||||
if timeSinceLastReminder < interval {
|
if timeSinceLastReminder < interval {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send urgent reminder
|
|
||||||
if err := s.SendUrgentReminder(assignment.UserID, &assignment); err != nil {
|
if err := s.SendUrgentReminder(assignment.UserID, &assignment); err != nil {
|
||||||
log.Printf("Error sending urgent reminder for assignment %d: %v", assignment.ID, err)
|
log.Printf("Error sending urgent reminder for assignment %d: %v", assignment.ID, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last sent time
|
|
||||||
database.GetDB().Model(&assignment).Update("last_urgent_reminder_sent", now)
|
database.GetDB().Model(&assignment).Update("last_urgent_reminder_sent", now)
|
||||||
log.Printf("Sent urgent reminder for assignment %d (priority: %s) to user %d",
|
log.Printf("Sent urgent reminder for assignment %d (priority: %s) to user %d",
|
||||||
assignment.ID, assignment.Priority, assignment.UserID)
|
assignment.ID, assignment.Priority, assignment.UserID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartReminderScheduler starts a background goroutine to process reminders
|
|
||||||
func (s *NotificationService) StartReminderScheduler() {
|
func (s *NotificationService) StartReminderScheduler() {
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(1 * time.Minute)
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
s.ProcessPendingReminders()
|
s.ProcessPendingReminders()
|
||||||
s.ProcessUrgentReminders()
|
s.ProcessUrgentReminders()
|
||||||
@@ -327,4 +357,3 @@ func (s *NotificationService) StartReminderScheduler() {
|
|||||||
}()
|
}()
|
||||||
log.Println("Reminder scheduler started (one-time + urgent reminders)")
|
log.Println("Reminder scheduler started (one-time + urgent reminders)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
467
internal/service/recurring_assignment_service.go
Normal file
467
internal/service/recurring_assignment_service.go
Normal file
@@ -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, " ")
|
||||||
|
}
|
||||||
@@ -56,13 +56,12 @@
|
|||||||
重要度により間隔が変わります:大=10分、中=30分、小=1時間
|
重要度により間隔が変わります:大=10分、中=30分、小=1時間
|
||||||
</div>
|
</div>
|
||||||
<hr class="my-2">
|
<hr class="my-2">
|
||||||
<!-- 1回リマインダー -->
|
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
<input class="form-check-input" type="checkbox" id="reminder_enabled"
|
<input class="form-check-input" type="checkbox" id="reminder_enabled"
|
||||||
name="reminder_enabled" {{if .assignment.ReminderEnabled}}checked{{end}}
|
name="reminder_enabled" {{if .assignment.ReminderEnabled}}checked{{end}}
|
||||||
onchange="toggleReminderDate(this)">
|
onchange="toggleReminderDate(this)">
|
||||||
<label class="form-check-label" for="reminder_enabled">
|
<label class="form-check-label" for="reminder_enabled">
|
||||||
1回リマインダー(指定日時に1回通知)
|
リマインダー(指定日時に通知)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2" id="reminder_at_group"
|
<div class="mt-2" id="reminder_at_group"
|
||||||
|
|||||||
@@ -18,19 +18,43 @@
|
|||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<ul class="nav nav-tabs border-0 mb-2" id="assignmentTabs">
|
<ul class="nav nav-tabs border-0 mb-2" id="assignmentTabs">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link py-2 rounded-0 border-0 {{if eq .filter " pending"}}fw-bold border-bottom border-dark
|
<a class="nav-link py-2 rounded-0 {{if eq .filter "pending"}}fw-bold border-bottom border-dark border-3
|
||||||
border-3{{else}}text-muted{{end}}" href="/assignments?filter=pending&q={{.query}}&priority={{.priority}}"
|
text-dark{{else}}border-0 text-muted{{end}}"
|
||||||
style="{{if eq .filter " pending"}}color: black !important;{{end}}">未完了</a>
|
href="/assignments?filter=pending&q={{.query}}&priority={{.priority}}">
|
||||||
|
未完了
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link py-2 rounded-0 border-0 {{if eq .filter " completed"}}fw-bold border-bottom border-dark
|
<a class="nav-link py-2 rounded-0 {{if eq .filter "due_today"}}fw-bold border-bottom border-dark border-3
|
||||||
border-3{{else}}text-muted{{end}}" href="/assignments?filter=completed&q={{.query}}&priority={{.priority}}"
|
text-dark{{else}}border-0 text-muted{{end}}"
|
||||||
style="{{if eq .filter " completed"}}color: black !important;{{end}}">完了済み</a>
|
href="/assignments?filter=due_today&q={{.query}}&priority={{.priority}}">
|
||||||
|
今日が期限
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link py-2 rounded-0 border-0 {{if eq .filter " overdue"}}fw-bold border-bottom border-dark
|
<a class="nav-link py-2 rounded-0 {{if eq .filter "due_this_week"}}fw-bold border-bottom border-dark border-3
|
||||||
border-3{{else}}text-muted{{end}}" href="/assignments?filter=overdue&q={{.query}}&priority={{.priority}}"
|
text-dark{{else}}border-0 text-muted{{end}}"
|
||||||
style="{{if eq .filter " overdue"}}color: black !important;{{end}}">期限切れ</a>
|
href="/assignments?filter=due_this_week&q={{.query}}&priority={{.priority}}">
|
||||||
|
今週が期限
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link py-2 rounded-0 {{if eq .filter "completed"}}fw-bold border-bottom border-dark border-3
|
||||||
|
text-dark{{else}}border-0 text-muted{{end}}"
|
||||||
|
href="/assignments?filter=completed&q={{.query}}&priority={{.priority}}">
|
||||||
|
完了済み
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link py-2 rounded-0 {{if eq .filter "overdue"}}fw-bold border-bottom border-dark border-3
|
||||||
|
text-dark{{else}}border-0 text-muted{{end}}"
|
||||||
|
href="/assignments?filter=overdue&q={{.query}}&priority={{.priority}}">
|
||||||
|
期限切れ
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<hr class="mt-0 mb-3 text-muted" style="opacity: 0.1;">
|
<hr class="mt-0 mb-3 text-muted" style="opacity: 0.1;">
|
||||||
|
|||||||
@@ -55,12 +55,11 @@
|
|||||||
重要度により間隔が変わります:大=10分、中=30分、小=1時間
|
重要度により間隔が変わります:大=10分、中=30分、小=1時間
|
||||||
</div>
|
</div>
|
||||||
<hr class="my-2">
|
<hr class="my-2">
|
||||||
<!-- 1回リマインダー -->
|
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
<input class="form-check-input" type="checkbox" id="reminder_enabled"
|
<input class="form-check-input" type="checkbox" id="reminder_enabled"
|
||||||
name="reminder_enabled" onchange="toggleReminderDate(this)">
|
name="reminder_enabled" onchange="toggleReminderDate(this)">
|
||||||
<label class="form-check-label" for="reminder_enabled">
|
<label class="form-check-label" for="reminder_enabled">
|
||||||
1回リマインダー(指定日時に1回通知)
|
リマインダー(指定日時に通知)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2" id="reminder_at_group" style="display: none;">
|
<div class="mt-2" id="reminder_at_group" style="display: none;">
|
||||||
@@ -70,18 +69,157 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="card bg-light mb-3">
|
||||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>登録</button>
|
<div class="card-header py-2" style="cursor: pointer;" data-bs-toggle="collapse"
|
||||||
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
|
data-bs-target="#recurringSettings">
|
||||||
|
<h6 class="mb-0"><i class="bi bi-arrow-repeat me-1"></i>繰り返し設定 <i
|
||||||
|
class="bi bi-chevron-down float-end"></i></h6>
|
||||||
|
</div>
|
||||||
|
<div class="collapse" id="recurringSettings">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-6">
|
||||||
|
<label for="recurrence_type" class="form-label small">繰り返しタイプ</label>
|
||||||
|
<select class="form-select form-select-sm" id="recurrence_type"
|
||||||
|
name="recurrence_type" onchange="updateRecurrenceOptions()">
|
||||||
|
<option value="none" selected>なし</option>
|
||||||
|
<option value="daily">毎日</option>
|
||||||
|
<option value="weekly">毎週</option>
|
||||||
|
<option value="monthly">毎月</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-6" id="interval_group" style="display: none;">
|
||||||
|
<label for="recurrence_interval" class="form-label small">間隔</label>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="number" class="form-control" id="recurrence_interval"
|
||||||
|
name="recurrence_interval" value="1" min="1" max="12">
|
||||||
|
<span class="input-group-text" id="interval_label">週</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="weekday_group" style="display: none;" class="mb-2">
|
||||||
|
<label class="form-label small">曜日</label>
|
||||||
|
<div class="btn-group btn-group-sm w-100" role="group">
|
||||||
|
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd0"
|
||||||
|
value="0">
|
||||||
|
<label class="btn btn-outline-primary" for="wd0">日</label>
|
||||||
|
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd1"
|
||||||
|
value="1" checked>
|
||||||
|
<label class="btn btn-outline-primary" for="wd1">月</label>
|
||||||
|
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd2"
|
||||||
|
value="2">
|
||||||
|
<label class="btn btn-outline-primary" for="wd2">火</label>
|
||||||
|
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd3"
|
||||||
|
value="3">
|
||||||
|
<label class="btn btn-outline-primary" for="wd3">水</label>
|
||||||
|
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd4"
|
||||||
|
value="4">
|
||||||
|
<label class="btn btn-outline-primary" for="wd4">木</label>
|
||||||
|
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd5"
|
||||||
|
value="5">
|
||||||
|
<label class="btn btn-outline-primary" for="wd5">金</label>
|
||||||
|
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd6"
|
||||||
|
value="6">
|
||||||
|
<label class="btn btn-outline-primary" for="wd6">土</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="day_group" style="display: none;" class="mb-2">
|
||||||
|
<label for="recurrence_day" class="form-label small">日</label>
|
||||||
|
<select class="form-select form-select-sm" id="recurrence_day"
|
||||||
|
name="recurrence_day">
|
||||||
|
<option value="1">1日</option>
|
||||||
|
<option value="2">2日</option>
|
||||||
|
<option value="3">3日</option>
|
||||||
|
<option value="4">4日</option>
|
||||||
|
<option value="5">5日</option>
|
||||||
|
<option value="6">6日</option>
|
||||||
|
<option value="7">7日</option>
|
||||||
|
<option value="8">8日</option>
|
||||||
|
<option value="9">9日</option>
|
||||||
|
<option value="10">10日</option>
|
||||||
|
<option value="11">11日</option>
|
||||||
|
<option value="12">12日</option>
|
||||||
|
<option value="13">13日</option>
|
||||||
|
<option value="14">14日</option>
|
||||||
|
<option value="15">15日</option>
|
||||||
|
<option value="16">16日</option>
|
||||||
|
<option value="17">17日</option>
|
||||||
|
<option value="18">18日</option>
|
||||||
|
<option value="19">19日</option>
|
||||||
|
<option value="20">20日</option>
|
||||||
|
<option value="21">21日</option>
|
||||||
|
<option value="22">22日</option>
|
||||||
|
<option value="23">23日</option>
|
||||||
|
<option value="24">24日</option>
|
||||||
|
<option value="25">25日</option>
|
||||||
|
<option value="26">26日</option>
|
||||||
|
<option value="27">27日</option>
|
||||||
|
<option value="28">28日</option>
|
||||||
|
<option value="29">29日</option>
|
||||||
|
<option value="30">30日</option>
|
||||||
|
<option value="31">31日</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="end_group" style="display: none;">
|
||||||
|
<label class="form-label small">終了条件</label>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="radio" name="end_type" id="end_never"
|
||||||
|
value="never" checked>
|
||||||
|
<label class="form-check-label small" for="end_never">無期限</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="radio" name="end_type" id="end_count"
|
||||||
|
value="count">
|
||||||
|
<label class="form-check-label small" for="end_count">回数</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="radio" name="end_type" id="end_date"
|
||||||
|
value="date">
|
||||||
|
<label class="form-check-label small" for="end_date">終了日</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1" id="end_count_group" style="display: none;">
|
||||||
|
<input type="number" class="form-control form-control-sm" id="end_count_value"
|
||||||
|
name="end_count" value="10" min="1" style="width: 100px;">
|
||||||
|
</div>
|
||||||
|
<div class="mt-1" id="end_date_group" style="display: none;">
|
||||||
|
<input type="date" class="form-control form-control-sm" id="end_date_value"
|
||||||
|
name="end_date" style="width: 150px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>登録</button>
|
||||||
|
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
function toggleReminderDate(checkbox) {
|
function toggleReminderDate(checkbox) {
|
||||||
document.getElementById('reminder_at_group').style.display = checkbox.checked ? 'block' : 'none';
|
document.getElementById('reminder_at_group').style.display = checkbox.checked ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
function updateRecurrenceOptions() {
|
||||||
|
const type = document.getElementById('recurrence_type').value;
|
||||||
|
const isRecurring = type !== 'none';
|
||||||
|
document.getElementById('interval_group').style.display = isRecurring ? 'block' : 'none';
|
||||||
|
document.getElementById('weekday_group').style.display = type === 'weekly' ? 'block' : 'none';
|
||||||
|
document.getElementById('day_group').style.display = type === 'monthly' ? 'block' : 'none';
|
||||||
|
document.getElementById('end_group').style.display = isRecurring ? 'block' : 'none';
|
||||||
|
const label = document.getElementById('interval_label');
|
||||||
|
if (type === 'daily') label.textContent = '日';
|
||||||
|
else if (type === 'weekly') label.textContent = '週';
|
||||||
|
else if (type === 'monthly') label.textContent = '月';
|
||||||
|
}
|
||||||
|
document.querySelectorAll('input[name="end_type"]').forEach(radio => {
|
||||||
|
radio.addEventListener('change', function () {
|
||||||
|
document.getElementById('end_count_group').style.display = this.value === 'count' ? 'block' : 'none';
|
||||||
|
document.getElementById('end_date_group').style.display = this.value === 'date' ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -30,7 +30,76 @@
|
|||||||
.pagination-info {
|
.pagination-info {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-table {
|
||||||
|
min-width: 700px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table th,
|
||||||
|
.stats-table td {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table th:first-child,
|
||||||
|
.stats-table td:first-child {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#shareCard {
|
||||||
|
width: 600px;
|
||||||
|
height: 315px;
|
||||||
|
position: fixed;
|
||||||
|
left: -9999px;
|
||||||
|
top: 0;
|
||||||
|
background: linear-gradient(135deg, #005bea 0%, #00c6fb 100%);
|
||||||
|
padding: 2rem;
|
||||||
|
color: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#shareCard .card-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
#shareCard .rate-display {
|
||||||
|
font-size: 5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#shareCard .stats-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#shareCard .stat-item {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#shareCard .stat-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#shareCard .stat-value {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
@@ -130,8 +199,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<i class="bi bi-clock-history me-2"></i>期限内完了率
|
<span><i class="bi bi-clock-history me-2"></i>期限内完了率</span>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="generateShareImage()">
|
||||||
|
<i class="bi bi-share me-1"></i>シェア
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row align-items-center">
|
<div class="row align-items-center">
|
||||||
@@ -174,7 +246,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0 stats-table">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>科目</th>
|
<th>科目</th>
|
||||||
@@ -206,7 +278,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0 stats-table">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>科目</th>
|
<th>科目</th>
|
||||||
@@ -238,6 +310,69 @@
|
|||||||
<p class="text-muted">課題を登録して科目を設定すると、ここに統計が表示されます。</p>
|
<p class="text-muted">課題を登録して科目を設定すると、ここに統計が表示されます。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="shareCard">
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-items-center mb-4">
|
||||||
|
<i class="bi bi-journal-check me-2" style="font-size: 1.5rem;"></i>
|
||||||
|
<span class="card-title">Super Homework Manager</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<div style="font-size: 1.5rem; font-weight: bold; margin-bottom: 0.5rem; opacity: 0.9;">期限内完了率</div>
|
||||||
|
<div class="rate-display" style="margin-top: 0;">
|
||||||
|
{{printf "%.1f" .stats.OnTimeCompletionRate}}<span style="font-size: 2.5rem;">%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">完了</span>
|
||||||
|
<span class="stat-value">{{.stats.CompletedAssignments}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">未完了</span>
|
||||||
|
<span class="stat-value">{{.stats.PendingAssignments}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">期限切れ</span>
|
||||||
|
<span class="stat-value">{{.stats.OverdueAssignments}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Share Modal -->
|
||||||
|
<div class="modal fade" id="shareModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-share me-2"></i>シェア</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body text-center">
|
||||||
|
<div id="sharePreviewContainer" class="mb-3" style="max-width: 100%; overflow: hidden;">
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
画像を保存またはコピーして、SNSに貼り付けてください。<br>
|
||||||
|
<span class="text-danger"><i class="bi bi-info-circle me-1"></i>ブラウザの制限により、自動で画像は添付されません。</span>
|
||||||
|
</p>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button class="btn btn-outline-primary" onclick="copyImageToClipboard(this)">
|
||||||
|
<i class="bi bi-clipboard me-2"></i>画像をコピー
|
||||||
|
</button>
|
||||||
|
<a id="downloadLink" class="btn btn-outline-secondary" download="stats.png">
|
||||||
|
<i class="bi bi-download me-2"></i>画像を保存
|
||||||
|
</a>
|
||||||
|
<a id="twitterShareBtn" href="#" target="_blank" class="btn btn-dark"
|
||||||
|
style="background-color: #000;">
|
||||||
|
<i class="bi bi-twitter-x me-2"></i>Xでポストする
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
@@ -255,6 +390,88 @@
|
|||||||
var activePage = 1;
|
var activePage = 1;
|
||||||
var archivedPage = 1;
|
var archivedPage = 1;
|
||||||
|
|
||||||
|
// Share Functionality
|
||||||
|
window.generateShareImage = function () {
|
||||||
|
var card = document.getElementById('shareCard');
|
||||||
|
// Ensure card is visible for rendering but off-screen
|
||||||
|
card.style.display = 'flex';
|
||||||
|
|
||||||
|
html2canvas(card, {
|
||||||
|
backgroundColor: null,
|
||||||
|
scale: 2 // High resolution
|
||||||
|
}).then(canvas => {
|
||||||
|
var imgData = canvas.toDataURL('image/png');
|
||||||
|
|
||||||
|
// Set up preview
|
||||||
|
var previewContainer = document.getElementById('sharePreviewContainer');
|
||||||
|
previewContainer.innerHTML = '';
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.src = imgData;
|
||||||
|
img.style.maxWidth = '100%';
|
||||||
|
img.style.borderRadius = '8px';
|
||||||
|
img.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
|
||||||
|
previewContainer.appendChild(img);
|
||||||
|
|
||||||
|
// Set up download link
|
||||||
|
var downloadLink = document.getElementById('downloadLink');
|
||||||
|
downloadLink.href = imgData;
|
||||||
|
|
||||||
|
// Set up Twitter button
|
||||||
|
var text = '私の課題完了状況\n期限内完了率: {{printf "%.1f" .stats.OnTimeCompletionRate}}%\n#SuperHomeworkManager';
|
||||||
|
var twitterBtn = document.getElementById('twitterShareBtn');
|
||||||
|
twitterBtn.href = "https://twitter.com/intent/tweet?text=" + encodeURIComponent(text);
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
var modal = new bootstrap.Modal(document.getElementById('shareModal'));
|
||||||
|
modal.show();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to convert Data URL to Blob
|
||||||
|
function dataURLtoBlob(dataurl) {
|
||||||
|
var arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1],
|
||||||
|
bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
|
||||||
|
while (n--) {
|
||||||
|
u8arr[n] = bstr.charCodeAt(n);
|
||||||
|
}
|
||||||
|
return new Blob([u8arr], { type: mime });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.copyImageToClipboard = function (btn) {
|
||||||
|
var canvas = document.querySelector('#sharePreviewContainer img');
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
if (!navigator.clipboard) {
|
||||||
|
alert('このブラウザまたは環境(非HTTPS/非localhost)では、クリップボードへのコピー機能がサポートされていません。\n「画像を保存」を使用してください。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var blob = dataURLtoBlob(canvas.src);
|
||||||
|
navigator.clipboard.write([
|
||||||
|
new ClipboardItem({
|
||||||
|
'image/png': blob
|
||||||
|
})
|
||||||
|
]).then(function () {
|
||||||
|
var originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<i class="bi bi-check me-2"></i>コピーしました';
|
||||||
|
btn.classList.remove('btn-outline-primary');
|
||||||
|
btn.classList.add('btn-success');
|
||||||
|
setTimeout(function () {
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.classList.add('btn-outline-primary');
|
||||||
|
btn.classList.remove('btn-success');
|
||||||
|
}, 2000);
|
||||||
|
}).catch(function (err) {
|
||||||
|
console.error('Failed to copy: ', err);
|
||||||
|
alert('画像のコピーに失敗しました。\nエラー: ' + err.message + '\n「画像を保存」を使用してください。');
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create blob: ', err);
|
||||||
|
alert('画像データの生成に失敗しました: ' + err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
function getRateClass(rate) {
|
function getRateClass(rate) {
|
||||||
if (rate >= 80) return 'text-success';
|
if (rate >= 80) return 'text-success';
|
||||||
if (rate >= 50) return 'text-warning';
|
if (rate >= 50) return 'text-warning';
|
||||||
|
|||||||
@@ -65,6 +65,16 @@
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-stat-card {
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
@@ -83,56 +93,64 @@
|
|||||||
|
|
||||||
<div class="row g-4 mb-4">
|
<div class="row g-4 mb-4">
|
||||||
<div class="col-6 col-md-3">
|
<div class="col-6 col-md-3">
|
||||||
<div class="card bg-primary text-white h-100">
|
<a href="/assignments?filter=pending" class="text-decoration-none">
|
||||||
<div class="card-body">
|
<div class="card bg-primary text-white h-100 dashboard-stat-card">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="card-body">
|
||||||
<div>
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<h6 class="text-white-50">未完了の課題</h6>
|
<div>
|
||||||
<h2 class="mb-0">{{.stats.TotalPending}}</h2>
|
<h6 class="text-white-50">未完了の課題</h6>
|
||||||
|
<h2 class="mb-0">{{.stats.TotalPending}}</h2>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-list-task display-4 opacity-50"></i>
|
||||||
</div>
|
</div>
|
||||||
<i class="bi bi-list-task display-4 opacity-50"></i>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-md-3">
|
<div class="col-6 col-md-3">
|
||||||
<div class="card bg-warning text-dark h-100">
|
<a href="/assignments?filter=due_today" class="text-decoration-none">
|
||||||
<div class="card-body">
|
<div class="card bg-warning text-dark h-100 dashboard-stat-card">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="card-body">
|
||||||
<div>
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<h6 class="text-dark-50">今日が期限</h6>
|
<div>
|
||||||
<h2 class="mb-0">{{.stats.DueToday}}</h2>
|
<h6 class="text-dark-50">今日が期限</h6>
|
||||||
|
<h2 class="mb-0">{{.stats.DueToday}}</h2>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-calendar-event display-4 opacity-50"></i>
|
||||||
</div>
|
</div>
|
||||||
<i class="bi bi-calendar-event display-4 opacity-50"></i>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-md-3">
|
<div class="col-6 col-md-3">
|
||||||
<div class="card bg-info text-white h-100">
|
<a href="/assignments?filter=due_this_week" class="text-decoration-none">
|
||||||
<div class="card-body">
|
<div class="card bg-info text-white h-100 dashboard-stat-card">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="card-body">
|
||||||
<div>
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<h6 class="text-white-50">今週が期限</h6>
|
<div>
|
||||||
<h2 class="mb-0">{{.stats.DueThisWeek}}</h2>
|
<h6 class="text-white-50">今週が期限</h6>
|
||||||
|
<h2 class="mb-0">{{.stats.DueThisWeek}}</h2>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-calendar-week display-4 opacity-50"></i>
|
||||||
</div>
|
</div>
|
||||||
<i class="bi bi-calendar-week display-4 opacity-50"></i>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-md-3">
|
<div class="col-6 col-md-3">
|
||||||
<div class="card bg-danger text-white h-100">
|
<a href="/assignments?filter=overdue" class="text-decoration-none">
|
||||||
<div class="card-body">
|
<div class="card bg-danger text-white h-100 dashboard-stat-card">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="card-body">
|
||||||
<div>
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<h6 class="text-white-50">期限切れ</h6>
|
<div>
|
||||||
<h2 class="mb-0">{{.stats.Overdue}}</h2>
|
<h6 class="text-white-50">期限切れ</h6>
|
||||||
|
<h2 class="mb-0">{{.stats.Overdue}}</h2>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-exclamation-triangle display-4 opacity-50"></i>
|
||||||
</div>
|
</div>
|
||||||
<i class="bi bi-exclamation-triangle display-4 opacity-50"></i>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<hr class="my-3">
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="notify_on_create" name="notify_on_create"
|
||||||
|
{{if .notifySettings.NotifyOnCreate}}checked{{end}}>
|
||||||
|
<label class="form-check-label" for="notify_on_create">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>課題追加時に通知する
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>通知設定を保存</button>
|
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>通知設定を保存</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user