Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b10b90bf5 | |||
| b982c8acee | |||
| 30ba9510a6 | |||
| 041556786d | |||
| c87e71d1ac | |||
| f0712bddfe | |||
| 920928746e |
33
Caddyfile
Normal file
33
Caddyfile
Normal file
@@ -0,0 +1,33 @@
|
||||
# Caddyfile - Reverse Proxy Configuration
|
||||
#
|
||||
# 使用方法:
|
||||
# 1. example.com を実際のドメインに置き換えてください
|
||||
# 2. DNS の A レコードをこのサーバーの IP アドレスに向けてください
|
||||
# 3. docker compose up -d で起動すると、自動的に HTTPS 証明書が取得されます
|
||||
#
|
||||
# ローカル開発用の場合は、以下のように変更してください
|
||||
# :80 {
|
||||
# reverse_proxy app:8080
|
||||
# }
|
||||
|
||||
example.com {
|
||||
reverse_proxy app:8080
|
||||
|
||||
# ログ設定
|
||||
log {
|
||||
output file /data/access.log
|
||||
format json
|
||||
}
|
||||
|
||||
# セキュリティヘッダー
|
||||
header {
|
||||
X-Content-Type-Options nosniff
|
||||
X-Frame-Options DENY
|
||||
X-XSS-Protection "1; mode=block"
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
-Server
|
||||
}
|
||||
|
||||
# gzip 圧縮
|
||||
encode gzip
|
||||
}
|
||||
33
Dockerfile
33
Dockerfile
@@ -1,12 +1,8 @@
|
||||
# Builder stage
|
||||
FROM golang:1.24-alpine AS builder
|
||||
FROM golang:1.24-trixie AS builder
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install git if needed for fetching dependencies (sometimes needed even with go modules)
|
||||
# RUN apk add --no-cache git
|
||||
|
||||
# Copy go mod and sum files
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
@@ -21,12 +17,18 @@ COPY . .
|
||||
RUN CGO_ENABLED=0 go build -o server ./cmd/server/main.go
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:latest
|
||||
FROM debian:trixie-slim
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -r -u 1000 -s /bin/false appuser
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy binary from builder
|
||||
@@ -35,8 +37,21 @@ COPY --from=builder /app/server .
|
||||
# Copy web assets (templates, static files)
|
||||
COPY --from=builder /app/web ./web
|
||||
|
||||
# Expose port (adjust if your app uses a different port)
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /app/data && chown -R appuser:appuser /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER appuser
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Volume for persistent data
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/ || exit 1
|
||||
|
||||
# Run the application
|
||||
ENTRYPOINT ["./server"]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
## 特徴
|
||||
|
||||
- **課題管理**: 課題の登録、編集、削除、完了状況の管理
|
||||
- **繰り返し課題**: 日次・週次・月次の繰り返し課題を自動生成・管理
|
||||
- **ダッシュボード**: 期限切れ、本日期限、今週期限の課題をひと目で確認
|
||||
- **API対応**: 外部連携用のRESTful API (APIキー認証)
|
||||
- **セキュリティ**:
|
||||
|
||||
34
config.ini.docker.example
Normal file
34
config.ini.docker.example
Normal file
@@ -0,0 +1,34 @@
|
||||
; Homework Manager 設定ファイル (Docker用)
|
||||
; docker-compose.yml と一緒に使用してください
|
||||
; ローカル実行する場合securityセクションのhttpsをfalseに変更するのを忘れないでください!!
|
||||
|
||||
[server]
|
||||
port = 8080
|
||||
debug = false
|
||||
|
||||
[database]
|
||||
driver = mysql
|
||||
host = db
|
||||
port = 3306
|
||||
user = homework
|
||||
password = homework_password
|
||||
name = homework_manager
|
||||
|
||||
[session]
|
||||
; 本番環境では必ず変更してください
|
||||
secret = CHANGE_THIS_TO_A_SECURE_RANDOM_STRING
|
||||
|
||||
[auth]
|
||||
allow_registration = true
|
||||
|
||||
[security]
|
||||
https = true
|
||||
; こちらも本番環境では必ず変更してください
|
||||
csrf_secret = CHANGE_THIS_TO_A_SECURE_RANDOM_STRING
|
||||
rate_limit_enabled = true
|
||||
rate_limit_requests = 100
|
||||
rate_limit_window = 60
|
||||
trusted_proxies = 172.16.0.0/12
|
||||
|
||||
[notification]
|
||||
telegram_bot_token =
|
||||
@@ -1,11 +1,64 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8080"
|
||||
container_name: homework-manager
|
||||
volumes:
|
||||
- ./homework.db:/app/homework.db
|
||||
- ./config.ini:/app/config.ini
|
||||
- ./config.ini:/app/config.ini:ro
|
||||
environment:
|
||||
- TZ=Asia/Tokyo
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
expose:
|
||||
- "8080"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: mysql:8.0
|
||||
container_name: homework-db
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-homework_manager}
|
||||
MYSQL_USER: ${MYSQL_USER:-homework}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-homework_password}
|
||||
TZ: Asia/Tokyo
|
||||
volumes:
|
||||
- db-data:/var/lib/mysql
|
||||
networks:
|
||||
- internal
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-rootpassword}" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: homework-caddy
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
environment:
|
||||
- TZ=Asia/Tokyo
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
|
||||
144
docs/API.md
144
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 <api_key>"}` |
|
||||
| 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**
|
||||
|
||||
@@ -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 <API_KEY>` ヘッダーで認証
|
||||
- **キー形式**: `hm_` プレフィックス + 32文字のランダム文字列
|
||||
- **ハッシュ保存**: SHA-256でハッシュ化して保存
|
||||
|
||||
@@ -151,17 +177,30 @@ 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 管理者機能
|
||||
|
||||
| 機能 | 説明 |
|
||||
|------|------|
|
||||
|
||||
@@ -62,19 +62,13 @@ func Connect(dbConfig config.DatabaseConfig, debug bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set connection pool settings
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// SetMaxIdleConns sets the maximum number of connections in the idle connection pool.
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
|
||||
// SetMaxOpenConns sets the maximum number of open connections to the database.
|
||||
sqlDB.SetMaxOpenConns(100)
|
||||
|
||||
// SetConnMaxLifetime sets the maximum amount of time a connection may be reused.
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
DB = db
|
||||
@@ -85,6 +79,7 @@ func Migrate() error {
|
||||
return DB.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Assignment{},
|
||||
&models.RecurringAssignment{},
|
||||
&models.APIKey{},
|
||||
&models.UserNotificationSettings{},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
167
internal/handler/api_recurring_handler.go
Normal file
167
internal/handler/api_recurring_handler.go
Normal file
@@ -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"})
|
||||
}
|
||||
@@ -14,12 +14,16 @@ import (
|
||||
)
|
||||
|
||||
type AssignmentHandler struct {
|
||||
assignmentService *service.AssignmentService
|
||||
assignmentService *service.AssignmentService
|
||||
notificationService *service.NotificationService
|
||||
recurringService *service.RecurringAssignmentService
|
||||
}
|
||||
|
||||
func NewAssignmentHandler() *AssignmentHandler {
|
||||
func NewAssignmentHandler(notificationService *service.NotificationService) *AssignmentHandler {
|
||||
return &AssignmentHandler{
|
||||
assignmentService: service.NewAssignmentService(),
|
||||
assignmentService: service.NewAssignmentService(),
|
||||
notificationService: notificationService,
|
||||
recurringService: service.NewRecurringAssignmentService(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -119,7 +126,6 @@ func (h *AssignmentHandler) Create(c *gin.Context) {
|
||||
priority := c.PostForm("priority")
|
||||
dueDateStr := c.PostForm("due_date")
|
||||
|
||||
// Parse reminder settings
|
||||
reminderEnabled := c.PostForm("reminder_enabled") == "on"
|
||||
reminderAtStr := c.PostForm("reminder_at")
|
||||
var reminderAt *time.Time
|
||||
@@ -151,21 +157,105 @@ func (h *AssignmentHandler) Create(c *gin.Context) {
|
||||
dueDate = dueDate.Add(23*time.Hour + 59*time.Minute)
|
||||
}
|
||||
|
||||
_, err = h.assignmentService.Create(userID, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
if err != nil {
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{
|
||||
"title": "課題登録",
|
||||
"error": "課題の登録に失敗しました",
|
||||
"formTitle": title,
|
||||
"description": description,
|
||||
"subject": subject,
|
||||
"priority": priority,
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
})
|
||||
return
|
||||
recurrenceType := c.PostForm("recurrence_type")
|
||||
if recurrenceType != "" && recurrenceType != "none" {
|
||||
|
||||
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")
|
||||
if endType == "" {
|
||||
endType = models.EndTypeNever
|
||||
}
|
||||
|
||||
var endCount *int
|
||||
if ec := c.PostForm("end_count"); ec != "" {
|
||||
if v, err := strconv.Atoi(ec); err == nil && v > 0 {
|
||||
endCount = &v
|
||||
}
|
||||
}
|
||||
|
||||
var endDate *time.Time
|
||||
if ed := c.PostForm("end_date"); ed != "" {
|
||||
if v, err := time.ParseInLocation("2006-01-02", ed, time.Local); err == nil {
|
||||
endDate = &v
|
||||
}
|
||||
}
|
||||
|
||||
dueTime := dueDate.Format("15:04")
|
||||
|
||||
recurringService := service.NewRecurringAssignmentService()
|
||||
input := service.CreateRecurringAssignmentInput{
|
||||
Title: title,
|
||||
Description: description,
|
||||
Subject: subject,
|
||||
Priority: priority,
|
||||
RecurrenceType: recurrenceType,
|
||||
RecurrenceInterval: recurrenceInterval,
|
||||
RecurrenceWeekday: recurrenceWeekday,
|
||||
RecurrenceDay: recurrenceDay,
|
||||
DueTime: dueTime,
|
||||
EndType: endType,
|
||||
EndCount: endCount,
|
||||
EndDate: endDate,
|
||||
ReminderEnabled: reminderEnabled,
|
||||
UrgentReminderEnabled: urgentReminderEnabled,
|
||||
FirstDueDate: dueDate,
|
||||
}
|
||||
|
||||
_, err = recurringService.Create(userID, input)
|
||||
if err != nil {
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{
|
||||
"title": "課題登録",
|
||||
"error": "繰り返し課題の登録に失敗しました: " + err.Error(),
|
||||
"formTitle": title,
|
||||
"description": description,
|
||||
"subject": subject,
|
||||
"priority": priority,
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
assignment, err := h.assignmentService.Create(userID, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
if err != nil {
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{
|
||||
"title": "課題登録",
|
||||
"error": "課題の登録に失敗しました",
|
||||
"formTitle": title,
|
||||
"description": description,
|
||||
"subject": subject,
|
||||
"priority": priority,
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if h.notificationService != nil {
|
||||
go h.notificationService.SendAssignmentCreatedNotification(userID, assignment)
|
||||
}
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/assignments")
|
||||
@@ -181,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,
|
||||
})
|
||||
@@ -202,7 +298,6 @@ func (h *AssignmentHandler) Update(c *gin.Context) {
|
||||
priority := c.PostForm("priority")
|
||||
dueDateStr := c.PostForm("due_date")
|
||||
|
||||
// Parse reminder settings
|
||||
reminderEnabled := c.PostForm("reminder_enabled") == "on"
|
||||
reminderAtStr := c.PostForm("reminder_at")
|
||||
var reminderAt *time.Time
|
||||
@@ -249,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")
|
||||
@@ -259,7 +362,6 @@ func (h *AssignmentHandler) Statistics(c *gin.Context) {
|
||||
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",
|
||||
@@ -268,7 +370,6 @@ func (h *AssignmentHandler) Statistics(c *gin.Context) {
|
||||
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 {
|
||||
@@ -276,7 +377,6 @@ func (h *AssignmentHandler) Statistics(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse to date
|
||||
if toStr != "" {
|
||||
toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local)
|
||||
if err == nil {
|
||||
@@ -293,11 +393,9 @@ func (h *AssignmentHandler) Statistics(c *gin.Context) {
|
||||
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
|
||||
@@ -339,4 +437,161 @@ 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")
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@ func (h *ProfileHandler) UpdateNotificationSettings(c *gin.Context) {
|
||||
TelegramChatID: c.PostForm("telegram_chat_id"),
|
||||
LineEnabled: c.PostForm("line_enabled") == "on",
|
||||
LineNotifyToken: c.PostForm("line_token"),
|
||||
NotifyOnCreate: c.PostForm("notify_on_create") == "on",
|
||||
}
|
||||
|
||||
err := h.notificationService.UpdateUserSettings(userID, settings)
|
||||
@@ -172,4 +173,3 @@ func (h *ProfileHandler) UpdateNotificationSettings(c *gin.Context) {
|
||||
"notifySettings": notifySettings,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ type APIKey struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
KeyHash string `gorm:"not null;uniqueIndex" json:"-"`
|
||||
KeyHash string `gorm:"not null;uniqueIndex;size:255" json:"-"`
|
||||
LastUsed *time.Time `json:"last_used,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
@@ -7,26 +7,29 @@ import (
|
||||
)
|
||||
|
||||
type Assignment struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
Title string `gorm:"not null" json:"title"`
|
||||
Description string `json:"description"`
|
||||
Subject string `json:"subject"`
|
||||
Priority string `gorm:"not null;default:medium" json:"priority"` // low, medium, high
|
||||
DueDate time.Time `gorm:"not null" json:"due_date"`
|
||||
IsCompleted bool `gorm:"default:false" json:"is_completed"`
|
||||
IsArchived bool `gorm:"default:false;index" json:"is_archived"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
// Reminder notification settings (one-time)
|
||||
ReminderEnabled bool `gorm:"default:false" json:"reminder_enabled"`
|
||||
ReminderAt *time.Time `json:"reminder_at,omitempty"`
|
||||
ReminderSent bool `gorm:"default:false;index" json:"reminder_sent"`
|
||||
// Urgent reminder settings (repeating until completed)
|
||||
UrgentReminderEnabled bool `gorm:"default:true" json:"urgent_reminder_enabled"`
|
||||
LastUrgentReminderSent *time.Time `json:"last_urgent_reminder_sent,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
Title string `gorm:"not null" json:"title"`
|
||||
Description string `json:"description"`
|
||||
Subject string `json:"subject"`
|
||||
Priority string `gorm:"not null;default:medium" json:"priority"`
|
||||
DueDate time.Time `gorm:"not null" json:"due_date"`
|
||||
IsCompleted bool `gorm:"default:false" json:"is_completed"`
|
||||
IsArchived bool `gorm:"default:false;index" json:"is_archived"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
ReminderEnabled bool `gorm:"default:false" json:"reminder_enabled"`
|
||||
ReminderAt *time.Time `json:"reminder_at,omitempty"`
|
||||
ReminderSent bool `gorm:"default:false;index" json:"reminder_sent"`
|
||||
UrgentReminderEnabled bool `gorm:"default:true" json:"urgent_reminder_enabled"`
|
||||
LastUrgentReminderSent *time.Time `json:"last_urgent_reminder_sent,omitempty"`
|
||||
|
||||
// Recurring assignment reference
|
||||
RecurringAssignmentID *uint `gorm:"index" json:"recurring_assignment_id,omitempty"`
|
||||
RecurringAssignment *RecurringAssignment `gorm:"foreignKey:RecurringAssignmentID" json:"-"`
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@ import (
|
||||
"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
|
||||
LineNotifyToken string `json:"-"`
|
||||
NotifyOnCreate bool `gorm:"default:true" json:"notify_on_create"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
102
internal/models/recurring_assignment.go
Normal file
102
internal/models/recurring_assignment.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
RecurrenceNone = "none"
|
||||
RecurrenceDaily = "daily"
|
||||
RecurrenceWeekly = "weekly"
|
||||
RecurrenceMonthly = "monthly"
|
||||
)
|
||||
|
||||
const (
|
||||
EndTypeNever = "never"
|
||||
EndTypeCount = "count"
|
||||
EndTypeDate = "date"
|
||||
)
|
||||
|
||||
const (
|
||||
EditBehaviorThisOnly = "this_only"
|
||||
EditBehaviorThisAndFuture = "this_and_future"
|
||||
EditBehaviorAll = "all"
|
||||
)
|
||||
|
||||
type RecurringAssignment struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
Title string `gorm:"not null" json:"title"`
|
||||
Description string `json:"description"`
|
||||
Subject string `json:"subject"`
|
||||
Priority string `gorm:"not null;default:medium" json:"priority"`
|
||||
|
||||
RecurrenceType string `gorm:"not null;default:none" json:"recurrence_type"`
|
||||
RecurrenceInterval int `gorm:"not null;default:1" json:"recurrence_interval"`
|
||||
RecurrenceWeekday *int `json:"recurrence_weekday,omitempty"`
|
||||
RecurrenceDay *int `json:"recurrence_day,omitempty"`
|
||||
DueTime string `gorm:"not null" json:"due_time"`
|
||||
|
||||
EndType string `gorm:"not null;default:never" json:"end_type"`
|
||||
EndCount *int `json:"end_count,omitempty"`
|
||||
EndDate *time.Time `json:"end_date,omitempty"`
|
||||
GeneratedCount int `gorm:"default:0" json:"generated_count"`
|
||||
EditBehavior string `gorm:"not null;default:this_only" json:"edit_behavior"`
|
||||
|
||||
ReminderEnabled bool `gorm:"default:false" json:"reminder_enabled"`
|
||||
ReminderOffset *int `json:"reminder_offset,omitempty"`
|
||||
UrgentReminderEnabled bool `gorm:"default:true" json:"urgent_reminder_enabled"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
Assignments []Assignment `gorm:"foreignKey:RecurringAssignmentID" json:"assignments,omitempty"`
|
||||
}
|
||||
|
||||
func (r *RecurringAssignment) ShouldGenerateNext() bool {
|
||||
if !r.IsActive || r.RecurrenceType == RecurrenceNone {
|
||||
return false
|
||||
}
|
||||
|
||||
switch r.EndType {
|
||||
case EndTypeCount:
|
||||
if r.EndCount != nil && r.GeneratedCount >= *r.EndCount {
|
||||
return false
|
||||
}
|
||||
case EndTypeDate:
|
||||
if r.EndDate != nil && time.Now().After(*r.EndDate) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *RecurringAssignment) CalculateNextDueDate(lastDueDate time.Time) time.Time {
|
||||
var nextDate time.Time
|
||||
|
||||
switch r.RecurrenceType {
|
||||
case RecurrenceDaily:
|
||||
nextDate = lastDueDate.AddDate(0, 0, r.RecurrenceInterval)
|
||||
case RecurrenceWeekly:
|
||||
nextDate = lastDueDate.AddDate(0, 0, 7*r.RecurrenceInterval)
|
||||
case RecurrenceMonthly:
|
||||
nextDate = lastDueDate.AddDate(0, r.RecurrenceInterval, 0)
|
||||
if r.RecurrenceDay != nil {
|
||||
day := *r.RecurrenceDay
|
||||
lastDayOfMonth := time.Date(nextDate.Year(), nextDate.Month()+1, 0, 0, 0, 0, 0, nextDate.Location()).Day()
|
||||
if day > lastDayOfMonth {
|
||||
day = lastDayOfMonth
|
||||
}
|
||||
nextDate = time.Date(nextDate.Year(), nextDate.Month(), day, nextDate.Hour(), nextDate.Minute(), 0, 0, nextDate.Location())
|
||||
}
|
||||
default:
|
||||
return lastDueDate
|
||||
}
|
||||
|
||||
return nextDate
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Email string `gorm:"uniqueIndex;not null" json:"email"`
|
||||
Email string `gorm:"uniqueIndex;not null;size:255" json:"email"`
|
||||
PasswordHash string `gorm:"not null" json:"-"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Role string `gorm:"not null;default:user" json:"role"` // "admin" or "user"
|
||||
|
||||
@@ -148,11 +148,19 @@ func (r *AssignmentRepository) Search(userID uint, queryStr, priority, filter st
|
||||
}
|
||||
|
||||
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)
|
||||
default: // pending
|
||||
dbQuery = dbQuery.Where("is_completed = ?", false)
|
||||
}
|
||||
@@ -187,7 +195,6 @@ func (r *AssignmentRepository) CountOverdueByUserID(userID uint) (int64, error)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// StatisticsFilter holds filter parameters for statistics queries
|
||||
type StatisticsFilter struct {
|
||||
Subject string
|
||||
From *time.Time
|
||||
@@ -195,17 +202,15 @@ type StatisticsFilter struct {
|
||||
IncludeArchived bool
|
||||
}
|
||||
|
||||
// AssignmentStatistics holds statistics data
|
||||
type AssignmentStatistics struct {
|
||||
Total int64
|
||||
Completed int64
|
||||
Pending int64
|
||||
Overdue int64
|
||||
CompletedOnTime int64
|
||||
OnTimeCompletionRate float64
|
||||
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
|
||||
@@ -216,64 +221,48 @@ type SubjectStatistics struct {
|
||||
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
|
||||
}
|
||||
@@ -281,7 +270,6 @@ func (r *AssignmentRepository) GetStatistics(userID uint, filter StatisticsFilte
|
||||
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)
|
||||
@@ -301,7 +289,6 @@ func (r *AssignmentRepository) GetStatisticsBySubjects(userID uint, filter Stati
|
||||
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 {
|
||||
@@ -328,21 +315,18 @@ func (r *AssignmentRepository) GetStatisticsBySubjects(userID uint, filter Stati
|
||||
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{}).
|
||||
@@ -352,7 +336,6 @@ func (r *AssignmentRepository) GetArchivedSubjects(userID uint) ([]string, 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{}).
|
||||
@@ -364,3 +347,58 @@ func (r *AssignmentRepository) GetSubjectsByUserIDWithArchived(userID uint, incl
|
||||
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
|
||||
}
|
||||
|
||||
123
internal/repository/recurring_assignment_repository.go
Normal file
123
internal/repository/recurring_assignment_repository.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"homework-manager/internal/database"
|
||||
"homework-manager/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type RecurringAssignmentRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRecurringAssignmentRepository() *RecurringAssignmentRepository {
|
||||
return &RecurringAssignmentRepository{db: database.GetDB()}
|
||||
}
|
||||
|
||||
func (r *RecurringAssignmentRepository) Create(recurring *models.RecurringAssignment) error {
|
||||
return r.db.Create(recurring).Error
|
||||
}
|
||||
|
||||
func (r *RecurringAssignmentRepository) FindByID(id uint) (*models.RecurringAssignment, error) {
|
||||
var recurring models.RecurringAssignment
|
||||
err := r.db.First(&recurring, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &recurring, nil
|
||||
}
|
||||
|
||||
func (r *RecurringAssignmentRepository) FindByUserID(userID uint) ([]models.RecurringAssignment, error) {
|
||||
var recurrings []models.RecurringAssignment
|
||||
err := r.db.Where("user_id = ?", userID).Order("created_at DESC").Find(&recurrings).Error
|
||||
return recurrings, err
|
||||
}
|
||||
|
||||
func (r *RecurringAssignmentRepository) FindActiveByUserID(userID uint) ([]models.RecurringAssignment, error) {
|
||||
var recurrings []models.RecurringAssignment
|
||||
err := r.db.Where("user_id = ? AND is_active = ?", userID, true).Order("created_at DESC").Find(&recurrings).Error
|
||||
return recurrings, err
|
||||
}
|
||||
|
||||
func (r *RecurringAssignmentRepository) Update(recurring *models.RecurringAssignment) error {
|
||||
return r.db.Save(recurring).Error
|
||||
}
|
||||
|
||||
func (r *RecurringAssignmentRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.RecurringAssignment{}, id).Error
|
||||
}
|
||||
|
||||
func (r *RecurringAssignmentRepository) FindDueForGeneration() ([]models.RecurringAssignment, error) {
|
||||
var recurrings []models.RecurringAssignment
|
||||
|
||||
err := r.db.Where("is_active = ? AND recurrence_type != ?", true, models.RecurrenceNone).
|
||||
Find(&recurrings).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []models.RecurringAssignment
|
||||
now := time.Now()
|
||||
for _, rec := range recurrings {
|
||||
shouldGenerate := true
|
||||
|
||||
switch rec.EndType {
|
||||
case models.EndTypeCount:
|
||||
if rec.EndCount != nil && rec.GeneratedCount >= *rec.EndCount {
|
||||
shouldGenerate = false
|
||||
}
|
||||
case models.EndTypeDate:
|
||||
if rec.EndDate != nil && now.After(*rec.EndDate) {
|
||||
shouldGenerate = false
|
||||
}
|
||||
}
|
||||
|
||||
if shouldGenerate {
|
||||
result = append(result, rec)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *RecurringAssignmentRepository) GetLatestAssignmentByRecurringID(recurringID uint) (*models.Assignment, error) {
|
||||
var assignment models.Assignment
|
||||
err := r.db.Where("recurring_assignment_id = ?", recurringID).
|
||||
Order("due_date DESC").
|
||||
First(&assignment).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &assignment, nil
|
||||
}
|
||||
|
||||
func (r *RecurringAssignmentRepository) GetAssignmentsByRecurringID(recurringID uint) ([]models.Assignment, error) {
|
||||
var assignments []models.Assignment
|
||||
err := r.db.Where("recurring_assignment_id = ?", recurringID).
|
||||
Order("due_date ASC").
|
||||
Find(&assignments).Error
|
||||
return assignments, err
|
||||
}
|
||||
|
||||
func (r *RecurringAssignmentRepository) GetFutureAssignmentsByRecurringID(recurringID uint, fromDate time.Time) ([]models.Assignment, error) {
|
||||
var assignments []models.Assignment
|
||||
err := r.db.Where("recurring_assignment_id = ? AND due_date >= ?", recurringID, fromDate).
|
||||
Order("due_date ASC").
|
||||
Find(&assignments).Error
|
||||
return assignments, err
|
||||
}
|
||||
|
||||
func (r *RecurringAssignmentRepository) CountPendingByRecurringID(recurringID uint) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.Assignment{}).
|
||||
Where("recurring_assignment_id = ? AND is_completed = ?", recurringID, false).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
@@ -45,6 +45,22 @@ func getFuncMap() template.FuncMap {
|
||||
"multiplyFloat": func(a float64, b float64) float64 {
|
||||
return a * b
|
||||
},
|
||||
"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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,9 +75,12 @@ func loadTemplates() (*template.Template, error) {
|
||||
pattern string
|
||||
prefix string
|
||||
}{
|
||||
{"web/templates/auth/*.html", ""},
|
||||
{"web/templates/pages/*.html", ""},
|
||||
{"web/templates/auth/*.html", ""},
|
||||
{"web/templates/pages/*.html", ""},
|
||||
{"web/templates/assignments/*.html", "assignments/"},
|
||||
{"web/templates/recurring/*.html", "recurring/"},
|
||||
{"web/templates/admin/*.html", "admin/"},
|
||||
}
|
||||
|
||||
@@ -175,14 +194,14 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
apiKeyService := service.NewAPIKeyService()
|
||||
notificationService := service.NewNotificationService(cfg.Notification.TelegramBotToken)
|
||||
|
||||
// Start notification reminder scheduler
|
||||
notificationService.StartReminderScheduler()
|
||||
|
||||
authHandler := handler.NewAuthHandler()
|
||||
assignmentHandler := handler.NewAssignmentHandler()
|
||||
assignmentHandler := handler.NewAssignmentHandler(notificationService)
|
||||
adminHandler := handler.NewAdminHandler()
|
||||
profileHandler := handler.NewProfileHandler(notificationService)
|
||||
apiHandler := handler.NewAPIHandler()
|
||||
apiRecurringHandler := handler.NewAPIRecurringHandler()
|
||||
|
||||
guest := r.Group("/")
|
||||
guest.Use(middleware.GuestOnly())
|
||||
@@ -222,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)
|
||||
@@ -254,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -209,7 +209,6 @@ func (s *AssignmentService) Update(userID, assignmentID uint, title, description
|
||||
assignment.ReminderEnabled = reminderEnabled
|
||||
assignment.ReminderAt = reminderAt
|
||||
assignment.UrgentReminderEnabled = urgentReminderEnabled
|
||||
// Reset reminder sent flag if reminder settings changed
|
||||
if reminderEnabled && reminderAt != nil {
|
||||
assignment.ReminderSent = false
|
||||
}
|
||||
@@ -256,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) {
|
||||
@@ -271,15 +270,14 @@ 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
|
||||
}
|
||||
|
||||
// StatisticsFilter holds filter parameters for statistics
|
||||
type StatisticsFilter struct {
|
||||
Subject string
|
||||
From *time.Time
|
||||
@@ -287,7 +285,6 @@ type StatisticsFilter struct {
|
||||
IncludeArchived bool
|
||||
}
|
||||
|
||||
// SubjectStats holds statistics for a subject
|
||||
type SubjectStats struct {
|
||||
Subject string `json:"subject"`
|
||||
Total int64 `json:"total"`
|
||||
@@ -298,7 +295,6 @@ type SubjectStats struct {
|
||||
IsArchived bool `json:"is_archived,omitempty"`
|
||||
}
|
||||
|
||||
// StatisticsSummary holds overall statistics
|
||||
type StatisticsSummary struct {
|
||||
TotalAssignments int64 `json:"total_assignments"`
|
||||
CompletedAssignments int64 `json:"completed_assignments"`
|
||||
@@ -309,7 +305,6 @@ type StatisticsSummary struct {
|
||||
Subjects []SubjectStats `json:"subjects,omitempty"`
|
||||
}
|
||||
|
||||
// FilterInfo shows applied filters in response
|
||||
type FilterInfo struct {
|
||||
Subject *string `json:"subject"`
|
||||
From *string `json:"from"`
|
||||
@@ -317,9 +312,7 @@ type FilterInfo struct {
|
||||
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,
|
||||
@@ -327,7 +320,6 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter)
|
||||
IncludeArchived: filter.IncludeArchived,
|
||||
}
|
||||
|
||||
// Get overall statistics
|
||||
stats, err := s.assignmentRepo.GetStatistics(userID, repoFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -341,7 +333,6 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter)
|
||||
OnTimeCompletionRate: stats.OnTimeCompletionRate,
|
||||
}
|
||||
|
||||
// Build filter info
|
||||
filterInfo := &FilterInfo{}
|
||||
hasFilter := false
|
||||
if filter.Subject != "" {
|
||||
@@ -366,7 +357,6 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter)
|
||||
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 {
|
||||
@@ -388,23 +378,17 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,24 +14,20 @@ import (
|
||||
"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,
|
||||
@@ -42,24 +38,19 @@ func (s *NotificationService) GetUserSettings(userID uint) (*models.UserNotifica
|
||||
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")
|
||||
@@ -69,65 +60,63 @@ func (s *NotificationService) SendTelegramNotification(chatID, message string) e
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -144,14 +133,12 @@ func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *mo
|
||||
|
||||
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))
|
||||
@@ -165,7 +152,63 @@ func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *mo
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendUrgentReminder sends an urgent reminder notification for an assignment
|
||||
func (s *NotificationService) SendAssignmentCreatedNotification(userID uint, assignment *models.Assignment) error {
|
||||
settings, err := s.GetUserSettings(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !settings.NotifyOnCreate {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !settings.TelegramEnabled && !settings.LineEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
message := fmt.Sprintf(
|
||||
"新しい課題が追加されました\n\n【%s】\n科目: %s\n優先度: %s\n期限: %s\n\n%s",
|
||||
assignment.Title,
|
||||
assignment.Subject,
|
||||
getPriorityLabel(assignment.Priority),
|
||||
assignment.DueDate.Format("2006/01/02 15:04"),
|
||||
assignment.Description,
|
||||
)
|
||||
|
||||
var errors []string
|
||||
|
||||
if settings.TelegramEnabled && settings.TelegramChatID != "" {
|
||||
if err := s.SendTelegramNotification(settings.TelegramChatID, message); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("Telegram: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
if settings.LineEnabled && settings.LineNotifyToken != "" {
|
||||
if err := s.SendLineNotification(settings.LineNotifyToken, message); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("LINE: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("notification errors: %s", strings.Join(errors, "; "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getPriorityLabel(priority string) string {
|
||||
switch priority {
|
||||
case "high":
|
||||
return "大"
|
||||
case "medium":
|
||||
return "中"
|
||||
case "low":
|
||||
return "小"
|
||||
default:
|
||||
return priority
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models.Assignment) error {
|
||||
settings, err := s.GetUserSettings(userID)
|
||||
if err != nil {
|
||||
@@ -203,14 +246,12 @@ func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models
|
||||
|
||||
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))
|
||||
@@ -224,8 +265,6 @@ func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models
|
||||
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":
|
||||
@@ -239,87 +278,78 @@ func getUrgentReminderInterval(priority string) time.Duration {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
urgentStartTime := 3 * time.Hour
|
||||
|
||||
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",
|
||||
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()
|
||||
@@ -327,4 +357,3 @@ func (s *NotificationService) StartReminderScheduler() {
|
||||
}()
|
||||
log.Println("Reminder scheduler started (one-time + urgent reminders)")
|
||||
}
|
||||
|
||||
|
||||
521
internal/service/recurring_assignment_service.go
Normal file
521
internal/service/recurring_assignment_service.go
Normal file
@@ -0,0 +1,521 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"homework-manager/internal/models"
|
||||
"homework-manager/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRecurringAssignmentNotFound = errors.New("recurring assignment not found")
|
||||
ErrRecurringUnauthorized = errors.New("unauthorized")
|
||||
ErrInvalidRecurrenceType = errors.New("invalid recurrence type")
|
||||
ErrInvalidEndType = errors.New("invalid end type")
|
||||
)
|
||||
|
||||
type RecurringAssignmentService struct {
|
||||
recurringRepo *repository.RecurringAssignmentRepository
|
||||
assignmentRepo *repository.AssignmentRepository
|
||||
}
|
||||
|
||||
func NewRecurringAssignmentService() *RecurringAssignmentService {
|
||||
return &RecurringAssignmentService{
|
||||
recurringRepo: repository.NewRecurringAssignmentRepository(),
|
||||
assignmentRepo: repository.NewAssignmentRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
type CreateRecurringAssignmentInput struct {
|
||||
Title string
|
||||
Description string
|
||||
Subject string
|
||||
Priority string
|
||||
RecurrenceType string
|
||||
RecurrenceInterval int
|
||||
RecurrenceWeekday *int
|
||||
RecurrenceDay *int
|
||||
DueTime string
|
||||
EndType string
|
||||
EndCount *int
|
||||
EndDate *time.Time
|
||||
EditBehavior string
|
||||
ReminderEnabled bool
|
||||
ReminderOffset *int
|
||||
UrgentReminderEnabled bool
|
||||
FirstDueDate time.Time
|
||||
}
|
||||
|
||||
func (s *RecurringAssignmentService) Create(userID uint, input CreateRecurringAssignmentInput) (*models.RecurringAssignment, error) {
|
||||
if !isValidRecurrenceType(input.RecurrenceType) {
|
||||
return nil, ErrInvalidRecurrenceType
|
||||
}
|
||||
|
||||
if !isValidEndType(input.EndType) {
|
||||
return nil, ErrInvalidEndType
|
||||
}
|
||||
|
||||
if input.RecurrenceInterval < 1 {
|
||||
input.RecurrenceInterval = 1
|
||||
}
|
||||
if input.EditBehavior == "" {
|
||||
input.EditBehavior = models.EditBehaviorThisOnly
|
||||
}
|
||||
|
||||
recurring := &models.RecurringAssignment{
|
||||
UserID: userID,
|
||||
Title: input.Title,
|
||||
Description: input.Description,
|
||||
Subject: input.Subject,
|
||||
Priority: input.Priority,
|
||||
RecurrenceType: input.RecurrenceType,
|
||||
RecurrenceInterval: input.RecurrenceInterval,
|
||||
RecurrenceWeekday: input.RecurrenceWeekday,
|
||||
RecurrenceDay: input.RecurrenceDay,
|
||||
DueTime: input.DueTime,
|
||||
EndType: input.EndType,
|
||||
EndCount: input.EndCount,
|
||||
EndDate: input.EndDate,
|
||||
EditBehavior: input.EditBehavior,
|
||||
ReminderEnabled: input.ReminderEnabled,
|
||||
ReminderOffset: input.ReminderOffset,
|
||||
UrgentReminderEnabled: input.UrgentReminderEnabled,
|
||||
IsActive: true,
|
||||
GeneratedCount: 0,
|
||||
}
|
||||
|
||||
if err := s.recurringRepo.Create(recurring); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.generateAssignment(recurring, input.FirstDueDate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return recurring, nil
|
||||
}
|
||||
|
||||
func (s *RecurringAssignmentService) GetByID(userID, recurringID uint) (*models.RecurringAssignment, error) {
|
||||
recurring, err := s.recurringRepo.FindByID(recurringID)
|
||||
if err != nil {
|
||||
return nil, ErrRecurringAssignmentNotFound
|
||||
}
|
||||
|
||||
if recurring.UserID != userID {
|
||||
return nil, ErrRecurringUnauthorized
|
||||
}
|
||||
|
||||
return recurring, nil
|
||||
}
|
||||
|
||||
func (s *RecurringAssignmentService) GetAllByUser(userID uint) ([]models.RecurringAssignment, error) {
|
||||
return s.recurringRepo.FindByUserID(userID)
|
||||
}
|
||||
|
||||
func (s *RecurringAssignmentService) GetActiveByUser(userID uint) ([]models.RecurringAssignment, error) {
|
||||
return s.recurringRepo.FindActiveByUserID(userID)
|
||||
}
|
||||
|
||||
type UpdateRecurringInput struct {
|
||||
Title *string
|
||||
Description *string
|
||||
Subject *string
|
||||
Priority *string
|
||||
RecurrenceType *string
|
||||
RecurrenceInterval *int
|
||||
RecurrenceWeekday *int
|
||||
RecurrenceDay *int
|
||||
DueTime *string
|
||||
EndType *string
|
||||
EndCount *int
|
||||
EndDate *time.Time
|
||||
EditBehavior string
|
||||
ReminderEnabled *bool
|
||||
ReminderOffset *int
|
||||
UrgentReminderEnabled *bool
|
||||
}
|
||||
|
||||
func (s *RecurringAssignmentService) Update(userID, recurringID uint, input UpdateRecurringInput) (*models.RecurringAssignment, error) {
|
||||
recurring, err := s.GetByID(userID, recurringID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
title, description, subject, priority string,
|
||||
dueDate time.Time,
|
||||
reminderEnabled bool,
|
||||
reminderAt *time.Time,
|
||||
urgentReminderEnabled bool,
|
||||
editBehavior string,
|
||||
) error {
|
||||
if assignment.RecurringAssignmentID == nil {
|
||||
return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
}
|
||||
|
||||
recurring, err := s.GetByID(userID, *assignment.RecurringAssignmentID)
|
||||
if err != nil {
|
||||
return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
}
|
||||
|
||||
switch editBehavior {
|
||||
case models.EditBehaviorThisOnly:
|
||||
return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
|
||||
case models.EditBehaviorThisAndFuture:
|
||||
if err := s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled); err != nil {
|
||||
return err
|
||||
}
|
||||
recurring.Title = title
|
||||
recurring.Description = description
|
||||
recurring.Subject = subject
|
||||
recurring.Priority = priority
|
||||
recurring.UrgentReminderEnabled = urgentReminderEnabled
|
||||
if err := s.recurringRepo.Update(recurring); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.updateFutureAssignments(recurring.ID, assignment.DueDate, title, description, subject, priority, urgentReminderEnabled)
|
||||
|
||||
case models.EditBehaviorAll:
|
||||
recurring.Title = title
|
||||
recurring.Description = description
|
||||
recurring.Subject = subject
|
||||
recurring.Priority = priority
|
||||
recurring.UrgentReminderEnabled = urgentReminderEnabled
|
||||
if err := s.recurringRepo.Update(recurring); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.updateAllPendingAssignments(recurring.ID, title, description, subject, priority, urgentReminderEnabled)
|
||||
|
||||
default:
|
||||
return s.updateSingleAssignment(assignment, title, description, subject, priority, dueDate, reminderEnabled, reminderAt, urgentReminderEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RecurringAssignmentService) updateSingleAssignment(
|
||||
assignment *models.Assignment,
|
||||
title, description, subject, priority string,
|
||||
dueDate time.Time,
|
||||
reminderEnabled bool,
|
||||
reminderAt *time.Time,
|
||||
urgentReminderEnabled bool,
|
||||
) error {
|
||||
assignment.Title = title
|
||||
assignment.Description = description
|
||||
assignment.Subject = subject
|
||||
assignment.Priority = priority
|
||||
assignment.DueDate = dueDate
|
||||
assignment.ReminderEnabled = reminderEnabled
|
||||
assignment.ReminderAt = reminderAt
|
||||
assignment.UrgentReminderEnabled = urgentReminderEnabled
|
||||
return s.assignmentRepo.Update(assignment)
|
||||
}
|
||||
|
||||
func (s *RecurringAssignmentService) updateFutureAssignments(
|
||||
recurringID uint,
|
||||
fromDate time.Time,
|
||||
title, description, subject, priority string,
|
||||
urgentReminderEnabled bool,
|
||||
) error {
|
||||
assignments, err := s.recurringRepo.GetFutureAssignmentsByRecurringID(recurringID, fromDate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, a := range assignments {
|
||||
if a.IsCompleted {
|
||||
continue
|
||||
}
|
||||
a.Title = title
|
||||
a.Description = description
|
||||
a.Subject = subject
|
||||
a.Priority = priority
|
||||
a.UrgentReminderEnabled = urgentReminderEnabled
|
||||
if err := s.assignmentRepo.Update(&a); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RecurringAssignmentService) updateAllPendingAssignments(
|
||||
recurringID uint,
|
||||
title, description, subject, priority string,
|
||||
urgentReminderEnabled bool,
|
||||
) error {
|
||||
assignments, err := s.recurringRepo.GetAssignmentsByRecurringID(recurringID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, a := range assignments {
|
||||
if a.IsCompleted {
|
||||
continue
|
||||
}
|
||||
a.Title = title
|
||||
a.Description = description
|
||||
a.Subject = subject
|
||||
a.Priority = priority
|
||||
a.UrgentReminderEnabled = urgentReminderEnabled
|
||||
if err := s.assignmentRepo.Update(&a); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RecurringAssignmentService) Delete(userID, recurringID uint, deleteFutureAssignments bool) error {
|
||||
recurring, err := s.GetByID(userID, recurringID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if deleteFutureAssignments {
|
||||
assignments, err := s.recurringRepo.GetFutureAssignmentsByRecurringID(recurringID, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, a := range assignments {
|
||||
if !a.IsCompleted {
|
||||
s.assignmentRepo.Delete(a.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s.recurringRepo.Delete(recurring.ID)
|
||||
}
|
||||
|
||||
func (s *RecurringAssignmentService) GenerateNextAssignments() error {
|
||||
recurrings, err := s.recurringRepo.FindDueForGeneration()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, recurring := range recurrings {
|
||||
pendingCount, err := s.recurringRepo.CountPendingByRecurringID(recurring.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if pendingCount == 0 {
|
||||
latest, err := s.recurringRepo.GetLatestAssignmentByRecurringID(recurring.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var nextDueDate time.Time
|
||||
if latest != nil {
|
||||
nextDueDate = recurring.CalculateNextDueDate(latest.DueDate)
|
||||
} else {
|
||||
nextDueDate = time.Now()
|
||||
}
|
||||
|
||||
if nextDueDate.After(time.Now()) {
|
||||
s.generateAssignment(&recurring, nextDueDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RecurringAssignmentService) generateAssignment(recurring *models.RecurringAssignment, dueDate time.Time) error {
|
||||
if recurring.DueTime != "" {
|
||||
parts := strings.Split(recurring.DueTime, ":")
|
||||
if len(parts) == 2 {
|
||||
hour, _ := strconv.Atoi(parts[0])
|
||||
minute, _ := strconv.Atoi(parts[1])
|
||||
dueDate = time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), hour, minute, 0, 0, dueDate.Location())
|
||||
}
|
||||
}
|
||||
|
||||
var reminderAt *time.Time
|
||||
if recurring.ReminderEnabled && recurring.ReminderOffset != nil {
|
||||
t := dueDate.Add(-time.Duration(*recurring.ReminderOffset) * time.Minute)
|
||||
reminderAt = &t
|
||||
}
|
||||
|
||||
assignment := &models.Assignment{
|
||||
UserID: userID(recurring.UserID),
|
||||
Title: recurring.Title,
|
||||
Description: recurring.Description,
|
||||
Subject: recurring.Subject,
|
||||
Priority: recurring.Priority,
|
||||
DueDate: dueDate,
|
||||
ReminderEnabled: recurring.ReminderEnabled,
|
||||
ReminderAt: reminderAt,
|
||||
UrgentReminderEnabled: recurring.UrgentReminderEnabled,
|
||||
RecurringAssignmentID: &recurring.ID,
|
||||
}
|
||||
|
||||
if err := s.assignmentRepo.Create(assignment); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
recurring.GeneratedCount++
|
||||
return s.recurringRepo.Update(recurring)
|
||||
}
|
||||
|
||||
func userID(id uint) uint {
|
||||
return id
|
||||
}
|
||||
|
||||
func isValidRecurrenceType(t string) bool {
|
||||
switch t {
|
||||
case models.RecurrenceNone, models.RecurrenceDaily, models.RecurrenceWeekly, models.RecurrenceMonthly:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isValidEndType(t string) bool {
|
||||
switch t {
|
||||
case models.EndTypeNever, models.EndTypeCount, models.EndTypeDate:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func GetRecurrenceTypeLabel(t string) string {
|
||||
switch t {
|
||||
case models.RecurrenceDaily:
|
||||
return "毎日"
|
||||
case models.RecurrenceWeekly:
|
||||
return "毎週"
|
||||
case models.RecurrenceMonthly:
|
||||
return "毎月"
|
||||
default:
|
||||
return "なし"
|
||||
}
|
||||
}
|
||||
|
||||
func GetEndTypeLabel(t string) string {
|
||||
switch t {
|
||||
case models.EndTypeCount:
|
||||
return "回数指定"
|
||||
case models.EndTypeDate:
|
||||
return "終了日指定"
|
||||
default:
|
||||
return "無期限"
|
||||
}
|
||||
}
|
||||
|
||||
func FormatRecurringSummary(recurring *models.RecurringAssignment) string {
|
||||
if recurring.RecurrenceType == models.RecurrenceNone {
|
||||
return ""
|
||||
}
|
||||
|
||||
var parts []string
|
||||
|
||||
typeLabel := GetRecurrenceTypeLabel(recurring.RecurrenceType)
|
||||
if recurring.RecurrenceInterval > 1 {
|
||||
switch recurring.RecurrenceType {
|
||||
case models.RecurrenceDaily:
|
||||
parts = append(parts, fmt.Sprintf("%d日ごと", recurring.RecurrenceInterval))
|
||||
case models.RecurrenceWeekly:
|
||||
parts = append(parts, fmt.Sprintf("%d週間ごと", recurring.RecurrenceInterval))
|
||||
case models.RecurrenceMonthly:
|
||||
parts = append(parts, fmt.Sprintf("%dヶ月ごと", recurring.RecurrenceInterval))
|
||||
}
|
||||
} else {
|
||||
parts = append(parts, typeLabel)
|
||||
}
|
||||
|
||||
if recurring.RecurrenceType == models.RecurrenceWeekly && recurring.RecurrenceWeekday != nil {
|
||||
weekdays := []string{"日", "月", "火", "水", "木", "金", "土"}
|
||||
if *recurring.RecurrenceWeekday >= 0 && *recurring.RecurrenceWeekday < 7 {
|
||||
parts = append(parts, fmt.Sprintf("(%s曜日)", weekdays[*recurring.RecurrenceWeekday]))
|
||||
}
|
||||
}
|
||||
|
||||
if recurring.RecurrenceType == models.RecurrenceMonthly && recurring.RecurrenceDay != nil {
|
||||
parts = append(parts, fmt.Sprintf("(%d日)", *recurring.RecurrenceDay))
|
||||
}
|
||||
|
||||
switch recurring.EndType {
|
||||
case models.EndTypeCount:
|
||||
if recurring.EndCount != nil {
|
||||
parts = append(parts, fmt.Sprintf("/ %d回まで", *recurring.EndCount))
|
||||
}
|
||||
case models.EndTypeDate:
|
||||
if recurring.EndDate != nil {
|
||||
parts = append(parts, fmt.Sprintf("/ %sまで", recurring.EndDate.Format("2006/01/02")))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -98,6 +98,11 @@
|
||||
<li><code>PUT /api/v1/assignments/:id</code> - 課題更新</li>
|
||||
<li><code>DELETE /api/v1/assignments/:id</code> - 課題削除</li>
|
||||
<li><code>PATCH /api/v1/assignments/:id/toggle</code> - 完了状態切替</li>
|
||||
<li><code>GET /api/v1/statistics</code> - 統計情報取得</li>
|
||||
<li><code>GET /api/v1/recurring</code> - 繰り返し設定一覧取得</li>
|
||||
<li><code>GET /api/v1/recurring/:id</code> - 繰り返し設定詳細取得</li>
|
||||
<li><code>PUT /api/v1/recurring/:id</code> - 繰り返し設定更新</li>
|
||||
<li><code>DELETE /api/v1/recurring/:id</code> - 繰り返し設定削除</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,13 +56,12 @@
|
||||
重要度により間隔が変わります:大=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"
|
||||
@@ -77,6 +76,39 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{if .recurring}}
|
||||
<!-- 繰り返し設定 -->
|
||||
<div class="card bg-light mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0"><i class="bi bi-arrow-repeat me-1"></i>繰り返し設定</h6>
|
||||
<a href="/recurring/{{.recurring.ID}}/edit" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-pencil me-1"></i>編集
|
||||
</a>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<small class="text-muted">タイプ</small>
|
||||
<div class="fw-bold">
|
||||
{{if eq .recurring.RecurrenceType "daily"}}毎日{{end}}
|
||||
{{if eq .recurring.RecurrenceType "weekly"}}毎週{{end}}
|
||||
{{if eq .recurring.RecurrenceType "monthly"}}毎月{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<small class="text-muted">状態</small>
|
||||
<div>
|
||||
{{if .recurring.IsActive}}
|
||||
<span class="badge bg-success">有効</span>
|
||||
{{else}}
|
||||
<span class="badge bg-secondary">停止中</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>更新</button>
|
||||
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
|
||||
|
||||
@@ -18,19 +18,50 @@
|
||||
<!-- Tabs -->
|
||||
<ul class="nav nav-tabs border-0 mb-2" id="assignmentTabs">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link py-2 rounded-0 border-0 {{if eq .filter " pending"}}fw-bold border-bottom border-dark
|
||||
border-3{{else}}text-muted{{end}}" href="/assignments?filter=pending&q={{.query}}&priority={{.priority}}"
|
||||
style="{{if eq .filter " pending"}}color: black !important;{{end}}">未完了</a>
|
||||
<a class="nav-link py-2 rounded-0 {{if eq .filter "pending"}}fw-bold border-bottom border-dark border-3
|
||||
text-dark{{else}}border-0 text-muted{{end}}"
|
||||
href="/assignments?filter=pending&q={{.query}}&priority={{.priority}}">
|
||||
未完了
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link py-2 rounded-0 border-0 {{if eq .filter " completed"}}fw-bold border-bottom border-dark
|
||||
border-3{{else}}text-muted{{end}}" href="/assignments?filter=completed&q={{.query}}&priority={{.priority}}"
|
||||
style="{{if eq .filter " completed"}}color: black !important;{{end}}">完了済み</a>
|
||||
<a class="nav-link py-2 rounded-0 {{if eq .filter "due_today"}}fw-bold border-bottom border-dark border-3
|
||||
text-dark{{else}}border-0 text-muted{{end}}"
|
||||
href="/assignments?filter=due_today&q={{.query}}&priority={{.priority}}">
|
||||
今日が期限
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link py-2 rounded-0 border-0 {{if eq .filter " overdue"}}fw-bold border-bottom border-dark
|
||||
border-3{{else}}text-muted{{end}}" href="/assignments?filter=overdue&q={{.query}}&priority={{.priority}}"
|
||||
style="{{if eq .filter " overdue"}}color: black !important;{{end}}">期限切れ</a>
|
||||
<a class="nav-link py-2 rounded-0 {{if eq .filter "due_this_week"}}fw-bold border-bottom border-dark border-3
|
||||
text-dark{{else}}border-0 text-muted{{end}}"
|
||||
href="/assignments?filter=due_this_week&q={{.query}}&priority={{.priority}}">
|
||||
今週が期限
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link py-2 rounded-0 {{if eq .filter "completed"}}fw-bold border-bottom border-dark border-3
|
||||
text-dark{{else}}border-0 text-muted{{end}}"
|
||||
href="/assignments?filter=completed&q={{.query}}&priority={{.priority}}">
|
||||
完了済み
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link py-2 rounded-0 {{if eq .filter "overdue"}}fw-bold border-bottom border-dark border-3
|
||||
text-dark{{else}}border-0 text-muted{{end}}"
|
||||
href="/assignments?filter=overdue&q={{.query}}&priority={{.priority}}">
|
||||
期限切れ
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link py-2 rounded-0 border-0 text-muted"
|
||||
href="/recurring">
|
||||
繰り返し
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<hr class="mt-0 mb-3 text-muted" style="opacity: 0.1;">
|
||||
@@ -68,9 +99,9 @@
|
||||
<thead class="bg-secondary-subtle">
|
||||
<tr>
|
||||
<th style="width: 50px;" class="ps-3 text-center text-dark fw-bold">状態</th>
|
||||
<th class="text-dark fw-bold">タイトル</th>
|
||||
<th style="width: 120px;" class="text-dark fw-bold">科目</th>
|
||||
<th style="width: 80px;" class="text-dark fw-bold">重要度</th>
|
||||
<th class="text-dark fw-bold">タイトル</th>
|
||||
<th style="width: 140px;" class="text-dark fw-bold">期限</th>
|
||||
<th style="width: 120px;" class="countdown-col text-dark fw-bold">残り</th>
|
||||
<th style="width: 80px;" class="text-end pe-3 text-dark fw-bold">操作</th>
|
||||
@@ -101,9 +132,6 @@
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-bold text-dark text-truncate" style="max-width: 300px;">{{.Title}}</div>
|
||||
</td>
|
||||
<td><span class="badge bg-secondary text-white border-0 fw-bold">{{.Subject}}</span></td>
|
||||
<td>
|
||||
{{if eq .Priority "high"}}
|
||||
@@ -114,6 +142,22 @@
|
||||
<span class="badge bg-dark text-white border-0 fw-bold small">低</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="fw-bold text-dark text-truncate" style="max-width: 280px;">{{.Title}}</div>
|
||||
{{if .RecurringAssignmentID}}
|
||||
<button type="button" class="btn btn-link p-0 ms-2 text-info" data-bs-toggle="modal"
|
||||
data-bs-target="#recurringModal" data-recurring-id="{{.RecurringAssignmentID}}"
|
||||
data-assignment-id="{{.ID}}"
|
||||
data-recurring-title="{{.Title}}"
|
||||
data-recurring-type="{{if .RecurringAssignment}}{{.RecurringAssignment.RecurrenceType}}{{else}}unknown{{end}}"
|
||||
data-recurring-active="{{if .RecurringAssignment}}{{.RecurringAssignment.IsActive}}{{else}}true{{end}}"
|
||||
title="繰り返し課題">
|
||||
<i class="fa-solid fa-repeat"></i>
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="small fw-bold text-dark user-select-all">{{.DueDate.Format "2006/01/02 15:04"}}
|
||||
</div>
|
||||
@@ -130,6 +174,12 @@
|
||||
<a href="/assignments/{{.ID}}/edit" class="text-primary me-3 text-decoration-none">
|
||||
<i class="bi bi-pencil-fill"></i>
|
||||
</a>
|
||||
{{if .RecurringAssignmentID}}
|
||||
<button type="button" class="btn btn-link p-0 text-danger text-decoration-none border-0 bg-transparent"
|
||||
onclick="showDeleteRecurringModal({{.ID}}, {{.RecurringAssignmentID}})">
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
{{else}}
|
||||
<form action="/assignments/{{.ID}}/delete" method="POST" class="d-inline"
|
||||
onsubmit="return confirm('削除しますか?');">
|
||||
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||
@@ -138,6 +188,7 @@
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -266,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 = '<span class="badge bg-success">有効</span>';
|
||||
document.getElementById('recurringStopBtn').style.display = 'inline-block';
|
||||
} else {
|
||||
statusEl.innerHTML = '<span class="badge bg-secondary">停止中</span>';
|
||||
document.getElementById('recurringStopBtn').style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Recurring Modal -->
|
||||
<div class="modal fade" id="recurringModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fa-solid fa-repeat me-2"></i>繰り返し課題</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h6 id="recurringModalTitle" class="mb-3 fw-bold"></h6>
|
||||
<table class="table table-sm table-borderless">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="text-muted" style="width: 100px;">繰り返し</th>
|
||||
<td id="recurringTypeLabel">読み込み中...</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-muted">状態</th>
|
||||
<td id="recurringStatus">読み込み中...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="alert alert-info small mb-0">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
繰り返しを停止すると、今後新しい課題は自動作成されなくなります。既存の課題はそのまま残ります。
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">閉じる</button>
|
||||
<a id="recurringEditBtn" href="#" class="btn btn-primary">
|
||||
<i class="bi bi-pencil me-1"></i>編集
|
||||
</a>
|
||||
<form id="recurringStopForm" method="POST" class="d-inline">
|
||||
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
|
||||
<button type="submit" id="recurringStopBtn" class="btn btn-danger"
|
||||
onclick="return confirm('繰り返しを停止しますか?');">
|
||||
<i class="bi bi-stop-fill me-1"></i>停止
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Recurring Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteRecurringModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-trash me-2"></i>繰り返し課題の削除</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>この課題は繰り返し設定に関連付けられています。</p>
|
||||
<p>繰り返し設定も停止しますか?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">キャンセル</button>
|
||||
<form id="deleteOnlyForm" method="POST" class="d-inline">
|
||||
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
|
||||
<button type="submit" class="btn btn-outline-danger">
|
||||
課題のみ削除
|
||||
</button>
|
||||
</form>
|
||||
<form id="deleteAndStopForm" method="POST" class="d-inline">
|
||||
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
削除して繰り返しも削除
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showDeleteRecurringModal(assignmentId, recurringId) {
|
||||
var modal = new bootstrap.Modal(document.getElementById('deleteRecurringModal'));
|
||||
document.getElementById('deleteOnlyForm').action = '/assignments/' + assignmentId + '/delete';
|
||||
document.getElementById('deleteAndStopForm').action = '/assignments/' + assignmentId + '/delete?stop_recurring=' + recurringId;
|
||||
modal.show();
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -55,12 +55,11 @@
|
||||
重要度により間隔が変わります:大=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;">
|
||||
@@ -70,18 +69,129 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>登録</button>
|
||||
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
|
||||
<div class="card bg-light mb-3">
|
||||
<div class="card-header py-2" style="cursor: pointer;" data-bs-toggle="collapse"
|
||||
data-bs-target="#recurringSettings">
|
||||
<h6 class="mb-0"><i class="bi bi-arrow-repeat me-1"></i>繰り返し設定 <i
|
||||
class="bi bi-chevron-down float-end"></i></h6>
|
||||
</div>
|
||||
<div class="collapse" id="recurringSettings">
|
||||
<div class="card-body py-2">
|
||||
<div class="row mb-2">
|
||||
<div class="col-6">
|
||||
<label for="recurrence_type" class="form-label small">繰り返しタイプ</label>
|
||||
<select class="form-select form-select-sm" id="recurrence_type"
|
||||
name="recurrence_type" onchange="updateRecurrenceOptions()">
|
||||
<option value="none" selected>なし</option>
|
||||
<option value="daily">毎日</option>
|
||||
<option value="weekly">毎週</option>
|
||||
<option value="monthly">毎月</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6" id="interval_group" style="display: none;">
|
||||
<label for="recurrence_interval" class="form-label small">間隔</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number" class="form-control" id="recurrence_interval"
|
||||
name="recurrence_interval" value="1" min="1" max="12">
|
||||
<span class="input-group-text" id="interval_label">週</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="weekday_group" style="display: none;" class="mb-2">
|
||||
<label class="form-label small">曜日</label>
|
||||
<div class="btn-group btn-group-sm w-100" role="group">
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd0"
|
||||
value="0" {{if eq .currentWeekday 0}}checked{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd0">日</label>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd1"
|
||||
value="1" {{if eq .currentWeekday 1}}checked{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd1">月</label>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd2"
|
||||
value="2" {{if eq .currentWeekday 2}}checked{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd2">火</label>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd3"
|
||||
value="3" {{if eq .currentWeekday 3}}checked{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd3">水</label>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd4"
|
||||
value="4" {{if eq .currentWeekday 4}}checked{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd4">木</label>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd5"
|
||||
value="5" {{if eq .currentWeekday 5}}checked{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd5">金</label>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd6"
|
||||
value="6" {{if eq .currentWeekday 6}}checked{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd6">土</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="day_group" style="display: none;" class="mb-2">
|
||||
<label for="recurrence_day" class="form-label small">日</label>
|
||||
<select class="form-select form-select-sm" id="recurrence_day"
|
||||
name="recurrence_day">
|
||||
{{range $i := seq 1 31}}
|
||||
<option value="{{$i}}" {{if eq $.currentDay $i}}selected{{end}}>{{$i}}日</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div id="end_group" style="display: none;">
|
||||
<label class="form-label small">終了条件</label>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="end_type" id="end_never"
|
||||
value="never" checked>
|
||||
<label class="form-check-label small" for="end_never">無期限</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="end_type" id="end_count"
|
||||
value="count">
|
||||
<label class="form-check-label small" for="end_count">回数</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="end_type" id="end_date"
|
||||
value="date">
|
||||
<label class="form-check-label small" for="end_date">終了日</label>
|
||||
</div>
|
||||
<div class="mt-1" id="end_count_group" style="display: none;">
|
||||
<input type="number" class="form-control form-control-sm" id="end_count_value"
|
||||
name="end_count" value="10" min="1" style="width: 100px;">
|
||||
</div>
|
||||
<div class="mt-1" id="end_date_group" style="display: none;">
|
||||
<input type="date" class="form-control form-control-sm" id="end_date_value"
|
||||
name="end_date" style="width: 150px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>登録</button>
|
||||
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function toggleReminderDate(checkbox) {
|
||||
document.getElementById('reminder_at_group').style.display = checkbox.checked ? 'block' : 'none';
|
||||
}
|
||||
function updateRecurrenceOptions() {
|
||||
const type = document.getElementById('recurrence_type').value;
|
||||
const isRecurring = type !== 'none';
|
||||
document.getElementById('interval_group').style.display = isRecurring ? 'block' : 'none';
|
||||
document.getElementById('weekday_group').style.display = type === 'weekly' ? 'block' : 'none';
|
||||
document.getElementById('day_group').style.display = type === 'monthly' ? 'block' : 'none';
|
||||
document.getElementById('end_group').style.display = isRecurring ? 'block' : 'none';
|
||||
const label = document.getElementById('interval_label');
|
||||
if (type === 'daily') label.textContent = '日';
|
||||
else if (type === 'weekly') label.textContent = '週';
|
||||
else if (type === 'monthly') label.textContent = '月';
|
||||
}
|
||||
document.querySelectorAll('input[name="end_type"]').forEach(radio => {
|
||||
radio.addEventListener('change', function () {
|
||||
document.getElementById('end_count_group').style.display = this.value === 'count' ? 'block' : 'none';
|
||||
document.getElementById('end_date_group').style.display = this.value === 'date' ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -30,7 +30,76 @@
|
||||
.pagination-info {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.stats-table {
|
||||
min-width: 700px;
|
||||
}
|
||||
|
||||
.stats-table th,
|
||||
.stats-table td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stats-table th:first-child,
|
||||
.stats-table td:first-child {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
#shareCard {
|
||||
width: 600px;
|
||||
height: 315px;
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
background: linear-gradient(135deg, #005bea 0%, #00c6fb 100%);
|
||||
padding: 2rem;
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
#shareCard .card-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
#shareCard .rate-display {
|
||||
font-size: 5rem;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
#shareCard .stats-row {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
#shareCard .stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#shareCard .stat-label {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
display: block;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
#shareCard .stat-value {
|
||||
font-size: 1.4rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
@@ -130,8 +199,11 @@
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-clock-history me-2"></i>期限内完了率
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-clock-history me-2"></i>期限内完了率</span>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="generateShareImage()">
|
||||
<i class="bi bi-share me-1"></i>シェア
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
@@ -174,7 +246,7 @@
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<table class="table table-hover mb-0 stats-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>科目</th>
|
||||
@@ -206,7 +278,7 @@
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<table class="table table-hover mb-0 stats-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>科目</th>
|
||||
@@ -238,6 +310,69 @@
|
||||
<p class="text-muted">課題を登録して科目を設定すると、ここに統計が表示されます。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="shareCard">
|
||||
<div>
|
||||
<div class="d-flex align-items-center mb-4">
|
||||
<i class="bi bi-journal-check me-2" style="font-size: 1.5rem;"></i>
|
||||
<span class="card-title">Super Homework Manager</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div style="font-size: 1.5rem; font-weight: bold; margin-bottom: 0.5rem; opacity: 0.9;">期限内完了率</div>
|
||||
<div class="rate-display" style="margin-top: 0;">
|
||||
{{printf "%.1f" .stats.OnTimeCompletionRate}}<span style="font-size: 2.5rem;">%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">完了</span>
|
||||
<span class="stat-value">{{.stats.CompletedAssignments}}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">未完了</span>
|
||||
<span class="stat-value">{{.stats.PendingAssignments}}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">期限切れ</span>
|
||||
<span class="stat-value">{{.stats.OverdueAssignments}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Share Modal -->
|
||||
<div class="modal fade" id="shareModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-share me-2"></i>シェア</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<div id="sharePreviewContainer" class="mb-3" style="max-width: 100%; overflow: hidden;">
|
||||
</div>
|
||||
<p class="text-muted small mb-3">
|
||||
画像を保存またはコピーして、SNSに貼り付けてください。<br>
|
||||
<span class="text-danger"><i class="bi bi-info-circle me-1"></i>ブラウザの制限により、自動で画像は添付されません。</span>
|
||||
</p>
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-outline-primary" onclick="copyImageToClipboard(this)">
|
||||
<i class="bi bi-clipboard me-2"></i>画像をコピー
|
||||
</button>
|
||||
<a id="downloadLink" class="btn btn-outline-secondary" download="stats.png">
|
||||
<i class="bi bi-download me-2"></i>画像を保存
|
||||
</a>
|
||||
<a id="twitterShareBtn" href="#" target="_blank" class="btn btn-dark"
|
||||
style="background-color: #000;">
|
||||
<i class="bi bi-twitter-x me-2"></i>Xでポストする
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
@@ -255,6 +390,88 @@
|
||||
var activePage = 1;
|
||||
var archivedPage = 1;
|
||||
|
||||
// Share Functionality
|
||||
window.generateShareImage = function () {
|
||||
var card = document.getElementById('shareCard');
|
||||
// Ensure card is visible for rendering but off-screen
|
||||
card.style.display = 'flex';
|
||||
|
||||
html2canvas(card, {
|
||||
backgroundColor: null,
|
||||
scale: 2 // High resolution
|
||||
}).then(canvas => {
|
||||
var imgData = canvas.toDataURL('image/png');
|
||||
|
||||
// Set up preview
|
||||
var previewContainer = document.getElementById('sharePreviewContainer');
|
||||
previewContainer.innerHTML = '';
|
||||
var img = document.createElement('img');
|
||||
img.src = imgData;
|
||||
img.style.maxWidth = '100%';
|
||||
img.style.borderRadius = '8px';
|
||||
img.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
|
||||
previewContainer.appendChild(img);
|
||||
|
||||
// Set up download link
|
||||
var downloadLink = document.getElementById('downloadLink');
|
||||
downloadLink.href = imgData;
|
||||
|
||||
// Set up Twitter button
|
||||
var text = '私の課題完了状況\n期限内完了率: {{printf "%.1f" .stats.OnTimeCompletionRate}}%\n#SuperHomeworkManager';
|
||||
var twitterBtn = document.getElementById('twitterShareBtn');
|
||||
twitterBtn.href = "https://twitter.com/intent/tweet?text=" + encodeURIComponent(text);
|
||||
|
||||
// Show modal
|
||||
var modal = new bootstrap.Modal(document.getElementById('shareModal'));
|
||||
modal.show();
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to convert Data URL to Blob
|
||||
function dataURLtoBlob(dataurl) {
|
||||
var arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1],
|
||||
bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
|
||||
while (n--) {
|
||||
u8arr[n] = bstr.charCodeAt(n);
|
||||
}
|
||||
return new Blob([u8arr], { type: mime });
|
||||
}
|
||||
|
||||
window.copyImageToClipboard = function (btn) {
|
||||
var canvas = document.querySelector('#sharePreviewContainer img');
|
||||
if (!canvas) return;
|
||||
|
||||
if (!navigator.clipboard) {
|
||||
alert('このブラウザまたは環境(非HTTPS/非localhost)では、クリップボードへのコピー機能がサポートされていません。\n「画像を保存」を使用してください。');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var blob = dataURLtoBlob(canvas.src);
|
||||
navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
'image/png': blob
|
||||
})
|
||||
]).then(function () {
|
||||
var originalText = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="bi bi-check me-2"></i>コピーしました';
|
||||
btn.classList.remove('btn-outline-primary');
|
||||
btn.classList.add('btn-success');
|
||||
setTimeout(function () {
|
||||
btn.innerHTML = originalText;
|
||||
btn.classList.add('btn-outline-primary');
|
||||
btn.classList.remove('btn-success');
|
||||
}, 2000);
|
||||
}).catch(function (err) {
|
||||
console.error('Failed to copy: ', err);
|
||||
alert('画像のコピーに失敗しました。\nエラー: ' + err.message + '\n「画像を保存」を使用してください。');
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to create blob: ', err);
|
||||
alert('画像データの生成に失敗しました: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
function getRateClass(rate) {
|
||||
if (rate >= 80) return 'text-success';
|
||||
if (rate >= 50) return 'text-warning';
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<title>{{.title}} - Super Homework Manager</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.5.1/css/all.min.css" rel="stylesheet">
|
||||
<link href="/static/css/style.css" rel="stylesheet">
|
||||
<style>
|
||||
.navbar-dark .navbar-nav .nav-link,
|
||||
|
||||
@@ -65,6 +65,16 @@
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dashboard-stat-card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
|
||||
@@ -74,7 +84,7 @@
|
||||
<i class="bi bi-exclamation-octagon-fill me-2"></i>
|
||||
<span id="urgentMessage"></span>
|
||||
<div class="urgent-countdown mt-1">
|
||||
<i class="bi bi-stopwatch"></i> あと <span id="urgentCountdown"></span>
|
||||
<i class="bi bi-stopwatch"></i> <span id="urgentCountdown"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,56 +93,64 @@
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="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">未完了の課題</h6>
|
||||
<h2 class="mb-0">{{.stats.TotalPending}}</h2>
|
||||
<a href="/assignments?filter=pending" class="text-decoration-none">
|
||||
<div class="card bg-primary text-white h-100 dashboard-stat-card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-white-50">未完了の課題</h6>
|
||||
<h2 class="mb-0">{{.stats.TotalPending}}</h2>
|
||||
</div>
|
||||
<i class="bi bi-list-task display-4 opacity-50"></i>
|
||||
</div>
|
||||
<i class="bi bi-list-task display-4 opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="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">今日が期限</h6>
|
||||
<h2 class="mb-0">{{.stats.DueToday}}</h2>
|
||||
<a href="/assignments?filter=due_today" class="text-decoration-none">
|
||||
<div class="card bg-warning text-dark h-100 dashboard-stat-card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-dark-50">今日が期限</h6>
|
||||
<h2 class="mb-0">{{.stats.DueToday}}</h2>
|
||||
</div>
|
||||
<i class="bi bi-calendar-event display-4 opacity-50"></i>
|
||||
</div>
|
||||
<i class="bi bi-calendar-event display-4 opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card bg-info text-white h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-white-50">今週が期限</h6>
|
||||
<h2 class="mb-0">{{.stats.DueThisWeek}}</h2>
|
||||
<a href="/assignments?filter=due_this_week" class="text-decoration-none">
|
||||
<div class="card bg-info text-white h-100 dashboard-stat-card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-white-50">今週が期限</h6>
|
||||
<h2 class="mb-0">{{.stats.DueThisWeek}}</h2>
|
||||
</div>
|
||||
<i class="bi bi-calendar-week display-4 opacity-50"></i>
|
||||
</div>
|
||||
<i class="bi bi-calendar-week display-4 opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="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">期限切れ</h6>
|
||||
<h2 class="mb-0">{{.stats.Overdue}}</h2>
|
||||
<a href="/assignments?filter=overdue" class="text-decoration-none">
|
||||
<div class="card bg-danger text-white h-100 dashboard-stat-card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-white-50">期限切れ</h6>
|
||||
<h2 class="mb-0">{{.stats.Overdue}}</h2>
|
||||
</div>
|
||||
<i class="bi bi-exclamation-triangle display-4 opacity-50"></i>
|
||||
</div>
|
||||
<i class="bi bi-exclamation-triangle display-4 opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -146,9 +164,9 @@
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center"
|
||||
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
|
||||
<div>
|
||||
{{if .Subject}}<span class="badge bg-secondary me-1">{{.Subject}}</span>{{end}}
|
||||
{{if eq .Priority "high"}}<span class="badge bg-danger me-1">重要</span>{{end}}
|
||||
<strong>{{.Title}}</strong>
|
||||
{{if eq .Priority "high"}}<span class="badge bg-danger ms-1">重要</span>{{end}}
|
||||
{{if .Subject}}<span class="badge bg-secondary ms-2">{{.Subject}}</span>{{end}}
|
||||
<br><small class="text-danger">{{formatDateTime .DueDate}}</small>
|
||||
</div>
|
||||
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
|
||||
@@ -169,9 +187,9 @@
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center"
|
||||
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
|
||||
<div>
|
||||
{{if .Subject}}<span class="badge bg-secondary me-1">{{.Subject}}</span>{{end}}
|
||||
{{if eq .Priority "high"}}<span class="badge bg-danger me-1">重要</span>{{end}}
|
||||
<strong>{{.Title}}</strong>
|
||||
{{if eq .Priority "high"}}<span class="badge bg-danger ms-1">重要</span>{{end}}
|
||||
{{if .Subject}}<span class="badge bg-secondary ms-2">{{.Subject}}</span>{{end}}
|
||||
<br><small class="text-muted">{{formatDateTime .DueDate}}</small>
|
||||
</div>
|
||||
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
|
||||
@@ -192,9 +210,9 @@
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center"
|
||||
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
|
||||
<div>
|
||||
{{if .Subject}}<span class="badge bg-secondary me-1">{{.Subject}}</span>{{end}}
|
||||
{{if eq .Priority "high"}}<span class="badge bg-danger me-1">重要</span>{{end}}
|
||||
<strong>{{.Title}}</strong>
|
||||
{{if eq .Priority "high"}}<span class="badge bg-danger ms-1">重要</span>{{end}}
|
||||
{{if .Subject}}<span class="badge bg-secondary ms-2">{{.Subject}}</span>{{end}}
|
||||
<br><small class="text-muted">{{formatDateTime .DueDate}}</small>
|
||||
</div>
|
||||
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
|
||||
@@ -288,10 +306,10 @@
|
||||
var mins = Math.floor((diff % 3600000) / 60000);
|
||||
var secs = Math.floor((diff % 60000) / 1000);
|
||||
|
||||
var text = '';
|
||||
if (days > 0) text = days + '日 ' + hours + '時間 ' + mins + '分 ' + secs + '秒';
|
||||
else if (hours > 0) text = hours + '時間 ' + mins + '分 ' + secs + '秒';
|
||||
else text = mins + '分 ' + secs + '秒';
|
||||
var text = 'あと ';
|
||||
if (days > 0) text += days + '日 ' + hours + '時間 ' + mins + '分 ' + secs + '秒';
|
||||
else if (hours > 0) text += hours + '時間 ' + mins + '分 ' + secs + '秒';
|
||||
else text += mins + '分 ' + secs + '秒';
|
||||
|
||||
countdown.textContent = text;
|
||||
}
|
||||
|
||||
@@ -112,6 +112,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-3">
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="notify_on_create" name="notify_on_create"
|
||||
{{if .notifySettings.NotifyOnCreate}}checked{{end}}>
|
||||
<label class="form-check-label" for="notify_on_create">
|
||||
<i class="bi bi-plus-circle me-1"></i>課題追加時に通知する
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>通知設定を保存</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
148
web/templates/recurring/edit.html
Normal file
148
web/templates/recurring/edit.html
Normal file
@@ -0,0 +1,148 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-arrow-repeat me-2"></i>繰り返し課題の編集</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
||||
<form method="POST" action="/recurring/{{.recurring.ID}}">
|
||||
{{.csrfField}}
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">タイトル <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="title" name="title" value="{{.recurring.Title}}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="subject" class="form-label">科目</label>
|
||||
<input type="text" class="form-control" id="subject" name="subject" value="{{.recurring.Subject}}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="priority" class="form-label">重要度</label>
|
||||
<select class="form-select" id="priority" name="priority">
|
||||
<option value="low" {{if eq .recurring.Priority "low"}}selected{{end}}>小</option>
|
||||
<option value="medium" {{if eq .recurring.Priority "medium"}}selected{{end}}>中</option>
|
||||
<option value="high" {{if eq .recurring.Priority "high"}}selected{{end}}>大</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">説明</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="3">{{.recurring.Description}}</textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="due_time" class="form-label">時刻</label>
|
||||
<input type="time" class="form-control" id="due_time" name="due_time" value="{{.recurring.DueTime}}">
|
||||
</div>
|
||||
|
||||
<div class="card bg-light mb-3">
|
||||
<div class="card-body py-3">
|
||||
<h6 class="mb-3"><i class="bi bi-arrow-repeat me-1"></i>繰り返し設定</h6>
|
||||
<div class="row mb-3">
|
||||
<div class="col-6">
|
||||
<label for="recurrence_type" class="form-label small">繰り返しタイプ</label>
|
||||
<select class="form-select form-select-sm" id="recurrence_type" name="recurrence_type" onchange="updateRecurrenceOptions()">
|
||||
<option value="daily" {{if eq .recurring.RecurrenceType "daily"}}selected{{end}}>毎日</option>
|
||||
<option value="weekly" {{if eq .recurring.RecurrenceType "weekly"}}selected{{end}}>毎週</option>
|
||||
<option value="monthly" {{if eq .recurring.RecurrenceType "monthly"}}selected{{end}}>毎月</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label for="recurrence_interval" class="form-label small">間隔</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number" class="form-control" id="recurrence_interval" name="recurrence_interval" value="{{.recurring.RecurrenceInterval}}" min="1" max="12">
|
||||
<span class="input-group-text" id="interval_label">{{if eq .recurring.RecurrenceType "daily"}}日{{else if eq .recurring.RecurrenceType "weekly"}}週{{else}}月{{end}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="weekday_group" class="mb-3">
|
||||
<label class="form-label small">曜日</label>
|
||||
<div class="btn-group btn-group-sm w-100" role="group">
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd0" value="0" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 0}}checked{{end}}{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd0">日</label>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd1" value="1" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 1}}checked{{end}}{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd1">月</label>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd2" value="2" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 2}}checked{{end}}{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd2">火</label>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd3" value="3" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 3}}checked{{end}}{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd3">水</label>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd4" value="4" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 4}}checked{{end}}{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd4">木</label>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd5" value="5" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 5}}checked{{end}}{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd5">金</label>
|
||||
<input type="radio" class="btn-check" name="recurrence_weekday" id="wd6" value="6" {{if .recurring.RecurrenceWeekday}}{{if eq (derefInt .recurring.RecurrenceWeekday) 6}}checked{{end}}{{end}}>
|
||||
<label class="btn btn-outline-primary" for="wd6">土</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="day_group" class="mb-3">
|
||||
<label for="recurrence_day" class="form-label small">日</label>
|
||||
<select class="form-select form-select-sm" id="recurrence_day" name="recurrence_day">
|
||||
{{range $i := seq 1 31}}
|
||||
<option value="{{$i}}" {{if $.recurring.RecurrenceDay}}{{if eq (derefInt $.recurring.RecurrenceDay) $i}}selected{{end}}{{end}}>{{$i}}日</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<hr class="my-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="small text-muted">状態:</span>
|
||||
{{if .recurring.IsActive}}
|
||||
<span class="badge bg-success">有効</span>
|
||||
{{else}}
|
||||
<span class="badge bg-secondary">停止中</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<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>
|
||||
{{if .recurring.IsActive}}
|
||||
<button type="button" class="btn btn-outline-danger" onclick="if(confirm('繰り返しを停止しますか?今後新しい課題は自動作成されなくなります。')) document.getElementById('stopForm').submit();">
|
||||
<i class="bi bi-stop-fill me-1"></i>停止
|
||||
</button>
|
||||
{{else}}
|
||||
<button type="button" class="btn btn-outline-success" onclick="document.getElementById('resumeForm').submit();">
|
||||
<i class="bi bi-play-fill me-1"></i>再開
|
||||
</button>
|
||||
{{end}}
|
||||
<button type="button" class="btn btn-outline-danger ms-auto" onclick="if(confirm('この繰り返し設定を削除しますか?この操作は取り消せません。')) document.getElementById('deleteForm').submit();">
|
||||
<i class="bi bi-trash me-1"></i>削除
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{if .recurring.IsActive}}
|
||||
<form id="stopForm" action="/recurring/{{.recurring.ID}}/stop" method="POST" class="d-none">
|
||||
{{.csrfField}}
|
||||
</form>
|
||||
{{else}}
|
||||
<form id="resumeForm" action="/recurring/{{.recurring.ID}}/resume" method="POST" class="d-none">
|
||||
{{.csrfField}}
|
||||
</form>
|
||||
{{end}}
|
||||
<form id="deleteForm" action="/recurring/{{.recurring.ID}}/delete" method="POST" class="d-none">
|
||||
{{.csrfField}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function updateRecurrenceOptions() {
|
||||
var type = document.getElementById('recurrence_type').value;
|
||||
document.getElementById('weekday_group').style.display = type === 'weekly' ? 'block' : 'none';
|
||||
document.getElementById('day_group').style.display = type === 'monthly' ? 'block' : 'none';
|
||||
var label = document.getElementById('interval_label');
|
||||
if (type === 'daily') label.textContent = '日';
|
||||
else if (type === 'weekly') label.textContent = '週';
|
||||
else if (type === 'monthly') label.textContent = '月';
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
updateRecurrenceOptions();
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
68
web/templates/recurring/index.html
Normal file
68
web/templates/recurring/index.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<h4 class="mb-0 fw-bold"><i class="bi bi-arrow-repeat me-2"></i>繰り返し設定一覧</h4>
|
||||
</div>
|
||||
<a href="/assignments" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>課題一覧に戻る
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">タイトル</th>
|
||||
<th>科目</th>
|
||||
<th>繰り返し</th>
|
||||
<th>状態</th>
|
||||
<th class="text-end pe-3">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .recurrings}}
|
||||
<tr>
|
||||
<td class="ps-3">
|
||||
<div class="fw-bold">{{.Title}}</div>
|
||||
{{if .Description}}
|
||||
<div class="text-muted small text-truncate" style="max-width: 200px;">{{.Description}}</div>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .Subject}}
|
||||
<span class="badge bg-secondary">{{.Subject}}</span>
|
||||
{{else}}
|
||||
<span class="text-muted">-</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-dark">{{recurringSummary .}}</span>
|
||||
</td>
|
||||
<td>
|
||||
{{if .IsActive}}
|
||||
<span class="badge bg-success">有効</span>
|
||||
{{else}}
|
||||
<span class="badge bg-secondary">停止中</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
<a href="/recurring/{{.ID}}/edit" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-4 text-muted">
|
||||
繰り返し設定がありません
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user