From b2fbb472df80f844e6dcc3aceddce318fc05b330 Mon Sep 17 00:00:00 2001 From: furu04 Date: Mon, 5 Jan 2026 11:27:37 +0900 Subject: [PATCH] =?UTF-8?q?.=E6=A9=9F=E8=83=BD=E8=BF=BD=E5=8A=A0=E3=80=81D?= =?UTF-8?q?B=E3=83=90=E3=82=B0=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.ini.example | 9 +- docs/API.md | 89 +++++ docs/SPECIFICATION.md | 66 +++- internal/config/config.go | 14 + internal/database/database.go | 18 +- internal/handler/api_handler.go | 46 ++- internal/handler/assignment_handler.go | 113 +++++- internal/handler/profile_handler.go | 117 +++++-- internal/models/assignment.go | 14 +- internal/models/notification_settings.go | 22 ++ internal/repository/assignment_repository.go | 178 ++++++++++ internal/router/router.go | 22 +- internal/service/assignment_service.go | 159 ++++++++- internal/service/notification_service.go | 330 ++++++++++++++++++ web/templates/assignments/edit.html | 43 +++ web/templates/assignments/new.html | 36 ++ web/templates/assignments/statistics.html | 344 +++++++++++++++++++ web/templates/layouts/base.html | 3 + web/templates/pages/profile.html | 52 ++- 19 files changed, 1619 insertions(+), 56 deletions(-) create mode 100644 internal/models/notification_settings.go create mode 100644 internal/service/notification_service.go create mode 100644 web/templates/assignments/statistics.html diff --git a/config.ini.example b/config.ini.example index c899a4b..246b6eb 100644 --- a/config.ini.example +++ b/config.ini.example @@ -51,5 +51,10 @@ rate_limit_enabled = true rate_limit_requests = 100 # Window size in seconds rate_limit_window = 60 -# Trusted proxies (comma separated IP addresses or CIDR) -# trusted_proxies = 127.0.0.1, 10.0.0.0/8 +#; Trusted proxies (comma separated IP addresses or CIDR) +; trusted_proxies = 127.0.0.1, 10.0.0.0/8 + +[notification] +; Telegram Bot Token (@BotFatherで取得) +; ユーザーはプロフィール画面でChat IDを設定します +telegram_bot_token = diff --git a/docs/API.md b/docs/API.md index 64bcc3b..8fb1272 100644 --- a/docs/API.md +++ b/docs/API.md @@ -45,6 +45,7 @@ X-API-Key: hm_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX | PUT | `/api/v1/assignments/:id` | 課題更新 | | DELETE | `/api/v1/assignments/:id` | 課題削除 | | PATCH | `/api/v1/assignments/:id/toggle` | 完了状態トグル | +| GET | `/api/v1/statistics` | 統計情報取得 | --- @@ -369,6 +370,94 @@ curl -X PATCH \ --- +## 統計情報取得 + +ユーザーの課題統計を取得します。科目、日付範囲でフィルタリング可能です。 + +``` +GET /api/v1/statistics +``` + +### クエリパラメータ + +| パラメータ | 型 | 説明 | +|------------|------|------| +| `subject` | string | 科目で絞り込み(省略時: 全科目) | +| `from` | string | 課題登録日の開始日(YYYY-MM-DD形式) | +| `to` | string | 課題登録日の終了日(YYYY-MM-DD形式) | + +### レスポンス + +**200 OK** + +```json +{ + "total_assignments": 45, + "completed_assignments": 30, + "pending_assignments": 12, + "overdue_assignments": 3, + "on_time_completion_rate": 86.7, + "filter": { + "subject": null, + "from": "2025-01-01", + "to": "2025-12-31" + }, + "subjects": [ + { + "subject": "数学", + "total": 15, + "completed": 12, + "pending": 2, + "overdue": 1, + "on_time_completion_rate": 91.7 + }, + { + "subject": "英語", + "total": 10, + "completed": 8, + "pending": 2, + "overdue": 0, + "on_time_completion_rate": 87.5 + } + ] +} +``` + +### 科目別統計 (特定科目のみ) + +```json +{ + "total_assignments": 15, + "completed_assignments": 12, + "pending_assignments": 2, + "overdue_assignments": 1, + "on_time_completion_rate": 91.7, + "filter": { + "subject": "数学", + "from": null, + "to": null + } +} +``` + +### 例 + +```bash +# 全体統計 +curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/statistics + +# 科目で絞り込み +curl -H "X-API-Key: hm_xxx" "http://localhost:8080/api/v1/statistics?subject=数学" + +# 日付範囲で絞り込み +curl -H "X-API-Key: hm_xxx" "http://localhost:8080/api/v1/statistics?from=2025-01-01&to=2025-03-31" + +# 科目と日付範囲の組み合わせ +curl -H "X-API-Key: hm_xxx" "http://localhost:8080/api/v1/statistics?subject=数学&from=2025-01-01&to=2025-03-31" +``` + +--- + ## エラーレスポンス すべてのエラーレスポンスは以下の形式で返されます: diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md index 4ed365e..78a05e0 100644 --- a/docs/SPECIFICATION.md +++ b/docs/SPECIFICATION.md @@ -65,14 +65,37 @@ homework-manager/ | Title | string | 課題タイトル | Not Null | | Description | string | 説明 | - | | Subject | string | 教科・科目 | - | +| Priority | string | 重要度 (`low`, `medium`, `high`) | Default: `medium` | | DueDate | time.Time | 提出期限 | Not Null | | IsCompleted | bool | 完了フラグ | Default: false | +| IsArchived | bool | アーカイブフラグ | Default: false | | CompletedAt | *time.Time | 完了日時 | Nullable | +| ReminderEnabled | bool | 1回リマインダー有効 | Default: false | +| ReminderAt | *time.Time | リマインダー通知日時 | Nullable | +| ReminderSent | bool | リマインダー送信済み | Default: false | +| UrgentReminderEnabled | bool | 督促通知有効 | Default: true | +| LastUrgentReminderSent | *time.Time | 最終督促通知日時 | Nullable | | CreatedAt | time.Time | 作成日時 | 自動設定 | | UpdatedAt | time.Time | 更新日時 | 自動更新 | | DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート | -### 2.3 APIKey(APIキー) +### 2.3 UserNotificationSettings(通知設定) + +ユーザーの通知設定を管理するモデル。 + +| フィールド | 型 | 説明 | 制約 | +|------------|------|------|------| +| ID | uint | 設定ID | Primary Key | +| UserID | uint | ユーザーID | Unique, Not Null | +| TelegramEnabled | bool | Telegram通知 | Default: false | +| TelegramChatID | string | Telegram Chat ID | - | +| LineEnabled | bool | LINE通知 | Default: false | +| LineNotifyToken | string | LINE Notifyトークン | - | +| CreatedAt | time.Time | 作成日時 | 自動設定 | +| UpdatedAt | time.Time | 更新日時 | 自動更新 | +| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート | + +### 2.4 APIKey(APIキー) REST API認証用のAPIキーを管理するモデル。 @@ -130,18 +153,50 @@ REST API認証用のAPIキーを管理するモデル。 |------|------| | ダッシュボード | 課題の統計情報、本日期限の課題、期限切れ課題、今週期限の課題を表示 | | 課題一覧 | フィルタ付き(未完了/完了済み/期限切れ)で課題を一覧表示 | -| 課題登録 | タイトル、説明、教科、提出期限を入力して新規登録 | +| 課題登録 | タイトル、説明、教科、重要度、提出期限、通知設定を入力して新規登録 | | 課題編集 | 既存の課題情報を編集 | | 課題削除 | 課題を論理削除 | | 完了トグル | 課題の完了/未完了状態を切り替え | +| 統計 | 科目別の完了率、期限内完了率等を表示 | -### 4.3 プロフィール機能 +### 4.3 通知機能 + +#### 4.3.1 1回リマインダー + +指定した日時に1回だけ通知を送信する機能。 + +| 項目 | 説明 | +|------|------| +| 設定 | 課題登録・編集画面で通知日時を指定 | +| 送信 | 指定日時にTelegram/LINEで通知 | + +#### 4.3.2 督促通知 + +課題を完了するまで繰り返し通知を送信する機能。デフォルトで有効。 + +| 項目 | 説明 | +|------|------| +| 開始タイミング | 期限の **3時間前** | +| 重要度「大」 | **10分**ごとに通知 | +| 重要度「中」 | **30分**ごとに通知 | +| 重要度「小」 | **60分**ごとに通知 | +| 停止条件 | 課題の完了ボタンを押すまで継続 | + +#### 4.3.3 通知チャンネル + +| チャンネル | 設定方法 | +|------------|----------| +| Telegram | config.iniでBot Token設定、プロフィールでChat ID入力 | +| LINE Notify | プロフィールでアクセストークン入力 | + +### 4.4 プロフィール機能 | 機能 | 説明 | |------|------| | プロフィール表示 | ユーザー情報を表示 | | プロフィール更新 | 表示名を変更 | | パスワード変更 | 現在のパスワードを確認後、新しいパスワードに変更 | +| 通知設定 | Telegram/LINE通知の有効化とトークン設定 | ### 4.4 管理者機能 @@ -183,6 +238,9 @@ csrf_secret = your-secure-csrf-secret rate_limit_enabled = true rate_limit_requests = 100 rate_limit_window = 60 + +[notification] + telegram_bot_token = your-telegram-bot-token ``` ### 5.2 設定項目 @@ -200,6 +258,7 @@ rate_limit_window = 60 | `security` | `rate_limit_enabled` | レート制限有効化 | `true` | | `security` | `rate_limit_requests` | 期間あたりの最大リクエスト数 | `100` | | `security` | `rate_limit_window` | 期間(秒) | `60` | +| `notification` | `telegram_bot_token` | Telegram Bot Token | - | ### 5.3 環境変数 @@ -216,6 +275,7 @@ rate_limit_window = 60 | `ALLOW_REGISTRATION` | 新規登録許可 (true/false) | | `HTTPS` | HTTPSモード (true/false) | | `TRUSTED_PROXIES` | 信頼するプロキシのリスト | +| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | ### 5.4 設定の優先順位 diff --git a/internal/config/config.go b/internal/config/config.go index 501ee34..0d50bbc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,10 @@ type DatabaseConfig struct { Name string } +type NotificationConfig struct { + TelegramBotToken string +} + type Config struct { Port string SessionSecret string @@ -29,6 +33,7 @@ type Config struct { RateLimitWindow int TrustedProxies []string Database DatabaseConfig + Notification NotificationConfig } func Load(configPath string) *Config { @@ -123,6 +128,12 @@ func Load(configPath string) *Config { cfg.TrustedProxies = []string{proxies} } } + + // Notification section + section = iniFile.Section("notification") + if section.HasKey("telegram_bot_token") { + cfg.Notification.TelegramBotToken = section.Key("telegram_bot_token").String() + } } else { log.Println("config.ini not found, using environment variables or defaults") } @@ -169,6 +180,9 @@ func Load(configPath string) *Config { if trustedProxies := os.Getenv("TRUSTED_PROXIES"); trustedProxies != "" { cfg.TrustedProxies = []string{trustedProxies} } + if telegramToken := os.Getenv("TELEGRAM_BOT_TOKEN"); telegramToken != "" { + cfg.Notification.TelegramBotToken = telegramToken + } if cfg.SessionSecret == "" { log.Fatal("FATAL: Session secret is not set. Please set it in config.ini ([session] secret) or via SESSION_SECRET environment variable.") diff --git a/internal/database/database.go b/internal/database/database.go index 376869e..111bc12 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -2,6 +2,7 @@ package database import ( "fmt" + "time" "homework-manager/internal/config" "homework-manager/internal/models" @@ -61,6 +62,21 @@ func Connect(dbConfig config.DatabaseConfig, debug bool) error { return err } + // Set connection pool settings + sqlDB, err := db.DB() + if err != nil { + return err + } + + // SetMaxIdleConns sets the maximum number of connections in the idle connection pool. + sqlDB.SetMaxIdleConns(10) + + // SetMaxOpenConns sets the maximum number of open connections to the database. + sqlDB.SetMaxOpenConns(100) + + // SetConnMaxLifetime sets the maximum amount of time a connection may be reused. + sqlDB.SetConnMaxLifetime(time.Hour) + DB = db return nil } @@ -70,10 +86,10 @@ func Migrate() error { &models.User{}, &models.Assignment{}, &models.APIKey{}, + &models.UserNotificationSettings{}, ) } func GetDB() *gorm.DB { return DB } - diff --git a/internal/handler/api_handler.go b/internal/handler/api_handler.go index e54960a..940bba2 100644 --- a/internal/handler/api_handler.go +++ b/internal/handler/api_handler.go @@ -285,7 +285,7 @@ func (h *APIHandler) CreateAssignment(c *gin.Context) { } } - assignment, err := h.assignmentService.Create(userID, input.Title, input.Description, input.Subject, input.Priority, dueDate) + assignment, err := h.assignmentService.Create(userID, input.Title, input.Description, input.Subject, input.Priority, dueDate, false, nil, true) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create assignment"}) return @@ -351,7 +351,8 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) { } } - assignment, err := h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate) + // Preserve existing reminder settings for API updates + assignment, err := h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate, existing.ReminderEnabled, existing.ReminderAt, existing.UrgentReminderEnabled) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update assignment"}) return @@ -396,3 +397,44 @@ func (h *APIHandler) ToggleAssignment(c *gin.Context) { c.JSON(http.StatusOK, assignment) } + +// GetStatistics returns statistics for the authenticated user +// GET /api/v1/statistics?subject=数学&from=2025-01-01&to=2025-12-31&include_archived=true +func (h *APIHandler) GetStatistics(c *gin.Context) { + userID := h.getUserID(c) + + // Parse filter parameters + filter := service.StatisticsFilter{ + Subject: c.Query("subject"), + IncludeArchived: c.Query("include_archived") == "true", + } + + // Parse from date + if fromStr := c.Query("from"); fromStr != "" { + fromDate, err := time.ParseInLocation("2006-01-02", fromStr, time.Local) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 'from' date format. Use YYYY-MM-DD"}) + return + } + filter.From = &fromDate + } + + // Parse to date + if toStr := c.Query("to"); toStr != "" { + toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 'to' date format. Use YYYY-MM-DD"}) + return + } + filter.To = &toDate + } + + stats, err := h.assignmentService.GetStatistics(userID, filter) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch statistics"}) + return + } + + c.JSON(http.StatusOK, stats) +} + diff --git a/internal/handler/assignment_handler.go b/internal/handler/assignment_handler.go index 14632b8..08bc1d3 100644 --- a/internal/handler/assignment_handler.go +++ b/internal/handler/assignment_handler.go @@ -119,6 +119,17 @@ func (h *AssignmentHandler) Create(c *gin.Context) { priority := c.PostForm("priority") dueDateStr := c.PostForm("due_date") + // Parse reminder settings + reminderEnabled := c.PostForm("reminder_enabled") == "on" + reminderAtStr := c.PostForm("reminder_at") + var reminderAt *time.Time + if reminderEnabled && reminderAtStr != "" { + if parsed, err := time.ParseInLocation("2006-01-02T15:04", reminderAtStr, time.Local); err == nil { + reminderAt = &parsed + } + } + urgentReminderEnabled := c.PostForm("urgent_reminder_enabled") == "on" + dueDate, err := time.ParseInLocation("2006-01-02T15:04", dueDateStr, time.Local) if err != nil { dueDate, err = time.ParseInLocation("2006-01-02", dueDateStr, time.Local) @@ -140,7 +151,7 @@ func (h *AssignmentHandler) Create(c *gin.Context) { dueDate = dueDate.Add(23*time.Hour + 59*time.Minute) } - _, err = h.assignmentService.Create(userID, title, description, subject, priority, dueDate) + _, 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) @@ -191,6 +202,17 @@ func (h *AssignmentHandler) Update(c *gin.Context) { priority := c.PostForm("priority") dueDateStr := c.PostForm("due_date") + // Parse reminder settings + reminderEnabled := c.PostForm("reminder_enabled") == "on" + reminderAtStr := c.PostForm("reminder_at") + var reminderAt *time.Time + if reminderEnabled && reminderAtStr != "" { + if parsed, err := time.ParseInLocation("2006-01-02T15:04", reminderAtStr, time.Local); err == nil { + reminderAt = &parsed + } + } + urgentReminderEnabled := c.PostForm("urgent_reminder_enabled") == "on" + dueDate, err := time.ParseInLocation("2006-01-02T15:04", dueDateStr, time.Local) if err != nil { dueDate, err = time.ParseInLocation("2006-01-02", dueDateStr, time.Local) @@ -201,7 +223,7 @@ func (h *AssignmentHandler) Update(c *gin.Context) { dueDate = dueDate.Add(23*time.Hour + 59*time.Minute) } - _, err = h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate) + _, err = h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled) if err != nil { c.Redirect(http.StatusFound, "/assignments") return @@ -231,3 +253,90 @@ func (h *AssignmentHandler) Delete(c *gin.Context) { c.Redirect(http.StatusFound, "/assignments") } + +func (h *AssignmentHandler) Statistics(c *gin.Context) { + userID := h.getUserID(c) + role, _ := c.Get(middleware.UserRoleKey) + name, _ := c.Get(middleware.UserNameKey) + + // Parse filter parameters + filter := service.StatisticsFilter{ + Subject: c.Query("subject"), + IncludeArchived: c.Query("include_archived") == "true", + } + + fromStr := c.Query("from") + toStr := c.Query("to") + + // Parse from date + if fromStr != "" { + fromDate, err := time.ParseInLocation("2006-01-02", fromStr, time.Local) + if err == nil { + filter.From = &fromDate + } + } + + // Parse to date + if toStr != "" { + toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local) + if err == nil { + filter.To = &toDate + } + } + + stats, err := h.assignmentService.GetStatistics(userID, filter) + if err != nil { + RenderHTML(c, http.StatusInternalServerError, "error.html", gin.H{ + "title": "エラー", + "message": "統計情報の取得に失敗しました", + }) + return + } + + // Get available subjects for filter dropdown (exclude archived) + subjects, _ := h.assignmentService.GetSubjectsWithArchived(userID, false) + archivedSubjects, _ := h.assignmentService.GetArchivedSubjects(userID) + + // Create a map for quick lookup of archived subjects + archivedMap := make(map[string]bool) + for _, s := range archivedSubjects { + archivedMap[s] = true + } + + RenderHTML(c, http.StatusOK, "assignments/statistics.html", gin.H{ + "title": "統計", + "stats": stats, + "subjects": subjects, + "archivedSubjects": archivedMap, + "selectedSubject": filter.Subject, + "fromDate": fromStr, + "toDate": toStr, + "includeArchived": filter.IncludeArchived, + "isAdmin": role == "admin", + "userName": name, + }) +} + +func (h *AssignmentHandler) ArchiveSubject(c *gin.Context) { + userID := h.getUserID(c) + subject := c.PostForm("subject") + + if subject != "" { + h.assignmentService.ArchiveSubject(userID, subject) + } + + c.Redirect(http.StatusFound, "/statistics") +} + +func (h *AssignmentHandler) UnarchiveSubject(c *gin.Context) { + userID := h.getUserID(c) + subject := c.PostForm("subject") + + if subject != "" { + h.assignmentService.UnarchiveSubject(userID, subject) + } + + c.Redirect(http.StatusFound, "/statistics?include_archived=true") +} + + diff --git a/internal/handler/profile_handler.go b/internal/handler/profile_handler.go index 54c2388..7b672b0 100644 --- a/internal/handler/profile_handler.go +++ b/internal/handler/profile_handler.go @@ -4,18 +4,21 @@ import ( "net/http" "homework-manager/internal/middleware" + "homework-manager/internal/models" "homework-manager/internal/service" "github.com/gin-gonic/gin" ) type ProfileHandler struct { - authService *service.AuthService + authService *service.AuthService + notificationService *service.NotificationService } -func NewProfileHandler() *ProfileHandler { +func NewProfileHandler(notificationService *service.NotificationService) *ProfileHandler { return &ProfileHandler{ - authService: service.NewAuthService(), + authService: service.NewAuthService(), + notificationService: notificationService, } } @@ -27,15 +30,17 @@ func (h *ProfileHandler) getUserID(c *gin.Context) uint { func (h *ProfileHandler) Show(c *gin.Context) { userID := h.getUserID(c) user, _ := h.authService.GetUserByID(userID) + notifySettings, _ := h.notificationService.GetUserSettings(userID) role, _ := c.Get(middleware.UserRoleKey) name, _ := c.Get(middleware.UserNameKey) RenderHTML(c, http.StatusOK, "profile.html", gin.H{ - "title": "プロフィール", - "user": user, - "isAdmin": role == "admin", - "userName": name, + "title": "プロフィール", + "user": user, + "isAdmin": role == "admin", + "userName": name, + "notifySettings": notifySettings, }) } @@ -47,24 +52,27 @@ func (h *ProfileHandler) Update(c *gin.Context) { role, _ := c.Get(middleware.UserRoleKey) user, _ := h.authService.GetUserByID(userID) + notifySettings, _ := h.notificationService.GetUserSettings(userID) if err != nil { RenderHTML(c, http.StatusOK, "profile.html", gin.H{ - "title": "プロフィール", - "user": user, - "error": "プロフィールの更新に失敗しました", - "isAdmin": role == "admin", - "userName": name, + "title": "プロフィール", + "user": user, + "error": "プロフィールの更新に失敗しました", + "isAdmin": role == "admin", + "userName": name, + "notifySettings": notifySettings, }) return } RenderHTML(c, http.StatusOK, "profile.html", gin.H{ - "title": "プロフィール", - "user": user, - "success": "プロフィールを更新しました", - "isAdmin": role == "admin", - "userName": user.Name, + "title": "プロフィール", + "user": user, + "success": "プロフィールを更新しました", + "isAdmin": role == "admin", + "userName": user.Name, + "notifySettings": notifySettings, }) } @@ -77,25 +85,28 @@ func (h *ProfileHandler) ChangePassword(c *gin.Context) { role, _ := c.Get(middleware.UserRoleKey) name, _ := c.Get(middleware.UserNameKey) user, _ := h.authService.GetUserByID(userID) + notifySettings, _ := h.notificationService.GetUserSettings(userID) if newPassword != confirmPassword { RenderHTML(c, http.StatusOK, "profile.html", gin.H{ - "title": "プロフィール", - "user": user, - "passwordError": "新しいパスワードが一致しません", - "isAdmin": role == "admin", - "userName": name, + "title": "プロフィール", + "user": user, + "passwordError": "新しいパスワードが一致しません", + "isAdmin": role == "admin", + "userName": name, + "notifySettings": notifySettings, }) return } if len(newPassword) < 8 { RenderHTML(c, http.StatusOK, "profile.html", gin.H{ - "title": "プロフィール", - "user": user, - "passwordError": "パスワードは8文字以上で入力してください", - "isAdmin": role == "admin", - "userName": name, + "title": "プロフィール", + "user": user, + "passwordError": "パスワードは8文字以上で入力してください", + "isAdmin": role == "admin", + "userName": name, + "notifySettings": notifySettings, }) return } @@ -103,11 +114,12 @@ func (h *ProfileHandler) ChangePassword(c *gin.Context) { err := h.authService.ChangePassword(userID, oldPassword, newPassword) if err != nil { RenderHTML(c, http.StatusOK, "profile.html", gin.H{ - "title": "プロフィール", - "user": user, - "passwordError": "現在のパスワードが正しくありません", - "isAdmin": role == "admin", - "userName": name, + "title": "プロフィール", + "user": user, + "passwordError": "現在のパスワードが正しくありません", + "isAdmin": role == "admin", + "userName": name, + "notifySettings": notifySettings, }) return } @@ -118,5 +130,46 @@ func (h *ProfileHandler) ChangePassword(c *gin.Context) { "passwordSuccess": "パスワードを変更しました", "isAdmin": role == "admin", "userName": name, + "notifySettings": notifySettings, }) } + +func (h *ProfileHandler) UpdateNotificationSettings(c *gin.Context) { + userID := h.getUserID(c) + role, _ := c.Get(middleware.UserRoleKey) + name, _ := c.Get(middleware.UserNameKey) + user, _ := h.authService.GetUserByID(userID) + + settings := &models.UserNotificationSettings{ + TelegramEnabled: c.PostForm("telegram_enabled") == "on", + TelegramChatID: c.PostForm("telegram_chat_id"), + LineEnabled: c.PostForm("line_enabled") == "on", + LineNotifyToken: c.PostForm("line_token"), + } + + err := h.notificationService.UpdateUserSettings(userID, settings) + + notifySettings, _ := h.notificationService.GetUserSettings(userID) + + if err != nil { + RenderHTML(c, http.StatusOK, "profile.html", gin.H{ + "title": "プロフィール", + "user": user, + "notifyError": "通知設定の更新に失敗しました", + "isAdmin": role == "admin", + "userName": name, + "notifySettings": notifySettings, + }) + return + } + + RenderHTML(c, http.StatusOK, "profile.html", gin.H{ + "title": "プロフィール", + "user": user, + "notifySuccess": "通知設定を更新しました", + "isAdmin": role == "admin", + "userName": name, + "notifySettings": notifySettings, + }) +} + diff --git a/internal/models/assignment.go b/internal/models/assignment.go index fe26be0..2a9e081 100644 --- a/internal/models/assignment.go +++ b/internal/models/assignment.go @@ -15,10 +15,18 @@ type Assignment struct { Priority string `gorm:"not null;default:medium" json:"priority"` // low, medium, high DueDate time.Time `gorm:"not null" json:"due_date"` IsCompleted bool `gorm:"default:false" json:"is_completed"` + IsArchived bool `gorm:"default:false;index" json:"is_archived"` CompletedAt *time.Time `json:"completed_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + // Reminder notification settings (one-time) + ReminderEnabled bool `gorm:"default:false" json:"reminder_enabled"` + ReminderAt *time.Time `json:"reminder_at,omitempty"` + ReminderSent bool `gorm:"default:false;index" json:"reminder_sent"` + // Urgent reminder settings (repeating until completed) + UrgentReminderEnabled bool `gorm:"default:true" json:"urgent_reminder_enabled"` + LastUrgentReminderSent *time.Time `json:"last_urgent_reminder_sent,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` User *User `gorm:"foreignKey:UserID" json:"user,omitempty"` } diff --git a/internal/models/notification_settings.go b/internal/models/notification_settings.go new file mode 100644 index 0000000..fe9d1ad --- /dev/null +++ b/internal/models/notification_settings.go @@ -0,0 +1,22 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// UserNotificationSettings stores user's notification preferences +type UserNotificationSettings struct { + ID uint `gorm:"primarykey" json:"id"` + UserID uint `gorm:"uniqueIndex;not null" json:"user_id"` + TelegramEnabled bool `gorm:"default:false" json:"telegram_enabled"` + TelegramChatID string `json:"telegram_chat_id"` + LineEnabled bool `gorm:"default:false" json:"line_enabled"` + LineNotifyToken string `json:"-"` // Hide token from JSON + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + User *User `gorm:"foreignKey:UserID" json:"-"` +} diff --git a/internal/repository/assignment_repository.go b/internal/repository/assignment_repository.go index 1489006..01bcb4b 100644 --- a/internal/repository/assignment_repository.go +++ b/internal/repository/assignment_repository.go @@ -186,3 +186,181 @@ func (r *AssignmentRepository) CountOverdueByUserID(userID uint) (int64, error) Where("user_id = ? AND is_completed = ? AND due_date < ?", userID, false, now).Count(&count).Error return count, err } + +// StatisticsFilter holds filter parameters for statistics queries +type StatisticsFilter struct { + Subject string + From *time.Time + To *time.Time + IncludeArchived bool +} + +// AssignmentStatistics holds statistics data +type AssignmentStatistics struct { + Total int64 + Completed int64 + Pending int64 + Overdue int64 + CompletedOnTime int64 + OnTimeCompletionRate float64 +} + +// SubjectStatistics holds statistics for a specific subject +type SubjectStatistics struct { + Subject string + Total int64 + Completed int64 + Pending int64 + Overdue int64 + CompletedOnTime int64 + OnTimeCompletionRate float64 +} + +// GetStatistics returns statistics for a user with optional filters +func (r *AssignmentRepository) GetStatistics(userID uint, filter StatisticsFilter) (*AssignmentStatistics, error) { + now := time.Now() + stats := &AssignmentStatistics{} + + // Base query + baseQuery := r.db.Model(&models.Assignment{}).Where("user_id = ?", userID) + + // Apply subject filter + if filter.Subject != "" { + baseQuery = baseQuery.Where("subject = ?", filter.Subject) + } + + // Apply date range filter (by created_at) + if filter.From != nil { + baseQuery = baseQuery.Where("created_at >= ?", *filter.From) + } + if filter.To != nil { + // Add 1 day to include the entire "to" date + toEnd := filter.To.AddDate(0, 0, 1) + baseQuery = baseQuery.Where("created_at < ?", toEnd) + } + + // Apply archived filter + if !filter.IncludeArchived { + baseQuery = baseQuery.Where("is_archived = ?", false) + } + + // Total count + if err := baseQuery.Count(&stats.Total).Error; err != nil { + return nil, err + } + + // Completed count + completedQuery := baseQuery.Session(&gorm.Session{}) + if err := completedQuery.Where("is_completed = ?", true).Count(&stats.Completed).Error; err != nil { + return nil, err + } + + // Pending count + pendingQuery := baseQuery.Session(&gorm.Session{}) + if err := pendingQuery.Where("is_completed = ?", false).Count(&stats.Pending).Error; err != nil { + return nil, err + } + + // Overdue count + overdueQuery := baseQuery.Session(&gorm.Session{}) + if err := overdueQuery.Where("is_completed = ? AND due_date < ?", false, now).Count(&stats.Overdue).Error; err != nil { + return nil, err + } + + // Completed on time count (completed_at <= due_date) + onTimeQuery := baseQuery.Session(&gorm.Session{}) + if err := onTimeQuery.Where("is_completed = ? AND completed_at <= due_date", true).Count(&stats.CompletedOnTime).Error; err != nil { + return nil, err + } + + // Calculate on-time completion rate + if stats.Completed > 0 { + stats.OnTimeCompletionRate = float64(stats.CompletedOnTime) / float64(stats.Completed) * 100 + } + + return stats, nil +} + +// GetStatisticsBySubjects returns statistics grouped by subject +func (r *AssignmentRepository) GetStatisticsBySubjects(userID uint, filter StatisticsFilter) ([]SubjectStatistics, error) { + now := time.Now() + subjects, err := r.GetSubjectsByUserID(userID) + if err != nil { + return nil, err + } + + var results []SubjectStatistics + for _, subject := range subjects { + subjectFilter := StatisticsFilter{ + Subject: subject, + From: filter.From, + To: filter.To, + } + stats, err := r.GetStatistics(userID, subjectFilter) + if err != nil { + return nil, err + } + + // Count overdue for this subject + overdueQuery := r.db.Model(&models.Assignment{}). + Where("user_id = ? AND subject = ? AND is_completed = ? AND due_date < ?", userID, subject, false, now) + if filter.From != nil { + overdueQuery = overdueQuery.Where("created_at >= ?", *filter.From) + } + if filter.To != nil { + toEnd := filter.To.AddDate(0, 0, 1) + overdueQuery = overdueQuery.Where("created_at < ?", toEnd) + } + var overdueCount int64 + overdueQuery.Count(&overdueCount) + + results = append(results, SubjectStatistics{ + Subject: subject, + Total: stats.Total, + Completed: stats.Completed, + Pending: stats.Pending, + Overdue: overdueCount, + CompletedOnTime: stats.CompletedOnTime, + OnTimeCompletionRate: stats.OnTimeCompletionRate, + }) + } + + return results, nil +} + +// ArchiveBySubject archives all assignments for a subject +func (r *AssignmentRepository) ArchiveBySubject(userID uint, subject string) error { + return r.db.Model(&models.Assignment{}). + Where("user_id = ? AND subject = ?", userID, subject). + Update("is_archived", true).Error +} + +// UnarchiveBySubject unarchives all assignments for a subject +func (r *AssignmentRepository) UnarchiveBySubject(userID uint, subject string) error { + return r.db.Model(&models.Assignment{}). + Where("user_id = ? AND subject = ?", userID, subject). + Update("is_archived", false).Error +} + +// GetArchivedSubjects returns a list of archived subjects for a user +func (r *AssignmentRepository) GetArchivedSubjects(userID uint) ([]string, error) { + var subjects []string + err := r.db.Model(&models.Assignment{}). + Where("user_id = ? AND is_archived = ? AND subject != ''", userID, true). + Distinct("subject"). + Pluck("subject", &subjects).Error + return subjects, err +} + +// GetSubjectsByUserIDWithArchived returns subjects optionally including archived +func (r *AssignmentRepository) GetSubjectsByUserIDWithArchived(userID uint, includeArchived bool) ([]string, error) { + var subjects []string + query := r.db.Model(&models.Assignment{}). + Where("user_id = ? AND subject != ''", userID) + if !includeArchived { + query = query.Where("is_archived = ?", false) + } + err := query.Distinct("subject").Pluck("subject", &subjects).Error + return subjects, err +} + diff --git a/internal/router/router.go b/internal/router/router.go index 3c417f9..a4d0d94 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -36,6 +36,15 @@ func getFuncMap() template.FuncMap { "daysUntil": func(t time.Time) int { return int(time.Until(t).Hours() / 24) }, + "divideFloat": func(a, b int64) float64 { + if b == 0 { + return 0 + } + return float64(a) / float64(b) + }, + "multiplyFloat": func(a float64, b float64) float64 { + return a * b + }, } } @@ -164,11 +173,15 @@ func Setup(cfg *config.Config) *gin.Engine { authService := service.NewAuthService() apiKeyService := service.NewAPIKeyService() + notificationService := service.NewNotificationService(cfg.Notification.TelegramBotToken) + + // Start notification reminder scheduler + notificationService.StartReminderScheduler() authHandler := handler.NewAuthHandler() assignmentHandler := handler.NewAssignmentHandler() adminHandler := handler.NewAdminHandler() - profileHandler := handler.NewProfileHandler() + profileHandler := handler.NewProfileHandler(notificationService) apiHandler := handler.NewAPIHandler() guest := r.Group("/") @@ -205,9 +218,15 @@ func Setup(cfg *config.Config) *gin.Engine { auth.POST("/assignments/:id/toggle", assignmentHandler.Toggle) auth.POST("/assignments/:id/delete", assignmentHandler.Delete) + auth.GET("/statistics", assignmentHandler.Statistics) + auth.POST("/statistics/archive-subject", assignmentHandler.ArchiveSubject) + auth.POST("/statistics/unarchive-subject", assignmentHandler.UnarchiveSubject) + auth.GET("/profile", profileHandler.Show) auth.POST("/profile", profileHandler.Update) auth.POST("/profile/password", profileHandler.ChangePassword) + auth.POST("/profile/notifications", profileHandler.UpdateNotificationSettings) + admin := auth.Group("/admin") admin.Use(middleware.AdminRequired()) { @@ -235,6 +254,7 @@ func Setup(cfg *config.Config) *gin.Engine { api.PUT("/assignments/:id", apiHandler.UpdateAssignment) api.DELETE("/assignments/:id", apiHandler.DeleteAssignment) api.PATCH("/assignments/:id/toggle", apiHandler.ToggleAssignment) + api.GET("/statistics", apiHandler.GetStatistics) } return r diff --git a/internal/service/assignment_service.go b/internal/service/assignment_service.go index e9ef4d4..350faa9 100644 --- a/internal/service/assignment_service.go +++ b/internal/service/assignment_service.go @@ -31,18 +31,22 @@ func NewAssignmentService() *AssignmentService { } } -func (s *AssignmentService) Create(userID uint, title, description, subject, priority string, dueDate time.Time) (*models.Assignment, error) { +func (s *AssignmentService) Create(userID uint, title, description, subject, priority string, dueDate time.Time, reminderEnabled bool, reminderAt *time.Time, urgentReminderEnabled bool) (*models.Assignment, error) { if priority == "" { priority = "medium" } assignment := &models.Assignment{ - UserID: userID, - Title: title, - Description: description, - Subject: subject, - Priority: priority, - DueDate: dueDate, - IsCompleted: false, + UserID: userID, + Title: title, + Description: description, + Subject: subject, + Priority: priority, + DueDate: dueDate, + IsCompleted: false, + ReminderEnabled: reminderEnabled, + ReminderAt: reminderAt, + ReminderSent: false, + UrgentReminderEnabled: urgentReminderEnabled, } if err := s.assignmentRepo.Create(assignment); err != nil { @@ -191,7 +195,7 @@ func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filt }, nil } -func (s *AssignmentService) Update(userID, assignmentID uint, title, description, subject, priority string, dueDate time.Time) (*models.Assignment, error) { +func (s *AssignmentService) Update(userID, assignmentID uint, title, description, subject, priority string, dueDate time.Time, reminderEnabled bool, reminderAt *time.Time, urgentReminderEnabled bool) (*models.Assignment, error) { assignment, err := s.GetByID(userID, assignmentID) if err != nil { return nil, err @@ -202,6 +206,13 @@ func (s *AssignmentService) Update(userID, assignmentID uint, title, description assignment.Subject = subject assignment.Priority = priority assignment.DueDate = dueDate + assignment.ReminderEnabled = reminderEnabled + assignment.ReminderAt = reminderAt + assignment.UrgentReminderEnabled = urgentReminderEnabled + // Reset reminder sent flag if reminder settings changed + if reminderEnabled && reminderAt != nil { + assignment.ReminderSent = false + } if err := s.assignmentRepo.Update(assignment); err != nil { return nil, err @@ -267,3 +278,133 @@ func (s *AssignmentService) GetDashboardStats(userID uint) (*DashboardStats, err Subjects: subjects, }, nil } + +// StatisticsFilter holds filter parameters for statistics +type StatisticsFilter struct { + Subject string + From *time.Time + To *time.Time + IncludeArchived bool +} + +// SubjectStats holds statistics for a subject +type SubjectStats struct { + Subject string `json:"subject"` + Total int64 `json:"total"` + Completed int64 `json:"completed"` + Pending int64 `json:"pending"` + Overdue int64 `json:"overdue"` + OnTimeCompletionRate float64 `json:"on_time_completion_rate"` + IsArchived bool `json:"is_archived,omitempty"` +} + +// StatisticsSummary holds overall statistics +type StatisticsSummary struct { + TotalAssignments int64 `json:"total_assignments"` + CompletedAssignments int64 `json:"completed_assignments"` + PendingAssignments int64 `json:"pending_assignments"` + OverdueAssignments int64 `json:"overdue_assignments"` + OnTimeCompletionRate float64 `json:"on_time_completion_rate"` + Filter *FilterInfo `json:"filter,omitempty"` + Subjects []SubjectStats `json:"subjects,omitempty"` +} + +// FilterInfo shows applied filters in response +type FilterInfo struct { + Subject *string `json:"subject"` + From *string `json:"from"` + To *string `json:"to"` + IncludeArchived bool `json:"include_archived"` +} + +// GetStatistics returns statistics for a user with optional filters +func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter) (*StatisticsSummary, error) { + // Convert filter to repository filter + repoFilter := repository.StatisticsFilter{ + Subject: filter.Subject, + From: filter.From, + To: filter.To, + IncludeArchived: filter.IncludeArchived, + } + + // Get overall statistics + stats, err := s.assignmentRepo.GetStatistics(userID, repoFilter) + if err != nil { + return nil, err + } + + summary := &StatisticsSummary{ + TotalAssignments: stats.Total, + CompletedAssignments: stats.Completed, + PendingAssignments: stats.Pending, + OverdueAssignments: stats.Overdue, + OnTimeCompletionRate: stats.OnTimeCompletionRate, + } + + // Build filter info + filterInfo := &FilterInfo{} + hasFilter := false + if filter.Subject != "" { + filterInfo.Subject = &filter.Subject + hasFilter = true + } + if filter.From != nil { + fromStr := filter.From.Format("2006-01-02") + filterInfo.From = &fromStr + hasFilter = true + } + if filter.To != nil { + toStr := filter.To.Format("2006-01-02") + filterInfo.To = &toStr + hasFilter = true + } + filterInfo.IncludeArchived = filter.IncludeArchived + if filter.IncludeArchived { + hasFilter = true + } + if hasFilter { + summary.Filter = filterInfo + } + + // If no specific subject filter, get per-subject statistics + if filter.Subject == "" { + subjectStats, err := s.assignmentRepo.GetStatisticsBySubjects(userID, repoFilter) + if err != nil { + return nil, err + } + + for _, ss := range subjectStats { + summary.Subjects = append(summary.Subjects, SubjectStats{ + Subject: ss.Subject, + Total: ss.Total, + Completed: ss.Completed, + Pending: ss.Pending, + Overdue: ss.Overdue, + OnTimeCompletionRate: ss.OnTimeCompletionRate, + }) + } + } + + return summary, nil +} + +// ArchiveSubject archives all assignments for a subject +func (s *AssignmentService) ArchiveSubject(userID uint, subject string) error { + return s.assignmentRepo.ArchiveBySubject(userID, subject) +} + +// UnarchiveSubject unarchives all assignments for a subject +func (s *AssignmentService) UnarchiveSubject(userID uint, subject string) error { + return s.assignmentRepo.UnarchiveBySubject(userID, subject) +} + +// GetSubjectsWithArchived returns subjects optionally including archived +func (s *AssignmentService) GetSubjectsWithArchived(userID uint, includeArchived bool) ([]string, error) { + return s.assignmentRepo.GetSubjectsByUserIDWithArchived(userID, includeArchived) +} + +// GetArchivedSubjects returns archived subjects only +func (s *AssignmentService) GetArchivedSubjects(userID uint) ([]string, error) { + return s.assignmentRepo.GetArchivedSubjects(userID) +} + diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go new file mode 100644 index 0000000..fc2968a --- /dev/null +++ b/internal/service/notification_service.go @@ -0,0 +1,330 @@ +package service + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "strings" + "time" + + "homework-manager/internal/database" + "homework-manager/internal/models" +) + +// NotificationService handles Telegram and LINE notifications +type NotificationService struct { + telegramBotToken string +} + +// NewNotificationService creates a new notification service +func NewNotificationService(telegramBotToken string) *NotificationService { + return &NotificationService{ + telegramBotToken: telegramBotToken, + } +} + +// GetUserSettings retrieves notification settings for a user +func (s *NotificationService) GetUserSettings(userID uint) (*models.UserNotificationSettings, error) { + var settings models.UserNotificationSettings + result := database.GetDB().Where("user_id = ?", userID).First(&settings) + if result.Error != nil { + // If not found, return a new empty settings object + if result.RowsAffected == 0 { + return &models.UserNotificationSettings{ + UserID: userID, + }, nil + } + return nil, result.Error + } + return &settings, nil +} + +// UpdateUserSettings updates notification settings for a user +func (s *NotificationService) UpdateUserSettings(userID uint, settings *models.UserNotificationSettings) error { + settings.UserID = userID + + var existing models.UserNotificationSettings + result := database.GetDB().Where("user_id = ?", userID).First(&existing) + + if result.RowsAffected == 0 { + // Create new + return database.GetDB().Create(settings).Error + } + + // Update existing + settings.ID = existing.ID + return database.GetDB().Save(settings).Error +} + +// SendTelegramNotification sends a message via Telegram Bot API +func (s *NotificationService) SendTelegramNotification(chatID, message string) error { + if s.telegramBotToken == "" { + return fmt.Errorf("telegram bot token is not configured") + } + if chatID == "" { + return fmt.Errorf("telegram chat ID is empty") + } + + apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", s.telegramBotToken) + + payload := map[string]string{ + "chat_id": chatID, + "text": message, + "parse_mode": "HTML", + } + + jsonPayload, err := json.Marshal(payload) + if err != nil { + return err + } + + resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonPayload)) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("telegram API returned status %d", resp.StatusCode) + } + + return nil +} + +// SendLineNotification sends a message via LINE Notify API +func (s *NotificationService) SendLineNotification(token, message string) error { + if token == "" { + return fmt.Errorf("LINE Notify token is empty") + } + + apiURL := "https://notify-api.line.me/api/notify" + + data := url.Values{} + data.Set("message", message) + + req, err := http.NewRequest("POST", apiURL, strings.NewReader(data.Encode())) + if err != nil { + return err + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("LINE Notify API returned status %d", resp.StatusCode) + } + + return nil +} + +// SendAssignmentReminder sends a reminder notification for an assignment +func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *models.Assignment) error { + settings, err := s.GetUserSettings(userID) + if err != nil { + return err + } + + message := fmt.Sprintf( + "📚 課題リマインダー\n\n【%s】\n科目: %s\n期限: %s\n\n%s", + assignment.Title, + assignment.Subject, + assignment.DueDate.Format("2006/01/02 15:04"), + assignment.Description, + ) + + var errors []string + + // Send to Telegram if enabled + if settings.TelegramEnabled && settings.TelegramChatID != "" { + if err := s.SendTelegramNotification(settings.TelegramChatID, message); err != nil { + errors = append(errors, fmt.Sprintf("Telegram: %v", err)) + } + } + + // Send to LINE if enabled + if settings.LineEnabled && settings.LineNotifyToken != "" { + if err := s.SendLineNotification(settings.LineNotifyToken, message); err != nil { + errors = append(errors, fmt.Sprintf("LINE: %v", err)) + } + } + + if len(errors) > 0 { + return fmt.Errorf("notification errors: %s", strings.Join(errors, "; ")) + } + + return nil +} + +// SendUrgentReminder sends an urgent reminder notification for an assignment +func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models.Assignment) error { + settings, err := s.GetUserSettings(userID) + if err != nil { + return err + } + + timeRemaining := time.Until(assignment.DueDate) + var timeStr string + if timeRemaining < 0 { + timeStr = "期限切れ!" + } else if timeRemaining < time.Hour { + timeStr = fmt.Sprintf("あと%d分", int(timeRemaining.Minutes())) + } else { + timeStr = fmt.Sprintf("あと%d時間%d分", int(timeRemaining.Hours()), int(timeRemaining.Minutes())%60) + } + + priorityEmoji := "📌" + switch assignment.Priority { + case "high": + priorityEmoji = "🚨" + case "medium": + priorityEmoji = "⚠️" + case "low": + priorityEmoji = "📌" + } + + message := fmt.Sprintf( + "%s 督促通知!\n\n【%s】\n科目: %s\n期限: %s (%s)\n\n完了したらアプリで完了ボタンを押してください!", + priorityEmoji, + assignment.Title, + assignment.Subject, + assignment.DueDate.Format("2006/01/02 15:04"), + timeStr, + ) + + var errors []string + + // Send to Telegram if enabled + if settings.TelegramEnabled && settings.TelegramChatID != "" { + if err := s.SendTelegramNotification(settings.TelegramChatID, message); err != nil { + errors = append(errors, fmt.Sprintf("Telegram: %v", err)) + } + } + + // Send to LINE if enabled + if settings.LineEnabled && settings.LineNotifyToken != "" { + if err := s.SendLineNotification(settings.LineNotifyToken, message); err != nil { + errors = append(errors, fmt.Sprintf("LINE: %v", err)) + } + } + + if len(errors) > 0 { + return fmt.Errorf("notification errors: %s", strings.Join(errors, "; ")) + } + + return nil +} + +// getUrgentReminderInterval returns the reminder interval based on priority +// high=10min, medium=30min, low=60min +func getUrgentReminderInterval(priority string) time.Duration { + switch priority { + case "high": + return 10 * time.Minute + case "medium": + return 30 * time.Minute + case "low": + return 60 * time.Minute + default: + return 30 * time.Minute + } +} + +// ProcessPendingReminders checks and sends pending one-time reminders +func (s *NotificationService) ProcessPendingReminders() { + now := time.Now() + + var assignments []models.Assignment + result := database.GetDB().Where( + "reminder_enabled = ? AND reminder_sent = ? AND reminder_at <= ? AND is_completed = ?", + true, false, now, false, + ).Find(&assignments) + + if result.Error != nil { + log.Printf("Error fetching pending reminders: %v", result.Error) + return + } + + for _, assignment := range assignments { + if err := s.SendAssignmentReminder(assignment.UserID, &assignment); err != nil { + log.Printf("Error sending reminder for assignment %d: %v", assignment.ID, err) + continue + } + + // Mark as sent + database.GetDB().Model(&assignment).Update("reminder_sent", true) + log.Printf("Sent reminder for assignment %d to user %d", assignment.ID, assignment.UserID) + } +} + +// ProcessUrgentReminders checks and sends urgent (repeating) reminders +// Starts 3 hours before deadline, repeats at interval based on priority +func (s *NotificationService) ProcessUrgentReminders() { + now := time.Now() + urgentStartTime := 3 * time.Hour // Start 3 hours before deadline + + var assignments []models.Assignment + result := database.GetDB().Where( + "urgent_reminder_enabled = ? AND is_completed = ? AND due_date > ?", + true, false, now, + ).Find(&assignments) + + if result.Error != nil { + log.Printf("Error fetching urgent reminders: %v", result.Error) + return + } + + for _, assignment := range assignments { + timeUntilDue := assignment.DueDate.Sub(now) + + // Only send if within 3 hours of deadline + if timeUntilDue > urgentStartTime { + continue + } + + // Check if enough time has passed since last urgent reminder + interval := getUrgentReminderInterval(assignment.Priority) + + if assignment.LastUrgentReminderSent != nil { + timeSinceLastReminder := now.Sub(*assignment.LastUrgentReminderSent) + if timeSinceLastReminder < interval { + continue + } + } + + // Send urgent reminder + if err := s.SendUrgentReminder(assignment.UserID, &assignment); err != nil { + log.Printf("Error sending urgent reminder for assignment %d: %v", assignment.ID, err) + continue + } + + // Update last sent time + database.GetDB().Model(&assignment).Update("last_urgent_reminder_sent", now) + log.Printf("Sent urgent reminder for assignment %d (priority: %s) to user %d", + assignment.ID, assignment.Priority, assignment.UserID) + } +} + +// StartReminderScheduler starts a background goroutine to process reminders +func (s *NotificationService) StartReminderScheduler() { + go func() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + s.ProcessPendingReminders() + s.ProcessUrgentReminders() + } + }() + log.Println("Reminder scheduler started (one-time + urgent reminders)") +} + diff --git a/web/templates/assignments/edit.html b/web/templates/assignments/edit.html index 0e840c1..6e1c997 100644 --- a/web/templates/assignments/edit.html +++ b/web/templates/assignments/edit.html @@ -39,6 +39,44 @@ + +
+
+
通知設定
+ +
+ + +
+
+ 重要度により間隔が変わります:大=10分、中=30分、小=1時間 +
+
+ +
+ + +
+
+ + + {{if .assignment.ReminderSent}} +
通知送信済み
+ {{end}} +
+
+
キャンセル @@ -48,4 +86,9 @@
+ {{end}} \ No newline at end of file diff --git a/web/templates/assignments/new.html b/web/templates/assignments/new.html index cc586ef..ad9a8c7 100644 --- a/web/templates/assignments/new.html +++ b/web/templates/assignments/new.html @@ -39,6 +39,37 @@ + +
+
+
通知設定
+ +
+ + +
+
+ 重要度により間隔が変わります:大=10分、中=30分、小=1時間 +
+
+ +
+ + +
+ +
+
キャンセル @@ -48,4 +79,9 @@
+ {{end}} \ No newline at end of file diff --git a/web/templates/assignments/statistics.html b/web/templates/assignments/statistics.html new file mode 100644 index 0000000..1f3b705 --- /dev/null +++ b/web/templates/assignments/statistics.html @@ -0,0 +1,344 @@ +{{template "base" .}} + +{{define "head"}} + +{{end}} + +{{define "content"}} +
+

統計

+ + 課題一覧に戻る + +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + リセット + +
+
+
+
+ +
+
+
+
+
+
+
総課題数
+

{{.stats.TotalAssignments}}

+
+ +
+
+
+
+
+
+
+
+
+
完了
+

{{.stats.CompletedAssignments}}

+
+ +
+
+
+
+
+
+
+
+
+
未完了
+

{{.stats.PendingAssignments}}

+
+ +
+
+
+
+
+
+
+
+
+
期限切れ
+

{{.stats.OverdueAssignments}}

+
+ +
+
+
+
+
+ +
+
+ 期限内完了率 +
+
+
+
+

+ {{printf "%.1f" .stats.OnTimeCompletionRate}}% +

+ 期限内完了率 +
+
+
+ {{if ge .stats.OnTimeCompletionRate 80.0}} +
+ {{printf "%.1f" .stats.OnTimeCompletionRate}}% 期限内に完了 +
+ {{else if ge .stats.OnTimeCompletionRate 50.0}} +
+ {{printf "%.1f" .stats.OnTimeCompletionRate}}% 期限内に完了 +
+ {{else}} +
+ {{printf "%.1f" .stats.OnTimeCompletionRate}}% 期限内に完了 +
+ {{end}} +
+ 完了した課題のうち、期限内に完了した割合を表示しています。 +
+
+
+
+ +
+
+ アクティブ科目 + 0 +
+
+
+ + + + + + + + + + + + + + +
科目総数完了未完了期限切れ完了率進捗操作
+
+
+ +
+ +
+
+ アーカイブ済み科目 + 0 +
+
+
+ + + + + + + + + + + + + + +
科目総数完了未完了期限切れ完了率進捗操作
+
+
+ +
+ +
+
+ +

科目別の統計データがありません

+

課題を登録して科目を設定すると、ここに統計が表示されます。

+
+
+{{end}} + +{{define "scripts"}} + + +{{end}} \ No newline at end of file diff --git a/web/templates/layouts/base.html b/web/templates/layouts/base.html index a1c1977..c2116f2 100644 --- a/web/templates/layouts/base.html +++ b/web/templates/layouts/base.html @@ -45,6 +45,9 @@ + {{if .isAdmin}}