Files
pulse-api/internal/scheduler/scheduler.go
Cosmo 2a50e50771 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
2026-02-16 06:48:09 +00:00

384 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package scheduler
import (
"fmt"
"log"
"time"
"github.com/robfig/cron/v3"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/daniil/homelab-api/internal/bot"
"github.com/daniil/homelab-api/internal/model"
"github.com/daniil/homelab-api/internal/repository"
)
type Scheduler struct {
cron *cron.Cron
bot *bot.Bot
userRepo *repository.UserRepository
taskRepo *repository.TaskRepository
habitRepo *repository.HabitRepository
freezeRepo *repository.HabitFreezeRepository
}
func New(b *bot.Bot, userRepo *repository.UserRepository, taskRepo *repository.TaskRepository, habitRepo *repository.HabitRepository, freezeRepo *repository.HabitFreezeRepository) *Scheduler {
return &Scheduler{
cron: cron.New(),
bot: b,
userRepo: userRepo,
taskRepo: taskRepo,
habitRepo: habitRepo,
freezeRepo: freezeRepo,
}
}
func (s *Scheduler) Start() {
if s.bot == nil || s.bot.GetAPI() == nil {
log.Println("Scheduler: bot not configured, skipping")
return
}
// Run every minute
s.cron.AddFunc("* * * * *", s.checkNotifications)
s.cron.Start()
log.Println("Scheduler started")
}
func (s *Scheduler) Stop() {
s.cron.Stop()
}
func (s *Scheduler) checkNotifications() {
users, err := s.userRepo.GetUsersWithNotifications()
if err != nil {
log.Printf("Scheduler: error getting users: %v", err)
return
}
for _, user := range users {
go s.checkUserNotifications(user)
}
}
func (s *Scheduler) checkUserNotifications(user model.User) {
if !user.TelegramChatID.Valid {
return
}
chatID := user.TelegramChatID.Int64
// Get user timezone
loc, err := time.LoadLocation(user.Timezone)
if err != nil {
loc = time.FixedZone("Europe/Moscow", 3*60*60)
}
now := time.Now().In(loc)
currentTime := now.Format("15:04")
today := now.Format("2006-01-02")
weekday := int(now.Weekday())
// Get user's reminder times
morningTime := "09:00"
if user.MorningReminderTime.Valid && len(user.MorningReminderTime.String) >= 5 {
morningTime = user.MorningReminderTime.String[:5]
}
eveningTime := "21:00"
if user.EveningReminderTime.Valid && len(user.EveningReminderTime.String) >= 5 {
eveningTime = user.EveningReminderTime.String[:5]
}
// 1. Morning briefing
if currentTime == morningTime {
s.sendMorningBriefing(user.ID, chatID, loc)
}
// 2. Evening summary
if currentTime == eveningTime {
s.sendEveningSummary(user.ID, chatID, loc)
}
// 3. Task reminders
s.checkTaskReminders(user.ID, chatID, currentTime, today)
// 4. Habit reminders
s.checkHabitReminders(user.ID, chatID, currentTime, weekday)
}
func (s *Scheduler) sendMorningBriefing(userID, chatID int64, loc *time.Location) {
tasks, err := s.taskRepo.GetTodayTasks(userID)
if err != nil {
log.Printf("Scheduler: error getting tasks for user %d: %v", userID, err)
return
}
habits, err := s.habitRepo.ListByUser(userID, false)
if err != nil {
log.Printf("Scheduler: error getting habits for user %d: %v", userID, err)
return
}
// Filter habits for today (excluding frozen ones)
weekday := int(time.Now().In(loc).Weekday())
today := time.Now().In(loc).Truncate(24 * time.Hour)
var todayHabits int
for _, habit := range habits {
if s.shouldShowHabitToday(habit, userID, weekday, today) {
todayHabits++
}
}
if len(tasks) == 0 && todayHabits == 0 {
return // Nothing to report
}
text := "☀️ <b>Доброе утро!</b>\n\n"
if len(tasks) > 0 {
text += fmt.Sprintf("📋 Задач на сегодня: <b>%d</b>\n", len(tasks))
for i, task := range tasks {
if i >= 3 {
text += fmt.Sprintf(" ... и ещё %d\n", len(tasks)-3)
break
}
text += fmt.Sprintf(" • %s %s\n", task.Icon, task.Title)
}
text += "\n"
}
if todayHabits > 0 {
text += fmt.Sprintf("🎯 Привычек: <b>%d</b>\n", todayHabits)
}
text += "\nИспользуй /tasks и /habits для просмотра"
s.bot.SendMessage(chatID, text)
}
func (s *Scheduler) sendEveningSummary(userID, chatID int64, loc *time.Location) {
// Get tasks
tasks, err := s.taskRepo.GetTodayTasks(userID)
if err != nil {
log.Printf("Scheduler: error getting tasks for user %d: %v", userID, err)
return
}
// Count completed/incomplete tasks
var completedTasks, incompleteTasks int
for _, task := range tasks {
if task.CompletedAt.Valid {
completedTasks++
} else {
incompleteTasks++
}
}
// Get habits
habits, err := s.habitRepo.ListByUser(userID, false)
if err != nil {
log.Printf("Scheduler: error getting habits for user %d: %v", userID, err)
return
}
// Filter and count today's habits (excluding frozen ones)
weekday := int(time.Now().In(loc).Weekday())
today := time.Now().In(loc).Truncate(24 * time.Hour)
var completedHabits, incompleteHabits int
var incompleteHabitNames []string
for _, habit := range habits {
if s.shouldShowHabitToday(habit, userID, weekday, today) {
completed, _ := s.habitRepo.IsHabitCompletedToday(habit.ID, userID)
if completed {
completedHabits++
} else {
incompleteHabits++
incompleteHabitNames = append(incompleteHabitNames, habit.Icon+" "+habit.Name)
}
}
}
// Don't send if nothing to report
totalTasks := completedTasks + incompleteTasks
totalHabits := completedHabits + incompleteHabits
if totalTasks == 0 && totalHabits == 0 {
return
}
text := "🌙 <b>Итоги дня</b>\n\n"
// Tasks summary
if totalTasks > 0 {
text += "📋 <b>Задачи:</b>\n"
text += fmt.Sprintf(" ✅ Выполнено: %d\n", completedTasks)
text += fmt.Sprintf(" ⬜ Осталось: %d\n", incompleteTasks)
text += "\n"
}
// Habits summary
if totalHabits > 0 {
text += "🎯 <b>Привычки:</b>\n"
text += fmt.Sprintf(" ✅ Выполнено: %d\n", completedHabits)
text += fmt.Sprintf(" ⬜ Осталось: %d\n", incompleteHabits)
// Show incomplete habits
if len(incompleteHabitNames) > 0 && len(incompleteHabitNames) <= 5 {
text += "\n Не выполнены:\n"
for _, name := range incompleteHabitNames {
text += fmt.Sprintf(" • %s\n", name)
}
}
text += "\n"
}
// Motivational message
taskPercent := 0
if totalTasks > 0 {
taskPercent = completedTasks * 100 / totalTasks
}
habitPercent := 0
if totalHabits > 0 {
habitPercent = completedHabits * 100 / totalHabits
}
avgPercent := (taskPercent + habitPercent) / 2
if totalTasks == 0 {
avgPercent = habitPercent
}
if totalHabits == 0 {
avgPercent = taskPercent
}
if avgPercent == 100 {
text += "🎉 Отличный день! Всё выполнено!"
} else if avgPercent >= 75 {
text += "👍 Хороший день! Почти всё сделано."
} else if avgPercent >= 50 {
text += "💪 Неплохо! Завтра будет лучше."
} else {
text += "🌱 Бывает. Завтра новый день!"
}
s.bot.SendMessage(chatID, text)
}
func (s *Scheduler) checkTaskReminders(userID, chatID int64, currentTime, today string) {
tasks, err := s.taskRepo.GetTasksWithReminder(currentTime, today)
if err != nil {
log.Printf("Scheduler: error getting task reminders: %v", err)
return
}
for _, task := range tasks {
if task.UserID != userID {
continue
}
text := fmt.Sprintf("⏰ <b>Напоминание о задаче:</b>\n\n%s <b>%s</b>", task.Icon, task.Title)
if task.Description != "" {
text += fmt.Sprintf("\n<i>%s</i>", task.Description)
}
keyboard := tgbotapi.NewInlineKeyboardMarkup(
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("✅ Выполнено", fmt.Sprintf("donetask_%d", task.ID)),
tgbotapi.NewInlineKeyboardButtonData("⏰ +30 мин", fmt.Sprintf("snoozetask_%d", task.ID)),
),
)
s.bot.SendMessageWithKeyboard(chatID, text, &keyboard)
}
}
func (s *Scheduler) checkHabitReminders(userID, chatID int64, currentTime string, weekday int) {
habits, err := s.habitRepo.GetHabitsWithReminder(currentTime, weekday)
if err != nil {
log.Printf("Scheduler: error getting habit reminders: %v", err)
return
}
today := time.Now().Truncate(24 * time.Hour)
for _, habit := range habits {
if habit.UserID != userID {
continue
}
// Check if habit is frozen today
frozen, err := s.freezeRepo.IsHabitFrozenOnDate(habit.ID, today)
if err != nil {
log.Printf("Scheduler: error checking freeze for habit %d: %v", habit.ID, err)
continue
}
if frozen {
continue
}
// Check if already completed today
completed, _ := s.habitRepo.IsHabitCompletedToday(habit.ID, userID)
if completed {
continue
}
// For interval habits, check if it should be shown today
if (habit.Frequency == "interval" || habit.Frequency == "custom") && habit.TargetCount > 0 {
shouldShow, err := s.habitRepo.ShouldShowIntervalHabitToday(habit.ID, userID, habit.TargetCount, habit.StartDate)
if err != nil || !shouldShow {
continue
}
}
text := fmt.Sprintf("⏰ <b>Напоминание о привычке:</b>\n\n%s <b>%s</b>", habit.Icon, habit.Name)
if habit.Description != "" {
text += fmt.Sprintf("\n<i>%s</i>", habit.Description)
}
keyboard := tgbotapi.NewInlineKeyboardMarkup(
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("✅ Выполнено", fmt.Sprintf("checkhabit_%d", habit.ID)),
tgbotapi.NewInlineKeyboardButtonData("⏰ +30 мин", fmt.Sprintf("snoozehabit_%d", habit.ID)),
),
)
s.bot.SendMessageWithKeyboard(chatID, text, &keyboard)
}
}
// shouldShowHabitToday checks if a habit should be shown today based on its frequency and freeze status
func (s *Scheduler) shouldShowHabitToday(habit model.Habit, userID int64, weekday int, today time.Time) bool {
// Check if habit is frozen today
frozen, err := s.freezeRepo.IsHabitFrozenOnDate(habit.ID, today)
if err != nil {
log.Printf("Scheduler: error checking freeze for habit %d: %v", habit.ID, err)
return false
}
if frozen {
return false
}
if habit.Frequency == "daily" {
return true
}
if habit.Frequency == "weekly" {
for _, day := range habit.TargetDays {
if day == weekday {
return true
}
}
return false
}
// For interval habits
if (habit.Frequency == "interval" || habit.Frequency == "custom") && habit.TargetCount > 0 {
shouldShow, err := s.habitRepo.ShouldShowIntervalHabitToday(habit.ID, userID, habit.TargetCount, habit.StartDate)
if err != nil {
return false
}
return shouldShow
}
return true
}