From 2fdcca35e6bdb54bc75eea88d1db1e893cc24cd4 Mon Sep 17 00:00:00 2001 From: furu04 Date: Sun, 26 Apr 2026 15:38:38 +0900 Subject: [PATCH] =?UTF-8?q?UX=E3=81=AE=E5=A4=A7=E5=B9=85=E3=81=AA=E6=94=B9?= =?UTF-8?q?=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/SPECIFICATION.md | 1 + internal/handler/assignment_handler.go | 77 ++- internal/models/assignment.go | 3 +- internal/repository/assignment_repository.go | 94 +++- internal/router/router.go | 3 + internal/service/assignment_service.go | 43 +- web/static/css/style.css | 87 +++ web/static/js/app.js | 494 +++++++++++++++++ web/templates/assignments/index.html | 550 ++++++++++--------- 9 files changed, 1076 insertions(+), 276 deletions(-) diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md index a8a57f5..33f787b 100644 --- a/docs/SPECIFICATION.md +++ b/docs/SPECIFICATION.md @@ -71,6 +71,7 @@ homework-manager/ | Priority | string | 重要度 (`low`, `medium`, `high`) | Default: `medium` | | DueDate | time.Time | 提出期限 | Not Null | | IsCompleted | bool | 完了フラグ | Default: false | +| IsPinned | bool | ピン留めフラグ | Default: false, Composite Index (user_id, is_pinned) | | IsArchived | bool | アーカイブフラグ | Default: false | | CompletedAt | *time.Time | 完了日時 | Nullable | | ReminderEnabled | bool | 1回リマインダー有効 | Default: false | diff --git a/internal/handler/assignment_handler.go b/internal/handler/assignment_handler.go index 37c7ef3..a3bfe18 100644 --- a/internal/handler/assignment_handler.go +++ b/internal/handler/assignment_handler.go @@ -3,6 +3,7 @@ package handler import ( "encoding/csv" "net/http" + "net/url" "strconv" "strings" "time" @@ -34,6 +35,18 @@ func (h *AssignmentHandler) getUserID(c *gin.Context) uint { return userID.(uint) } +func safeReferer(c *gin.Context, defaultPath string) string { + referer := c.Request.Referer() + if referer == "" { + return defaultPath + } + u, err := url.Parse(referer) + if err != nil || u.Host != c.Request.Host { + return defaultPath + } + return referer +} + func (h *AssignmentHandler) Dashboard(c *gin.Context) { userID := h.getUserID(c) stats, _ := h.assignmentService.GetDashboardStats(userID) @@ -64,6 +77,8 @@ func (h *AssignmentHandler) Index(c *gin.Context) { } query := c.Query("q") priority := c.Query("priority") + subject := c.Query("subject") + sort := c.Query("sort") pageStr := c.DefaultQuery("page", "1") page, err := strconv.Atoi(pageStr) if err != nil || page < 1 { @@ -71,7 +86,7 @@ func (h *AssignmentHandler) Index(c *gin.Context) { } const pageSize = 10 - result, err := h.assignmentService.SearchAssignments(userID, query, priority, filter, page, pageSize) + result, err := h.assignmentService.SearchAssignments(userID, query, priority, filter, sort, subject, page, pageSize) var assignments []models.Assignment var totalPages, currentPage int @@ -85,6 +100,9 @@ func (h *AssignmentHandler) Index(c *gin.Context) { currentPage = result.CurrentPage } + subjects, _ := h.assignmentService.GetSubjectsByUser(userID) + tabCounts := h.assignmentService.GetTabCounts(userID) + role, _ := c.Get(middleware.UserRoleKey) name, _ := c.Get(middleware.UserNameKey) @@ -94,6 +112,10 @@ func (h *AssignmentHandler) Index(c *gin.Context) { "filter": filter, "query": query, "priority": priority, + "subject": subject, + "sort": sort, + "subjects": subjects, + "tabCounts": tabCounts, "isAdmin": role == "admin", "userName": name, "currentPage": currentPage, @@ -105,6 +127,59 @@ func (h *AssignmentHandler) Index(c *gin.Context) { }) } +func (h *AssignmentHandler) TogglePin(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 + } + if _, err := h.assignmentService.TogglePin(userID, uint(id)); err != nil { + c.Redirect(http.StatusFound, "/assignments") + return + } + c.Redirect(http.StatusFound, safeReferer(c, "/assignments")) +} + +func (h *AssignmentHandler) BulkComplete(c *gin.Context) { + userID := h.getUserID(c) + rawIDs := c.PostFormArray("ids") + var ids []uint + for _, raw := range rawIDs { + if v, err := strconv.ParseUint(raw, 10, 32); err == nil { + ids = append(ids, uint(v)) + } + } + if err := h.assignmentService.BulkComplete(userID, ids); err != nil { + c.Redirect(http.StatusFound, "/assignments") + return + } + c.Redirect(http.StatusFound, safeReferer(c, "/assignments")) +} + +func (h *AssignmentHandler) BulkDelete(c *gin.Context) { + userID := h.getUserID(c) + rawIDs := c.PostFormArray("ids") + var ids []uint + for _, raw := range rawIDs { + if v, err := strconv.ParseUint(raw, 10, 32); err == nil { + ids = append(ids, uint(v)) + } + } + if c.PostForm("delete_recurring") == "true" { + if recurringIDs, err := h.assignmentService.GetRecurringIDsByIDs(userID, ids); err == nil { + for _, rid := range recurringIDs { + h.recurringService.Delete(userID, rid, false) + } + } + } + if err := h.assignmentService.BulkDelete(userID, ids); err != nil { + c.Redirect(http.StatusFound, "/assignments") + return + } + c.Redirect(http.StatusFound, safeReferer(c, "/assignments")) +} + func (h *AssignmentHandler) New(c *gin.Context) { role, _ := c.Get(middleware.UserRoleKey) name, _ := c.Get(middleware.UserNameKey) diff --git a/internal/models/assignment.go b/internal/models/assignment.go index 6600a79..9a194f6 100644 --- a/internal/models/assignment.go +++ b/internal/models/assignment.go @@ -8,7 +8,7 @@ import ( type Assignment struct { ID uint `gorm:"primarykey" json:"id"` - UserID uint `gorm:"not null;index" json:"user_id"` + UserID uint `gorm:"not null;index;index:idx_user_pinned" json:"user_id"` Title string `gorm:"not null" json:"title"` Description string `json:"description"` Subject string `json:"subject"` @@ -16,6 +16,7 @@ type Assignment struct { DueDate time.Time `gorm:"not null" json:"due_date"` SoftDueDate *time.Time `json:"soft_due_date,omitempty"` IsCompleted bool `gorm:"default:false" json:"is_completed"` + IsPinned bool `gorm:"default:false;index:idx_user_pinned" json:"is_pinned"` IsArchived bool `gorm:"default:false;index" json:"is_archived"` CompletedAt *time.Time `json:"completed_at,omitempty"` ReminderEnabled bool `gorm:"default:false" json:"reminder_enabled"` diff --git a/internal/repository/assignment_repository.go b/internal/repository/assignment_repository.go index 5b77f3c..7abbad6 100644 --- a/internal/repository/assignment_repository.go +++ b/internal/repository/assignment_repository.go @@ -204,6 +204,28 @@ func (r *AssignmentRepository) CountOverdueByUserID(userID uint) (int64, error) return count, err } +func (r *AssignmentRepository) CountDueTodayByUserID(userID uint) (int64, error) { + now := time.Now() + startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + endOfDay := startOfDay.AddDate(0, 0, 1) + var count int64 + err := r.db.Model(&models.Assignment{}). + Where("user_id = ? AND is_completed = ? AND due_date >= ? AND due_date < ?", + userID, false, startOfDay, endOfDay).Count(&count).Error + return count, err +} + +func (r *AssignmentRepository) CountDueThisWeekByUserID(userID uint) (int64, error) { + now := time.Now() + startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + weekLater := startOfDay.AddDate(0, 0, 7) + var count int64 + err := r.db.Model(&models.Assignment{}). + Where("user_id = ? AND is_completed = ? AND due_date >= ? AND due_date < ?", + userID, false, startOfDay, weekLater).Count(&count).Error + return count, err +} + type StatisticsFilter struct { Subject string From *time.Time @@ -372,7 +394,7 @@ 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) { +func (r *AssignmentRepository) SearchWithPreload(userID uint, queryStr, priority, filter, sort, subject string, page, pageSize int) ([]models.Assignment, int64, error) { var assignments []models.Assignment var totalCount int64 @@ -386,6 +408,10 @@ func (r *AssignmentRepository) SearchWithPreload(userID uint, queryStr, priority dbQuery = dbQuery.Where("priority = ?", priority) } + if subject != "" { + dbQuery = dbQuery.Where("subject = ?", subject) + } + now := time.Now() startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) endOfDay := startOfDay.AddDate(0, 0, 1) @@ -410,11 +436,24 @@ func (r *AssignmentRepository) SearchWithPreload(userID uint, queryStr, priority return nil, 0, err } - if filter == "completed" { - dbQuery = dbQuery.Order("completed_at DESC") - } else { - dbQuery = dbQuery.Order("due_date ASC") + var orderClause string + switch sort { + case "due_desc": + orderClause = "is_pinned DESC, due_date DESC" + case "priority": + orderClause = "is_pinned DESC, CASE priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END ASC, due_date ASC" + case "created_desc": + orderClause = "is_pinned DESC, created_at DESC" + case "subject": + orderClause = "is_pinned DESC, subject ASC, due_date ASC" + default: + if filter == "completed" { + orderClause = "is_pinned DESC, completed_at DESC" + } else { + orderClause = "is_pinned DESC, due_date ASC" + } } + dbQuery = dbQuery.Order(orderClause) if page < 1 { page = 1 @@ -427,3 +466,48 @@ func (r *AssignmentRepository) SearchWithPreload(userID uint, queryStr, priority err := dbQuery.Preload("RecurringAssignment").Limit(pageSize).Offset(offset).Find(&assignments).Error return assignments, totalCount, err } + +func (r *AssignmentRepository) TogglePin(userID, assignmentID uint) (*models.Assignment, error) { + var assignment models.Assignment + if err := r.db.Where("id = ? AND user_id = ?", assignmentID, userID).First(&assignment).Error; err != nil { + return nil, err + } + newPinned := !assignment.IsPinned + if err := r.db.Model(&assignment).Update("is_pinned", newPinned).Error; err != nil { + return nil, err + } + assignment.IsPinned = newPinned + return &assignment, nil +} + +func (r *AssignmentRepository) BulkComplete(userID uint, ids []uint) error { + if len(ids) == 0 { + return nil + } + now := time.Now() + return r.db.Model(&models.Assignment{}). + Where("user_id = ? AND id IN ? AND is_completed = ?", userID, ids, false). + Updates(map[string]interface{}{ + "is_completed": true, + "completed_at": now, + }).Error +} + +func (r *AssignmentRepository) BulkDelete(userID uint, ids []uint) error { + if len(ids) == 0 { + return nil + } + return r.db.Where("user_id = ? AND id IN ?", userID, ids). + Delete(&models.Assignment{}).Error +} + +func (r *AssignmentRepository) GetRecurringIDsByIDs(userID uint, ids []uint) ([]uint, error) { + if len(ids) == 0 { + return nil, nil + } + var recurringIDs []uint + err := r.db.Model(&models.Assignment{}). + Where("user_id = ? AND id IN ? AND recurring_assignment_id IS NOT NULL", userID, ids). + Pluck("recurring_assignment_id", &recurringIDs).Error + return recurringIDs, err +} diff --git a/internal/router/router.go b/internal/router/router.go index 78a47f4..bf23958 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -259,9 +259,12 @@ func Setup(cfg *config.Config) *gin.Engine { auth.GET("/assignments", assignmentHandler.Index) auth.GET("/assignments/new", assignmentHandler.New) auth.POST("/assignments", assignmentHandler.Create) + auth.POST("/assignments/bulk-complete", assignmentHandler.BulkComplete) + auth.POST("/assignments/bulk-delete", assignmentHandler.BulkDelete) auth.GET("/assignments/:id/edit", assignmentHandler.Edit) auth.POST("/assignments/:id", assignmentHandler.Update) auth.POST("/assignments/:id/toggle", assignmentHandler.Toggle) + auth.POST("/assignments/:id/pin", assignmentHandler.TogglePin) auth.POST("/assignments/:id/delete", assignmentHandler.Delete) auth.GET("/assignments/export", assignmentHandler.ExportCSV) diff --git a/internal/service/assignment_service.go b/internal/service/assignment_service.go index 2042c95..0455d41 100644 --- a/internal/service/assignment_service.go +++ b/internal/service/assignment_service.go @@ -172,7 +172,7 @@ func (s *AssignmentService) GetOverdueByUserPaginated(userID uint, page, pageSiz }, nil } -func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filter string, page, pageSize int) (*PaginatedResult, error) { +func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filter, sort, subject string, page, pageSize int) (*PaginatedResult, error) { if page < 1 { page = 1 } @@ -180,7 +180,7 @@ func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filt pageSize = 10 } - assignments, totalCount, err := s.assignmentRepo.SearchWithPreload(userID, query, priority, filter, page, pageSize) + assignments, totalCount, err := s.assignmentRepo.SearchWithPreload(userID, query, priority, filter, sort, subject, page, pageSize) if err != nil { return nil, err } @@ -196,6 +196,22 @@ func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filt }, nil } +func (s *AssignmentService) TogglePin(userID, assignmentID uint) (*models.Assignment, error) { + return s.assignmentRepo.TogglePin(userID, assignmentID) +} + +func (s *AssignmentService) BulkComplete(userID uint, ids []uint) error { + return s.assignmentRepo.BulkComplete(userID, ids) +} + +func (s *AssignmentService) BulkDelete(userID uint, ids []uint) error { + return s.assignmentRepo.BulkDelete(userID, ids) +} + +func (s *AssignmentService) GetRecurringIDsByIDs(userID uint, ids []uint) ([]uint, error) { + return s.assignmentRepo.GetRecurringIDsByIDs(userID, ids) +} + func (s *AssignmentService) Update(userID, assignmentID uint, title, description, subject, priority string, dueDate time.Time, softDueDate *time.Time, reminderEnabled bool, reminderAt *time.Time, urgentReminderEnabled bool) (*models.Assignment, error) { assignment, err := s.GetByID(userID, assignmentID) if err != nil { @@ -398,3 +414,26 @@ func (s *AssignmentService) GetSubjectsWithArchived(userID uint, includeArchived func (s *AssignmentService) GetArchivedSubjects(userID uint) ([]string, error) { return s.assignmentRepo.GetArchivedSubjects(userID) } + +type TabCounts struct { + Pending int64 + DueToday int64 + DueThisWeek int64 + Completed int64 + Overdue int64 +} + +func (s *AssignmentService) GetTabCounts(userID uint) TabCounts { + pending, _ := s.assignmentRepo.CountPendingByUserID(userID) + dueToday, _ := s.assignmentRepo.CountDueTodayByUserID(userID) + dueThisWeek, _ := s.assignmentRepo.CountDueThisWeekByUserID(userID) + completed, _ := s.assignmentRepo.CountCompletedByUserID(userID) + overdue, _ := s.assignmentRepo.CountOverdueByUserID(userID) + return TabCounts{ + Pending: pending, + DueToday: dueToday, + DueThisWeek: dueThisWeek, + Completed: completed, + Overdue: overdue, + } +} diff --git a/web/static/css/style.css b/web/static/css/style.css index d44cef6..4aa822c 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -151,6 +151,12 @@ main { } } +.custom-table thead { + position: sticky; + top: 0; + z-index: 10; +} + .custom-table thead th { border-bottom: 2px solid #dee2e6; font-size: 0.8rem; @@ -158,6 +164,7 @@ main { font-weight: 600; padding: 0.35rem 0.5rem; white-space: nowrap; + background-color: #f1f3f5; } .custom-table tbody tr { @@ -271,3 +278,83 @@ main { opacity: 1; transform: translateY(0); } + +.title-clamp { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-all; +} + +.sort-th a { + color: inherit; + text-decoration: none; + display: flex; + align-items: center; + gap: 0.25rem; +} +.sort-th a:hover { color: var(--primary-color); } + +.subject-group-row td { + background-color: #e9ecef; + font-weight: 700; + font-size: 0.8rem; + padding: 0.2rem 0.5rem; + cursor: pointer; + user-select: none; +} + +.assignment-row[data-priority="high"] td:first-child { + box-shadow: inset 3px 0 0 #dc3545; +} +.assignment-row[data-priority="medium"] td:first-child { + box-shadow: inset 3px 0 0 #fd7e14; +} +.assignment-row[data-priority="low"] td:first-child { + box-shadow: inset 3px 0 0 #adb5bd; +} + +tr.row-pinned { + background-color: #fffbe6 !important; +} +tr.row-pinned:hover { + background-color: #fff3cd !important; +} +.pin-btn.pinned { color: #ffc107; } +.pin-btn:not(.pinned) { color: #dee2e6; } +.pin-btn:not(.pinned):hover { color: #ffc107; } + +#bulkBar { + position: sticky; + top: 0; + z-index: 100; + background: #4361ee; + color: #fff; + padding: 0.4rem 0.75rem; + border-radius: 0.5rem; + margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.kanban-col-body { + min-height: 100px; + max-height: 70vh; + overflow-y: auto; +} +.kanban-card { + background: #fff; + border: 1px solid #dee2e6; + border-radius: 0.5rem; + padding: 0.5rem 0.75rem; + margin-bottom: 0.5rem; + font-size: 0.85rem; +} +.kanban-card:last-child { margin-bottom: 0; } + +tr.kb-focus { + outline: 2px solid var(--primary-color); + outline-offset: -2px; +} diff --git a/web/static/js/app.js b/web/static/js/app.js index 136ce6a..1367af3 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -25,6 +25,40 @@ const XSS = { window.XSS = XSS; +var SUBJECT_PALETTE = [ + { bg: '#4361ee', text: '#fff' }, + { bg: '#7b2d8b', text: '#fff' }, + { bg: '#2d9b4e', text: '#fff' }, + { bg: '#c87800', text: '#fff' }, + { bg: '#0077b6', text: '#fff' }, + { bg: '#c1121f', text: '#fff' }, + { bg: '#457b9d', text: '#fff' }, + { bg: '#588157', text: '#fff' }, + { bg: '#6d4c41', text: '#fff' }, + { bg: '#6a4c93', text: '#fff' }, +]; + +function subjectColorFor(subject) { + if (!subject) return null; + var hash = 0; + for (var i = 0; i < subject.length; i++) { + hash = (hash * 31 + subject.charCodeAt(i)) | 0; + } + return SUBJECT_PALETTE[Math.abs(hash) % SUBJECT_PALETTE.length]; +} + +function applySubjectColors() { + document.querySelectorAll('.subject-badge[data-subject]').forEach(function (badge) { + var color = subjectColorFor(badge.dataset.subject); + if (color) { + badge.style.backgroundColor = color.bg; + badge.style.color = color.text; + } + }); +} + +window.subjectColorFor = subjectColorFor; + let _pendingConfirmForm = null; function showConfirmModal(message, onOk) { @@ -92,6 +126,8 @@ document.addEventListener('DOMContentLoaded', function () { document.querySelectorAll('form:not([data-confirm])').forEach(setupFormSubmitOnce); + applySubjectColors(); + const dueDateInput = document.getElementById('due_date'); if (dueDateInput && !dueDateInput.value) { const tomorrow = new Date(); @@ -99,4 +135,462 @@ document.addEventListener('DOMContentLoaded', function () { tomorrow.setHours(23, 59, 0, 0); dueDateInput.value = tomorrow.toISOString().slice(0, 16); } + + initAssignmentIndex(); }); + +function initAssignmentIndex() { + if (!document.getElementById('tableView')) return; + + var _countdownInterval = null; + var _view = localStorage.getItem('viewMode') || 'table'; + var _grouped = localStorage.getItem('grouped') === 'true'; + var _kbFocusIndex = -1; + + function getRows() { + return Array.from(document.querySelectorAll('#tableView .assignment-row')); + } + + function updateCountdowns() { + var now = new Date(); + var hasUnder24h = false; + + getRows().forEach(function (row) { + if (row.dataset.completed === 'true') return; + var dueTs = row.dataset.dueTs; + if (!dueTs) return; + var due = new Date(parseInt(dueTs) * 1000); + if (isNaN(due.getTime())) return; + + var diff = due - now; + var countdownEl = row.querySelector('.countdown'); + + row.classList.remove('anxiety-danger', 'anxiety-warning', 'bg-danger-subtle'); + if (countdownEl) countdownEl.className = 'countdown small fw-bold font-monospace'; + + if (diff < 0) { + if (countdownEl) { countdownEl.textContent = '期限切れ'; countdownEl.classList.add('text-danger'); } + row.classList.add('bg-danger-subtle'); + return; + } + + var days = Math.floor(diff / 86400000); + var hours = Math.floor((diff % 86400000) / 3600000); + var minutes = Math.floor((diff % 3600000) / 60000); + var seconds = Math.floor((diff % 60000) / 1000); + var remainingHours = days * 24 + hours; + + var text = (days > 0 ? days + '日 ' : '') + + String(hours).padStart(2, '0') + ':' + + String(minutes).padStart(2, '0') + ':' + + String(seconds).padStart(2, '0'); + + if (countdownEl) countdownEl.textContent = text; + + if (remainingHours < 24) { + hasUnder24h = true; + row.classList.add('anxiety-danger'); + if (countdownEl) { + countdownEl.classList.add('text-danger', 'countdown-urgent'); + countdownEl.innerHTML = '' + text; + } + } else if (days < 7) { + row.classList.add('anxiety-warning'); + if (countdownEl) { + countdownEl.classList.add('text-dark'); + countdownEl.innerHTML = '' + text; + } + } else { + if (countdownEl) countdownEl.classList.add('text-secondary'); + } + }); + + if (_countdownInterval !== null) return; + var interval = hasUnder24h ? 1000 : 60000; + _countdownInterval = setTimeout(function () { _countdownInterval = null; updateCountdowns(); }, interval); + } + + function toggleCountdown() { + var cols = document.querySelectorAll('.countdown-col'); + var btn = document.getElementById('toggleCountdownBtn'); + var btnText = document.getElementById('countdownBtnText'); + var isHidden = cols[0] && cols[0].style.display === 'none'; + cols.forEach(function (col) { col.style.display = isHidden ? '' : 'none'; }); + var nowHidden = !isHidden; + btnText.textContent = nowHidden ? '残り表示' : '残り非表示'; + btn.setAttribute('aria-pressed', nowHidden ? 'true' : 'false'); + localStorage.setItem('countdownHidden', nowHidden); + } + window.toggleCountdown = toggleCountdown; + + function setView(mode) { + _view = mode; + localStorage.setItem('viewMode', mode); + var tableView = document.getElementById('tableView'); + var kanbanView = document.getElementById('kanbanView'); + var tableBtn = document.getElementById('viewTableBtn'); + var kanbanBtn = document.getElementById('viewKanbanBtn'); + var groupBtn = document.getElementById('groupToggleBtn'); + + if (mode === 'kanban') { + tableView.classList.add('d-none'); + kanbanView.classList.remove('d-none'); + tableBtn.classList.remove('active', 'btn-secondary'); + tableBtn.classList.add('btn-outline-secondary'); + kanbanBtn.classList.remove('btn-outline-secondary'); + kanbanBtn.classList.add('active', 'btn-secondary'); + groupBtn.classList.add('d-none'); + buildKanban(); + } else { + kanbanView.classList.add('d-none'); + tableView.classList.remove('d-none'); + kanbanBtn.classList.remove('active', 'btn-secondary'); + kanbanBtn.classList.add('btn-outline-secondary'); + tableBtn.classList.remove('btn-outline-secondary'); + tableBtn.classList.add('active', 'btn-secondary'); + groupBtn.classList.remove('d-none'); + if (_grouped) applyGrouping(); + } + } + window.setView = setView; + + function buildKanban() { + var cols = { overdue: [], today: [], week: [], later: [] }; + var now = new Date(); + var startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + var endOfDay = new Date(startOfDay.getTime() + 86400000); + var endOfWeek = new Date(startOfDay.getTime() + 7 * 86400000); + + getRows().forEach(function (row) { + if (row.dataset.completed === 'true') return; + var ts = parseInt(row.dataset.dueTs) * 1000; + var due = new Date(ts); + var bucket = due < now ? 'overdue' : due < endOfDay ? 'today' : due < endOfWeek ? 'week' : 'later'; + cols[bucket].push({ + id: row.dataset.id, + title: row.dataset.title, + subject: row.dataset.subject, + priority: row.dataset.priority, + pinned: row.dataset.pinned === 'true', + dueTs: ts + }); + }); + + var priorityColor = { high: 'danger', medium: 'warning', low: 'secondary' }; + var priorityLabel = { high: '高', medium: '中', low: '低' }; + var priorityText = { high: 'white', medium: 'dark', low: 'white' }; + + function renderCol(key, colId, countId) { + var el = document.getElementById(colId); + var cntEl = document.getElementById(countId); + el.innerHTML = ''; + cntEl.textContent = cols[key].length; + if (!cols[key].length) { + el.innerHTML = '

なし

'; + return; + } + var csrf = (document.getElementById('_csrf_global') || {}).value || ''; + cols[key].forEach(function (a) { + var due = new Date(a.dueTs); + var dateStr = due.getFullYear() + '/' + String(due.getMonth() + 1).padStart(2, '0') + '/' + + String(due.getDate()).padStart(2, '0') + ' ' + + String(due.getHours()).padStart(2, '0') + ':' + String(due.getMinutes()).padStart(2, '0'); + var toggleForm = document.querySelector('form[data-row-id="' + a.id + '"]'); + var toggleAction = toggleForm ? XSS.sanitizeUrl(toggleForm.action) : '#'; + var pc = priorityColor[a.priority] || 'secondary'; + var pl = priorityLabel[a.priority] || a.priority; + var pt = priorityText[a.priority] || 'white'; + var card = document.createElement('div'); + card.className = 'kanban-card' + (a.pinned ? ' row-pinned' : ''); + card.innerHTML = + '
' + + '
' + XSS.escapeHtml(a.title) + '
' + + '
' + + '' + + '' + + '
' + + '
' + + (a.subject ? (function() { + var c = subjectColorFor(a.subject); + var bg = c ? c.bg : '#6c757d'; + return '' + XSS.escapeHtml(a.subject) + ''; + })() : '') + + '' + pl + '' + + '
' + + '
' + XSS.escapeHtml(dateStr) + '
' + + (a.pinned ? '
ピン留め
' : ''); + el.appendChild(card); + }); + } + + renderCol('overdue', 'kb-overdue', 'kb-count-overdue'); + renderCol('today', 'kb-today', 'kb-count-today'); + renderCol('week', 'kb-week', 'kb-count-week'); + renderCol('later', 'kb-later', 'kb-count-later'); + } + + function applyGrouping() { + removeGrouping(); + var rows = getRows(); + if (!rows.length) return; + var groups = {}; + var order = []; + rows.forEach(function (row) { + var subj = row.dataset.subject || '(科目なし)'; + if (!groups[subj]) { groups[subj] = []; order.push(subj); } + groups[subj].push(row); + }); + if (order.length <= 1) return; + var tbody = rows[0].closest('tbody'); + var theadRow = rows[0].closest('table').querySelector('thead tr'); + var colCount = theadRow ? theadRow.children.length : 8; + order.forEach(function (subj) { + var groupRows = groups[subj]; + var headerRow = document.createElement('tr'); + headerRow.className = 'subject-group-row'; + headerRow.dataset.group = subj; + var td = document.createElement('td'); + td.colSpan = colCount; + td.innerHTML = '' + XSS.escapeHtml(subj) + + ' ' + groupRows.length + ''; + headerRow.appendChild(td); + tbody.insertBefore(headerRow, groupRows[0]); + headerRow.addEventListener('click', function () { + var collapsed = headerRow.classList.toggle('collapsed'); + headerRow.querySelector('i').className = collapsed ? 'bi bi-chevron-right me-1' : 'bi bi-chevron-down me-1'; + groupRows.forEach(function (r) { r.style.display = collapsed ? 'none' : ''; }); + }); + }); + } + + function removeGrouping() { + document.querySelectorAll('.subject-group-row').forEach(function (r) { r.remove(); }); + getRows().forEach(function (r) { r.style.display = ''; }); + } + + function toggleGrouping() { + _grouped = !_grouped; + localStorage.setItem('grouped', _grouped); + var btn = document.getElementById('groupToggleBtn'); + var text = document.getElementById('groupBtnText'); + if (_grouped) { + applyGrouping(); + btn.classList.add('btn-secondary', 'text-white'); + btn.classList.remove('btn-outline-secondary'); + text.textContent = 'グループ解除'; + } else { + removeGrouping(); + btn.classList.remove('btn-secondary', 'text-white'); + btn.classList.add('btn-outline-secondary'); + text.textContent = 'グループ化'; + } + } + window.toggleGrouping = toggleGrouping; + + function updateBulkBar() { + var checked = document.querySelectorAll('.row-check:checked'); + var bar = document.getElementById('bulkBar'); + var countEl = document.getElementById('bulkCount'); + if (checked.length > 0) { + bar.classList.remove('d-none'); + countEl.textContent = checked.length + '件選択中'; + } else { + bar.classList.add('d-none'); + } + } + + function getCheckedIDs() { + return Array.from(document.querySelectorAll('.row-check:checked')).map(function (c) { return c.value; }); + } + + function submitBulkComplete() { + var ids = getCheckedIDs(); + if (!ids.length) return; + var form = document.getElementById('bulkCompleteForm'); + ids.forEach(function (id) { + var inp = document.createElement('input'); + inp.type = 'hidden'; inp.name = 'ids'; inp.value = id; + form.appendChild(inp); + }); + form.submit(); + } + window.submitBulkComplete = submitBulkComplete; + + function confirmBulkDelete() { + var ids = getCheckedIDs(); + if (!ids.length) return; + + var recurringMap = {}; + ids.forEach(function (id) { + var row = document.querySelector('.assignment-row[data-id="' + id + '"]'); + if (row && row.dataset.recurringId) { + var rid = row.dataset.recurringId; + if (!recurringMap[rid]) { + recurringMap[rid] = { title: row.dataset.title, count: 0 }; + } + recurringMap[rid].count++; + } + }); + + var recurringKeys = Object.keys(recurringMap); + + if (recurringKeys.length > 0) { + var list = document.getElementById('bulkDeleteRecurringList'); + list.innerHTML = ''; + recurringKeys.forEach(function (rid) { + var item = recurringMap[rid]; + var li = document.createElement('li'); + li.className = 'list-group-item py-2 px-2 small'; + li.innerHTML = '' + + XSS.escapeHtml(item.title) + + (item.count > 1 ? ' ' + item.count + '件' : ''); + list.appendChild(li); + }); + + var modalEl = document.getElementById('bulkDeleteRecurringModal'); + var modal = new bootstrap.Modal(modalEl); + + document.getElementById('bulkDeleteOnlyBtn').onclick = function () { + modal.hide(); + submitBulkDeleteForm(ids, false); + }; + document.getElementById('bulkDeleteWithRecurringBtn').onclick = function () { + modal.hide(); + submitBulkDeleteForm(ids, true); + }; + + modal.show(); + } else { + showConfirmModal(ids.length + '件の課題を削除しますか?', function () { + submitBulkDeleteForm(ids, false); + }); + } + } + window.confirmBulkDelete = confirmBulkDelete; + + function submitBulkDeleteForm(ids, deleteRecurring) { + var form = document.getElementById('bulkDeleteForm'); + form.querySelectorAll('input[name="ids"], input[name="delete_recurring"]').forEach(function (inp) { inp.remove(); }); + ids.forEach(function (id) { + var inp = document.createElement('input'); + inp.type = 'hidden'; inp.name = 'ids'; inp.value = id; + form.appendChild(inp); + }); + if (deleteRecurring) { + var inp = document.createElement('input'); + inp.type = 'hidden'; inp.name = 'delete_recurring'; inp.value = 'true'; + form.appendChild(inp); + } + form.submit(); + } + + function clearSelection() { + document.querySelectorAll('.row-check, #selectAll').forEach(function (c) { c.checked = false; }); + updateBulkBar(); + } + window.clearSelection = clearSelection; + + function moveFocus(delta) { + var rows = getRows().filter(function (r) { return r.style.display !== 'none'; }); + if (!rows.length) return; + rows.forEach(function (r) { r.classList.remove('kb-focus'); }); + _kbFocusIndex = Math.max(0, Math.min(rows.length - 1, _kbFocusIndex + delta)); + rows[_kbFocusIndex].classList.add('kb-focus'); + rows[_kbFocusIndex].scrollIntoView({ block: 'nearest' }); + } + + function toggleFocused() { + var rows = getRows().filter(function (r) { return r.style.display !== 'none'; }); + if (_kbFocusIndex < 0 || _kbFocusIndex >= rows.length) return; + var form = rows[_kbFocusIndex].querySelector('form[data-row-id]'); + if (form) form.submit(); + } + + document.addEventListener('keydown', function (e) { + if (!document.getElementById('tableView')) return; + if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + switch (e.key) { + case '/': + e.preventDefault(); + var s = document.getElementById('searchInput'); + if (s) s.focus(); + break; + case 'j': moveFocus(1); break; + case 'k': moveFocus(-1); break; + case 'x': toggleFocused(); break; + case 'n': + if (!document.activeElement || document.activeElement === document.body) { + window.location.href = '/assignments/new'; + } + break; + case 'Escape': clearSelection(); break; + } + }); + + var selectAll = document.getElementById('selectAll'); + if (selectAll) { + selectAll.addEventListener('change', function () { + document.querySelectorAll('.row-check').forEach(function (c) { c.checked = selectAll.checked; }); + updateBulkBar(); + }); + } + document.querySelectorAll('.row-check').forEach(function (c) { + c.addEventListener('change', function () { + var all = document.querySelectorAll('.row-check'); + var checked = document.querySelectorAll('.row-check:checked'); + if (selectAll) selectAll.checked = all.length === checked.length; + updateBulkBar(); + }); + }); + + var recurringModal = document.getElementById('recurringModal'); + if (recurringModal) { + recurringModal.addEventListener('show.bs.modal', function (event) { + var button = event.relatedTarget; + var id = button.getAttribute('data-recurring-id'); + var title = button.getAttribute('data-recurring-title'); + var type = button.getAttribute('data-recurring-type'); + var 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'; + var typeLabels = { daily: '毎日', weekly: '毎週', monthly: '毎月', unknown: '(不明)' }; + document.getElementById('recurringTypeLabel').textContent = typeLabels[type] || type || '不明'; + var statusEl = document.getElementById('recurringStatus'); + if (isActive) { + statusEl.innerHTML = '有効'; + document.getElementById('recurringStopBtn').style.display = 'inline-block'; + } else { + statusEl.innerHTML = '停止中'; + document.getElementById('recurringStopBtn').style.display = 'none'; + } + }); + } + + if (localStorage.getItem('countdownHidden') === 'true') { + document.querySelectorAll('.countdown-col').forEach(function (col) { col.style.display = 'none'; }); + var btn = document.getElementById('toggleCountdownBtn'); + var btnText = document.getElementById('countdownBtnText'); + if (btnText) btnText.textContent = '残り表示'; + if (btn) btn.setAttribute('aria-pressed', 'true'); + } + + var gBtn = document.getElementById('groupToggleBtn'); + var gText = document.getElementById('groupBtnText'); + if (_grouped && gBtn) { + gBtn.classList.add('btn-secondary', 'text-white'); + gBtn.classList.remove('btn-outline-secondary'); + if (gText) gText.textContent = 'グループ解除'; + } + + window.showDeleteRecurringModal = function (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(); + }; + + setView(_view); + updateCountdowns(); +} diff --git a/web/templates/assignments/index.html b/web/templates/assignments/index.html index 4b92a96..e537b27 100644 --- a/web/templates/assignments/index.html +++ b/web/templates/assignments/index.html @@ -1,11 +1,24 @@ {{template "base" .}} {{define "content"}} + +

課題一覧

-
+
+
+ + +
+ 新規登録 @@ -16,23 +29,23 @@