From 90d5628a428870905f6c08901b39bf2c34b5e030 Mon Sep 17 00:00:00 2001 From: David Folch Agulles Date: Sat, 29 Nov 2025 02:09:30 +0100 Subject: [PATCH] Fix today's habits timezone calculation and include entry data - Calculate today based on user's timezone instead of always using UTC - Include habit entry in response if it exists for the current day - Update GetTodaysHabitsHandler to fetch and return entry information - Add entry field to TodaysHabitDTO and TodaysHabitResponse - Update tests to reflect new behavior of including completed habits - Add test for habits with value entries --- cmd/api/main.go | 2 +- .../application/queries/get_todays_habits.go | 41 +++++++----- .../queries/get_todays_habits_test.go | 62 ++++++++++++++++++- internal/infrastructure/http/dto.go | 21 ++++--- .../infrastructure/http/habit_handlers.go | 34 ++++++++-- .../infrastructure/http/integration_test.go | 2 +- 6 files changed, 130 insertions(+), 32 deletions(-) diff --git a/cmd/api/main.go b/cmd/api/main.go index 2b3a4da..f337944 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -108,7 +108,7 @@ func main() { unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo) authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry) - habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler) + habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler, userRepo) statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler) healthHandlers := httpInfra.NewHealthHandlers(db.Conn()) userHandlers := httpInfra.NewUserHandlers(deleteUserHandler) diff --git a/internal/application/queries/get_todays_habits.go b/internal/application/queries/get_todays_habits.go index e9d820b..be224e2 100644 --- a/internal/application/queries/get_todays_habits.go +++ b/internal/application/queries/get_todays_habits.go @@ -9,6 +9,12 @@ import ( "apocapoc-api/internal/shared/utils" ) +type TodaysHabitEntryDTO struct { + ID string + Value *float64 + CompletedAt time.Time +} + type TodaysHabitDTO struct { ID string Name string @@ -17,6 +23,7 @@ type TodaysHabitDTO struct { IsNegative bool ScheduledDate time.Time IsCarriedOver bool + Entry *TodaysHabitEntryDTO } type GetTodaysHabitsQuery struct { @@ -66,29 +73,29 @@ func (h *GetTodaysHabitsHandler) Handle( entries, _ := h.entryRepo.FindByHabitIDAndDateRange( ctx, habit.ID, - query.Date.AddDate(0, 0, -30), + query.Date, query.Date, ) - isCompleted := false - for _, entry := range entries { - if entry.ScheduledDate.Equal(query.Date) { - isCompleted = true - break + var entryDTO *TodaysHabitEntryDTO + if len(entries) > 0 && entries[0].ScheduledDate.Format("2006-01-02") == query.Date.Format("2006-01-02") { + entryDTO = &TodaysHabitEntryDTO{ + ID: entries[0].ID, + Value: entries[0].Value, + CompletedAt: entries[0].CompletedAt, } } - if !isCompleted { - result = append(result, TodaysHabitDTO{ - ID: habit.ID, - Name: habit.Name, - Type: habit.Type, - TargetValue: habit.TargetValue, - IsNegative: habit.IsNegative, - ScheduledDate: query.Date, - IsCarriedOver: !shouldAppear && habit.CarryOver, - }) - } + result = append(result, TodaysHabitDTO{ + ID: habit.ID, + Name: habit.Name, + Type: habit.Type, + TargetValue: habit.TargetValue, + IsNegative: habit.IsNegative, + ScheduledDate: query.Date, + IsCarriedOver: !shouldAppear && habit.CarryOver, + Entry: entryDTO, + }) } return result, nil diff --git a/internal/application/queries/get_todays_habits_test.go b/internal/application/queries/get_todays_habits_test.go index 2e0c865..dce552e 100644 --- a/internal/application/queries/get_todays_habits_test.go +++ b/internal/application/queries/get_todays_habits_test.go @@ -107,6 +107,10 @@ func TestGetTodaysHabitsHandler_DailyHabitNoEntries(t *testing.T) { if results[0].IsCarriedOver { t.Error("Expected IsCarriedOver to be false") } + + if results[0].Entry != nil { + t.Error("Expected entry to be nil when no entry exists") + } } func TestGetTodaysHabitsHandler_DailyHabitAlreadyCompleted(t *testing.T) { @@ -115,6 +119,7 @@ func TestGetTodaysHabitsHandler_DailyHabitAlreadyCompleted(t *testing.T) { targetDate := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC) entry := entities.NewHabitEntry("habit-1", targetDate, nil) + entry.ID = "entry-1" habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit}} entryRepo := &mockEntryRepo{entries: []*entities.HabitEntry{entry}} @@ -133,8 +138,61 @@ func TestGetTodaysHabitsHandler_DailyHabitAlreadyCompleted(t *testing.T) { t.Fatalf("Expected no error, got %v", err) } - if len(results) != 0 { - t.Fatalf("Expected 0 habits (already completed), got %d", len(results)) + if len(results) != 1 { + t.Fatalf("Expected 1 habit (with entry), got %d", len(results)) + } + + if results[0].Entry == nil { + t.Fatal("Expected entry to be present") + } + + if results[0].Entry.ID != "entry-1" { + t.Errorf("Expected entry ID entry-1, got %s", results[0].Entry.ID) + } +} + +func TestGetTodaysHabitsHandler_HabitWithValueEntry(t *testing.T) { + habit := entities.NewHabit("user-123", "Water", value_objects.HabitTypeValue, value_objects.FrequencyDaily, false, false) + habit.ID = "habit-1" + targetValue := 2000.0 + habit.TargetValue = &targetValue + + targetDate := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC) + value := 1500.0 + entry := entities.NewHabitEntry("habit-1", targetDate, &value) + entry.ID = "entry-1" + + habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit}} + entryRepo := &mockEntryRepo{entries: []*entities.HabitEntry{entry}} + + handler := NewGetTodaysHabitsHandler(habitRepo, entryRepo) + + query := GetTodaysHabitsQuery{ + UserID: "user-123", + Timezone: "UTC", + Date: targetDate, + } + + results, err := handler.Handle(context.Background(), query) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(results) != 1 { + t.Fatalf("Expected 1 habit, got %d", len(results)) + } + + if results[0].Entry == nil { + t.Fatal("Expected entry to be present") + } + + if results[0].Entry.Value == nil { + t.Fatal("Expected entry value to be present") + } + + if *results[0].Entry.Value != 1500.0 { + t.Errorf("Expected entry value 1500.0, got %f", *results[0].Entry.Value) } } diff --git a/internal/infrastructure/http/dto.go b/internal/infrastructure/http/dto.go index b207f1e..28448f2 100644 --- a/internal/infrastructure/http/dto.go +++ b/internal/infrastructure/http/dto.go @@ -48,14 +48,21 @@ type MarkHabitRequest struct { Value *float64 `json:"value,omitempty"` } +type TodaysHabitEntryResponse struct { + ID string `json:"id"` + Value *float64 `json:"value,omitempty"` + CompletedAt time.Time `json:"completed_at"` +} + type TodaysHabitResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Type value_objects.HabitType `json:"type"` - TargetValue *float64 `json:"target_value,omitempty"` - IsNegative bool `json:"is_negative"` - ScheduledDate time.Time `json:"scheduled_date"` - IsCarriedOver bool `json:"is_carried_over"` + ID string `json:"id"` + Name string `json:"name"` + Type value_objects.HabitType `json:"type"` + TargetValue *float64 `json:"target_value,omitempty"` + IsNegative bool `json:"is_negative"` + ScheduledDate time.Time `json:"scheduled_date"` + IsCarriedOver bool `json:"is_carried_over"` + Entry *TodaysHabitEntryResponse `json:"entry,omitempty"` } type UserHabitResponse struct { diff --git a/internal/infrastructure/http/habit_handlers.go b/internal/infrastructure/http/habit_handlers.go index 385f6ed..34b88e0 100644 --- a/internal/infrastructure/http/habit_handlers.go +++ b/internal/infrastructure/http/habit_handlers.go @@ -9,6 +9,7 @@ import ( "apocapoc-api/internal/application/commands" "apocapoc-api/internal/application/queries" + "apocapoc-api/internal/domain/repositories" "apocapoc-api/internal/shared/errors" "github.com/go-chi/chi/v5" @@ -24,6 +25,7 @@ type HabitHandlers struct { archiveHandler *commands.ArchiveHabitHandler markHandler *commands.MarkHabitHandler unmarkHandler *commands.UnmarkHabitHandler + userRepo repositories.UserRepository } func NewHabitHandlers( @@ -36,6 +38,7 @@ func NewHabitHandlers( archiveHandler *commands.ArchiveHabitHandler, markHandler *commands.MarkHabitHandler, unmarkHandler *commands.UnmarkHabitHandler, + userRepo repositories.UserRepository, ) *HabitHandlers { return &HabitHandlers{ createHandler: createHandler, @@ -47,6 +50,7 @@ func NewHabitHandlers( archiveHandler: archiveHandler, markHandler: markHandler, unmarkHandler: unmarkHandler, + userRepo: userRepo, } } @@ -432,7 +436,7 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request) // GetTodaysHabits godoc // @Summary Get today's habits -// @Description Get all habits scheduled for today for the authenticated user +// @Description Get all habits scheduled for today for the authenticated user. Includes the entry for today if it exists. // @Tags habits // @Produce json // @Security BearerAuth @@ -447,12 +451,24 @@ func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request) return } - timezone := "UTC" + user, err := h.userRepo.FindByID(r.Context(), userID) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to get user") + return + } + + loc, err := time.LoadLocation(user.Timezone) + if err != nil { + loc = time.UTC + } + + today := time.Now().In(loc) + todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC) query := queries.GetTodaysHabitsQuery{ UserID: userID, - Timezone: timezone, - Date: time.Now().UTC(), + Timezone: user.Timezone, + Date: todayDate, } habits, err := h.getTodaysHandler.Handle(r.Context(), query) @@ -463,6 +479,15 @@ func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request) response := make([]TodaysHabitResponse, len(habits)) for i, habit := range habits { + var entryResponse *TodaysHabitEntryResponse + if habit.Entry != nil { + entryResponse = &TodaysHabitEntryResponse{ + ID: habit.Entry.ID, + Value: habit.Entry.Value, + CompletedAt: habit.Entry.CompletedAt, + } + } + response[i] = TodaysHabitResponse{ ID: habit.ID, Name: habit.Name, @@ -471,6 +496,7 @@ func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request) IsNegative: habit.IsNegative, ScheduledDate: habit.ScheduledDate, IsCarriedOver: habit.IsCarriedOver, + Entry: entryResponse, } } diff --git a/internal/infrastructure/http/integration_test.go b/internal/infrastructure/http/integration_test.go index 08a182b..4317b45 100644 --- a/internal/infrastructure/http/integration_test.go +++ b/internal/infrastructure/http/integration_test.go @@ -67,7 +67,7 @@ func setupTestServer(t *testing.T) *TestServer { deleteUserHandler := commands.NewDeleteUserHandler(userRepo) authHandlers := NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry) - habitHandlers := NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler) + habitHandlers := NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler, userRepo) statsHandlers := NewStatsHandlers(getHabitStatsHandler) healthHandlers := NewHealthHandlers(db) userHandlers := NewUserHandlers(deleteUserHandler)