Migrate from Gin to Echo framework and add comprehensive integration tests
Major changes: - Migrate all handlers from Gin to Echo framework - Add new apperrors, echohelpers, and validator packages - Update middleware for Echo compatibility - Add ArchivedHandler to task categorization chain (archived tasks go to cancelled_tasks column) - Add 6 new integration tests: - RecurringTaskLifecycle: NextDueDate advancement for weekly/monthly tasks - MultiUserSharing: Complex sharing with user removal - TaskStateTransitions: All state transitions and kanban column changes - DateBoundaryEdgeCases: Threshold boundary testing - CascadeOperations: Residence deletion cascade effects - MultiUserOperations: Shared residence collaboration - Add single-purpose repository functions for kanban columns (GetOverdueTasks, GetDueSoonTasks, etc.) - Fix RemoveUser route param mismatch (userId -> user_id) - Fix determineExpectedColumn helper to correctly prioritize in_progress over overdue 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,38 +36,59 @@ func (c KanbanColumn) String() string {
|
||||
// Context holds the data needed to categorize a task
|
||||
type Context struct {
|
||||
Task *models.Task
|
||||
Now time.Time
|
||||
Now time.Time // Always normalized to start of day
|
||||
DaysThreshold int
|
||||
}
|
||||
|
||||
// startOfDay normalizes a time to the start of that day (midnight)
|
||||
func startOfDay(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
|
||||
// normalizeToTimezone converts a date to start of day in a specific timezone.
|
||||
// This is needed because task due dates are stored as midnight UTC, but we need
|
||||
// to compare them as calendar dates in the user's timezone.
|
||||
//
|
||||
// Example: A task due Dec 17 is stored as 2025-12-17 00:00:00 UTC.
|
||||
// For a user in Tokyo (UTC+9), we need to compare against Dec 17 in Tokyo time,
|
||||
// not against the UTC timestamp.
|
||||
func normalizeToTimezone(t time.Time, loc *time.Location) time.Time {
|
||||
// Extract the calendar date (year, month, day) from the time
|
||||
// regardless of its original timezone, then create midnight in target timezone
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
|
||||
}
|
||||
|
||||
// NewContext creates a new categorization context with sensible defaults.
|
||||
// Uses UTC time. For timezone-aware categorization, use NewContextWithTime.
|
||||
// Uses UTC time, normalized to start of day.
|
||||
// For timezone-aware categorization, use NewContextWithTime.
|
||||
func NewContext(t *models.Task, daysThreshold int) *Context {
|
||||
if daysThreshold <= 0 {
|
||||
daysThreshold = 30
|
||||
}
|
||||
return &Context{
|
||||
Task: t,
|
||||
Now: time.Now().UTC(),
|
||||
Now: startOfDay(time.Now().UTC()),
|
||||
DaysThreshold: daysThreshold,
|
||||
}
|
||||
}
|
||||
|
||||
// NewContextWithTime creates a new categorization context with a specific time.
|
||||
// Use this when you need timezone-aware categorization - pass the start of day
|
||||
// in the user's timezone.
|
||||
// The time is normalized to start of day for consistent date comparisons.
|
||||
// Use this when you need timezone-aware categorization - pass the current time
|
||||
// in the user's timezone (it will be normalized to start of day).
|
||||
func NewContextWithTime(t *models.Task, daysThreshold int, now time.Time) *Context {
|
||||
if daysThreshold <= 0 {
|
||||
daysThreshold = 30
|
||||
}
|
||||
return &Context{
|
||||
Task: t,
|
||||
Now: now,
|
||||
Now: startOfDay(now),
|
||||
DaysThreshold: daysThreshold,
|
||||
}
|
||||
}
|
||||
|
||||
// ThresholdDate returns the date threshold for "due soon" categorization
|
||||
// (start of day + daysThreshold days)
|
||||
func (c *Context) ThresholdDate() time.Time {
|
||||
return c.Now.AddDate(0, 0, c.DaysThreshold)
|
||||
}
|
||||
@@ -118,8 +139,24 @@ func (h *CancelledHandler) Handle(ctx *Context) KanbanColumn {
|
||||
return h.HandleNext(ctx)
|
||||
}
|
||||
|
||||
// ArchivedHandler checks if the task is archived
|
||||
// Priority: 2 - Archived tasks go to cancelled column (both are "inactive" states)
|
||||
type ArchivedHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
|
||||
func (h *ArchivedHandler) Handle(ctx *Context) KanbanColumn {
|
||||
// Uses predicate: predicates.IsArchived
|
||||
// Archived tasks are placed in the cancelled column since both represent
|
||||
// "inactive" task states that are removed from active workflow
|
||||
if predicates.IsArchived(ctx.Task) {
|
||||
return ColumnCancelled
|
||||
}
|
||||
return h.HandleNext(ctx)
|
||||
}
|
||||
|
||||
// CompletedHandler checks if the task is completed (one-time task with completions and no next due date)
|
||||
// Priority: 2
|
||||
// Priority: 3
|
||||
type CompletedHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
@@ -134,7 +171,7 @@ func (h *CompletedHandler) Handle(ctx *Context) KanbanColumn {
|
||||
}
|
||||
|
||||
// InProgressHandler checks if the task status is "In Progress"
|
||||
// Priority: 3
|
||||
// Priority: 4
|
||||
type InProgressHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
@@ -148,7 +185,7 @@ func (h *InProgressHandler) Handle(ctx *Context) KanbanColumn {
|
||||
}
|
||||
|
||||
// OverdueHandler checks if the task is overdue based on NextDueDate or DueDate
|
||||
// Priority: 4
|
||||
// Priority: 5
|
||||
type OverdueHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
@@ -158,14 +195,22 @@ func (h *OverdueHandler) Handle(ctx *Context) KanbanColumn {
|
||||
// Note: We don't use predicates.IsOverdue here because the chain has already
|
||||
// filtered out cancelled and completed tasks. We just need the date check.
|
||||
effectiveDate := predicates.EffectiveDate(ctx.Task)
|
||||
if effectiveDate != nil && effectiveDate.Before(ctx.Now) {
|
||||
if effectiveDate == nil {
|
||||
return h.HandleNext(ctx)
|
||||
}
|
||||
|
||||
// Normalize the effective date to the same timezone as ctx.Now for proper
|
||||
// calendar date comparison. Task dates are stored as UTC but represent
|
||||
// calendar dates (YYYY-MM-DD), not timestamps.
|
||||
normalizedEffective := normalizeToTimezone(*effectiveDate, ctx.Now.Location())
|
||||
if normalizedEffective.Before(ctx.Now) {
|
||||
return ColumnOverdue
|
||||
}
|
||||
return h.HandleNext(ctx)
|
||||
}
|
||||
|
||||
// DueSoonHandler checks if the task is due within the threshold period
|
||||
// Priority: 5
|
||||
// Priority: 6
|
||||
type DueSoonHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
@@ -173,16 +218,24 @@ type DueSoonHandler struct {
|
||||
func (h *DueSoonHandler) Handle(ctx *Context) KanbanColumn {
|
||||
// Uses predicate: predicates.EffectiveDate
|
||||
effectiveDate := predicates.EffectiveDate(ctx.Task)
|
||||
if effectiveDate == nil {
|
||||
return h.HandleNext(ctx)
|
||||
}
|
||||
|
||||
// Normalize the effective date to the same timezone as ctx.Now for proper
|
||||
// calendar date comparison. Task dates are stored as UTC but represent
|
||||
// calendar dates (YYYY-MM-DD), not timestamps.
|
||||
normalizedEffective := normalizeToTimezone(*effectiveDate, ctx.Now.Location())
|
||||
threshold := ctx.ThresholdDate()
|
||||
|
||||
if effectiveDate != nil && effectiveDate.Before(threshold) {
|
||||
if normalizedEffective.Before(threshold) {
|
||||
return ColumnDueSoon
|
||||
}
|
||||
return h.HandleNext(ctx)
|
||||
}
|
||||
|
||||
// UpcomingHandler is the final handler that catches all remaining tasks
|
||||
// Priority: 6 (lowest - default)
|
||||
// Priority: 7 (lowest - default)
|
||||
type UpcomingHandler struct {
|
||||
BaseHandler
|
||||
}
|
||||
@@ -206,14 +259,16 @@ type Chain struct {
|
||||
func NewChain() *Chain {
|
||||
// Build the chain in priority order (first handler has highest priority)
|
||||
cancelled := &CancelledHandler{}
|
||||
archived := &ArchivedHandler{}
|
||||
completed := &CompletedHandler{}
|
||||
inProgress := &InProgressHandler{}
|
||||
overdue := &OverdueHandler{}
|
||||
dueSoon := &DueSoonHandler{}
|
||||
upcoming := &UpcomingHandler{}
|
||||
|
||||
// Chain them together: cancelled -> completed -> inProgress -> overdue -> dueSoon -> upcoming
|
||||
cancelled.SetNext(completed).
|
||||
// Chain them together: cancelled -> archived -> completed -> inProgress -> overdue -> dueSoon -> upcoming
|
||||
cancelled.SetNext(archived).
|
||||
SetNext(completed).
|
||||
SetNext(inProgress).
|
||||
SetNext(overdue).
|
||||
SetNext(dueSoon).
|
||||
|
||||
Reference in New Issue
Block a user