UXの大幅な改善

This commit is contained in:
2026-04-26 15:38:38 +09:00
parent 098f636a65
commit 2fdcca35e6
9 changed files with 1076 additions and 276 deletions

View File

@@ -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)

View File

@@ -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"`

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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,
}
}