From b982c8aceecfc212a0e5d0091eb1f9dd21da87b0 Mon Sep 17 00:00:00 2001 From: furu04 Date: Sun, 11 Jan 2026 12:02:04 +0900 Subject: [PATCH] =?UTF-8?q?=E7=B9=B0=E3=82=8A=E8=BF=94=E3=81=97=E8=AA=B2?= =?UTF-8?q?=E9=A1=8C=E3=81=AEAPI=E7=AE=A1=E7=90=86UI=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=E3=80=81=E8=AA=B2=E9=A1=8C=E4=B8=80=E8=A6=A7=E3=81=AE?= =?UTF-8?q?UX=E5=90=91=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + docs/API.md | 144 ++++++++- docs/SPECIFICATION.md | 59 +++- internal/handler/api_handler.go | 293 ++++++++++++------ internal/handler/api_recurring_handler.go | 167 ++++++++++ internal/handler/assignment_handler.go | 186 ++++++++++- internal/repository/assignment_repository.go | 56 ++++ internal/router/router.go | 28 ++ internal/service/assignment_service.go | 23 +- .../service/recurring_assignment_service.go | 90 ++++-- web/static/js/app.js | 4 +- web/templates/admin/api_keys.html | 5 + web/templates/assignments/edit.html | 33 ++ web/templates/assignments/index.html | 156 +++++++++- web/templates/assignments/new.html | 48 +-- web/templates/layouts/base.html | 1 + web/templates/pages/dashboard.html | 22 +- web/templates/recurring/edit.html | 148 +++++++++ web/templates/recurring/index.html | 68 ++++ 19 files changed, 1328 insertions(+), 204 deletions(-) create mode 100644 internal/handler/api_recurring_handler.go create mode 100644 web/templates/recurring/edit.html create mode 100644 web/templates/recurring/index.html diff --git a/README.md b/README.md index bba6089..f157458 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ ## 特徴 - **課題管理**: 課題の登録、編集、削除、完了状況の管理 +- **繰り返し課題**: 日次・週次・月次の繰り返し課題を自動生成・管理 - **ダッシュボード**: 期限切れ、本日期限、今週期限の課題をひと目で確認 - **API対応**: 外部連携用のRESTful API (APIキー認証) - **セキュリティ**: diff --git a/docs/API.md b/docs/API.md index 8fb1272..0ffc114 100644 --- a/docs/API.md +++ b/docs/API.md @@ -23,14 +23,15 @@ Super Homework Manager REST APIは、課題管理機能をプログラムから ### 認証ヘッダー ``` -X-API-Key: hm_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +Authorization: Bearer hm_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ``` ### 認証エラー | ステータスコード | レスポンス | |------------------|------------| -| 401 Unauthorized | `{"error": "API key required"}` | +| 401 Unauthorized | `{"error": "Authorization header required"}` | +| 401 Unauthorized | `{"error": "Invalid authorization format. Use: Bearer "}` | | 401 Unauthorized | `{"error": "Invalid API key"}` | --- @@ -46,6 +47,10 @@ X-API-Key: hm_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX | DELETE | `/api/v1/assignments/:id` | 課題削除 | | PATCH | `/api/v1/assignments/:id/toggle` | 完了状態トグル | | GET | `/api/v1/statistics` | 統計情報取得 | +| GET | `/api/v1/recurring` | 繰り返し設定一覧取得 | +| GET | `/api/v1/recurring/:id` | 繰り返し設定詳細取得 | +| PUT | `/api/v1/recurring/:id` | 繰り返し設定更新 | +| DELETE | `/api/v1/recurring/:id` | 繰り返し設定削除 | --- @@ -88,13 +93,13 @@ GET /api/v1/assignments ```bash # 全件取得 -curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments +curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments # 未完了のみ取得 -curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments?filter=pending +curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments?filter=pending # 期限切れのみ取得 -curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments?filter=overdue +curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments?filter=overdue ``` --- @@ -140,7 +145,7 @@ GET /api/v1/assignments/:id ### 例 ```bash -curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments/1 +curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/assignments/1 ``` --- @@ -159,6 +164,28 @@ POST /api/v1/assignments | `description` | string | | 説明 | | `subject` | string | | 教科・科目 | | `due_date` | string | ✅ | 提出期限(RFC3339 または `YYYY-MM-DDTHH:MM` または `YYYY-MM-DD`) | +| `reminder_enabled` | boolean | | リマインダーを有効にするか(省略時: false) | +| `reminder_at` | string | | リマインダー設定時刻(形式はdue_dateと同じ) | +| `urgent_reminder_enabled` | boolean | | 期限切れ時の督促リマインダーを有効にするか(省略時: true) | +| `recurrence` | object | | 繰り返し設定(以下参照) | + +### Recurrence オブジェクト + +| フィールド | 型 | 説明 | +|------------|------|------| +| `type` | string | 繰り返しタイプ (`daily`, `weekly`, `monthly`, または空文字で無効) | +| `interval` | integer | 間隔 (例: 1 = 毎週, 2 = 隔週) | +| `weekday` | integer | 週次の曜日 (0=日, 1=月, ..., 6=土) | +| `day` | integer | 月次の日付 (1-31) | +| `until` | object | 終了条件 | + +#### Recurrence.Until オブジェクト + +| フィールド | 型 | 説明 | +|------------|------|------| +| `type` | string | 終了タイプ (`never`, `count`, `date`) | +| `count` | integer | 回数指定時の終了回数 | +| `date` | string | 日付指定時の終了日 | ### リクエスト例 @@ -201,7 +228,7 @@ POST /api/v1/assignments ```bash curl -X POST \ - -H "X-API-Key: hm_xxx" \ + -H "Authorization: Bearer hm_xxx" \ -H "Content-Type: application/json" \ -d '{"title":"英語エッセイ","due_date":"2025-01-20"}' \ http://localhost:8080/api/v1/assignments @@ -231,6 +258,9 @@ PUT /api/v1/assignments/:id | `description` | string | 説明 | | `subject` | string | 教科・科目 | | `due_date` | string | 提出期限 | +| `reminder_enabled` | boolean | リマインダー有効/無効 | +| `reminder_at` | string | リマインダー時刻 | +| `urgent_reminder_enabled` | boolean | 督促リマインダー有効/無効 | ### リクエスト例 @@ -271,7 +301,7 @@ PUT /api/v1/assignments/:id ```bash curl -X PUT \ - -H "X-API-Key: hm_xxx" \ + -H "Authorization: Bearer hm_xxx" \ -H "Content-Type: application/json" \ -d '{"title":"更新されたタイトル"}' \ http://localhost:8080/api/v1/assignments/2 @@ -291,6 +321,12 @@ DELETE /api/v1/assignments/:id |------------|------|------| | `id` | integer | 課題ID | +### クエリパラメータ + +| パラメータ | 型 | 説明 | +|------------|------|------| +| `delete_recurring` | boolean | `true` の場合、関連する繰り返し設定も削除する | + ### レスポンス **200 OK** @@ -313,7 +349,7 @@ DELETE /api/v1/assignments/:id ```bash curl -X DELETE \ - -H "X-API-Key: hm_xxx" \ + -H "Authorization: Bearer hm_xxx" \ http://localhost:8080/api/v1/assignments/2 ``` @@ -364,7 +400,7 @@ PATCH /api/v1/assignments/:id/toggle ```bash curl -X PATCH \ - -H "X-API-Key: hm_xxx" \ + -H "Authorization: Bearer hm_xxx" \ http://localhost:8080/api/v1/assignments/1/toggle ``` @@ -444,16 +480,16 @@ GET /api/v1/statistics ```bash # 全体統計 -curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/statistics +curl -H "Authorization: Bearer hm_xxx" http://localhost:8080/api/v1/statistics # 科目で絞り込み -curl -H "X-API-Key: hm_xxx" "http://localhost:8080/api/v1/statistics?subject=数学" +curl -H "Authorization: Bearer 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 "Authorization: Bearer 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" +curl -H "Authorization: Bearer hm_xxx" "http://localhost:8080/api/v1/statistics?subject=数学&from=2025-01-01&to=2025-03-31" ``` --- @@ -506,3 +542,83 @@ APIは以下の日付形式を受け付けます(優先度順): ``` 設定ファイル (`config.ini`) または環境変数で制限値を変更可能です。 + +--- + +## 繰り返し設定一覧取得 + +``` +GET /api/v1/recurring +``` + +### レスポンス + +**200 OK** + +```json +{ + "recurring_assignments": [ + { + "id": 1, + "user_id": 1, + "title": "週次ミーティング", + "recurrence_type": "weekly", + "interval": 1, + "weekday": 1, + "is_active": true + } + ], + "count": 1 +} +``` + +--- + +## 繰り返し設定詳細取得 + +``` +GET /api/v1/recurring/:id +``` + +### レスポンス + +**200 OK** + +--- + +## 繰り返し設定更新 + +``` +PUT /api/v1/recurring/:id +``` + +### リクエストボディ + +各フィールドはオプション。省略時は更新なし。 + +| フィールド | 型 | 説明 | +|------------|------|------| +| `title` | string | タイトル | +| `is_active` | boolean | `false` で停止、`true` で再開 | +| `recurrence_type` | string | `daily`, `weekly`, `monthly` | +| ... | ... | その他の設定フィールド | + +### リクエスト例(停止) + +```json +{ + "is_active": false +} +``` + +--- + +## 繰り返し設定削除 + +``` +DELETE /api/v1/recurring/:id +``` + +### レスポンス + +**200 OK** diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md index f0f2290..33f4f3b 100644 --- a/docs/SPECIFICATION.md +++ b/docs/SPECIFICATION.md @@ -79,7 +79,33 @@ homework-manager/ | UpdatedAt | time.Time | 更新日時 | 自動更新 | | DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート | -### 2.3 UserNotificationSettings(通知設定) + +### 2.3 RecurringAssignment(繰り返し課題) + +繰り返し課題の設定を管理するモデル。 + +| フィールド | 型 | 説明 | 制約 | +|------------|------|------|------| +| ID | uint | 設定ID | Primary Key | +| UserID | uint | 所有ユーザーID | Not Null, Index | +| Title | string | 課題タイトル | Not Null | +| Description | string | 説明 | - | +| Subject | string | 教科・科目 | - | +| Priority | string | 重要度 | Default: `medium` | +| RecurrenceType | string | 繰り返しタイプ (`daily`, `weekly`, `monthly`) | Not Null | +| RecurrenceInterval | int | 繰り返し間隔 | Default: 1 | +| RecurrenceWeekday | *int | 曜日 (0-6, 日-土) | Nullable | +| RecurrenceDay | *int | 日 (1-31) | Nullable | +| DueTime | string | 締切時刻 (HH:MM) | Not Null | +| EndType | string | 終了条件 (`never`, `count`, `date`) | Default: `never` | +| EndCount | *int | 終了回数 | Nullable | +| EndDate | *time.Time | 終了日 | Nullable | +| IsActive | bool | 有効フラグ | Default: true | +| CreatedAt | time.Time | 作成日時 | 自動設定 | +| UpdatedAt | time.Time | 更新日時 | 自動更新 | +| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート | + +### 2.4 UserNotificationSettings(通知設定) ユーザーの通知設定を管理するモデル。 @@ -95,7 +121,7 @@ homework-manager/ | UpdatedAt | time.Time | 更新日時 | 自動更新 | | DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート | -### 2.4 APIKey(APIキー) +### 2.5 APIKey(APIキー) REST API認証用のAPIキーを管理するモデル。 @@ -123,7 +149,7 @@ REST API認証用のAPIキーを管理するモデル。 ### 3.2 API認証 -- **APIキー認証**: `X-API-Key` ヘッダーで認証 +- **APIキー認証**: `Authorization: Bearer ` ヘッダーで認証 - **キー形式**: `hm_` プレフィックス + 32文字のランダム文字列 - **ハッシュ保存**: SHA-256でハッシュ化して保存 @@ -155,13 +181,26 @@ REST API認証用のAPIキーを管理するモデル。 | 課題一覧 | フィルタ付き(未完了/今日が期限/今週が期限/完了済み/期限切れ)で課題を一覧表示 | | 課題登録 | タイトル、説明、教科、重要度、提出期限、通知設定を入力して新規登録 | | 課題編集 | 既存の課題情報を編集 | -| 課題削除 | 課題を論理削除 | +| 課題削除 | 課題を論理削除(繰り返し課題に関連する場合、繰り返し設定ごと削除するか選択可能) | | 完了トグル | 課題の完了/未完了状態を切り替え | | 統計 | 科目別の完了率、期限内完了率等を表示 | -### 4.3 通知機能 +### 4.3 繰り返し課題機能 -#### 4.3.1 1回リマインダー +周期的に発生する課題を自動生成する機能。 + +| 機能 | 説明 | +|------|------| +| 繰り返し作成 | 課題登録時に繰り返し条件(毎日/毎週/毎月)を設定して作成 | +| 自動生成 | 未完了の課題がなくなったタイミングで、設定に基づき次回の課題を自動生成 | +| 繰り返し一覧 | 登録されている繰り返し設定を一覧表示 (`/recurring`) | +| 繰り返し編集 | 繰り返し設定の内容(タイトル、条件、時刻など)を編集 | +| 停止・再開 | 繰り返し設定を一時停止、または停止中の設定を再開 | +| 繰り返し削除 | 繰り返し設定を完全に削除 | + +### 4.4 通知機能 + +#### 4.4.1 1回リマインダー 指定した日時に1回だけ通知を送信する機能。 @@ -170,7 +209,7 @@ REST API認証用のAPIキーを管理するモデル。 | 設定 | 課題登録・編集画面で通知日時を指定 | | 送信 | 指定日時にTelegram/LINEで通知 | -#### 4.3.2 督促通知 +#### 4.4.2 督促通知 課題を完了するまで繰り返し通知を送信する機能。デフォルトで有効。 @@ -182,14 +221,14 @@ REST API認証用のAPIキーを管理するモデル。 | 重要度「小」 | **60分**ごとに通知 | | 停止条件 | 課題の完了ボタンを押すまで継続 | -#### 4.3.3 通知チャンネル +#### 4.4.3 通知チャンネル | チャンネル | 設定方法 | |------------|----------| | Telegram | config.iniでBot Token設定、プロフィールでChat ID入力 | | LINE Notify | プロフィールでアクセストークン入力 | -### 4.4 プロフィール機能 +### 4.5 プロフィール機能 | 機能 | 説明 | |------|------| @@ -198,7 +237,7 @@ REST API認証用のAPIキーを管理するモデル。 | パスワード変更 | 現在のパスワードを確認後、新しいパスワードに変更 | | 通知設定 | Telegram/LINE通知の有効化とトークン設定 | -### 4.4 管理者機能 +### 4.6 管理者機能 | 機能 | 説明 | |------|------| diff --git a/internal/handler/api_handler.go b/internal/handler/api_handler.go index 940bba2..8865c52 100644 --- a/internal/handler/api_handler.go +++ b/internal/handler/api_handler.go @@ -13,11 +13,13 @@ import ( type APIHandler struct { assignmentService *service.AssignmentService + recurringService *service.RecurringAssignmentService } func NewAPIHandler() *APIHandler { return &APIHandler{ assignmentService: service.NewAssignmentService(), + recurringService: service.NewRecurringAssignmentService(), } } @@ -26,17 +28,12 @@ func (h *APIHandler) getUserID(c *gin.Context) uint { return userID.(uint) } -// ListAssignments returns all assignments for the authenticated user with pagination -// GET /api/v1/assignments?filter=pending&page=1&page_size=20 func (h *APIHandler) ListAssignments(c *gin.Context) { userID := h.getUserID(c) filter := c.Query("filter") // pending, completed, overdue - - // Parse pagination parameters page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) - // Validate pagination parameters if page < 1 { page = 1 } @@ -44,10 +41,9 @@ func (h *APIHandler) ListAssignments(c *gin.Context) { pageSize = 20 } if pageSize > 100 { - pageSize = 100 // Maximum page size to prevent abuse + pageSize = 100 } - // Use paginated methods for filtered queries switch filter { case "completed": result, err := h.assignmentService.GetCompletedByUserPaginated(userID, page, pageSize) @@ -56,12 +52,12 @@ func (h *APIHandler) ListAssignments(c *gin.Context) { return } c.JSON(http.StatusOK, gin.H{ - "assignments": result.Assignments, - "count": len(result.Assignments), - "total_count": result.TotalCount, - "total_pages": result.TotalPages, - "current_page": result.CurrentPage, - "page_size": result.PageSize, + "assignments": result.Assignments, + "count": len(result.Assignments), + "total_count": result.TotalCount, + "total_pages": result.TotalPages, + "current_page": result.CurrentPage, + "page_size": result.PageSize, }) return case "overdue": @@ -71,12 +67,12 @@ func (h *APIHandler) ListAssignments(c *gin.Context) { return } c.JSON(http.StatusOK, gin.H{ - "assignments": result.Assignments, - "count": len(result.Assignments), - "total_count": result.TotalCount, - "total_pages": result.TotalPages, - "current_page": result.CurrentPage, - "page_size": result.PageSize, + "assignments": result.Assignments, + "count": len(result.Assignments), + "total_count": result.TotalCount, + "total_pages": result.TotalPages, + "current_page": result.CurrentPage, + "page_size": result.PageSize, }) return case "pending": @@ -86,23 +82,21 @@ func (h *APIHandler) ListAssignments(c *gin.Context) { return } c.JSON(http.StatusOK, gin.H{ - "assignments": result.Assignments, - "count": len(result.Assignments), - "total_count": result.TotalCount, - "total_pages": result.TotalPages, - "current_page": result.CurrentPage, - "page_size": result.PageSize, + "assignments": result.Assignments, + "count": len(result.Assignments), + "total_count": result.TotalCount, + "total_pages": result.TotalPages, + "current_page": result.CurrentPage, + "page_size": result.PageSize, }) return default: - // For "all" filter, use simple pagination without a dedicated method assignments, err := h.assignmentService.GetAllByUser(userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"}) return } - // Manual pagination for all assignments totalCount := len(assignments) totalPages := (totalCount + pageSize - 1) / pageSize start := (page - 1) * pageSize @@ -115,18 +109,16 @@ func (h *APIHandler) ListAssignments(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "assignments": assignments[start:end], - "count": end - start, - "total_count": totalCount, - "total_pages": totalPages, - "current_page": page, - "page_size": pageSize, + "assignments": assignments[start:end], + "count": end - start, + "total_count": totalCount, + "total_pages": totalPages, + "current_page": page, + "page_size": pageSize, }) } } -// ListPendingAssignments returns pending assignments with pagination -// GET /api/v1/assignments/pending?page=1&page_size=20 func (h *APIHandler) ListPendingAssignments(c *gin.Context) { userID := h.getUserID(c) page, pageSize := h.parsePagination(c) @@ -140,8 +132,6 @@ func (h *APIHandler) ListPendingAssignments(c *gin.Context) { h.sendPaginatedResponse(c, result) } -// ListCompletedAssignments returns completed assignments with pagination -// GET /api/v1/assignments/completed?page=1&page_size=20 func (h *APIHandler) ListCompletedAssignments(c *gin.Context) { userID := h.getUserID(c) page, pageSize := h.parsePagination(c) @@ -155,8 +145,6 @@ func (h *APIHandler) ListCompletedAssignments(c *gin.Context) { h.sendPaginatedResponse(c, result) } -// ListOverdueAssignments returns overdue assignments with pagination -// GET /api/v1/assignments/overdue?page=1&page_size=20 func (h *APIHandler) ListOverdueAssignments(c *gin.Context) { userID := h.getUserID(c) page, pageSize := h.parsePagination(c) @@ -170,8 +158,6 @@ func (h *APIHandler) ListOverdueAssignments(c *gin.Context) { h.sendPaginatedResponse(c, result) } -// ListDueTodayAssignments returns assignments due today -// GET /api/v1/assignments/due-today func (h *APIHandler) ListDueTodayAssignments(c *gin.Context) { userID := h.getUserID(c) @@ -187,8 +173,6 @@ func (h *APIHandler) ListDueTodayAssignments(c *gin.Context) { }) } -// ListDueThisWeekAssignments returns assignments due within this week -// GET /api/v1/assignments/due-this-week func (h *APIHandler) ListDueThisWeekAssignments(c *gin.Context) { userID := h.getUserID(c) @@ -204,7 +188,6 @@ func (h *APIHandler) ListDueThisWeekAssignments(c *gin.Context) { }) } -// parsePagination extracts and validates pagination parameters func (h *APIHandler) parsePagination(c *gin.Context) (page int, pageSize int) { page, _ = strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ = strconv.Atoi(c.DefaultQuery("page_size", "20")) @@ -221,7 +204,6 @@ func (h *APIHandler) parsePagination(c *gin.Context) (page int, pageSize int) { return page, pageSize } -// sendPaginatedResponse sends a standard paginated JSON response func (h *APIHandler) sendPaginatedResponse(c *gin.Context, result *service.PaginatedResult) { c.JSON(http.StatusOK, gin.H{ "assignments": result.Assignments, @@ -233,8 +215,6 @@ func (h *APIHandler) sendPaginatedResponse(c *gin.Context, result *service.Pagin }) } -// GetAssignment returns a single assignment by ID -// GET /api/v1/assignments/:id func (h *APIHandler) GetAssignment(c *gin.Context) { userID := h.getUserID(c) id, err := strconv.ParseUint(c.Param("id"), 10, 32) @@ -252,40 +232,121 @@ func (h *APIHandler) GetAssignment(c *gin.Context) { c.JSON(http.StatusOK, assignment) } -// CreateAssignmentInput represents the JSON input for creating an assignment type CreateAssignmentInput struct { Title string `json:"title" binding:"required"` Description string `json:"description"` Subject string `json:"subject"` - Priority string `json:"priority"` // low, medium, high (default: medium) - DueDate string `json:"due_date" binding:"required"` // RFC3339 or 2006-01-02T15:04 + Priority string `json:"priority"` + DueDate string `json:"due_date" binding:"required"` + + ReminderEnabled bool `json:"reminder_enabled"` + ReminderAt string `json:"reminder_at"` + UrgentReminderEnabled *bool `json:"urgent_reminder_enabled"` + Recurrence struct { + Type string `json:"type"` + Interval int `json:"interval"` + Weekday interface{} `json:"weekday"` + Day interface{} `json:"day"` + Until struct { + Type string `json:"type"` + Count int `json:"count"` + Date string `json:"date"` + } `json:"until"` + } `json:"recurrence"` } -// CreateAssignment creates a new assignment -// POST /api/v1/assignments func (h *APIHandler) CreateAssignment(c *gin.Context) { userID := h.getUserID(c) var input CreateAssignmentInput if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input: title and due_date are required"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input: " + err.Error()}) return } - dueDate, err := time.Parse(time.RFC3339, input.DueDate) + dueDate, err := parseDateString(input.DueDate) if err != nil { - dueDate, err = time.ParseInLocation("2006-01-02T15:04", input.DueDate, time.Local) - if err != nil { - dueDate, err = time.ParseInLocation("2006-01-02", input.DueDate, time.Local) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format. Use RFC3339 or 2006-01-02T15:04"}) - return - } - dueDate = dueDate.Add(23*time.Hour + 59*time.Minute) - } + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format. Use RFC3339 or 2006-01-02T15:04"}) + return } - assignment, err := h.assignmentService.Create(userID, input.Title, input.Description, input.Subject, input.Priority, dueDate, false, nil, true) + var reminderAt *time.Time + if input.ReminderEnabled && input.ReminderAt != "" { + reminderTime, err := parseDateString(input.ReminderAt) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid reminder_at format"}) + return + } + reminderAt = &reminderTime + } + + urgentReminder := true + if input.UrgentReminderEnabled != nil { + urgentReminder = *input.UrgentReminderEnabled + } + + if input.Recurrence.Type != "" && input.Recurrence.Type != "none" { + serviceInput := service.CreateRecurringAssignmentInput{ + Title: input.Title, + Description: input.Description, + Subject: input.Subject, + Priority: input.Priority, + FirstDueDate: dueDate, + DueTime: dueDate.Format("15:04"), + RecurrenceType: input.Recurrence.Type, + RecurrenceInterval: input.Recurrence.Interval, + ReminderEnabled: input.ReminderEnabled, + ReminderOffset: nil, + UrgentReminderEnabled: urgentReminder, + } + + if serviceInput.RecurrenceInterval < 1 { + serviceInput.RecurrenceInterval = 1 + } + + if input.Recurrence.Weekday != nil { + if wd, ok := input.Recurrence.Weekday.(float64); ok { + wdInt := int(wd) + serviceInput.RecurrenceWeekday = &wdInt + } + } + + if input.Recurrence.Day != nil { + if d, ok := input.Recurrence.Day.(float64); ok { + dInt := int(d) + serviceInput.RecurrenceDay = &dInt + } + } + + serviceInput.EndType = input.Recurrence.Until.Type + if serviceInput.EndType == "" { + serviceInput.EndType = "never" + } + + if serviceInput.EndType == "count" { + count := input.Recurrence.Until.Count + serviceInput.EndCount = &count + } else if serviceInput.EndType == "date" && input.Recurrence.Until.Date != "" { + endDate, err := parseDateString(input.Recurrence.Until.Date) + if err == nil { + serviceInput.EndDate = &endDate + } + } + + recurring, err := h.recurringService.Create(userID, serviceInput) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create recurring assignment: " + err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Recurring assignment created", + "recurring_assignment": recurring, + }) + return + } + + assignment, err := h.assignmentService.Create(userID, input.Title, input.Description, input.Subject, input.Priority, dueDate, input.ReminderEnabled, reminderAt, urgentReminder) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create assignment"}) return @@ -294,17 +355,17 @@ func (h *APIHandler) CreateAssignment(c *gin.Context) { c.JSON(http.StatusCreated, assignment) } -// UpdateAssignmentInput represents the JSON input for updating an assignment type UpdateAssignmentInput struct { - Title string `json:"title"` - Description string `json:"description"` - Subject string `json:"subject"` - Priority string `json:"priority"` - DueDate string `json:"due_date"` + Title string `json:"title"` + Description string `json:"description"` + Subject string `json:"subject"` + Priority string `json:"priority"` + DueDate string `json:"due_date"` + ReminderEnabled *bool `json:"reminder_enabled"` + ReminderAt string `json:"reminder_at"` + UrgentReminderEnabled *bool `json:"urgent_reminder_enabled"` } -// UpdateAssignment updates an existing assignment -// PUT /api/v1/assignments/:id func (h *APIHandler) UpdateAssignment(c *gin.Context) { userID := h.getUserID(c) id, err := strconv.ParseUint(c.Param("id"), 10, 32) @@ -313,7 +374,6 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) { return } - // Get existing assignment existing, err := h.assignmentService.GetByID(userID, uint(id)) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"}) @@ -326,14 +386,21 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) { return } - // Use existing values if not provided title := input.Title if title == "" { title = existing.Title } description := input.Description + if description == "" { + description = existing.Description + } + subject := input.Subject + if subject == "" { + subject = existing.Subject + } + priority := input.Priority if priority == "" { priority = existing.Priority @@ -341,18 +408,36 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) { dueDate := existing.DueDate if input.DueDate != "" { - dueDate, err = time.Parse(time.RFC3339, input.DueDate) + parsedDate, err := parseDateString(input.DueDate) if err != nil { - dueDate, err = time.ParseInLocation("2006-01-02T15:04", input.DueDate, time.Local) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format"}) - return - } + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format"}) + return } + dueDate = parsedDate } - // 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) + reminderEnabled := existing.ReminderEnabled + if input.ReminderEnabled != nil { + reminderEnabled = *input.ReminderEnabled + } + + reminderAt := existing.ReminderAt + if input.ReminderAt != "" { + parsedReminderAt, err := parseDateString(input.ReminderAt) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid reminder_at format"}) + return + } + reminderAt = &parsedReminderAt + } else if input.ReminderEnabled != nil && !*input.ReminderEnabled { + } + + urgentReminderEnabled := existing.UrgentReminderEnabled + if input.UrgentReminderEnabled != nil { + urgentReminderEnabled = *input.UrgentReminderEnabled + } + + assignment, err := h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update assignment"}) return @@ -361,8 +446,6 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) { c.JSON(http.StatusOK, assignment) } -// DeleteAssignment deletes an assignment -// DELETE /api/v1/assignments/:id func (h *APIHandler) DeleteAssignment(c *gin.Context) { userID := h.getUserID(c) id, err := strconv.ParseUint(c.Param("id"), 10, 32) @@ -371,6 +454,26 @@ func (h *APIHandler) DeleteAssignment(c *gin.Context) { return } + deleteRecurring := c.Query("delete_recurring") == "true" + + if deleteRecurring { + + assignment, err := h.assignmentService.GetByID(userID, uint(id)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"}) + return + } + + if assignment.RecurringAssignmentID != nil { + if err := h.recurringService.Delete(userID, *assignment.RecurringAssignmentID, false); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete recurring assignment"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Assignment and recurring settings deleted"}) + return + } + } + if err := h.assignmentService.Delete(userID, uint(id)); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"}) return @@ -379,8 +482,6 @@ func (h *APIHandler) DeleteAssignment(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Assignment deleted"}) } -// ToggleAssignment toggles the completion status of an assignment -// PATCH /api/v1/assignments/:id/toggle func (h *APIHandler) ToggleAssignment(c *gin.Context) { userID := h.getUserID(c) id, err := strconv.ParseUint(c.Param("id"), 10, 32) @@ -398,18 +499,14 @@ 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 { @@ -419,7 +516,6 @@ func (h *APIHandler) GetStatistics(c *gin.Context) { 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 { @@ -438,3 +534,18 @@ func (h *APIHandler) GetStatistics(c *gin.Context) { c.JSON(http.StatusOK, stats) } +func parseDateString(dateStr string) (time.Time, error) { + t, err := time.Parse(time.RFC3339, dateStr) + if err == nil { + return t, nil + } + t, err = time.ParseInLocation("2006-01-02T15:04", dateStr, time.Local) + if err == nil { + return t, nil + } + t, err = time.ParseInLocation("2006-01-02", dateStr, time.Local) + if err == nil { + return t.Add(23*time.Hour + 59*time.Minute), nil + } + return time.Time{}, err +} diff --git a/internal/handler/api_recurring_handler.go b/internal/handler/api_recurring_handler.go new file mode 100644 index 0000000..02bd270 --- /dev/null +++ b/internal/handler/api_recurring_handler.go @@ -0,0 +1,167 @@ +package handler + +import ( + "net/http" + "strconv" + + "homework-manager/internal/middleware" + "homework-manager/internal/models" + "homework-manager/internal/service" + + "github.com/gin-gonic/gin" +) + +type APIRecurringHandler struct { + recurringService *service.RecurringAssignmentService +} + +func NewAPIRecurringHandler() *APIRecurringHandler { + return &APIRecurringHandler{ + recurringService: service.NewRecurringAssignmentService(), + } +} + +func (h *APIRecurringHandler) getUserID(c *gin.Context) uint { + userID, _ := c.Get(middleware.UserIDKey) + return userID.(uint) +} + +func (h *APIRecurringHandler) ListRecurring(c *gin.Context) { + userID := h.getUserID(c) + + recurringList, err := h.recurringService.GetAllByUser(userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch recurring assignments"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "recurring_assignments": recurringList, + "count": len(recurringList), + }) +} + +func (h *APIRecurringHandler) GetRecurring(c *gin.Context) { + userID := h.getUserID(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"}) + return + } + + recurring, err := h.recurringService.GetByID(userID, uint(id)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Recurring assignment not found"}) + return + } + + c.JSON(http.StatusOK, recurring) +} + +type UpdateRecurringAPIInput struct { + Title *string `json:"title"` + Description *string `json:"description"` + Subject *string `json:"subject"` + Priority *string `json:"priority"` + RecurrenceType *string `json:"recurrence_type"` + RecurrenceInterval *int `json:"recurrence_interval"` + RecurrenceWeekday *int `json:"recurrence_weekday"` + RecurrenceDay *int `json:"recurrence_day"` + DueTime *string `json:"due_time"` + EndType *string `json:"end_type"` + EndCount *int `json:"end_count"` + EndDate *string `json:"end_date"` // YYYY-MM-DD + IsActive *bool `json:"is_active"` // To stop/resume + ReminderEnabled *bool `json:"reminder_enabled"` + ReminderOffset *int `json:"reminder_offset"` + UrgentReminderEnabled *bool `json:"urgent_reminder_enabled"` + EditBehavior string `json:"edit_behavior"` // this_only, this_and_future, all (default: this_only) +} + +func (h *APIRecurringHandler) UpdateRecurring(c *gin.Context) { + userID := h.getUserID(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"}) + return + } + + var input UpdateRecurringAPIInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input: " + err.Error()}) + return + } + + existing, err := h.recurringService.GetByID(userID, uint(id)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Recurring assignment not found"}) + return + } + + if input.IsActive != nil { + if err := h.recurringService.SetActive(userID, uint(id), *input.IsActive); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update active status"}) + return + } + existing.IsActive = *input.IsActive + } + + serviceInput := service.UpdateRecurringInput{ + Title: input.Title, + Description: input.Description, + Subject: input.Subject, + Priority: input.Priority, + RecurrenceType: input.RecurrenceType, + RecurrenceInterval: input.RecurrenceInterval, + RecurrenceWeekday: input.RecurrenceWeekday, + RecurrenceDay: input.RecurrenceDay, + DueTime: input.DueTime, + EndType: input.EndType, + EndCount: input.EndCount, + EditBehavior: input.EditBehavior, + ReminderEnabled: input.ReminderEnabled, + ReminderOffset: input.ReminderOffset, + UrgentReminderEnabled: input.UrgentReminderEnabled, + } + + if input.EndDate != nil && *input.EndDate != "" { + endDate, err := parseDateString(*input.EndDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end_date format"}) + return + } + serviceInput.EndDate = &endDate + } + + if serviceInput.EditBehavior == "" { + serviceInput.EditBehavior = models.EditBehaviorThisOnly + } + + updated, err := h.recurringService.Update(userID, uint(id), serviceInput) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update recurring assignment"}) + return + } + + updated.IsActive = existing.IsActive + + c.JSON(http.StatusOK, updated) +} + +func (h *APIRecurringHandler) DeleteRecurring(c *gin.Context) { + userID := h.getUserID(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"}) + return + } + + err = h.recurringService.Delete(userID, uint(id), false) + + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Recurring assignment not found or failed to delete"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Recurring assignment deleted"}) +} diff --git a/internal/handler/assignment_handler.go b/internal/handler/assignment_handler.go index 8bdc272..cfe45b9 100644 --- a/internal/handler/assignment_handler.go +++ b/internal/handler/assignment_handler.go @@ -16,12 +16,14 @@ import ( type AssignmentHandler struct { assignmentService *service.AssignmentService notificationService *service.NotificationService + recurringService *service.RecurringAssignmentService } func NewAssignmentHandler(notificationService *service.NotificationService) *AssignmentHandler { return &AssignmentHandler{ assignmentService: service.NewAssignmentService(), notificationService: notificationService, + recurringService: service.NewRecurringAssignmentService(), } } @@ -104,11 +106,14 @@ func (h *AssignmentHandler) Index(c *gin.Context) { func (h *AssignmentHandler) New(c *gin.Context) { role, _ := c.Get(middleware.UserRoleKey) name, _ := c.Get(middleware.UserNameKey) + now := time.Now() RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{ - "title": "課題登録", - "isAdmin": role == "admin", - "userName": name, + "title": "課題登録", + "isAdmin": role == "admin", + "userName": name, + "currentWeekday": int(now.Weekday()), + "currentDay": now.Day(), }) } @@ -196,7 +201,7 @@ func (h *AssignmentHandler) Create(c *gin.Context) { dueTime := dueDate.Format("15:04") recurringService := service.NewRecurringAssignmentService() - input := service.CreateRecurringInput{ + input := service.CreateRecurringAssignmentInput{ Title: title, Description: description, Subject: subject, @@ -266,12 +271,18 @@ func (h *AssignmentHandler) Edit(c *gin.Context) { return } + var recurring *models.RecurringAssignment + if assignment.RecurringAssignmentID != nil { + recurring, _ = h.recurringService.GetByID(userID, *assignment.RecurringAssignmentID) + } + role, _ := c.Get(middleware.UserRoleKey) name, _ := c.Get(middleware.UserNameKey) RenderHTML(c, http.StatusOK, "assignments/edit.html", gin.H{ "title": "課題編集", "assignment": assignment, + "recurring": recurring, "isAdmin": role == "admin", "userName": name, }) @@ -333,6 +344,14 @@ func (h *AssignmentHandler) Delete(c *gin.Context) { userID := h.getUserID(c) id, _ := strconv.ParseUint(c.Param("id"), 10, 32) + deleteRecurringStr := c.Query("stop_recurring") + if deleteRecurringStr != "" { + recurringID, err := strconv.ParseUint(deleteRecurringStr, 10, 32) + if err == nil { + h.recurringService.Delete(userID, uint(recurringID), false) + } + } + h.assignmentService.Delete(userID, uint(id)) c.Redirect(http.StatusFound, "/assignments") @@ -417,3 +436,162 @@ func (h *AssignmentHandler) UnarchiveSubject(c *gin.Context) { c.Redirect(http.StatusFound, "/statistics?include_archived=true") } + +func (h *AssignmentHandler) StopRecurring(c *gin.Context) { + userID := h.getUserID(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.Redirect(http.StatusFound, "/assignments") + return + } + + h.recurringService.SetActive(userID, uint(id), false) + c.Redirect(http.StatusFound, "/assignments") +} + +func (h *AssignmentHandler) ResumeRecurring(c *gin.Context) { + userID := h.getUserID(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.Redirect(http.StatusFound, "/assignments") + return + } + + h.recurringService.SetActive(userID, uint(id), true) + referer := c.Request.Referer() + if referer == "" { + referer = "/assignments" + } + c.Redirect(http.StatusFound, referer) +} + +func (h *AssignmentHandler) ListRecurring(c *gin.Context) { + userID := h.getUserID(c) + role, _ := c.Get(middleware.UserRoleKey) + name, _ := c.Get(middleware.UserNameKey) + + recurrings, err := h.recurringService.GetAllByUser(userID) + if err != nil { + recurrings = []models.RecurringAssignment{} + } + + RenderHTML(c, http.StatusOK, "recurring/index.html", gin.H{ + "title": "繰り返し設定一覧", + "recurrings": recurrings, + "isAdmin": role == "admin", + "userName": name, + }) +} + +func (h *AssignmentHandler) EditRecurring(c *gin.Context) { + userID := h.getUserID(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.Redirect(http.StatusFound, "/assignments") + return + } + + recurring, err := h.recurringService.GetByID(userID, uint(id)) + if err != nil { + c.Redirect(http.StatusFound, "/assignments") + return + } + + role, _ := c.Get(middleware.UserRoleKey) + name, _ := c.Get(middleware.UserNameKey) + + RenderHTML(c, http.StatusOK, "recurring/edit.html", gin.H{ + "title": "繰り返し課題の編集", + "recurring": recurring, + "isAdmin": role == "admin", + "userName": name, + }) +} + +func (h *AssignmentHandler) UpdateRecurring(c *gin.Context) { + userID := h.getUserID(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.Redirect(http.StatusFound, "/assignments") + return + } + + title := c.PostForm("title") + description := c.PostForm("description") + subject := c.PostForm("subject") + priority := c.PostForm("priority") + recurrenceType := c.PostForm("recurrence_type") + dueTime := c.PostForm("due_time") + editBehavior := c.PostForm("edit_behavior") + + recurrenceInterval := 1 + if v, err := strconv.Atoi(c.PostForm("recurrence_interval")); err == nil && v > 0 { + recurrenceInterval = v + } + + var recurrenceWeekday *int + if wd := c.PostForm("recurrence_weekday"); wd != "" { + if v, err := strconv.Atoi(wd); err == nil && v >= 0 && v <= 6 { + recurrenceWeekday = &v + } + } + + var recurrenceDay *int + if d := c.PostForm("recurrence_day"); d != "" { + if v, err := strconv.Atoi(d); err == nil && v >= 1 && v <= 31 { + recurrenceDay = &v + } + } + + endType := c.PostForm("end_type") + var endCount *int + if ec := c.PostForm("end_count"); ec != "" { + if v, err := strconv.Atoi(ec); err == nil && v > 0 { + endCount = &v + } + } + + var endDate *time.Time + if ed := c.PostForm("end_date"); ed != "" { + if v, err := time.ParseInLocation("2006-01-02", ed, time.Local); err == nil { + endDate = &v + } + } + + input := service.UpdateRecurringInput{ + Title: &title, + Description: &description, + Subject: &subject, + Priority: &priority, + RecurrenceType: &recurrenceType, + RecurrenceInterval: &recurrenceInterval, + RecurrenceWeekday: recurrenceWeekday, + RecurrenceDay: recurrenceDay, + DueTime: &dueTime, + EndType: &endType, + EndCount: endCount, + EndDate: endDate, + EditBehavior: editBehavior, + } + + _, err = h.recurringService.Update(userID, uint(id), input) + if err != nil { + c.Redirect(http.StatusFound, "/recurring/"+c.Param("id")+"/edit") + return + } + + c.Redirect(http.StatusFound, "/assignments") +} + +func (h *AssignmentHandler) DeleteRecurring(c *gin.Context) { + userID := h.getUserID(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.Redirect(http.StatusFound, "/assignments") + return + } + + h.recurringService.Delete(userID, uint(id), false) + + c.Redirect(http.StatusFound, "/assignments") +} diff --git a/internal/repository/assignment_repository.go b/internal/repository/assignment_repository.go index fb50d7b..51e0beb 100644 --- a/internal/repository/assignment_repository.go +++ b/internal/repository/assignment_repository.go @@ -346,3 +346,59 @@ func (r *AssignmentRepository) GetSubjectsByUserIDWithArchived(userID uint, incl err := query.Distinct("subject").Pluck("subject", &subjects).Error return subjects, err } + +func (r *AssignmentRepository) SearchWithPreload(userID uint, queryStr, priority, filter string, page, pageSize int) ([]models.Assignment, int64, error) { + var assignments []models.Assignment + var totalCount int64 + + dbQuery := r.db.Model(&models.Assignment{}).Where("user_id = ?", userID) + + if queryStr != "" { + dbQuery = dbQuery.Where("title LIKE ? OR description LIKE ?", "%"+queryStr+"%", "%"+queryStr+"%") + } + + if priority != "" { + dbQuery = dbQuery.Where("priority = ?", priority) + } + + now := time.Now() + startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + endOfDay := startOfDay.AddDate(0, 0, 1) + weekLater := startOfDay.AddDate(0, 0, 7) + + switch filter { + case "completed": + dbQuery = dbQuery.Where("is_completed = ?", true) + case "overdue": + dbQuery = dbQuery.Where("is_completed = ? AND due_date < ?", false, now) + case "due_today": + dbQuery = dbQuery.Where("is_completed = ? AND due_date >= ? AND due_date < ?", false, startOfDay, endOfDay) + case "due_this_week": + dbQuery = dbQuery.Where("is_completed = ? AND due_date >= ? AND due_date < ?", false, startOfDay, weekLater) + case "recurring": + dbQuery = dbQuery.Where("recurring_assignment_id IS NOT NULL") + default: + dbQuery = dbQuery.Where("is_completed = ?", false) + } + + if err := dbQuery.Count(&totalCount).Error; err != nil { + return nil, 0, err + } + + if filter == "completed" { + dbQuery = dbQuery.Order("completed_at DESC") + } else { + dbQuery = dbQuery.Order("due_date ASC") + } + + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 10 + } + offset := (page - 1) * pageSize + + err := dbQuery.Preload("RecurringAssignment").Limit(pageSize).Offset(offset).Find(&assignments).Error + return assignments, totalCount, err +} diff --git a/internal/router/router.go b/internal/router/router.go index cb37715..5d4e469 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -48,6 +48,19 @@ func getFuncMap() template.FuncMap { "recurringLabel": service.GetRecurrenceTypeLabel, "endTypeLabel": service.GetEndTypeLabel, "recurringSummary": service.FormatRecurringSummary, + "derefInt": func(i *int) int { + if i == nil { + return 0 + } + return *i + }, + "seq": func(start, end int) []int { + var result []int + for i := start; i <= end; i++ { + result = append(result, i) + } + return result + }, } } @@ -67,6 +80,7 @@ func loadTemplates() (*template.Template, error) { {"web/templates/auth/*.html", ""}, {"web/templates/pages/*.html", ""}, {"web/templates/assignments/*.html", "assignments/"}, + {"web/templates/recurring/*.html", "recurring/"}, {"web/templates/admin/*.html", "admin/"}, } @@ -187,6 +201,7 @@ func Setup(cfg *config.Config) *gin.Engine { adminHandler := handler.NewAdminHandler() profileHandler := handler.NewProfileHandler(notificationService) apiHandler := handler.NewAPIHandler() + apiRecurringHandler := handler.NewAPIRecurringHandler() guest := r.Group("/") guest.Use(middleware.GuestOnly()) @@ -226,6 +241,13 @@ func Setup(cfg *config.Config) *gin.Engine { auth.POST("/statistics/archive-subject", assignmentHandler.ArchiveSubject) auth.POST("/statistics/unarchive-subject", assignmentHandler.UnarchiveSubject) + auth.POST("/recurring/:id/stop", assignmentHandler.StopRecurring) + auth.POST("/recurring/:id/resume", assignmentHandler.ResumeRecurring) + auth.POST("/recurring/:id/delete", assignmentHandler.DeleteRecurring) + auth.GET("/recurring", assignmentHandler.ListRecurring) + auth.GET("/recurring/:id/edit", assignmentHandler.EditRecurring) + auth.POST("/recurring/:id", assignmentHandler.UpdateRecurring) + auth.GET("/profile", profileHandler.Show) auth.POST("/profile", profileHandler.Update) auth.POST("/profile/password", profileHandler.ChangePassword) @@ -258,7 +280,13 @@ 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) + + api.GET("/recurring", apiRecurringHandler.ListRecurring) + api.GET("/recurring/:id", apiRecurringHandler.GetRecurring) + api.PUT("/recurring/:id", apiRecurringHandler.UpdateRecurring) + api.DELETE("/recurring/:id", apiRecurringHandler.DeleteRecurring) } return r diff --git a/internal/service/assignment_service.go b/internal/service/assignment_service.go index ac2b0b4..65b02c2 100644 --- a/internal/service/assignment_service.go +++ b/internal/service/assignment_service.go @@ -179,7 +179,7 @@ func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filt pageSize = 10 } - assignments, totalCount, err := s.assignmentRepo.Search(userID, query, priority, filter, page, pageSize) + assignments, totalCount, err := s.assignmentRepo.SearchWithPreload(userID, query, priority, filter, page, pageSize) if err != nil { return nil, err } @@ -255,11 +255,11 @@ func (s *AssignmentService) GetSubjectsByUser(userID uint) ([]string, error) { } type DashboardStats struct { - TotalPending int64 - DueToday int - DueThisWeek int - Overdue int - Subjects []string + TotalPending int64 + DueToday int + DueThisWeek int + Overdue int + Subjects []string } func (s *AssignmentService) GetDashboardStats(userID uint) (*DashboardStats, error) { @@ -270,11 +270,11 @@ func (s *AssignmentService) GetDashboardStats(userID uint) (*DashboardStats, err subjects, _ := s.assignmentRepo.GetSubjectsByUserID(userID) return &DashboardStats{ - TotalPending: pending, - DueToday: len(dueToday), - DueThisWeek: len(dueThisWeek), - Overdue: int(overdueCount), - Subjects: subjects, + TotalPending: pending, + DueToday: len(dueToday), + DueThisWeek: len(dueThisWeek), + Overdue: int(overdueCount), + Subjects: subjects, }, nil } @@ -392,4 +392,3 @@ func (s *AssignmentService) GetSubjectsWithArchived(userID uint, includeArchived func (s *AssignmentService) GetArchivedSubjects(userID uint) ([]string, error) { return s.assignmentRepo.GetArchivedSubjects(userID) } - diff --git a/internal/service/recurring_assignment_service.go b/internal/service/recurring_assignment_service.go index b3c7ab9..19270cb 100644 --- a/internal/service/recurring_assignment_service.go +++ b/internal/service/recurring_assignment_service.go @@ -30,7 +30,7 @@ func NewRecurringAssignmentService() *RecurringAssignmentService { } } -type CreateRecurringInput struct { +type CreateRecurringAssignmentInput struct { Title string Description string Subject string @@ -50,7 +50,7 @@ type CreateRecurringInput struct { FirstDueDate time.Time } -func (s *RecurringAssignmentService) Create(userID uint, input CreateRecurringInput) (*models.RecurringAssignment, error) { +func (s *RecurringAssignmentService) Create(userID uint, input CreateRecurringAssignmentInput) (*models.RecurringAssignment, error) { if !isValidRecurrenceType(input.RecurrenceType) { return nil, ErrInvalidRecurrenceType } @@ -121,15 +121,22 @@ func (s *RecurringAssignmentService) GetActiveByUser(userID uint) ([]models.Recu } type UpdateRecurringInput struct { - Title string - Description string - Subject string - Priority string - DueTime string + Title *string + Description *string + Subject *string + Priority *string + RecurrenceType *string + RecurrenceInterval *int + RecurrenceWeekday *int + RecurrenceDay *int + DueTime *string + EndType *string + EndCount *int + EndDate *time.Time EditBehavior string - ReminderEnabled bool + ReminderEnabled *bool ReminderOffset *int - UrgentReminderEnabled bool + UrgentReminderEnabled *bool } func (s *RecurringAssignmentService) Update(userID, recurringID uint, input UpdateRecurringInput) (*models.RecurringAssignment, error) { @@ -138,19 +145,56 @@ func (s *RecurringAssignmentService) Update(userID, recurringID uint, input Upda return nil, err } - recurring.Title = input.Title - recurring.Description = input.Description - recurring.Subject = input.Subject - recurring.Priority = input.Priority - if input.DueTime != "" { - recurring.DueTime = input.DueTime + if input.Title != nil { + recurring.Title = *input.Title + } + if input.Description != nil { + recurring.Description = *input.Description + } + if input.Subject != nil { + recurring.Subject = *input.Subject + } + if input.Priority != nil { + recurring.Priority = *input.Priority + } + if input.DueTime != nil { + recurring.DueTime = *input.DueTime } if input.EditBehavior != "" { recurring.EditBehavior = input.EditBehavior } - recurring.ReminderEnabled = input.ReminderEnabled - recurring.ReminderOffset = input.ReminderOffset - recurring.UrgentReminderEnabled = input.UrgentReminderEnabled + if input.ReminderEnabled != nil { + recurring.ReminderEnabled = *input.ReminderEnabled + } + if input.ReminderOffset != nil { + recurring.ReminderOffset = input.ReminderOffset + } + if input.UrgentReminderEnabled != nil { + recurring.UrgentReminderEnabled = *input.UrgentReminderEnabled + } + + if input.RecurrenceType != nil && *input.RecurrenceType != "" && isValidRecurrenceType(*input.RecurrenceType) { + recurring.RecurrenceType = *input.RecurrenceType + } + if input.RecurrenceInterval != nil && *input.RecurrenceInterval > 0 { + recurring.RecurrenceInterval = *input.RecurrenceInterval + } + if input.RecurrenceWeekday != nil { + recurring.RecurrenceWeekday = input.RecurrenceWeekday + } + if input.RecurrenceDay != nil { + recurring.RecurrenceDay = input.RecurrenceDay + } + + if input.EndType != nil && isValidEndType(*input.EndType) { + recurring.EndType = *input.EndType + } + if input.EndCount != nil { + recurring.EndCount = input.EndCount + } + if input.EndDate != nil { + recurring.EndDate = input.EndDate + } if err := s.recurringRepo.Update(recurring); err != nil { return nil, err @@ -159,6 +203,16 @@ func (s *RecurringAssignmentService) Update(userID, recurringID uint, input Upda return recurring, nil } +func (s *RecurringAssignmentService) SetActive(userID, recurringID uint, isActive bool) error { + recurring, err := s.GetByID(userID, recurringID) + if err != nil { + return err + } + + recurring.IsActive = isActive + return s.recurringRepo.Update(recurring) +} + func (s *RecurringAssignmentService) UpdateAssignmentWithBehavior( userID uint, assignment *models.Assignment, diff --git a/web/static/js/app.js b/web/static/js/app.js index 97fb353..cff7f03 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -1,8 +1,8 @@ // Homework Manager JavaScript document.addEventListener('DOMContentLoaded', function() { - // Auto-dismiss alerts after 5 seconds - const alerts = document.querySelectorAll('.alert:not(.alert-danger)'); + // Auto-dismiss alerts after 5 seconds (exclude alerts inside modals) + const alerts = document.querySelectorAll('.alert:not(.alert-danger):not(.modal .alert)'); alerts.forEach(function(alert) { setTimeout(function() { alert.classList.add('fade'); diff --git a/web/templates/admin/api_keys.html b/web/templates/admin/api_keys.html index 9aa6291..e9dc4fa 100644 --- a/web/templates/admin/api_keys.html +++ b/web/templates/admin/api_keys.html @@ -98,6 +98,11 @@
  • PUT /api/v1/assignments/:id - 課題更新
  • DELETE /api/v1/assignments/:id - 課題削除
  • PATCH /api/v1/assignments/:id/toggle - 完了状態切替
  • +
  • GET /api/v1/statistics - 統計情報取得
  • +
  • GET /api/v1/recurring - 繰り返し設定一覧取得
  • +
  • GET /api/v1/recurring/:id - 繰り返し設定詳細取得
  • +
  • PUT /api/v1/recurring/:id - 繰り返し設定更新
  • +
  • DELETE /api/v1/recurring/:id - 繰り返し設定削除
  • diff --git a/web/templates/assignments/edit.html b/web/templates/assignments/edit.html index ca4f02a..d4bf8f5 100644 --- a/web/templates/assignments/edit.html +++ b/web/templates/assignments/edit.html @@ -76,6 +76,39 @@ + {{if .recurring}} + +
    +
    +
    +
    繰り返し設定
    + + 編集 + +
    +
    +
    + タイプ +
    + {{if eq .recurring.RecurrenceType "daily"}}毎日{{end}} + {{if eq .recurring.RecurrenceType "weekly"}}毎週{{end}} + {{if eq .recurring.RecurrenceType "monthly"}}毎月{{end}} +
    +
    +
    + 状態 +
    + {{if .recurring.IsActive}} + 有効 + {{else}} + 停止中 + {{end}} +
    +
    +
    +
    +
    + {{end}}
    キャンセル diff --git a/web/templates/assignments/index.html b/web/templates/assignments/index.html index 87cd58d..82ccaa7 100644 --- a/web/templates/assignments/index.html +++ b/web/templates/assignments/index.html @@ -56,6 +56,13 @@ 期限切れ + +
    @@ -92,9 +99,9 @@ 状態 - タイトル 科目 重要度 + タイトル 期限 残り 操作 @@ -125,9 +132,6 @@ {{end}} - -
    {{.Title}}
    - {{.Subject}} {{if eq .Priority "high"}} @@ -138,6 +142,22 @@ {{end}} + +
    +
    {{.Title}}
    + {{if .RecurringAssignmentID}} + + {{end}} +
    +
    {{.DueDate.Format "2006/01/02 15:04"}}
    @@ -154,6 +174,12 @@ + {{if .RecurringAssignmentID}} + + {{else}}
    @@ -162,6 +188,7 @@
    + {{end}}
    @@ -290,5 +317,126 @@ const btnText = document.getElementById('countdownBtnText'); if (btnText) btnText.textContent = 'カウントダウン非表示中'; } + + // Recurring modal handler - wait for DOM to be ready + document.addEventListener('DOMContentLoaded', function() { + const recurringModal = document.getElementById('recurringModal'); + if (recurringModal) { + recurringModal.addEventListener('show.bs.modal', function (event) { + const button = event.relatedTarget; + const id = button.getAttribute('data-recurring-id'); + const assignmentId = button.getAttribute('data-assignment-id'); + const title = button.getAttribute('data-recurring-title'); + const type = button.getAttribute('data-recurring-type'); + const isActive = button.getAttribute('data-recurring-active') === 'true'; + + document.getElementById('recurringModalTitle').textContent = title; + document.getElementById('recurringStopForm').action = '/recurring/' + id + '/stop'; + document.getElementById('recurringEditBtn').href = '/recurring/' + id + '/edit'; + + const typeLabels = { + 'daily': '毎日', + 'weekly': '毎週', + 'monthly': '毎月', + 'unknown': '(読み込み中...)' + }; + document.getElementById('recurringTypeLabel').textContent = typeLabels[type] || type || '不明'; + + + const statusEl = document.getElementById('recurringStatus'); + if (isActive) { + statusEl.innerHTML = '有効'; + document.getElementById('recurringStopBtn').style.display = 'inline-block'; + } else { + statusEl.innerHTML = '停止中'; + document.getElementById('recurringStopBtn').style.display = 'none'; + } + }); + } + }); + + + + + + + + + {{end}} \ No newline at end of file diff --git a/web/templates/assignments/new.html b/web/templates/assignments/new.html index a25ff49..fa1ab82 100644 --- a/web/templates/assignments/new.html +++ b/web/templates/assignments/new.html @@ -101,25 +101,25 @@
    + value="0" {{if eq .currentWeekday 0}}checked{{end}}> + value="1" {{if eq .currentWeekday 1}}checked{{end}}> + value="2" {{if eq .currentWeekday 2}}checked{{end}}> + value="3" {{if eq .currentWeekday 3}}checked{{end}}> + value="4" {{if eq .currentWeekday 4}}checked{{end}}> + value="5" {{if eq .currentWeekday 5}}checked{{end}}> + value="6" {{if eq .currentWeekday 6}}checked{{end}}>
    @@ -127,37 +127,9 @@