diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md index b975538..a8a57f5 100644 --- a/docs/SPECIFICATION.md +++ b/docs/SPECIFICATION.md @@ -232,7 +232,18 @@ REST API認証用のAPIキーを管理するモデル。 |------------|----------| | Telegram | config.iniでBot Token設定、プロフィールでChat ID入力 | -### 4.5 プロフィール機能 +### 4.5 CSVエクスポート機能 + +| 機能 | 説明 | +|------|------| +| CSVダウンロード | 統計ページから課題一覧をCSVファイルとしてダウンロード (`GET /assignments/export`) | +| 期間フィルタ | 提出期限(`due_date`)を基準に開始日・終了日で絞り込み可能 | +| 科目フィルタ | 特定の科目のみを対象にエクスポート可能 | +| 全件エクスポート | 期間・科目を未指定の場合は全課題を出力 | +| CSVカラム | ID, タイトル, 科目, 説明, 重要度, 提出期限, 完了状態, 完了日時, 登録日時 | +| エンコーディング | UTF-8 BOM付き(Excel直接開き対応) | + +### 4.6 プロフィール機能 | 機能 | 説明 | |------|------| diff --git a/internal/database/database.go b/internal/database/database.go index 05f83a3..df80c9d 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -76,13 +76,19 @@ func Connect(dbConfig config.DatabaseConfig, debug bool) error { } func Migrate() error { - return DB.AutoMigrate( + if err := DB.AutoMigrate( &models.User{}, &models.Assignment{}, &models.RecurringAssignment{}, &models.APIKey{}, &models.UserNotificationSettings{}, - ) + ); err != nil { + return err + } + + return DB.Model(&models.RecurringAssignment{}). + Where("recurrence_type = ? AND generation_lead_days = 0", models.RecurrenceWeekly). + Update("generation_lead_days", 7).Error } func GetDB() *gorm.DB { diff --git a/internal/handler/assignment_handler.go b/internal/handler/assignment_handler.go index e5f865e..dd2ad4c 100644 --- a/internal/handler/assignment_handler.go +++ b/internal/handler/assignment_handler.go @@ -1,6 +1,7 @@ package handler import ( + "encoding/csv" "net/http" "strconv" "strings" @@ -108,6 +109,8 @@ func (h *AssignmentHandler) New(c *gin.Context) { role, _ := c.Get(middleware.UserRoleKey) name, _ := c.Get(middleware.UserNameKey) now := time.Now() + tomorrow := now.AddDate(0, 0, 1) + defaultDue := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 23, 59, 0, 0, now.Location()) RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{ "title": "課題登録", @@ -115,6 +118,7 @@ func (h *AssignmentHandler) New(c *gin.Context) { "userName": name, "currentWeekday": int(now.Weekday()), "currentDay": now.Day(), + "defaultDueDate": defaultDue.Format("2006-01-02T15:04"), }) } @@ -217,6 +221,12 @@ func (h *AssignmentHandler) Create(c *gin.Context) { dueTime := dueDate.Format("15:04") + generationLeadDays := 0 + if v, err := strconv.Atoi(c.PostForm("generation_lead_days")); err == nil && v >= 0 { + generationLeadDays = v + } + generationLeadTime := c.PostForm("generation_lead_time") + recurringService := service.NewRecurringAssignmentService() input := service.CreateRecurringAssignmentInput{ Title: title, @@ -233,6 +243,8 @@ func (h *AssignmentHandler) Create(c *gin.Context) { EndDate: endDate, ReminderEnabled: reminderEnabled, UrgentReminderEnabled: urgentReminderEnabled, + GenerationLeadDays: generationLeadDays, + GenerationLeadTime: generationLeadTime, FirstDueDate: dueDate, } @@ -353,7 +365,10 @@ func (h *AssignmentHandler) Toggle(c *gin.Context) { userID := h.getUserID(c) id, _ := strconv.ParseUint(c.Param("id"), 10, 32) - h.assignmentService.ToggleComplete(userID, uint(id)) + assignment, err := h.assignmentService.ToggleComplete(userID, uint(id)) + if err == nil && assignment.IsCompleted && assignment.RecurringAssignmentID != nil { + h.recurringService.TriggerForRecurring(*assignment.RecurringAssignmentID) + } referer := c.Request.Referer() if referer == "" { @@ -459,6 +474,69 @@ func (h *AssignmentHandler) UnarchiveSubject(c *gin.Context) { c.Redirect(http.StatusFound, "/statistics?include_archived=true") } +func (h *AssignmentHandler) ExportCSV(c *gin.Context) { + userID := h.getUserID(c) + + var from, to *time.Time + if fromStr := c.Query("from"); fromStr != "" { + if t, err := time.ParseInLocation("2006-01-02", fromStr, time.Local); err == nil { + from = &t + } + } + if toStr := c.Query("to"); toStr != "" { + if t, err := time.ParseInLocation("2006-01-02", toStr, time.Local); err == nil { + to = &t + } + } + subject := c.Query("subject") + + assignments, err := h.assignmentService.GetForExport(userID, from, to, subject) + if err != nil { + c.String(http.StatusInternalServerError, "エクスポートに失敗しました") + return + } + + filename := "assignments_" + time.Now().Format("20060102") + ".csv" + c.Header("Content-Type", "text/csv; charset=utf-8") + c.Header("Content-Disposition", "attachment; filename=\""+filename+"\"") + + w := csv.NewWriter(c.Writer) + // UTF-8 BOM for Excel compatibility + c.Writer.Write([]byte("\xef\xbb\xbf")) + + headers := []string{"ID", "タイトル", "科目", "説明", "重要度", "提出期限", "完了", "完了日時", "登録日時"} + w.Write(headers) + + priorityLabel := map[string]string{"low": "低", "medium": "中", "high": "高"} + for _, a := range assignments { + completed := "未完了" + if a.IsCompleted { + completed = "完了" + } + completedAt := "" + if a.CompletedAt != nil { + completedAt = a.CompletedAt.Format("2006/01/02 15:04") + } + label := priorityLabel[a.Priority] + if label == "" { + label = a.Priority + } + w.Write([]string{ + strconv.FormatUint(uint64(a.ID), 10), + a.Title, + a.Subject, + a.Description, + label, + a.DueDate.Format("2006/01/02 15:04"), + completed, + completedAt, + a.CreatedAt.Format("2006/01/02 15:04"), + }) + } + + w.Flush() +} + func (h *AssignmentHandler) StopRecurring(c *gin.Context) { userID := h.getUserID(c) id, err := strconv.ParseUint(c.Param("id"), 10, 32) @@ -580,6 +658,12 @@ func (h *AssignmentHandler) UpdateRecurring(c *gin.Context) { } } + generationLeadDays := 0 + if v, err := strconv.Atoi(c.PostForm("generation_lead_days")); err == nil && v >= 0 { + generationLeadDays = v + } + generationLeadTime := c.PostForm("generation_lead_time") + input := service.UpdateRecurringInput{ Title: &title, Description: &description, @@ -594,6 +678,8 @@ func (h *AssignmentHandler) UpdateRecurring(c *gin.Context) { EndCount: endCount, EndDate: endDate, EditBehavior: editBehavior, + GenerationLeadDays: &generationLeadDays, + GenerationLeadTime: &generationLeadTime, } _, err = h.recurringService.Update(userID, uint(id), input) diff --git a/internal/repository/assignment_repository.go b/internal/repository/assignment_repository.go index 51e0beb..5b77f3c 100644 --- a/internal/repository/assignment_repository.go +++ b/internal/repository/assignment_repository.go @@ -21,6 +21,15 @@ func (r *AssignmentRepository) Create(assignment *models.Assignment) error { return r.db.Create(assignment).Error } +func (r *AssignmentRepository) FindByRecurringAndDue(recurringID uint, dueDate time.Time) (*models.Assignment, error) { + var a models.Assignment + err := r.db.Where("recurring_assignment_id = ? AND due_date = ?", recurringID, dueDate).First(&a).Error + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return &a, err +} + func (r *AssignmentRepository) FindByID(id uint) (*models.Assignment, error) { var assignment models.Assignment err := r.db.First(&assignment, id).Error @@ -336,6 +345,22 @@ func (r *AssignmentRepository) GetArchivedSubjects(userID uint) ([]string, error return subjects, err } +func (r *AssignmentRepository) FindForExport(userID uint, from, to *time.Time, subject string) ([]models.Assignment, error) { + var assignments []models.Assignment + q := r.db.Where("user_id = ?", userID) + if from != nil { + q = q.Where("due_date >= ?", *from) + } + if to != nil { + q = q.Where("due_date < ?", to.AddDate(0, 0, 1)) + } + if subject != "" { + q = q.Where("subject = ?", subject) + } + err := q.Order("due_date ASC").Find(&assignments).Error + return assignments, err +} + func (r *AssignmentRepository) GetSubjectsByUserIDWithArchived(userID uint, includeArchived bool) ([]string, error) { var subjects []string query := r.db.Model(&models.Assignment{}). diff --git a/internal/router/router.go b/internal/router/router.go index 7dac7c4..dd8f249 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -248,6 +248,7 @@ func Setup(cfg *config.Config) *gin.Engine { auth.POST("/assignments/:id/toggle", assignmentHandler.Toggle) auth.POST("/assignments/:id/delete", assignmentHandler.Delete) + auth.GET("/assignments/export", assignmentHandler.ExportCSV) auth.GET("/statistics", assignmentHandler.Statistics) auth.POST("/statistics/archive-subject", assignmentHandler.ArchiveSubject) auth.POST("/statistics/unarchive-subject", assignmentHandler.UnarchiveSubject) diff --git a/internal/service/assignment_service.go b/internal/service/assignment_service.go index 65b02c2..7f86424 100644 --- a/internal/service/assignment_service.go +++ b/internal/service/assignment_service.go @@ -378,6 +378,10 @@ func (s *AssignmentService) GetStatistics(userID uint, filter StatisticsFilter) return summary, nil } +func (s *AssignmentService) GetForExport(userID uint, from, to *time.Time, subject string) ([]models.Assignment, error) { + return s.assignmentRepo.FindForExport(userID, from, to, subject) +} + func (s *AssignmentService) ArchiveSubject(userID uint, subject string) error { return s.assignmentRepo.ArchiveBySubject(userID, subject) } diff --git a/internal/service/recurring_assignment_service.go b/internal/service/recurring_assignment_service.go index 7d9d5f7..4d9556e 100644 --- a/internal/service/recurring_assignment_service.go +++ b/internal/service/recurring_assignment_service.go @@ -413,11 +413,6 @@ func (s *RecurringAssignmentService) generateNextIfPending(recurring *models.Rec return nil } - pendingCount, err := s.recurringRepo.CountPendingByRecurringID(recurring.ID) - if err != nil || pendingCount > 0 { - return err - } - latest, err := s.recurringRepo.GetLatestAssignmentByRecurringID(recurring.ID) if err != nil { return err @@ -426,6 +421,10 @@ func (s *RecurringAssignmentService) generateNextIfPending(recurring *models.Rec return nil } + if !latest.IsCompleted && !latest.IsOverdue() { + return nil + } + nextDueDate := recurring.CalculateNextDueDate(latest.DueDate) if recurring.GenerationLeadDays > 0 { diff --git a/web/static/css/style.css b/web/static/css/style.css index 922a645..d44cef6 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -1,5 +1,3 @@ -/* Custom styles for Homework Manager */ - :root { --primary-color: #4361ee; --secondary-color: #3f37c9; @@ -13,8 +11,8 @@ body { display: flex; flex-direction: column; background-color: #f8f9fa; - margin-top: 0 !important; - padding-top: 0 !important; + margin-top: 0; + padding-top: 0; } .countdown { @@ -27,7 +25,6 @@ main { flex: 1; } -/* Card enhancements */ .card { border: none; border-radius: 0.75rem; @@ -44,7 +41,6 @@ main { font-weight: 600; } -/* Navbar customization */ .navbar { box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } @@ -53,7 +49,6 @@ main { font-weight: 700; } -/* Table improvements */ .table { background-color: #fff; border-radius: 0.5rem; @@ -69,7 +64,6 @@ main { background-color: rgba(67, 97, 238, 0.05); } -/* Button styles */ .btn { border-radius: 0.5rem; font-weight: 500; @@ -85,25 +79,21 @@ main { border-color: var(--secondary-color); } -/* Badge styles */ .badge { font-weight: 500; padding: 0.4em 0.8em; } -/* Form styles */ .form-control:focus { border-color: var(--primary-color); box-shadow: 0 0 0 0.25rem rgba(67, 97, 238, 0.25); } -/* Alert enhancements */ .alert { border: none; border-radius: 0.5rem; } -/* Stats cards */ .card.bg-primary, .card.bg-warning, .card.bg-info, @@ -119,27 +109,38 @@ main { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); } -/* Tabs - Removed conflicted specific styles as they are managed in templates */ .nav-tabs .nav-link { font-weight: 500; + font-size: 0.9rem; + padding: 0.5rem 1rem; + color: #6c757d; + border: none; +} + +.nav-tabs .nav-link:hover { + color: #000; + border: none; +} + +.nav-tabs .nav-link.active { + color: #000; + font-weight: 700; + border-bottom: 3px solid #000; + background: transparent; } -/* Footer */ .footer { border-top: 1px solid #e9ecef; } -/* Login/Register cards */ .card.shadow { border-radius: 1rem; } -/* Empty states */ .text-muted.display-1 { - color: #dee2e6 !important; + color: #dee2e6; } -/* Responsive adjustments */ @media (max-width: 768px) { .card-body { padding: 0.75rem; @@ -150,68 +151,12 @@ main { } } -.table td, -.table th { - padding: 0.35rem 0.5rem !important; -} - -.page-header { - margin-bottom: 0.75rem !important; -} - -/* Animations for Anxiety/Urgency */ -@keyframes pulse-bg { - - 0%, - 100% { - background-color: #fff3cd; - } - - 50% { - background-color: #ffe69c; - } -} - -@keyframes pulse-bg-danger { - - 0%, - 100% { - background-color: #f8d7da; - } - - 50% { - background-color: #f5c2c7; - } -} - -@keyframes blink-text { - - 0%, - 100% { - opacity: 1; - } - - 50% { - opacity: 0.7; - } -} - -.anxiety-warning { - animation: pulse-bg 2s infinite; -} - -.anxiety-danger { - animation: pulse-bg-danger 1s infinite; -} - -/* Custom Table Styles - Compact */ .custom-table thead th { border-bottom: 2px solid #dee2e6; font-size: 0.8rem; color: #495057; font-weight: 600; - padding-top: 0.5rem !important; - padding-bottom: 0.5rem !important; + padding: 0.35rem 0.5rem; white-space: nowrap; } @@ -222,8 +167,7 @@ main { .custom-table tbody td { border-bottom: 1px solid #dee2e6; vertical-align: middle; - padding-top: 0.5rem !important; - padding-bottom: 0.5rem !important; + padding: 0.35rem 0.5rem; font-size: 0.9rem; } @@ -231,7 +175,10 @@ main { border-bottom: none; } -/* Compact form elements */ +.page-header { + margin-bottom: 0.75rem; +} + .form-control-custom, .form-select-custom, .btn-custom { @@ -240,41 +187,87 @@ main { border-radius: 0.25rem; } -/* Custom Tab Styles */ -.nav-tabs .nav-link { - font-size: 0.9rem; - padding: 0.5rem 1rem; - color: #6c757d; - /* text-muted */ - border: none; -} - -.nav-tabs .nav-link:hover { - color: #000; - border: none; -} - -.nav-tabs .nav-link.active { - color: #000 !important; - font-weight: 700; - border-bottom: 3px solid #000 !important; - background: transparent; -} - -/* Status Icon Size */ .bi-circle, .bi-check-circle-fill { font-size: 1rem; } -/* Urgency styles for countdown */ .countdown-urgent { color: #dc3545; font-weight: 700; - animation: blink-text 1s infinite; } .countdown-warning { color: #fd7e14; font-weight: 700; -} \ No newline at end of file +} + +@keyframes pulse-bg { + 0%, 100% { background-color: #fff3cd; } + 50% { background-color: #ffe69c; } +} + +@keyframes pulse-bg-danger { + 0%, 100% { background-color: #f8d7da; } + 50% { background-color: #f5c2c7; } +} + +@keyframes blink-text { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +@keyframes blink-banner { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.75; } +} + +.anxiety-warning { + animation: pulse-bg 2s infinite; +} + +.anxiety-danger { + animation: pulse-bg-danger 1s infinite; +} + +.urgent-banner { + position: relative; + animation: blink-banner 1.5s infinite; +} + +@media (prefers-reduced-motion: reduce) { + *, ::before, ::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +#weekday_group, +#day_group { + display: none; +} + +.btn-touch { + min-width: 44px; + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.copy-feedback { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + z-index: 9999; + opacity: 0; + transform: translateY(0.5rem); + transition: opacity 0.2s ease, transform 0.2s ease; + pointer-events: none; +} + +.copy-feedback.show { + opacity: 1; + transform: translateY(0); +} diff --git a/web/static/js/app.js b/web/static/js/app.js index 69f9eba..136ce6a 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -7,9 +7,7 @@ const XSS = { }, setTextSafe: function (element, text) { - if (element) { - element.textContent = text; - } + if (element) element.textContent = text; }, sanitizeUrl: function (url) { @@ -17,13 +15,9 @@ const XSS = { const cleaned = String(url).replace(/[\x00-\x1F\x7F]/g, '').trim(); try { const parsed = new URL(cleaned, window.location.origin); - if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { - return parsed.href; - } + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return parsed.href; } catch (e) { - if (cleaned.startsWith('/') && !cleaned.startsWith('//')) { - return cleaned; - } + if (cleaned.startsWith('/') && !cleaned.startsWith('//')) return cleaned; } return ''; } @@ -31,26 +25,73 @@ const XSS = { window.XSS = XSS; +let _pendingConfirmForm = null; + +function showConfirmModal(message, onOk) { + const bodyEl = document.getElementById('confirmModalBody'); + const okBtn = document.getElementById('confirmModalOk'); + if (!bodyEl || !okBtn) { if (onOk) onOk(); return; } + bodyEl.textContent = message; + const handler = function () { + okBtn.removeEventListener('click', handler); + bootstrap.Modal.getInstance(document.getElementById('confirmModal')).hide(); + if (onOk) onOk(); + }; + okBtn.addEventListener('click', handler); + new bootstrap.Modal(document.getElementById('confirmModal')).show(); +} + +window.showConfirmModal = showConfirmModal; + +function setupFormSubmitOnce(form) { + form.addEventListener('submit', function () { + const btn = form.querySelector('[type=submit]'); + if (!btn || btn.disabled) return; + btn.disabled = true; + const orig = btn.innerHTML; + btn.innerHTML = '処理中...'; + window.addEventListener('pageshow', function () { + btn.disabled = false; + btn.innerHTML = orig; + }, { once: true }); + }); +} + +function showCopyFeedback(message) { + let el = document.getElementById('globalCopyFeedback'); + if (!el) { + el = document.createElement('div'); + el.id = 'globalCopyFeedback'; + el.className = 'copy-feedback alert alert-success shadow-sm py-2 px-3'; + document.body.appendChild(el); + } + el.textContent = message; + el.classList.add('show'); + clearTimeout(el._timeout); + el._timeout = setTimeout(function () { el.classList.remove('show'); }, 2000); +} + +window.showCopyFeedback = showCopyFeedback; + document.addEventListener('DOMContentLoaded', function () { const alerts = document.querySelectorAll('.alert:not(.alert-danger):not(.modal .alert)'); alerts.forEach(function (alert) { setTimeout(function () { alert.classList.add('fade'); - setTimeout(function () { - alert.remove(); - }, 150); + setTimeout(function () { alert.remove(); }, 150); }, 5000); }); - const confirmForms = document.querySelectorAll('form[data-confirm]'); - confirmForms.forEach(function (form) { + document.querySelectorAll('form[data-confirm]').forEach(function (form) { form.addEventListener('submit', function (e) { - if (!confirm(form.dataset.confirm)) { - e.preventDefault(); - } + e.preventDefault(); + const msg = form.dataset.confirm; + showConfirmModal(msg, function () { form.submit(); }); }); }); + document.querySelectorAll('form:not([data-confirm])').forEach(setupFormSubmitOnce); + const dueDateInput = document.getElementById('due_date'); if (dueDateInput && !dueDateInput.value) { const tomorrow = new Date(); diff --git a/web/templates/admin/api_keys.html b/web/templates/admin/api_keys.html index e9dc4fa..7270fdd 100644 --- a/web/templates/admin/api_keys.html +++ b/web/templates/admin/api_keys.html @@ -1,35 +1,38 @@ {{template "base" .}} {{define "content"}} -
キー名: {{.newKeyName}}
以下のキーを安全な場所に保存してください。このキーは二度と表示されません。
{{.newKey}}
-
+ {{.newKey}}
+
| ID | -キー名 | -作成者 | -最終使用 | -作成日 | -操作 | +ID | +キー名 | +作成者 | +最終使用 | +作成日 | +操作 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| {{.ID}} | -{{.Name}} | +{{.Name}} | {{if .User}}{{.User.Name}}{{else}}-{{end}} | {{if .LastUsed}}{{formatDateTime .LastUsed}}{{else}}未使用{{end}} | {{formatDate .CreatedAt}} |
| ID | -名前 | -メールアドレス | -ロール | -登録日 | -操作 | +ID | +名前 | +メールアドレス | +ロール | +登録日 | +操作 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| {{.ID}} | {{.Name}}{{if eq .ID $.currentUserID}}自分{{end}} | {{.Email}} | -{{if eq .Role "admin"}}管理者{{else}}ユーザー{{end}} | ++ {{if eq .Role "admin"}} + 管理者 + {{else}} + ユーザー + {{end}} + | {{formatDate .CreatedAt}} | {{if ne .ID $.currentUserID}} - - - {{else}}-{{end}} + {{end}} + + {{else}} + - + {{end}} |