.機能追加、DBバグ修正
This commit is contained in:
@@ -51,5 +51,10 @@ rate_limit_enabled = true
|
|||||||
rate_limit_requests = 100
|
rate_limit_requests = 100
|
||||||
# Window size in seconds
|
# Window size in seconds
|
||||||
rate_limit_window = 60
|
rate_limit_window = 60
|
||||||
# Trusted proxies (comma separated IP addresses or CIDR)
|
#; Trusted proxies (comma separated IP addresses or CIDR)
|
||||||
# trusted_proxies = 127.0.0.1, 10.0.0.0/8
|
; trusted_proxies = 127.0.0.1, 10.0.0.0/8
|
||||||
|
|
||||||
|
[notification]
|
||||||
|
; Telegram Bot Token (@BotFatherで取得)
|
||||||
|
; ユーザーはプロフィール画面でChat IDを設定します
|
||||||
|
telegram_bot_token =
|
||||||
|
|||||||
89
docs/API.md
89
docs/API.md
@@ -45,6 +45,7 @@ X-API-Key: hm_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
|||||||
| PUT | `/api/v1/assignments/:id` | 課題更新 |
|
| PUT | `/api/v1/assignments/:id` | 課題更新 |
|
||||||
| DELETE | `/api/v1/assignments/:id` | 課題削除 |
|
| DELETE | `/api/v1/assignments/:id` | 課題削除 |
|
||||||
| PATCH | `/api/v1/assignments/:id/toggle` | 完了状態トグル |
|
| 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"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## エラーレスポンス
|
## エラーレスポンス
|
||||||
|
|
||||||
すべてのエラーレスポンスは以下の形式で返されます:
|
すべてのエラーレスポンスは以下の形式で返されます:
|
||||||
|
|||||||
@@ -65,14 +65,37 @@ homework-manager/
|
|||||||
| Title | string | 課題タイトル | Not Null |
|
| Title | string | 課題タイトル | Not Null |
|
||||||
| Description | string | 説明 | - |
|
| Description | string | 説明 | - |
|
||||||
| Subject | string | 教科・科目 | - |
|
| Subject | string | 教科・科目 | - |
|
||||||
|
| Priority | string | 重要度 (`low`, `medium`, `high`) | Default: `medium` |
|
||||||
| DueDate | time.Time | 提出期限 | Not Null |
|
| DueDate | time.Time | 提出期限 | Not Null |
|
||||||
| IsCompleted | bool | 完了フラグ | Default: false |
|
| IsCompleted | bool | 完了フラグ | Default: false |
|
||||||
|
| IsArchived | bool | アーカイブフラグ | Default: false |
|
||||||
| CompletedAt | *time.Time | 完了日時 | Nullable |
|
| 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 | 作成日時 | 自動設定 |
|
| CreatedAt | time.Time | 作成日時 | 自動設定 |
|
||||||
| UpdatedAt | time.Time | 更新日時 | 自動更新 |
|
| UpdatedAt | time.Time | 更新日時 | 自動更新 |
|
||||||
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
|
| 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キーを管理するモデル。
|
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 管理者機能
|
### 4.4 管理者機能
|
||||||
|
|
||||||
@@ -183,6 +238,9 @@ csrf_secret = your-secure-csrf-secret
|
|||||||
rate_limit_enabled = true
|
rate_limit_enabled = true
|
||||||
rate_limit_requests = 100
|
rate_limit_requests = 100
|
||||||
rate_limit_window = 60
|
rate_limit_window = 60
|
||||||
|
|
||||||
|
[notification]
|
||||||
|
telegram_bot_token = your-telegram-bot-token
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5.2 設定項目
|
### 5.2 設定項目
|
||||||
@@ -200,6 +258,7 @@ rate_limit_window = 60
|
|||||||
| `security` | `rate_limit_enabled` | レート制限有効化 | `true` |
|
| `security` | `rate_limit_enabled` | レート制限有効化 | `true` |
|
||||||
| `security` | `rate_limit_requests` | 期間あたりの最大リクエスト数 | `100` |
|
| `security` | `rate_limit_requests` | 期間あたりの最大リクエスト数 | `100` |
|
||||||
| `security` | `rate_limit_window` | 期間(秒) | `60` |
|
| `security` | `rate_limit_window` | 期間(秒) | `60` |
|
||||||
|
| `notification` | `telegram_bot_token` | Telegram Bot Token | - |
|
||||||
|
|
||||||
### 5.3 環境変数
|
### 5.3 環境変数
|
||||||
|
|
||||||
@@ -216,6 +275,7 @@ rate_limit_window = 60
|
|||||||
| `ALLOW_REGISTRATION` | 新規登録許可 (true/false) |
|
| `ALLOW_REGISTRATION` | 新規登録許可 (true/false) |
|
||||||
| `HTTPS` | HTTPSモード (true/false) |
|
| `HTTPS` | HTTPSモード (true/false) |
|
||||||
| `TRUSTED_PROXIES` | 信頼するプロキシのリスト |
|
| `TRUSTED_PROXIES` | 信頼するプロキシのリスト |
|
||||||
|
| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token |
|
||||||
|
|
||||||
### 5.4 設定の優先順位
|
### 5.4 設定の優先順位
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ type DatabaseConfig struct {
|
|||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NotificationConfig struct {
|
||||||
|
TelegramBotToken string
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port string
|
Port string
|
||||||
SessionSecret string
|
SessionSecret string
|
||||||
@@ -29,6 +33,7 @@ type Config struct {
|
|||||||
RateLimitWindow int
|
RateLimitWindow int
|
||||||
TrustedProxies []string
|
TrustedProxies []string
|
||||||
Database DatabaseConfig
|
Database DatabaseConfig
|
||||||
|
Notification NotificationConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load(configPath string) *Config {
|
func Load(configPath string) *Config {
|
||||||
@@ -123,6 +128,12 @@ func Load(configPath string) *Config {
|
|||||||
cfg.TrustedProxies = []string{proxies}
|
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 {
|
} else {
|
||||||
log.Println("config.ini not found, using environment variables or defaults")
|
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 != "" {
|
if trustedProxies := os.Getenv("TRUSTED_PROXIES"); trustedProxies != "" {
|
||||||
cfg.TrustedProxies = []string{trustedProxies}
|
cfg.TrustedProxies = []string{trustedProxies}
|
||||||
}
|
}
|
||||||
|
if telegramToken := os.Getenv("TELEGRAM_BOT_TOKEN"); telegramToken != "" {
|
||||||
|
cfg.Notification.TelegramBotToken = telegramToken
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.SessionSecret == "" {
|
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.")
|
log.Fatal("FATAL: Session secret is not set. Please set it in config.ini ([session] secret) or via SESSION_SECRET environment variable.")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"homework-manager/internal/config"
|
"homework-manager/internal/config"
|
||||||
"homework-manager/internal/models"
|
"homework-manager/internal/models"
|
||||||
@@ -61,6 +62,21 @@ func Connect(dbConfig config.DatabaseConfig, debug bool) error {
|
|||||||
return err
|
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
|
DB = db
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -70,10 +86,10 @@ func Migrate() error {
|
|||||||
&models.User{},
|
&models.User{},
|
||||||
&models.Assignment{},
|
&models.Assignment{},
|
||||||
&models.APIKey{},
|
&models.APIKey{},
|
||||||
|
&models.UserNotificationSettings{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetDB() *gorm.DB {
|
func GetDB() *gorm.DB {
|
||||||
return DB
|
return DB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create assignment"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create assignment"})
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update assignment"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update assignment"})
|
||||||
return
|
return
|
||||||
@@ -396,3 +397,44 @@ func (h *APIHandler) ToggleAssignment(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, assignment)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,17 @@ 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"
|
||||||
|
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)
|
dueDate, err := time.ParseInLocation("2006-01-02T15:04", dueDateStr, time.Local)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dueDate, err = time.ParseInLocation("2006-01-02", dueDateStr, time.Local)
|
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)
|
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 {
|
if err != nil {
|
||||||
role, _ := c.Get(middleware.UserRoleKey)
|
role, _ := c.Get(middleware.UserRoleKey)
|
||||||
name, _ := c.Get(middleware.UserNameKey)
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
@@ -191,6 +202,17 @@ 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"
|
||||||
|
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)
|
dueDate, err := time.ParseInLocation("2006-01-02T15:04", dueDateStr, time.Local)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dueDate, err = time.ParseInLocation("2006-01-02", dueDateStr, time.Local)
|
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)
|
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 {
|
if err != nil {
|
||||||
c.Redirect(http.StatusFound, "/assignments")
|
c.Redirect(http.StatusFound, "/assignments")
|
||||||
return
|
return
|
||||||
@@ -231,3 +253,90 @@ func (h *AssignmentHandler) Delete(c *gin.Context) {
|
|||||||
|
|
||||||
c.Redirect(http.StatusFound, "/assignments")
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,21 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"homework-manager/internal/middleware"
|
"homework-manager/internal/middleware"
|
||||||
|
"homework-manager/internal/models"
|
||||||
"homework-manager/internal/service"
|
"homework-manager/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProfileHandler struct {
|
type ProfileHandler struct {
|
||||||
authService *service.AuthService
|
authService *service.AuthService
|
||||||
|
notificationService *service.NotificationService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProfileHandler() *ProfileHandler {
|
func NewProfileHandler(notificationService *service.NotificationService) *ProfileHandler {
|
||||||
return &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) {
|
func (h *ProfileHandler) Show(c *gin.Context) {
|
||||||
userID := h.getUserID(c)
|
userID := h.getUserID(c)
|
||||||
user, _ := h.authService.GetUserByID(userID)
|
user, _ := h.authService.GetUserByID(userID)
|
||||||
|
notifySettings, _ := h.notificationService.GetUserSettings(userID)
|
||||||
|
|
||||||
role, _ := c.Get(middleware.UserRoleKey)
|
role, _ := c.Get(middleware.UserRoleKey)
|
||||||
name, _ := c.Get(middleware.UserNameKey)
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
|
|
||||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||||
"title": "プロフィール",
|
"title": "プロフィール",
|
||||||
"user": user,
|
"user": user,
|
||||||
"isAdmin": role == "admin",
|
"isAdmin": role == "admin",
|
||||||
"userName": name,
|
"userName": name,
|
||||||
|
"notifySettings": notifySettings,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,24 +52,27 @@ func (h *ProfileHandler) Update(c *gin.Context) {
|
|||||||
|
|
||||||
role, _ := c.Get(middleware.UserRoleKey)
|
role, _ := c.Get(middleware.UserRoleKey)
|
||||||
user, _ := h.authService.GetUserByID(userID)
|
user, _ := h.authService.GetUserByID(userID)
|
||||||
|
notifySettings, _ := h.notificationService.GetUserSettings(userID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||||
"title": "プロフィール",
|
"title": "プロフィール",
|
||||||
"user": user,
|
"user": user,
|
||||||
"error": "プロフィールの更新に失敗しました",
|
"error": "プロフィールの更新に失敗しました",
|
||||||
"isAdmin": role == "admin",
|
"isAdmin": role == "admin",
|
||||||
"userName": name,
|
"userName": name,
|
||||||
|
"notifySettings": notifySettings,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||||
"title": "プロフィール",
|
"title": "プロフィール",
|
||||||
"user": user,
|
"user": user,
|
||||||
"success": "プロフィールを更新しました",
|
"success": "プロフィールを更新しました",
|
||||||
"isAdmin": role == "admin",
|
"isAdmin": role == "admin",
|
||||||
"userName": user.Name,
|
"userName": user.Name,
|
||||||
|
"notifySettings": notifySettings,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,25 +85,28 @@ func (h *ProfileHandler) ChangePassword(c *gin.Context) {
|
|||||||
role, _ := c.Get(middleware.UserRoleKey)
|
role, _ := c.Get(middleware.UserRoleKey)
|
||||||
name, _ := c.Get(middleware.UserNameKey)
|
name, _ := c.Get(middleware.UserNameKey)
|
||||||
user, _ := h.authService.GetUserByID(userID)
|
user, _ := h.authService.GetUserByID(userID)
|
||||||
|
notifySettings, _ := h.notificationService.GetUserSettings(userID)
|
||||||
|
|
||||||
if newPassword != confirmPassword {
|
if newPassword != confirmPassword {
|
||||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||||
"title": "プロフィール",
|
"title": "プロフィール",
|
||||||
"user": user,
|
"user": user,
|
||||||
"passwordError": "新しいパスワードが一致しません",
|
"passwordError": "新しいパスワードが一致しません",
|
||||||
"isAdmin": role == "admin",
|
"isAdmin": role == "admin",
|
||||||
"userName": name,
|
"userName": name,
|
||||||
|
"notifySettings": notifySettings,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(newPassword) < 8 {
|
if len(newPassword) < 8 {
|
||||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||||
"title": "プロフィール",
|
"title": "プロフィール",
|
||||||
"user": user,
|
"user": user,
|
||||||
"passwordError": "パスワードは8文字以上で入力してください",
|
"passwordError": "パスワードは8文字以上で入力してください",
|
||||||
"isAdmin": role == "admin",
|
"isAdmin": role == "admin",
|
||||||
"userName": name,
|
"userName": name,
|
||||||
|
"notifySettings": notifySettings,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -103,11 +114,12 @@ func (h *ProfileHandler) ChangePassword(c *gin.Context) {
|
|||||||
err := h.authService.ChangePassword(userID, oldPassword, newPassword)
|
err := h.authService.ChangePassword(userID, oldPassword, newPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||||
"title": "プロフィール",
|
"title": "プロフィール",
|
||||||
"user": user,
|
"user": user,
|
||||||
"passwordError": "現在のパスワードが正しくありません",
|
"passwordError": "現在のパスワードが正しくありません",
|
||||||
"isAdmin": role == "admin",
|
"isAdmin": role == "admin",
|
||||||
"userName": name,
|
"userName": name,
|
||||||
|
"notifySettings": notifySettings,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -118,5 +130,46 @@ func (h *ProfileHandler) ChangePassword(c *gin.Context) {
|
|||||||
"passwordSuccess": "パスワードを変更しました",
|
"passwordSuccess": "パスワードを変更しました",
|
||||||
"isAdmin": role == "admin",
|
"isAdmin": role == "admin",
|
||||||
"userName": name,
|
"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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,18 @@ type Assignment struct {
|
|||||||
Priority string `gorm:"not null;default:medium" json:"priority"` // low, medium, high
|
Priority string `gorm:"not null;default:medium" json:"priority"` // low, medium, high
|
||||||
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"`
|
||||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
// Reminder notification settings (one-time)
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
ReminderEnabled bool `gorm:"default:false" json:"reminder_enabled"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
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"`
|
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
22
internal/models/notification_settings.go
Normal file
22
internal/models/notification_settings.go
Normal file
@@ -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:"-"`
|
||||||
|
}
|
||||||
@@ -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
|
Where("user_id = ? AND is_completed = ? AND due_date < ?", userID, false, now).Count(&count).Error
|
||||||
return count, err
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ func getFuncMap() template.FuncMap {
|
|||||||
"daysUntil": func(t time.Time) int {
|
"daysUntil": func(t time.Time) int {
|
||||||
return int(time.Until(t).Hours() / 24)
|
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()
|
authService := service.NewAuthService()
|
||||||
apiKeyService := service.NewAPIKeyService()
|
apiKeyService := service.NewAPIKeyService()
|
||||||
|
notificationService := service.NewNotificationService(cfg.Notification.TelegramBotToken)
|
||||||
|
|
||||||
|
// Start notification reminder scheduler
|
||||||
|
notificationService.StartReminderScheduler()
|
||||||
|
|
||||||
authHandler := handler.NewAuthHandler()
|
authHandler := handler.NewAuthHandler()
|
||||||
assignmentHandler := handler.NewAssignmentHandler()
|
assignmentHandler := handler.NewAssignmentHandler()
|
||||||
adminHandler := handler.NewAdminHandler()
|
adminHandler := handler.NewAdminHandler()
|
||||||
profileHandler := handler.NewProfileHandler()
|
profileHandler := handler.NewProfileHandler(notificationService)
|
||||||
apiHandler := handler.NewAPIHandler()
|
apiHandler := handler.NewAPIHandler()
|
||||||
|
|
||||||
guest := r.Group("/")
|
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/toggle", assignmentHandler.Toggle)
|
||||||
auth.POST("/assignments/:id/delete", assignmentHandler.Delete)
|
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.GET("/profile", profileHandler.Show)
|
||||||
auth.POST("/profile", profileHandler.Update)
|
auth.POST("/profile", profileHandler.Update)
|
||||||
auth.POST("/profile/password", profileHandler.ChangePassword)
|
auth.POST("/profile/password", profileHandler.ChangePassword)
|
||||||
|
auth.POST("/profile/notifications", profileHandler.UpdateNotificationSettings)
|
||||||
|
|
||||||
admin := auth.Group("/admin")
|
admin := auth.Group("/admin")
|
||||||
admin.Use(middleware.AdminRequired())
|
admin.Use(middleware.AdminRequired())
|
||||||
{
|
{
|
||||||
@@ -235,6 +254,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
|||||||
api.PUT("/assignments/:id", apiHandler.UpdateAssignment)
|
api.PUT("/assignments/:id", apiHandler.UpdateAssignment)
|
||||||
api.DELETE("/assignments/:id", apiHandler.DeleteAssignment)
|
api.DELETE("/assignments/:id", apiHandler.DeleteAssignment)
|
||||||
api.PATCH("/assignments/:id/toggle", apiHandler.ToggleAssignment)
|
api.PATCH("/assignments/:id/toggle", apiHandler.ToggleAssignment)
|
||||||
|
api.GET("/statistics", apiHandler.GetStatistics)
|
||||||
}
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -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 == "" {
|
if priority == "" {
|
||||||
priority = "medium"
|
priority = "medium"
|
||||||
}
|
}
|
||||||
assignment := &models.Assignment{
|
assignment := &models.Assignment{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Title: title,
|
Title: title,
|
||||||
Description: description,
|
Description: description,
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
Priority: priority,
|
Priority: priority,
|
||||||
DueDate: dueDate,
|
DueDate: dueDate,
|
||||||
IsCompleted: false,
|
IsCompleted: false,
|
||||||
|
ReminderEnabled: reminderEnabled,
|
||||||
|
ReminderAt: reminderAt,
|
||||||
|
ReminderSent: false,
|
||||||
|
UrgentReminderEnabled: urgentReminderEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.assignmentRepo.Create(assignment); err != nil {
|
if err := s.assignmentRepo.Create(assignment); err != nil {
|
||||||
@@ -191,7 +195,7 @@ func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filt
|
|||||||
}, nil
|
}, 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)
|
assignment, err := s.GetByID(userID, assignmentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -202,6 +206,13 @@ func (s *AssignmentService) Update(userID, assignmentID uint, title, description
|
|||||||
assignment.Subject = subject
|
assignment.Subject = subject
|
||||||
assignment.Priority = priority
|
assignment.Priority = priority
|
||||||
assignment.DueDate = dueDate
|
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 {
|
if err := s.assignmentRepo.Update(assignment); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -267,3 +278,133 @@ func (s *AssignmentService) GetDashboardStats(userID uint) (*DashboardStats, err
|
|||||||
Subjects: subjects,
|
Subjects: subjects,
|
||||||
}, nil
|
}, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
330
internal/service/notification_service.go
Normal file
330
internal/service/notification_service.go
Normal file
@@ -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)")
|
||||||
|
}
|
||||||
|
|
||||||
@@ -39,6 +39,44 @@
|
|||||||
<textarea class="form-control" id="description" name="description"
|
<textarea class="form-control" id="description" name="description"
|
||||||
rows="3">{{.assignment.Description}}</textarea>
|
rows="3">{{.assignment.Description}}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 通知設定 -->
|
||||||
|
<div class="card bg-light mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<h6 class="mb-2"><i class="bi bi-bell me-1"></i>通知設定</h6>
|
||||||
|
<!-- 督促通知 -->
|
||||||
|
<div class="form-check form-switch mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="urgent_reminder_enabled"
|
||||||
|
name="urgent_reminder_enabled" {{if
|
||||||
|
.assignment.UrgentReminderEnabled}}checked{{end}}>
|
||||||
|
<label class="form-check-label" for="urgent_reminder_enabled">
|
||||||
|
督促通知(期限3時間前から繰り返し通知)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text small mb-2">
|
||||||
|
重要度により間隔が変わります:大=10分、中=30分、小=1時間
|
||||||
|
</div>
|
||||||
|
<hr class="my-2">
|
||||||
|
<!-- 1回リマインダー -->
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="reminder_enabled"
|
||||||
|
name="reminder_enabled" {{if .assignment.ReminderEnabled}}checked{{end}}
|
||||||
|
onchange="toggleReminderDate(this)">
|
||||||
|
<label class="form-check-label" for="reminder_enabled">
|
||||||
|
1回リマインダー(指定日時に1回通知)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2" id="reminder_at_group"
|
||||||
|
style="display: {{if .assignment.ReminderEnabled}}block{{else}}none{{end}};">
|
||||||
|
<label for="reminder_at" class="form-label small">通知日時</label>
|
||||||
|
<input type="datetime-local" class="form-control form-control-sm" id="reminder_at"
|
||||||
|
name="reminder_at"
|
||||||
|
value="{{if .assignment.ReminderAt}}{{formatDateInput .assignment.ReminderAt}}{{end}}">
|
||||||
|
{{if .assignment.ReminderSent}}
|
||||||
|
<div class="text-success small mt-1"><i class="bi bi-check-circle me-1"></i>通知送信済み</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<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>
|
||||||
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
|
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
|
||||||
@@ -48,4 +86,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
function toggleReminderDate(checkbox) {
|
||||||
|
document.getElementById('reminder_at_group').style.display = checkbox.checked ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -39,6 +39,37 @@
|
|||||||
<textarea class="form-control" id="description" name="description"
|
<textarea class="form-control" id="description" name="description"
|
||||||
rows="3">{{.description}}</textarea>
|
rows="3">{{.description}}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 通知設定 -->
|
||||||
|
<div class="card bg-light mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<h6 class="mb-2"><i class="bi bi-bell me-1"></i>通知設定</h6>
|
||||||
|
<!-- 督促通知 -->
|
||||||
|
<div class="form-check form-switch mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="urgent_reminder_enabled"
|
||||||
|
name="urgent_reminder_enabled" checked>
|
||||||
|
<label class="form-check-label" for="urgent_reminder_enabled">
|
||||||
|
督促通知(期限3時間前から繰り返し通知)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text small mb-2">
|
||||||
|
重要度により間隔が変わります:大=10分、中=30分、小=1時間
|
||||||
|
</div>
|
||||||
|
<hr class="my-2">
|
||||||
|
<!-- 1回リマインダー -->
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="reminder_enabled"
|
||||||
|
name="reminder_enabled" onchange="toggleReminderDate(this)">
|
||||||
|
<label class="form-check-label" for="reminder_enabled">
|
||||||
|
1回リマインダー(指定日時に1回通知)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2" id="reminder_at_group" style="display: none;">
|
||||||
|
<label for="reminder_at" class="form-label small">通知日時</label>
|
||||||
|
<input type="datetime-local" class="form-control form-control-sm" id="reminder_at"
|
||||||
|
name="reminder_at">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<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>
|
||||||
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
|
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
|
||||||
@@ -48,4 +79,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
function toggleReminderDate(checkbox) {
|
||||||
|
document.getElementById('reminder_at_group').style.display = checkbox.checked ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
344
web/templates/assignments/statistics.html
Normal file
344
web/templates/assignments/statistics.html
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "head"}}
|
||||||
|
<style>
|
||||||
|
.stat-card {
|
||||||
|
transition: transform 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
height: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-row:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-progress {
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1><i class="bi bi-bar-chart me-2"></i>統計</h1>
|
||||||
|
<a href="/assignments" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>課題一覧に戻る
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="GET" action="/statistics" class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">科目</label>
|
||||||
|
<select name="subject" class="form-select">
|
||||||
|
<option value="">すべての科目</option>
|
||||||
|
{{range .subjects}}
|
||||||
|
<option value="{{.}}" {{if eq . $.selectedSubject}}selected{{end}}>{{.}}{{if index
|
||||||
|
$.archivedSubjects .}} (アーカイブ済){{end}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">登録日(開始)</label>
|
||||||
|
<input type="date" name="from" class="form-control" value="{{.fromDate}}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">登録日(終了)</label>
|
||||||
|
<input type="date" name="to" class="form-control" value="{{.toDate}}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 d-flex align-items-end">
|
||||||
|
<button type="submit" class="btn btn-primary me-2">
|
||||||
|
<i class="bi bi-filter me-1"></i>絞り込み
|
||||||
|
</button>
|
||||||
|
<a href="/statistics" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-x-lg me-1"></i>リセット
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-6 col-lg-3">
|
||||||
|
<div class="card stat-card bg-primary text-white h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h6 class="text-white-50 mb-1">総課題数</h6>
|
||||||
|
<h2 class="mb-0">{{.stats.TotalAssignments}}</h2>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-list-task display-4 opacity-50"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-3">
|
||||||
|
<div class="card stat-card bg-success text-white h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h6 class="text-white-50 mb-1">完了</h6>
|
||||||
|
<h2 class="mb-0">{{.stats.CompletedAssignments}}</h2>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-check-circle display-4 opacity-50"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-3">
|
||||||
|
<div class="card stat-card bg-warning text-dark h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h6 class="text-dark-50 mb-1">未完了</h6>
|
||||||
|
<h2 class="mb-0">{{.stats.PendingAssignments}}</h2>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-hourglass-split display-4 opacity-50"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-3">
|
||||||
|
<div class="card stat-card bg-danger text-white h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h6 class="text-white-50 mb-1">期限切れ</h6>
|
||||||
|
<h2 class="mb-0">{{.stats.OverdueAssignments}}</h2>
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-exclamation-triangle display-4 opacity-50"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-clock-history me-2"></i>期限内完了率
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-md-3 text-center mb-3 mb-md-0">
|
||||||
|
<h1
|
||||||
|
class="display-3 mb-0 {{if ge .stats.OnTimeCompletionRate 80.0}}text-success{{else if ge .stats.OnTimeCompletionRate 50.0}}text-warning{{else}}text-danger{{end}}">
|
||||||
|
{{printf "%.1f" .stats.OnTimeCompletionRate}}%
|
||||||
|
</h1>
|
||||||
|
<small class="text-muted">期限内完了率</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-9">
|
||||||
|
<div class="progress">
|
||||||
|
{{if ge .stats.OnTimeCompletionRate 80.0}}
|
||||||
|
<div class="progress-bar bg-success" role="progressbar"
|
||||||
|
style="width: {{.stats.OnTimeCompletionRate}}%">
|
||||||
|
{{printf "%.1f" .stats.OnTimeCompletionRate}}% 期限内に完了
|
||||||
|
</div>
|
||||||
|
{{else if ge .stats.OnTimeCompletionRate 50.0}}
|
||||||
|
<div class="progress-bar bg-warning" role="progressbar"
|
||||||
|
style="width: {{.stats.OnTimeCompletionRate}}%">
|
||||||
|
{{printf "%.1f" .stats.OnTimeCompletionRate}}% 期限内に完了
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="progress-bar bg-danger" role="progressbar"
|
||||||
|
style="width: {{.stats.OnTimeCompletionRate}}%">
|
||||||
|
{{printf "%.1f" .stats.OnTimeCompletionRate}}% 期限内に完了
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<small class="text-muted mt-2 d-block">完了した課題のうち、期限内に完了した割合を表示しています。</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4" id="activeSubjectsCard">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-collection me-2"></i>アクティブ科目</span>
|
||||||
|
<span class="badge bg-primary" id="activeCount">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>科目</th>
|
||||||
|
<th class="text-center">総数</th>
|
||||||
|
<th class="text-center">完了</th>
|
||||||
|
<th class="text-center">未完了</th>
|
||||||
|
<th class="text-center">期限切れ</th>
|
||||||
|
<th class="text-center">完了率</th>
|
||||||
|
<th style="width: 150px;">進捗</th>
|
||||||
|
<th class="text-center">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="activeSubjectsBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||||
|
<span class="pagination-info" id="activePageInfo"></span>
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination pagination-sm mb-0" id="activePagination"></ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="archivedSubjectsCard">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-archive me-2"></i>アーカイブ済み科目</span>
|
||||||
|
<span class="badge bg-secondary" id="archivedCount">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>科目</th>
|
||||||
|
<th class="text-center">総数</th>
|
||||||
|
<th class="text-center">完了</th>
|
||||||
|
<th class="text-center">未完了</th>
|
||||||
|
<th class="text-center">期限切れ</th>
|
||||||
|
<th class="text-center">完了率</th>
|
||||||
|
<th style="width: 150px;">進捗</th>
|
||||||
|
<th class="text-center">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="archivedSubjectsBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||||
|
<span class="pagination-info" id="archivedPageInfo"></span>
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination pagination-sm mb-0" id="archivedPagination"></ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card d-none" id="noSubjectsCard">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<i class="bi bi-inbox display-1 text-muted"></i>
|
||||||
|
<h4 class="mt-3">科目別の統計データがありません</h4>
|
||||||
|
<p class="text-muted">課題を登録して科目を設定すると、ここに統計が表示されます。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script id="subjectsData" type="application/json">
|
||||||
|
{"csrfToken":"{{.csrfToken}}","subjects":[{{range $i, $s := .stats.Subjects}}{{if $i}},{{end}}{"subject":"{{$s.Subject}}","total":{{$s.Total}},"completed":{{$s.Completed}},"pending":{{$s.Pending}},"overdue":{{$s.Overdue}},"rate":{{$s.OnTimeCompletionRate}},"isArchived":{{if index $.archivedSubjects $s.Subject}}true{{else}}false{{end}}}{{end}}]}
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var data = JSON.parse(document.getElementById('subjectsData').textContent);
|
||||||
|
var csrfToken = data.csrfToken;
|
||||||
|
var subjects = data.subjects;
|
||||||
|
var PAGE_SIZE = 10;
|
||||||
|
var activeSubjects = subjects.filter(function (s) { return !s.isArchived; });
|
||||||
|
var archivedSubjects = subjects.filter(function (s) { return s.isArchived; });
|
||||||
|
var activePage = 1;
|
||||||
|
var archivedPage = 1;
|
||||||
|
|
||||||
|
function getRateClass(rate) {
|
||||||
|
if (rate >= 80) return 'text-success';
|
||||||
|
if (rate >= 50) return 'text-warning';
|
||||||
|
return 'text-danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProgress(completed, pending, overdue, total) {
|
||||||
|
if (total === 0) return '<div class="progress table-progress"></div>';
|
||||||
|
var cP = (completed / total * 100).toFixed(1);
|
||||||
|
var pP = (pending / total * 100).toFixed(1);
|
||||||
|
var oP = (overdue / total * 100).toFixed(1);
|
||||||
|
return '<div class="progress table-progress">' +
|
||||||
|
'<div class="progress-bar bg-success" style="width:' + cP + '%" title="完了: ' + completed + '"></div>' +
|
||||||
|
'<div class="progress-bar bg-warning" style="width:' + pP + '%" title="未完了: ' + pending + '"></div>' +
|
||||||
|
'<div class="progress-bar bg-danger" style="width:' + oP + '%" title="期限切れ: ' + overdue + '"></div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRow(s, isArchived) {
|
||||||
|
var action = isArchived ?
|
||||||
|
'<form action="/statistics/unarchive-subject" method="POST" class="d-inline"><input type="hidden" name="_csrf" value="' + csrfToken + '"><input type="hidden" name="subject" value="' + s.subject + '"><button type="submit" class="btn btn-sm btn-outline-success" title="復元"><i class="bi bi-arrow-counterclockwise"></i></button></form>' :
|
||||||
|
'<form action="/statistics/archive-subject" method="POST" class="d-inline"><input type="hidden" name="_csrf" value="' + csrfToken + '"><input type="hidden" name="subject" value="' + s.subject + '"><button type="submit" class="btn btn-sm btn-outline-secondary" title="アーカイブ"><i class="bi bi-archive"></i></button></form>';
|
||||||
|
return '<tr class="subject-row">' +
|
||||||
|
'<td><a href="/statistics?subject=' + encodeURIComponent(s.subject) + '" class="text-decoration-none"><i class="bi bi-folder me-1"></i>' + s.subject + '</a></td>' +
|
||||||
|
'<td class="text-center">' + s.total + '</td>' +
|
||||||
|
'<td class="text-center text-success">' + s.completed + '</td>' +
|
||||||
|
'<td class="text-center text-warning">' + s.pending + '</td>' +
|
||||||
|
'<td class="text-center text-danger">' + s.overdue + '</td>' +
|
||||||
|
'<td class="text-center"><span class="' + getRateClass(s.rate) + '">' + s.rate.toFixed(1) + '%</span></td>' +
|
||||||
|
'<td>' + renderProgress(s.completed, s.pending, s.overdue, s.total) + '</td>' +
|
||||||
|
'<td class="text-center">' + action + '</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPagination(id, page, total, cb) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
el.innerHTML = '';
|
||||||
|
if (total <= 1) return;
|
||||||
|
var prev = document.createElement('li');
|
||||||
|
prev.className = 'page-item' + (page === 1 ? ' disabled' : '');
|
||||||
|
prev.innerHTML = '<a class="page-link" href="#">«</a>';
|
||||||
|
if (page > 1) prev.onclick = function (e) { e.preventDefault(); cb(page - 1); };
|
||||||
|
el.appendChild(prev);
|
||||||
|
for (var i = 1; i <= total; i++) {
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.className = 'page-item' + (i === page ? ' active' : '');
|
||||||
|
li.innerHTML = '<a class="page-link" href="#">' + i + '</a>';
|
||||||
|
(function (p) { li.onclick = function (e) { e.preventDefault(); cb(p); }; })(i);
|
||||||
|
el.appendChild(li);
|
||||||
|
}
|
||||||
|
var next = document.createElement('li');
|
||||||
|
next.className = 'page-item' + (page === total ? ' disabled' : '');
|
||||||
|
next.innerHTML = '<a class="page-link" href="#">»</a>';
|
||||||
|
if (page < total) next.onclick = function (e) { e.preventDefault(); cb(page + 1); };
|
||||||
|
el.appendChild(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActiveSubjects() {
|
||||||
|
var start = (activePage - 1) * PAGE_SIZE;
|
||||||
|
var end = Math.min(start + PAGE_SIZE, activeSubjects.length);
|
||||||
|
var totalPages = Math.ceil(activeSubjects.length / PAGE_SIZE);
|
||||||
|
document.getElementById('activeSubjectsBody').innerHTML = activeSubjects.slice(start, end).map(function (s) { return renderRow(s, false); }).join('');
|
||||||
|
document.getElementById('activeCount').textContent = activeSubjects.length;
|
||||||
|
document.getElementById('activePageInfo').textContent = activeSubjects.length > 0 ? (start + 1) + '-' + end + ' / ' + activeSubjects.length + ' 件' : '0 件';
|
||||||
|
renderPagination('activePagination', activePage, totalPages, function (p) { activePage = p; renderActiveSubjects(); });
|
||||||
|
document.getElementById('activeSubjectsCard').style.display = activeSubjects.length > 0 ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderArchivedSubjects() {
|
||||||
|
var start = (archivedPage - 1) * PAGE_SIZE;
|
||||||
|
var end = Math.min(start + PAGE_SIZE, archivedSubjects.length);
|
||||||
|
var totalPages = Math.ceil(archivedSubjects.length / PAGE_SIZE);
|
||||||
|
document.getElementById('archivedSubjectsBody').innerHTML = archivedSubjects.slice(start, end).map(function (s) { return renderRow(s, true); }).join('');
|
||||||
|
document.getElementById('archivedCount').textContent = archivedSubjects.length;
|
||||||
|
document.getElementById('archivedPageInfo').textContent = archivedSubjects.length > 0 ? (start + 1) + '-' + end + ' / ' + archivedSubjects.length + ' 件' : '0 件';
|
||||||
|
renderPagination('archivedPagination', archivedPage, totalPages, function (p) { archivedPage = p; renderArchivedSubjects(); });
|
||||||
|
document.getElementById('archivedSubjectsCard').style.display = archivedSubjects.length > 0 ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderActiveSubjects();
|
||||||
|
renderArchivedSubjects();
|
||||||
|
if (activeSubjects.length === 0 && archivedSubjects.length === 0) {
|
||||||
|
document.getElementById('noSubjectsCard').classList.remove('d-none');
|
||||||
|
document.getElementById('activeSubjectsCard').style.display = 'none';
|
||||||
|
document.getElementById('archivedSubjectsCard').style.display = 'none';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -45,6 +45,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/assignments/new"><i class="bi bi-plus-circle me-1"></i>課題登録</a>
|
<a class="nav-link" href="/assignments/new"><i class="bi bi-plus-circle me-1"></i>課題登録</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/statistics"><i class="bi bi-bar-chart me-1"></i>統計</a>
|
||||||
|
</li>
|
||||||
{{if .isAdmin}}
|
{{if .isAdmin}}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/admin/users"><i class="bi bi-people me-1"></i>ユーザー管理</a>
|
<a class="nav-link" href="/admin/users"><i class="bi bi-people me-1"></i>ユーザー管理</a>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-10">
|
||||||
<h1 class="mb-4"><i class="bi bi-person me-2"></i>プロフィール</h1>
|
<h1 class="mb-4"><i class="bi bi-person me-2"></i>プロフィール</h1>
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
@@ -66,6 +66,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 通知設定 -->
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-bell me-2"></i>通知設定</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{if .notifyError}}<div class="alert alert-danger">{{.notifyError}}</div>{{end}}
|
||||||
|
{{if .notifySuccess}}<div class="alert alert-success">{{.notifySuccess}}</div>{{end}}
|
||||||
|
<form method="POST" action="/profile/notifications">
|
||||||
|
{{.csrfField}}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="mb-3"><i class="bi bi-telegram me-1"></i>Telegram</h6>
|
||||||
|
<div class="form-check form-switch mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="telegram_enabled"
|
||||||
|
name="telegram_enabled" {{if .notifySettings.TelegramEnabled}}checked{{end}}>
|
||||||
|
<label class="form-check-label" for="telegram_enabled">Telegram通知を有効化</label>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="telegram_chat_id" class="form-label">Chat ID</label>
|
||||||
|
<input type="text" class="form-control" id="telegram_chat_id" name="telegram_chat_id"
|
||||||
|
value="{{.notifySettings.TelegramChatID}}" placeholder="例: 123456789">
|
||||||
|
<div class="form-text">
|
||||||
|
Botに<code>/start</code>を送信後、<a href="https://t.me/userinfobot"
|
||||||
|
target="_blank">@userinfobot</a>でIDを確認
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="mb-3"><i class="bi bi-chat-dots me-1"></i>LINE Notify</h6>
|
||||||
|
<div class="form-check form-switch mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="line_enabled" name="line_enabled"
|
||||||
|
{{if .notifySettings.LineEnabled}}checked{{end}}>
|
||||||
|
<label class="form-check-label" for="line_enabled">LINE通知を有効化</label>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="line_token" class="form-label">アクセストークン</label>
|
||||||
|
<input type="password" class="form-control" id="line_token" name="line_token"
|
||||||
|
value="{{.notifySettings.LineNotifyToken}}" placeholder="トークンを入力">
|
||||||
|
<div class="form-text">
|
||||||
|
<a href="https://notify-bot.line.me/my/" target="_blank">LINE Notify</a>でトークンを発行
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>通知設定を保存</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
Reference in New Issue
Block a user