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
This commit is contained in:
2025-11-29 02:09:30 +01:00
parent 9a0637aa4c
commit 90d5628a42
6 changed files with 130 additions and 32 deletions
+1 -1
View File
@@ -108,7 +108,7 @@ func main() {
unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo) unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo)
authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry) 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) statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler)
healthHandlers := httpInfra.NewHealthHandlers(db.Conn()) healthHandlers := httpInfra.NewHealthHandlers(db.Conn())
userHandlers := httpInfra.NewUserHandlers(deleteUserHandler) userHandlers := httpInfra.NewUserHandlers(deleteUserHandler)
@@ -9,6 +9,12 @@ import (
"apocapoc-api/internal/shared/utils" "apocapoc-api/internal/shared/utils"
) )
type TodaysHabitEntryDTO struct {
ID string
Value *float64
CompletedAt time.Time
}
type TodaysHabitDTO struct { type TodaysHabitDTO struct {
ID string ID string
Name string Name string
@@ -17,6 +23,7 @@ type TodaysHabitDTO struct {
IsNegative bool IsNegative bool
ScheduledDate time.Time ScheduledDate time.Time
IsCarriedOver bool IsCarriedOver bool
Entry *TodaysHabitEntryDTO
} }
type GetTodaysHabitsQuery struct { type GetTodaysHabitsQuery struct {
@@ -66,19 +73,19 @@ func (h *GetTodaysHabitsHandler) Handle(
entries, _ := h.entryRepo.FindByHabitIDAndDateRange( entries, _ := h.entryRepo.FindByHabitIDAndDateRange(
ctx, ctx,
habit.ID, habit.ID,
query.Date.AddDate(0, 0, -30), query.Date,
query.Date, query.Date,
) )
isCompleted := false var entryDTO *TodaysHabitEntryDTO
for _, entry := range entries { if len(entries) > 0 && entries[0].ScheduledDate.Format("2006-01-02") == query.Date.Format("2006-01-02") {
if entry.ScheduledDate.Equal(query.Date) { entryDTO = &TodaysHabitEntryDTO{
isCompleted = true ID: entries[0].ID,
break Value: entries[0].Value,
CompletedAt: entries[0].CompletedAt,
} }
} }
if !isCompleted {
result = append(result, TodaysHabitDTO{ result = append(result, TodaysHabitDTO{
ID: habit.ID, ID: habit.ID,
Name: habit.Name, Name: habit.Name,
@@ -87,9 +94,9 @@ func (h *GetTodaysHabitsHandler) Handle(
IsNegative: habit.IsNegative, IsNegative: habit.IsNegative,
ScheduledDate: query.Date, ScheduledDate: query.Date,
IsCarriedOver: !shouldAppear && habit.CarryOver, IsCarriedOver: !shouldAppear && habit.CarryOver,
Entry: entryDTO,
}) })
} }
}
return result, nil return result, nil
} }
@@ -107,6 +107,10 @@ func TestGetTodaysHabitsHandler_DailyHabitNoEntries(t *testing.T) {
if results[0].IsCarriedOver { if results[0].IsCarriedOver {
t.Error("Expected IsCarriedOver to be false") 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) { 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) targetDate := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC)
entry := entities.NewHabitEntry("habit-1", targetDate, nil) entry := entities.NewHabitEntry("habit-1", targetDate, nil)
entry.ID = "entry-1"
habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit}} habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit}}
entryRepo := &mockEntryRepo{entries: []*entities.HabitEntry{entry}} entryRepo := &mockEntryRepo{entries: []*entities.HabitEntry{entry}}
@@ -133,8 +138,61 @@ func TestGetTodaysHabitsHandler_DailyHabitAlreadyCompleted(t *testing.T) {
t.Fatalf("Expected no error, got %v", err) t.Fatalf("Expected no error, got %v", err)
} }
if len(results) != 0 { if len(results) != 1 {
t.Fatalf("Expected 0 habits (already completed), got %d", len(results)) 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)
} }
} }
+7
View File
@@ -48,6 +48,12 @@ type MarkHabitRequest struct {
Value *float64 `json:"value,omitempty"` 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 { type TodaysHabitResponse struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -56,6 +62,7 @@ type TodaysHabitResponse struct {
IsNegative bool `json:"is_negative"` IsNegative bool `json:"is_negative"`
ScheduledDate time.Time `json:"scheduled_date"` ScheduledDate time.Time `json:"scheduled_date"`
IsCarriedOver bool `json:"is_carried_over"` IsCarriedOver bool `json:"is_carried_over"`
Entry *TodaysHabitEntryResponse `json:"entry,omitempty"`
} }
type UserHabitResponse struct { type UserHabitResponse struct {
+30 -4
View File
@@ -9,6 +9,7 @@ import (
"apocapoc-api/internal/application/commands" "apocapoc-api/internal/application/commands"
"apocapoc-api/internal/application/queries" "apocapoc-api/internal/application/queries"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/errors" "apocapoc-api/internal/shared/errors"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -24,6 +25,7 @@ type HabitHandlers struct {
archiveHandler *commands.ArchiveHabitHandler archiveHandler *commands.ArchiveHabitHandler
markHandler *commands.MarkHabitHandler markHandler *commands.MarkHabitHandler
unmarkHandler *commands.UnmarkHabitHandler unmarkHandler *commands.UnmarkHabitHandler
userRepo repositories.UserRepository
} }
func NewHabitHandlers( func NewHabitHandlers(
@@ -36,6 +38,7 @@ func NewHabitHandlers(
archiveHandler *commands.ArchiveHabitHandler, archiveHandler *commands.ArchiveHabitHandler,
markHandler *commands.MarkHabitHandler, markHandler *commands.MarkHabitHandler,
unmarkHandler *commands.UnmarkHabitHandler, unmarkHandler *commands.UnmarkHabitHandler,
userRepo repositories.UserRepository,
) *HabitHandlers { ) *HabitHandlers {
return &HabitHandlers{ return &HabitHandlers{
createHandler: createHandler, createHandler: createHandler,
@@ -47,6 +50,7 @@ func NewHabitHandlers(
archiveHandler: archiveHandler, archiveHandler: archiveHandler,
markHandler: markHandler, markHandler: markHandler,
unmarkHandler: unmarkHandler, unmarkHandler: unmarkHandler,
userRepo: userRepo,
} }
} }
@@ -432,7 +436,7 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request)
// GetTodaysHabits godoc // GetTodaysHabits godoc
// @Summary Get today's habits // @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 // @Tags habits
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
@@ -447,12 +451,24 @@ func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request)
return 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{ query := queries.GetTodaysHabitsQuery{
UserID: userID, UserID: userID,
Timezone: timezone, Timezone: user.Timezone,
Date: time.Now().UTC(), Date: todayDate,
} }
habits, err := h.getTodaysHandler.Handle(r.Context(), query) 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)) response := make([]TodaysHabitResponse, len(habits))
for i, habit := range 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{ response[i] = TodaysHabitResponse{
ID: habit.ID, ID: habit.ID,
Name: habit.Name, Name: habit.Name,
@@ -471,6 +496,7 @@ func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request)
IsNegative: habit.IsNegative, IsNegative: habit.IsNegative,
ScheduledDate: habit.ScheduledDate, ScheduledDate: habit.ScheduledDate,
IsCarriedOver: habit.IsCarriedOver, IsCarriedOver: habit.IsCarriedOver,
Entry: entryResponse,
} }
} }
@@ -67,7 +67,7 @@ func setupTestServer(t *testing.T) *TestServer {
deleteUserHandler := commands.NewDeleteUserHandler(userRepo) deleteUserHandler := commands.NewDeleteUserHandler(userRepo)
authHandlers := NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry) 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) statsHandlers := NewStatsHandlers(getHabitStatsHandler)
healthHandlers := NewHealthHandlers(db) healthHandlers := NewHealthHandlers(db)
userHandlers := NewUserHandlers(deleteUserHandler) userHandlers := NewUserHandlers(deleteUserHandler)