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:
Trey t
2025-12-16 13:52:08 -06:00
parent c51f1ce34a
commit 6dac34e373
98 changed files with 8209 additions and 4425 deletions

View File

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