feat(savings): Add savings module with categories, transactions, recurring plans

- 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
This commit is contained in:
Cosmo
2026-02-16 06:48:09 +00:00
parent 9e90aa6d95
commit 2a50e50771
18 changed files with 2910 additions and 162 deletions

View File

@@ -95,6 +95,28 @@ func RunMigrations(db *sqlx.DB) error {
`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS reminder_time TIME`,
`ALTER TABLE habits ADD COLUMN IF NOT EXISTS reminder_time TIME`,
`CREATE INDEX IF NOT EXISTS idx_users_telegram_chat_id ON users(telegram_chat_id)`,
// Recurring tasks support
`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS is_recurring BOOLEAN DEFAULT false`,
`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS recurrence_type VARCHAR(20)`,
`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS recurrence_interval INTEGER DEFAULT 1`,
`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS recurrence_end_date DATE`,
`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS parent_task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL`,
`CREATE INDEX IF NOT EXISTS idx_tasks_parent_id ON tasks(parent_task_id)`,
`CREATE INDEX IF NOT EXISTS idx_tasks_recurring ON tasks(is_recurring) WHERE is_recurring = true`,
// Habit freezes support
`CREATE TABLE IF NOT EXISTS habit_freezes (
id SERIAL PRIMARY KEY,
habit_id INTEGER REFERENCES habits(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
reason VARCHAR(255) DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_habit_freezes_habit ON habit_freezes(habit_id)`,
`CREATE INDEX IF NOT EXISTS idx_habit_freezes_dates ON habit_freezes(start_date, end_date)`,
// Habit start_date support
`ALTER TABLE habits ADD COLUMN IF NOT EXISTS start_date DATE`,
}
for _, migration := range migrations {

View File

@@ -23,8 +23,8 @@ func NewHabitRepository(db *sqlx.DB) *HabitRepository {
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)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
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)
@@ -42,6 +42,7 @@ func (r *HabitRepository) Create(habit *model.Habit) error {
targetDays,
habit.TargetCount,
habit.ReminderTime,
habit.StartDate,
).Scan(&habit.ID, &habit.CreatedAt, &habit.UpdatedAt)
}
@@ -50,13 +51,13 @@ func (r *HabitRepository) GetByID(id, userID int64) (*model.Habit, error) {
var targetDays pq.Int64Array
query := `
SELECT id, user_id, name, description, color, icon, frequency, target_days, target_count, reminder_time, is_archived, created_at, updated_at
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.IsArchived, &habit.CreatedAt, &habit.UpdatedAt,
&habit.TargetCount, &habit.ReminderTime, &habit.StartDate, &habit.IsArchived, &habit.CreatedAt, &habit.UpdatedAt,
)
if err != nil {
@@ -77,7 +78,7 @@ func (r *HabitRepository) GetByID(id, userID int64) (*model.Habit, error) {
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, is_archived, created_at, updated_at
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 {
@@ -99,7 +100,7 @@ func (r *HabitRepository) ListByUser(userID int64, includeArchived bool) ([]mode
if err := rows.Scan(
&habit.ID, &habit.UserID, &habit.Name, &habit.Description,
&habit.Color, &habit.Icon, &habit.Frequency, &targetDays,
&habit.TargetCount, &habit.ReminderTime, &habit.IsArchived, &habit.CreatedAt, &habit.UpdatedAt,
&habit.TargetCount, &habit.ReminderTime, &habit.StartDate, &habit.IsArchived, &habit.CreatedAt, &habit.UpdatedAt,
); err != nil {
return nil, err
}
@@ -118,14 +119,19 @@ func (r *HabitRepository) ListByUser(userID int64, includeArchived bool) ([]mode
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.is_archived, h.created_at, h.updated_at
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 (h.frequency = 'daily' OR $2 = ANY(h.target_days))
AND u.telegram_chat_id IS NOT NULL
AND u.notifications_enabled = true`
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 {
@@ -141,7 +147,7 @@ func (r *HabitRepository) GetHabitsWithReminder(reminderTime string, weekday int
if err := rows.Scan(
&habit.ID, &habit.UserID, &habit.Name, &habit.Description,
&habit.Color, &habit.Icon, &habit.Frequency, &targetDays,
&habit.TargetCount, &habit.ReminderTime, &habit.IsArchived, &habit.CreatedAt, &habit.UpdatedAt,
&habit.TargetCount, &habit.ReminderTime, &habit.StartDate, &habit.IsArchived, &habit.CreatedAt, &habit.UpdatedAt,
); err != nil {
return nil, err
}
@@ -158,12 +164,41 @@ func (r *HabitRepository) GetHabitsWithReminder(reminderTime string, weekday int
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, is_archived = $10, updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND user_id = $11
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,
@@ -176,6 +211,7 @@ func (r *HabitRepository) Update(habit *model.Habit) error {
pq.Array(habit.TargetDays),
habit.TargetCount,
habit.ReminderTime,
habit.StartDate,
habit.IsArchived,
habit.UserID,
).Scan(&habit.UpdatedAt)
@@ -264,40 +300,115 @@ func (r *HabitRepository) IsHabitCompletedToday(habitID, userID int64) (bool, er
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
weekStart := time.Now().AddDate(0, 0, -int(time.Now().Weekday()))
// 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(time.Now().Year(), time.Now().Month(), 1, 0, 0, 0, 0, time.UTC)
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
stats.CurrentStreak, stats.LongestStreak = r.calculateStreaks(habitID, userID)
// 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
}
func (r *HabitRepository) calculateStreaks(habitID, userID int64) (current, longest int) {
// 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 dates []time.Time
if err := r.db.Select(&dates, query, habitID, userID); err != nil || len(dates) == 0 {
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)
yesterday := today.AddDate(0, 0, -1)
// Current streak
if dates[0].Truncate(24*time.Hour).Equal(today) || dates[0].Truncate(24*time.Hour).Equal(yesterday) {
// 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(dates); i++ {
expected := dates[i-1].AddDate(0, 0, -1).Truncate(24 * time.Hour)
if dates[i].Truncate(24 * time.Hour).Equal(expected) {
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
@@ -305,12 +416,15 @@ func (r *HabitRepository) calculateStreaks(habitID, userID int64) (current, long
}
}
// Longest streak
streak := 1
// Longest streak calculation
longest = 1
for i := 1; i < len(dates); i++ {
expected := dates[i-1].AddDate(0, 0, -1).Truncate(24 * time.Hour)
if dates[i].Truncate(24 * time.Hour).Equal(expected) {
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
@@ -322,3 +436,88 @@ func (r *HabitRepository) calculateStreaks(habitID, userID int64) (current, long
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
}

View File

@@ -0,0 +1,151 @@
package repository
import (
"errors"
"time"
"github.com/daniil/homelab-api/internal/model"
"github.com/jmoiron/sqlx"
)
var ErrFreezeNotFound = errors.New("freeze not found")
var ErrInvalidDateRange = errors.New("invalid date range")
type HabitFreezeRepository struct {
db *sqlx.DB
}
func NewHabitFreezeRepository(db *sqlx.DB) *HabitFreezeRepository {
return &HabitFreezeRepository{db: db}
}
func (r *HabitFreezeRepository) Create(freeze *model.HabitFreeze) error {
// Validate date range
if freeze.EndDate.Before(freeze.StartDate) {
return ErrInvalidDateRange
}
query := `
INSERT INTO habit_freezes (habit_id, user_id, start_date, end_date, reason)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at`
return r.db.QueryRow(query,
freeze.HabitID,
freeze.UserID,
freeze.StartDate,
freeze.EndDate,
freeze.Reason,
).Scan(&freeze.ID, &freeze.CreatedAt)
}
func (r *HabitFreezeRepository) GetByHabitID(habitID, userID int64) ([]model.HabitFreeze, error) {
query := `
SELECT id, habit_id, user_id, start_date, end_date, reason, created_at
FROM habit_freezes
WHERE habit_id = $1 AND user_id = $2
ORDER BY start_date DESC`
var freezes []model.HabitFreeze
if err := r.db.Select(&freezes, query, habitID, userID); err != nil {
return nil, err
}
if freezes == nil {
freezes = []model.HabitFreeze{}
}
return freezes, nil
}
func (r *HabitFreezeRepository) GetActiveForHabit(habitID int64, date time.Time) (*model.HabitFreeze, error) {
query := `
SELECT id, habit_id, user_id, start_date, end_date, reason, created_at
FROM habit_freezes
WHERE habit_id = $1 AND start_date <= $2 AND end_date >= $2`
var freeze model.HabitFreeze
err := r.db.Get(&freeze, query, habitID, date)
if err != nil {
return nil, err
}
return &freeze, nil
}
func (r *HabitFreezeRepository) IsHabitFrozenOnDate(habitID int64, date time.Time) (bool, error) {
query := `
SELECT COUNT(*) FROM habit_freezes
WHERE habit_id = $1 AND start_date <= $2 AND end_date >= $2`
var count int
err := r.db.Get(&count, query, habitID, date)
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *HabitFreezeRepository) GetFreezesForDateRange(habitID int64, startDate, endDate time.Time) ([]model.HabitFreeze, error) {
query := `
SELECT id, habit_id, user_id, start_date, end_date, reason, created_at
FROM habit_freezes
WHERE habit_id = $1
AND NOT (end_date < $2 OR start_date > $3)
ORDER BY start_date`
var freezes []model.HabitFreeze
if err := r.db.Select(&freezes, query, habitID, startDate, endDate); err != nil {
return nil, err
}
if freezes == nil {
freezes = []model.HabitFreeze{}
}
return freezes, nil
}
func (r *HabitFreezeRepository) Delete(freezeID, userID int64) error {
query := `DELETE FROM habit_freezes WHERE id = $1 AND user_id = $2`
result, err := r.db.Exec(query, freezeID, userID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return ErrFreezeNotFound
}
return nil
}
func (r *HabitFreezeRepository) CountFrozenDaysInRange(habitID int64, startDate, endDate time.Time) (int, error) {
freezes, err := r.GetFreezesForDateRange(habitID, startDate, endDate)
if err != nil {
return 0, err
}
frozenDays := 0
for _, freeze := range freezes {
// Calculate overlap between freeze period and query range
overlapStart := freeze.StartDate
if startDate.After(freeze.StartDate) {
overlapStart = startDate
}
overlapEnd := freeze.EndDate
if endDate.Before(freeze.EndDate) {
overlapEnd = endDate
}
if !overlapEnd.Before(overlapStart) {
days := int(overlapEnd.Sub(overlapStart).Hours()/24) + 1
frozenDays += days
}
}
return frozenDays, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -21,8 +21,8 @@ func NewTaskRepository(db *sqlx.DB) *TaskRepository {
func (r *TaskRepository) Create(task *model.Task) error {
query := `
INSERT INTO tasks (user_id, title, description, icon, color, due_date, priority, reminder_time)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
INSERT INTO tasks (user_id, title, description, icon, color, due_date, priority, reminder_time, is_recurring, recurrence_type, recurrence_interval, recurrence_end_date, parent_task_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id, created_at, updated_at`
return r.db.QueryRow(query,
@@ -34,6 +34,11 @@ func (r *TaskRepository) Create(task *model.Task) error {
task.DueDate,
task.Priority,
task.ReminderTime,
task.IsRecurring,
task.RecurrenceType,
task.RecurrenceInterval,
task.RecurrenceEndDate,
task.ParentTaskID,
).Scan(&task.ID, &task.CreatedAt, &task.UpdatedAt)
}
@@ -41,13 +46,17 @@ func (r *TaskRepository) GetByID(id, userID int64) (*model.Task, error) {
var task model.Task
query := `
SELECT id, user_id, title, description, icon, color, due_date, priority, reminder_time, completed_at, created_at, updated_at
SELECT id, user_id, title, description, icon, color, due_date, priority, reminder_time, completed_at,
is_recurring, recurrence_type, recurrence_interval, recurrence_end_date, parent_task_id,
created_at, updated_at
FROM tasks WHERE id = $1 AND user_id = $2`
err := r.db.QueryRow(query, id, userID).Scan(
&task.ID, &task.UserID, &task.Title, &task.Description,
&task.Icon, &task.Color, &task.DueDate, &task.Priority,
&task.ReminderTime, &task.CompletedAt, &task.CreatedAt, &task.UpdatedAt,
&task.ReminderTime, &task.CompletedAt,
&task.IsRecurring, &task.RecurrenceType, &task.RecurrenceInterval, &task.RecurrenceEndDate, &task.ParentTaskID,
&task.CreatedAt, &task.UpdatedAt,
)
if err != nil {
@@ -63,7 +72,9 @@ func (r *TaskRepository) GetByID(id, userID int64) (*model.Task, error) {
func (r *TaskRepository) ListByUser(userID int64, completed *bool) ([]model.Task, error) {
query := `
SELECT id, user_id, title, description, icon, color, due_date, priority, reminder_time, completed_at, created_at, updated_at
SELECT id, user_id, title, description, icon, color, due_date, priority, reminder_time, completed_at,
is_recurring, recurrence_type, recurrence_interval, recurrence_end_date, parent_task_id,
created_at, updated_at
FROM tasks WHERE user_id = $1`
if completed != nil {
@@ -88,7 +99,9 @@ func (r *TaskRepository) ListByUser(userID int64, completed *bool) ([]model.Task
if err := rows.Scan(
&task.ID, &task.UserID, &task.Title, &task.Description,
&task.Icon, &task.Color, &task.DueDate, &task.Priority,
&task.ReminderTime, &task.CompletedAt, &task.CreatedAt, &task.UpdatedAt,
&task.ReminderTime, &task.CompletedAt,
&task.IsRecurring, &task.RecurrenceType, &task.RecurrenceInterval, &task.RecurrenceEndDate, &task.ParentTaskID,
&task.CreatedAt, &task.UpdatedAt,
); err != nil {
return nil, err
}
@@ -104,7 +117,9 @@ func (r *TaskRepository) GetTodayTasks(userID int64) ([]model.Task, error) {
today := time.Now().Format("2006-01-02")
query := `
SELECT id, user_id, title, description, icon, color, due_date, priority, reminder_time, completed_at, created_at, updated_at
SELECT id, user_id, title, description, icon, color, due_date, priority, reminder_time, completed_at,
is_recurring, recurrence_type, recurrence_interval, recurrence_end_date, parent_task_id,
created_at, updated_at
FROM tasks
WHERE user_id = $1 AND completed_at IS NULL AND due_date <= $2
ORDER BY priority DESC, due_date, created_at`
@@ -122,7 +137,9 @@ func (r *TaskRepository) GetTodayTasks(userID int64) ([]model.Task, error) {
if err := rows.Scan(
&task.ID, &task.UserID, &task.Title, &task.Description,
&task.Icon, &task.Color, &task.DueDate, &task.Priority,
&task.ReminderTime, &task.CompletedAt, &task.CreatedAt, &task.UpdatedAt,
&task.ReminderTime, &task.CompletedAt,
&task.IsRecurring, &task.RecurrenceType, &task.RecurrenceInterval, &task.RecurrenceEndDate, &task.ParentTaskID,
&task.CreatedAt, &task.UpdatedAt,
); err != nil {
return nil, err
}
@@ -136,12 +153,14 @@ func (r *TaskRepository) GetTodayTasks(userID int64) ([]model.Task, error) {
func (r *TaskRepository) GetTasksWithReminder(reminderTime string, date string) ([]model.Task, error) {
query := `
SELECT t.id, t.user_id, t.title, t.description, t.icon, t.color, t.due_date, t.priority, t.reminder_time, t.completed_at, t.created_at, t.updated_at
SELECT t.id, t.user_id, t.title, t.description, t.icon, t.color, t.due_date, t.priority, t.reminder_time, t.completed_at,
t.is_recurring, t.recurrence_type, t.recurrence_interval, t.recurrence_end_date, t.parent_task_id,
t.created_at, t.updated_at
FROM tasks t
JOIN users u ON t.user_id = u.id
WHERE t.reminder_time = $1
AND t.completed_at IS NULL
AND (t.due_date IS NULL OR t.due_date >= $2)
AND t.due_date = $2
AND u.telegram_chat_id IS NOT NULL
AND u.notifications_enabled = true`
@@ -157,7 +176,9 @@ func (r *TaskRepository) GetTasksWithReminder(reminderTime string, date string)
if err := rows.Scan(
&task.ID, &task.UserID, &task.Title, &task.Description,
&task.Icon, &task.Color, &task.DueDate, &task.Priority,
&task.ReminderTime, &task.CompletedAt, &task.CreatedAt, &task.UpdatedAt,
&task.ReminderTime, &task.CompletedAt,
&task.IsRecurring, &task.RecurrenceType, &task.RecurrenceInterval, &task.RecurrenceEndDate, &task.ParentTaskID,
&task.CreatedAt, &task.UpdatedAt,
); err != nil {
return nil, err
}
@@ -171,8 +192,10 @@ func (r *TaskRepository) GetTasksWithReminder(reminderTime string, date string)
func (r *TaskRepository) Update(task *model.Task) error {
query := `
UPDATE tasks
SET title = $2, description = $3, icon = $4, color = $5, due_date = $6, priority = $7, reminder_time = $8, updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND user_id = $9
SET title = $2, description = $3, icon = $4, color = $5, due_date = $6, priority = $7, reminder_time = $8,
is_recurring = $9, recurrence_type = $10, recurrence_interval = $11, recurrence_end_date = $12,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND user_id = $13
RETURNING updated_at`
return r.db.QueryRow(query,
@@ -184,6 +207,10 @@ func (r *TaskRepository) Update(task *model.Task) error {
task.DueDate,
task.Priority,
task.ReminderTime,
task.IsRecurring,
task.RecurrenceType,
task.RecurrenceInterval,
task.RecurrenceEndDate,
task.UserID,
).Scan(&task.UpdatedAt)
}