- 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
524 lines
14 KiB
Go
524 lines
14 KiB
Go
package repository
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/daniil/homelab-api/internal/model"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
var ErrHabitNotFound = errors.New("habit not found")
|
|
var ErrLogNotFound = errors.New("log not found")
|
|
|
|
type HabitRepository struct {
|
|
db *sqlx.DB
|
|
}
|
|
|
|
func NewHabitRepository(db *sqlx.DB) *HabitRepository {
|
|
return &HabitRepository{db: db}
|
|
}
|
|
|
|
func (r *HabitRepository) Create(habit *model.Habit) error {
|
|
query := `
|
|
INSERT INTO habits (user_id, name, description, color, icon, frequency, target_days, target_count, reminder_time, start_date)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
RETURNING id, created_at, updated_at`
|
|
|
|
targetDays := pq.Array(habit.TargetDays)
|
|
if len(habit.TargetDays) == 0 {
|
|
targetDays = pq.Array([]int{0, 1, 2, 3, 4, 5, 6})
|
|
}
|
|
|
|
return r.db.QueryRow(query,
|
|
habit.UserID,
|
|
habit.Name,
|
|
habit.Description,
|
|
habit.Color,
|
|
habit.Icon,
|
|
habit.Frequency,
|
|
targetDays,
|
|
habit.TargetCount,
|
|
habit.ReminderTime,
|
|
habit.StartDate,
|
|
).Scan(&habit.ID, &habit.CreatedAt, &habit.UpdatedAt)
|
|
}
|
|
|
|
func (r *HabitRepository) GetByID(id, userID int64) (*model.Habit, error) {
|
|
var habit model.Habit
|
|
var targetDays pq.Int64Array
|
|
|
|
query := `
|
|
SELECT id, user_id, name, description, color, icon, frequency, target_days, target_count, reminder_time, start_date, is_archived, created_at, updated_at
|
|
FROM habits WHERE id = $1 AND user_id = $2`
|
|
|
|
err := r.db.QueryRow(query, id, userID).Scan(
|
|
&habit.ID, &habit.UserID, &habit.Name, &habit.Description,
|
|
&habit.Color, &habit.Icon, &habit.Frequency, &targetDays,
|
|
&habit.TargetCount, &habit.ReminderTime, &habit.StartDate, &habit.IsArchived, &habit.CreatedAt, &habit.UpdatedAt,
|
|
)
|
|
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrHabitNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
habit.TargetDays = make([]int, len(targetDays))
|
|
for i, v := range targetDays {
|
|
habit.TargetDays[i] = int(v)
|
|
}
|
|
habit.ProcessForJSON()
|
|
|
|
return &habit, nil
|
|
}
|
|
|
|
func (r *HabitRepository) ListByUser(userID int64, includeArchived bool) ([]model.Habit, error) {
|
|
query := `
|
|
SELECT id, user_id, name, description, color, icon, frequency, target_days, target_count, reminder_time, start_date, is_archived, created_at, updated_at
|
|
FROM habits WHERE user_id = $1`
|
|
|
|
if !includeArchived {
|
|
query += " AND is_archived = FALSE"
|
|
}
|
|
query += " ORDER BY created_at DESC"
|
|
|
|
rows, err := r.db.Query(query, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var habits []model.Habit
|
|
for rows.Next() {
|
|
var habit model.Habit
|
|
var targetDays pq.Int64Array
|
|
|
|
if err := rows.Scan(
|
|
&habit.ID, &habit.UserID, &habit.Name, &habit.Description,
|
|
&habit.Color, &habit.Icon, &habit.Frequency, &targetDays,
|
|
&habit.TargetCount, &habit.ReminderTime, &habit.StartDate, &habit.IsArchived, &habit.CreatedAt, &habit.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
habit.TargetDays = make([]int, len(targetDays))
|
|
for i, v := range targetDays {
|
|
habit.TargetDays[i] = int(v)
|
|
}
|
|
habit.ProcessForJSON()
|
|
|
|
habits = append(habits, habit)
|
|
}
|
|
|
|
return habits, nil
|
|
}
|
|
|
|
func (r *HabitRepository) GetHabitsWithReminder(reminderTime string, weekday int) ([]model.Habit, error) {
|
|
query := `
|
|
SELECT h.id, h.user_id, h.name, h.description, h.color, h.icon, h.frequency, h.target_days, h.target_count, h.reminder_time, h.start_date, h.is_archived, h.created_at, h.updated_at
|
|
FROM habits h
|
|
JOIN users u ON h.user_id = u.id
|
|
WHERE h.reminder_time = $1
|
|
AND h.is_archived = false
|
|
AND u.telegram_chat_id IS NOT NULL
|
|
AND u.notifications_enabled = true
|
|
AND (
|
|
h.frequency = 'daily'
|
|
OR (h.frequency = 'weekly' AND $2 = ANY(h.target_days))
|
|
OR h.frequency = 'interval'
|
|
OR h.frequency = 'custom'
|
|
)`
|
|
|
|
rows, err := r.db.Query(query, reminderTime, weekday)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var habits []model.Habit
|
|
for rows.Next() {
|
|
var habit model.Habit
|
|
var targetDays pq.Int64Array
|
|
|
|
if err := rows.Scan(
|
|
&habit.ID, &habit.UserID, &habit.Name, &habit.Description,
|
|
&habit.Color, &habit.Icon, &habit.Frequency, &targetDays,
|
|
&habit.TargetCount, &habit.ReminderTime, &habit.StartDate, &habit.IsArchived, &habit.CreatedAt, &habit.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
habit.TargetDays = make([]int, len(targetDays))
|
|
for i, v := range targetDays {
|
|
habit.TargetDays[i] = int(v)
|
|
}
|
|
habit.ProcessForJSON()
|
|
|
|
habits = append(habits, habit)
|
|
}
|
|
|
|
return habits, nil
|
|
}
|
|
|
|
// ShouldShowIntervalHabitToday checks if an interval habit should be shown today
|
|
func (r *HabitRepository) ShouldShowIntervalHabitToday(habitID, userID int64, intervalDays int, startDate sql.NullTime) (bool, error) {
|
|
// Get the last log date for this habit
|
|
var lastLogDate sql.NullTime
|
|
err := r.db.Get(&lastLogDate, `
|
|
SELECT MAX(date) FROM habit_logs WHERE habit_id = $1 AND user_id = $2
|
|
`, habitID, userID)
|
|
|
|
if err != nil && err != sql.ErrNoRows {
|
|
return false, err
|
|
}
|
|
|
|
today := time.Now().Truncate(24 * time.Hour)
|
|
|
|
// If no logs exist, check if today >= start_date (show on start_date)
|
|
if !lastLogDate.Valid {
|
|
if startDate.Valid {
|
|
return !today.Before(startDate.Time.Truncate(24*time.Hour)), nil
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// Calculate days since last log
|
|
lastLog := lastLogDate.Time.Truncate(24 * time.Hour)
|
|
daysSinceLastLog := int(today.Sub(lastLog).Hours() / 24)
|
|
|
|
return daysSinceLastLog >= intervalDays, nil
|
|
}
|
|
|
|
func (r *HabitRepository) Update(habit *model.Habit) error {
|
|
query := `
|
|
UPDATE habits
|
|
SET name = $2, description = $3, color = $4, icon = $5, frequency = $6,
|
|
target_days = $7, target_count = $8, reminder_time = $9, start_date = $10, is_archived = $11, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $1 AND user_id = $12
|
|
RETURNING updated_at`
|
|
|
|
return r.db.QueryRow(query,
|
|
habit.ID,
|
|
habit.Name,
|
|
habit.Description,
|
|
habit.Color,
|
|
habit.Icon,
|
|
habit.Frequency,
|
|
pq.Array(habit.TargetDays),
|
|
habit.TargetCount,
|
|
habit.ReminderTime,
|
|
habit.StartDate,
|
|
habit.IsArchived,
|
|
habit.UserID,
|
|
).Scan(&habit.UpdatedAt)
|
|
}
|
|
|
|
func (r *HabitRepository) Delete(id, userID int64) error {
|
|
query := `DELETE FROM habits WHERE id = $1 AND user_id = $2`
|
|
result, err := r.db.Exec(query, id, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return ErrHabitNotFound
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Logs
|
|
|
|
func (r *HabitRepository) CreateLog(log *model.HabitLog) error {
|
|
query := `
|
|
INSERT INTO habit_logs (habit_id, user_id, date, count, note)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT (habit_id, date)
|
|
DO UPDATE SET count = habit_logs.count + EXCLUDED.count, note = EXCLUDED.note
|
|
RETURNING id, created_at`
|
|
|
|
return r.db.QueryRow(query, log.HabitID, log.UserID, log.Date, log.Count, log.Note).
|
|
Scan(&log.ID, &log.CreatedAt)
|
|
}
|
|
|
|
func (r *HabitRepository) GetLogs(habitID, userID int64, from, to time.Time) ([]model.HabitLog, error) {
|
|
query := `
|
|
SELECT id, habit_id, user_id, date, count, note, created_at
|
|
FROM habit_logs
|
|
WHERE habit_id = $1 AND user_id = $2 AND date >= $3 AND date <= $4
|
|
ORDER BY date DESC`
|
|
|
|
var logs []model.HabitLog
|
|
if err := r.db.Select(&logs, query, habitID, userID, from, to); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return logs, nil
|
|
}
|
|
|
|
func (r *HabitRepository) DeleteLog(logID, userID int64) error {
|
|
query := `DELETE FROM habit_logs WHERE id = $1 AND user_id = $2`
|
|
result, err := r.db.Exec(query, logID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return ErrLogNotFound
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *HabitRepository) GetUserLogsForDate(userID int64, date time.Time) ([]model.HabitLog, error) {
|
|
query := `
|
|
SELECT id, habit_id, user_id, date, count, note, created_at
|
|
FROM habit_logs
|
|
WHERE user_id = $1 AND date = $2`
|
|
|
|
var logs []model.HabitLog
|
|
if err := r.db.Select(&logs, query, userID, date); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return logs, nil
|
|
}
|
|
|
|
func (r *HabitRepository) IsHabitCompletedToday(habitID, userID int64) (bool, error) {
|
|
today := time.Now().Format("2006-01-02")
|
|
var count int
|
|
err := r.db.Get(&count, `SELECT COUNT(*) FROM habit_logs WHERE habit_id = $1 AND user_id = $2 AND date = $3`, habitID, userID, today)
|
|
return count > 0, err
|
|
}
|
|
|
|
func (r *HabitRepository) GetStats(habitID, userID int64) (*model.HabitStats, error) {
|
|
stats := &model.HabitStats{HabitID: habitID}
|
|
|
|
// Get habit info
|
|
habit, err := r.GetByID(habitID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Total logs
|
|
r.db.Get(&stats.TotalLogs, `SELECT COUNT(*) FROM habit_logs WHERE habit_id = $1 AND user_id = $2`, habitID, userID)
|
|
|
|
// This week (Monday-based)
|
|
now := time.Now()
|
|
weekday := int(now.Weekday())
|
|
if weekday == 0 {
|
|
weekday = 7
|
|
}
|
|
weekStart := now.AddDate(0, 0, -(weekday - 1)).Truncate(24 * time.Hour)
|
|
r.db.Get(&stats.ThisWeek, `SELECT COUNT(*) FROM habit_logs WHERE habit_id = $1 AND user_id = $2 AND date >= $3`, habitID, userID, weekStart)
|
|
|
|
// This month
|
|
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
r.db.Get(&stats.ThisMonth, `SELECT COUNT(*) FROM habit_logs WHERE habit_id = $1 AND user_id = $2 AND date >= $3`, habitID, userID, monthStart)
|
|
|
|
// Streaks calculation (respecting target_days and interval)
|
|
stats.CurrentStreak, stats.LongestStreak = r.calculateStreaksWithDays(habitID, userID, habit.Frequency, habit.TargetDays, habit.TargetCount)
|
|
|
|
// Completion percentage since habit creation/start_date
|
|
stats.CompletionPct = r.calculateCompletionPct(habit, stats.TotalLogs)
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
// calculateStreaksWithDays counts consecutive completions on expected days
|
|
func (r *HabitRepository) calculateStreaksWithDays(habitID, userID int64, frequency string, targetDays []int, targetCount int) (current, longest int) {
|
|
query := `SELECT date FROM habit_logs WHERE habit_id = $1 AND user_id = $2 ORDER BY date DESC`
|
|
|
|
var logDates []time.Time
|
|
if err := r.db.Select(&logDates, query, habitID, userID); err != nil || len(logDates) == 0 {
|
|
return 0, 0
|
|
}
|
|
|
|
// Convert log dates to map for quick lookup
|
|
logMap := make(map[string]bool)
|
|
for _, d := range logDates {
|
|
logMap[d.Format("2006-01-02")] = true
|
|
}
|
|
|
|
// For interval habits, calculate streaks differently
|
|
if (frequency == "interval" || frequency == "custom") && targetCount > 0 {
|
|
return r.calculateIntervalStreaks(logDates, targetCount)
|
|
}
|
|
|
|
// Generate expected days from today backwards
|
|
today := time.Now().Truncate(24 * time.Hour)
|
|
expectedDays := r.getExpectedDays(today, frequency, targetDays, 365) // Look back up to 1 year
|
|
|
|
if len(expectedDays) == 0 {
|
|
return 0, 0
|
|
}
|
|
|
|
// Current streak: count from most recent expected day
|
|
current = 0
|
|
for _, day := range expectedDays {
|
|
if logMap[day.Format("2006-01-02")] {
|
|
current++
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Longest streak
|
|
longest = 0
|
|
streak := 0
|
|
for _, day := range expectedDays {
|
|
if logMap[day.Format("2006-01-02")] {
|
|
streak++
|
|
if streak > longest {
|
|
longest = streak
|
|
}
|
|
} else {
|
|
streak = 0
|
|
}
|
|
}
|
|
|
|
return current, longest
|
|
}
|
|
|
|
// calculateIntervalStreaks calculates streaks for interval-based habits
|
|
func (r *HabitRepository) calculateIntervalStreaks(logDates []time.Time, intervalDays int) (current, longest int) {
|
|
if len(logDates) == 0 {
|
|
return 0, 0
|
|
}
|
|
|
|
today := time.Now().Truncate(24 * time.Hour)
|
|
|
|
// Check if the most recent log is within the interval window from today
|
|
lastLogDate := logDates[0].Truncate(24 * time.Hour)
|
|
daysSinceLastLog := int(today.Sub(lastLogDate).Hours() / 24)
|
|
|
|
// Current streak: if we're within interval, count consecutive logs that are within interval of each other
|
|
current = 0
|
|
if daysSinceLastLog < intervalDays {
|
|
current = 1
|
|
for i := 1; i < len(logDates); i++ {
|
|
prevDate := logDates[i-1].Truncate(24 * time.Hour)
|
|
currDate := logDates[i].Truncate(24 * time.Hour)
|
|
daysBetween := int(prevDate.Sub(currDate).Hours() / 24)
|
|
|
|
// If the gap is exactly the interval (or less, if done early), continue streak
|
|
if daysBetween <= intervalDays {
|
|
current++
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Longest streak calculation
|
|
longest = 1
|
|
streak := 1
|
|
for i := 1; i < len(logDates); i++ {
|
|
prevDate := logDates[i-1].Truncate(24 * time.Hour)
|
|
currDate := logDates[i].Truncate(24 * time.Hour)
|
|
daysBetween := int(prevDate.Sub(currDate).Hours() / 24)
|
|
|
|
if daysBetween <= intervalDays {
|
|
streak++
|
|
if streak > longest {
|
|
longest = streak
|
|
}
|
|
} else {
|
|
streak = 1
|
|
}
|
|
}
|
|
|
|
return current, longest
|
|
}
|
|
|
|
// getExpectedDays returns a list of days when the habit should be done, sorted descending
|
|
func (r *HabitRepository) getExpectedDays(from time.Time, frequency string, targetDays []int, maxDays int) []time.Time {
|
|
var result []time.Time
|
|
|
|
for i := 0; i < maxDays; i++ {
|
|
day := from.AddDate(0, 0, -i)
|
|
|
|
if frequency == "daily" {
|
|
result = append(result, day)
|
|
} else if frequency == "weekly" && len(targetDays) > 0 {
|
|
weekday := int(day.Weekday())
|
|
if weekday == 0 {
|
|
weekday = 7 // Sunday = 7
|
|
}
|
|
for _, td := range targetDays {
|
|
if td == weekday {
|
|
result = append(result, day)
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
result = append(result, day)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// calculateCompletionPct calculates completion percentage since habit start_date (or created_at)
|
|
func (r *HabitRepository) calculateCompletionPct(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)
|
|
|
|
expectedCount := 0
|
|
|
|
// For interval habits, calculate expected differently
|
|
if (habit.Frequency == "interval" || habit.Frequency == "custom") && habit.TargetCount > 0 {
|
|
// Expected = (days since start) / interval + 1
|
|
daysSinceStart := int(today.Sub(startDate).Hours()/24) + 1
|
|
expectedCount = (daysSinceStart / habit.TargetCount) + 1
|
|
} else {
|
|
for d := startDate; !d.After(today); d = d.AddDate(0, 0, 1) {
|
|
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 0
|
|
}
|
|
|
|
return float64(totalLogs) / float64(expectedCount) * 100
|
|
}
|
|
|
|
func (r *HabitRepository) IsHabitCompletedOnDate(habitID, userID int64, date time.Time) (bool, error) {
|
|
dateStr := date.Format("2006-01-02")
|
|
var count int
|
|
err := r.db.Get(&count, `SELECT COUNT(*) FROM habit_logs WHERE habit_id = $1 AND user_id = $2 AND date = $3`, habitID, userID, dateStr)
|
|
return count > 0, err
|
|
}
|