- Categories: regular, deposits, credits, recurring, multi-user, accounts - Transactions: deposits and withdrawals with user tracking - Recurring plans: monthly payment obligations per user - Stats: overdues calculation with allocation algorithm - Excludes is_account categories from total sums - Documentation: docs/SAVINGS.md
329 lines
7.8 KiB
Go
329 lines
7.8 KiB
Go
package service
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/daniil/homelab-api/internal/model"
|
|
"github.com/daniil/homelab-api/internal/repository"
|
|
)
|
|
|
|
var ErrFutureDate = errors.New("cannot log habit for future date")
|
|
var ErrAlreadyLogged = errors.New("habit already logged for this date")
|
|
|
|
type HabitService struct {
|
|
habitRepo *repository.HabitRepository
|
|
freezeRepo *repository.HabitFreezeRepository
|
|
}
|
|
|
|
func NewHabitService(habitRepo *repository.HabitRepository, freezeRepo *repository.HabitFreezeRepository) *HabitService {
|
|
return &HabitService{
|
|
habitRepo: habitRepo,
|
|
freezeRepo: freezeRepo,
|
|
}
|
|
}
|
|
|
|
func (s *HabitService) Create(userID int64, req *model.CreateHabitRequest) (*model.Habit, error) {
|
|
habit := &model.Habit{
|
|
UserID: userID,
|
|
Name: req.Name,
|
|
Description: req.Description,
|
|
Color: defaultString(req.Color, "#6366f1"),
|
|
Icon: defaultString(req.Icon, "check"),
|
|
Frequency: defaultString(req.Frequency, "daily"),
|
|
TargetDays: req.TargetDays,
|
|
TargetCount: defaultInt(req.TargetCount, 1),
|
|
}
|
|
|
|
if req.ReminderTime != nil && *req.ReminderTime != "" {
|
|
habit.ReminderTime = sql.NullString{String: *req.ReminderTime, Valid: true}
|
|
}
|
|
|
|
// Handle start_date - default to today if not provided
|
|
if req.StartDate != nil && *req.StartDate != "" {
|
|
parsed, err := time.Parse("2006-01-02", *req.StartDate)
|
|
if err == nil {
|
|
habit.StartDate = sql.NullTime{Time: parsed, Valid: true}
|
|
}
|
|
} else {
|
|
// Default to today
|
|
habit.StartDate = sql.NullTime{Time: time.Now().Truncate(24 * time.Hour), Valid: true}
|
|
}
|
|
|
|
if err := s.habitRepo.Create(habit); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
habit.ProcessForJSON()
|
|
return habit, nil
|
|
}
|
|
|
|
func (s *HabitService) Get(id, userID int64) (*model.Habit, error) {
|
|
return s.habitRepo.GetByID(id, userID)
|
|
}
|
|
|
|
func (s *HabitService) List(userID int64, includeArchived bool) ([]model.Habit, error) {
|
|
habits, err := s.habitRepo.ListByUser(userID, includeArchived)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if habits == nil {
|
|
habits = []model.Habit{}
|
|
}
|
|
return habits, nil
|
|
}
|
|
|
|
func (s *HabitService) Update(id, userID int64, req *model.UpdateHabitRequest) (*model.Habit, error) {
|
|
habit, err := s.habitRepo.GetByID(id, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if req.Name != nil {
|
|
habit.Name = *req.Name
|
|
}
|
|
if req.Description != nil {
|
|
habit.Description = *req.Description
|
|
}
|
|
if req.Color != nil {
|
|
habit.Color = *req.Color
|
|
}
|
|
if req.Icon != nil {
|
|
habit.Icon = *req.Icon
|
|
}
|
|
if req.Frequency != nil {
|
|
habit.Frequency = *req.Frequency
|
|
}
|
|
if req.TargetDays != nil {
|
|
habit.TargetDays = req.TargetDays
|
|
}
|
|
if req.TargetCount != nil {
|
|
habit.TargetCount = *req.TargetCount
|
|
}
|
|
if req.ReminderTime != nil {
|
|
if *req.ReminderTime == "" {
|
|
habit.ReminderTime = sql.NullString{Valid: false}
|
|
} else {
|
|
habit.ReminderTime = sql.NullString{String: *req.ReminderTime, Valid: true}
|
|
}
|
|
}
|
|
if req.StartDate != nil {
|
|
if *req.StartDate == "" {
|
|
habit.StartDate = sql.NullTime{Valid: false}
|
|
} else {
|
|
parsed, err := time.Parse("2006-01-02", *req.StartDate)
|
|
if err == nil {
|
|
habit.StartDate = sql.NullTime{Time: parsed, Valid: true}
|
|
}
|
|
}
|
|
}
|
|
if req.IsArchived != nil {
|
|
habit.IsArchived = *req.IsArchived
|
|
}
|
|
|
|
if err := s.habitRepo.Update(habit); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
habit.ProcessForJSON()
|
|
return habit, nil
|
|
}
|
|
|
|
func (s *HabitService) Delete(id, userID int64) error {
|
|
return s.habitRepo.Delete(id, userID)
|
|
}
|
|
|
|
func (s *HabitService) Log(habitID, userID int64, req *model.LogHabitRequest) (*model.HabitLog, error) {
|
|
// Verify habit exists and belongs to user
|
|
if _, err := s.habitRepo.GetByID(habitID, userID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
today := time.Now().Truncate(24 * time.Hour)
|
|
date := today
|
|
|
|
if req.Date != "" {
|
|
parsed, err := time.Parse("2006-01-02", req.Date)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
date = parsed.Truncate(24 * time.Hour)
|
|
}
|
|
|
|
// Validate: cannot log for future date
|
|
if date.After(today) {
|
|
return nil, ErrFutureDate
|
|
}
|
|
|
|
// Check if already logged for this date
|
|
alreadyLogged, err := s.habitRepo.IsHabitCompletedOnDate(habitID, userID, date)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if alreadyLogged {
|
|
return nil, ErrAlreadyLogged
|
|
}
|
|
|
|
log := &model.HabitLog{
|
|
HabitID: habitID,
|
|
UserID: userID,
|
|
Date: date,
|
|
Count: defaultInt(req.Count, 1),
|
|
Note: req.Note,
|
|
}
|
|
|
|
if err := s.habitRepo.CreateLog(log); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return log, nil
|
|
}
|
|
|
|
func (s *HabitService) GetLogs(habitID, userID int64, days int) ([]model.HabitLog, error) {
|
|
// Verify habit exists and belongs to user
|
|
if _, err := s.habitRepo.GetByID(habitID, userID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
to := time.Now()
|
|
from := to.AddDate(0, 0, -days)
|
|
|
|
logs, err := s.habitRepo.GetLogs(habitID, userID, from, to)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if logs == nil {
|
|
logs = []model.HabitLog{}
|
|
}
|
|
return logs, nil
|
|
}
|
|
|
|
func (s *HabitService) DeleteLog(logID, userID int64) error {
|
|
return s.habitRepo.DeleteLog(logID, userID)
|
|
}
|
|
|
|
func (s *HabitService) GetHabitStats(habitID, userID int64) (*model.HabitStats, error) {
|
|
// Verify habit exists and belongs to user
|
|
habit, err := s.habitRepo.GetByID(habitID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
stats, err := s.habitRepo.GetStats(habitID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Recalculate completion percentage with frozen days excluded
|
|
stats.CompletionPct = s.calculateCompletionPctWithFreezes(habit, stats.TotalLogs)
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
func (s *HabitService) GetOverallStats(userID int64) (*model.OverallStats, error) {
|
|
habits, err := s.habitRepo.ListByUser(userID, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
allHabits, err := s.habitRepo.ListByUser(userID, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
todayLogs, err := s.habitRepo.GetUserLogsForDate(userID, time.Now().Truncate(24*time.Hour))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.OverallStats{
|
|
TotalHabits: len(allHabits),
|
|
ActiveHabits: len(habits),
|
|
TodayCompleted: len(todayLogs),
|
|
}, nil
|
|
}
|
|
|
|
// calculateCompletionPctWithFreezes calculates completion % excluding frozen days
|
|
func (s *HabitService) calculateCompletionPctWithFreezes(habit *model.Habit, totalLogs int) float64 {
|
|
if totalLogs == 0 {
|
|
return 0
|
|
}
|
|
|
|
// Use start_date if set, otherwise use created_at
|
|
var startDate time.Time
|
|
if habit.StartDate.Valid {
|
|
startDate = habit.StartDate.Time.Truncate(24 * time.Hour)
|
|
} else {
|
|
startDate = habit.CreatedAt.Truncate(24 * time.Hour)
|
|
}
|
|
today := time.Now().Truncate(24 * time.Hour)
|
|
|
|
// Get frozen days count for this habit
|
|
frozenDays, err := s.freezeRepo.CountFrozenDaysInRange(habit.ID, startDate, today)
|
|
if err != nil {
|
|
frozenDays = 0
|
|
}
|
|
|
|
expectedCount := 0
|
|
|
|
// For interval habits, calculate expected differently
|
|
if (habit.Frequency == "interval" || habit.Frequency == "custom") && habit.TargetCount > 0 {
|
|
// Expected = (days since start - frozen days) / interval + 1
|
|
totalDays := int(today.Sub(startDate).Hours()/24) + 1 - frozenDays
|
|
if totalDays <= 0 {
|
|
return 100
|
|
}
|
|
expectedCount = (totalDays / habit.TargetCount) + 1
|
|
} else {
|
|
for d := startDate; !d.After(today); d = d.AddDate(0, 0, 1) {
|
|
// Check if this day is frozen
|
|
frozen, _ := s.freezeRepo.IsHabitFrozenOnDate(habit.ID, d)
|
|
if frozen {
|
|
continue
|
|
}
|
|
|
|
if habit.Frequency == "daily" {
|
|
expectedCount++
|
|
} else if habit.Frequency == "weekly" && len(habit.TargetDays) > 0 {
|
|
weekday := int(d.Weekday())
|
|
if weekday == 0 {
|
|
weekday = 7
|
|
}
|
|
for _, td := range habit.TargetDays {
|
|
if td == weekday {
|
|
expectedCount++
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
expectedCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
if expectedCount == 0 {
|
|
return 100
|
|
}
|
|
|
|
pct := float64(totalLogs) / float64(expectedCount) * 100
|
|
if pct > 100 {
|
|
pct = 100
|
|
}
|
|
return pct
|
|
}
|
|
|
|
func defaultString(val, def string) string {
|
|
if val == "" {
|
|
return def
|
|
}
|
|
return val
|
|
}
|
|
|
|
func defaultInt(val, def int) int {
|
|
if val == 0 {
|
|
return def
|
|
}
|
|
return val
|
|
}
|