package storage import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/denisovdennis/autohero/internal/model" ) // DailyTaskStore handles daily/weekly task operations against PostgreSQL. type DailyTaskStore struct { pool *pgxpool.Pool } // NewDailyTaskStore creates a new DailyTaskStore backed by the given connection pool. func NewDailyTaskStore(pool *pgxpool.Pool) *DailyTaskStore { return &DailyTaskStore{pool: pool} } // periodStart returns the start of the current period for a given period type. // Daily: start of today (UTC). Weekly: start of this Monday (UTC). func periodStart(now time.Time, period string) time.Time { t := now.UTC() today := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) if period == "weekly" { // Go back to Monday. weekday := int(today.Weekday()) if weekday == 0 { weekday = 7 // Sunday -> 7 } return today.AddDate(0, 0, -(weekday - 1)) } return today } // EnsureHeroTasks creates task rows for the current period if they don't already exist. // Called lazily when the hero checks their tasks. func (s *DailyTaskStore) EnsureHeroTasks(ctx context.Context, heroID int64, now time.Time) error { // Load all task definitions. rows, err := s.pool.Query(ctx, `SELECT id, period FROM daily_tasks`) if err != nil { return fmt.Errorf("ensure hero tasks list: %w", err) } defer rows.Close() type taskDef struct { id string period string } var tasks []taskDef for rows.Next() { var t taskDef if err := rows.Scan(&t.id, &t.period); err != nil { return fmt.Errorf("scan task def: %w", err) } tasks = append(tasks, t) } if err := rows.Err(); err != nil { return fmt.Errorf("task defs rows: %w", err) } for _, t := range tasks { ps := periodStart(now, t.period) _, err := s.pool.Exec(ctx, ` INSERT INTO hero_daily_tasks (hero_id, task_id, progress, completed, claimed, period_start) VALUES ($1, $2, 0, false, false, $3) ON CONFLICT DO NOTHING `, heroID, t.id, ps) if err != nil { return fmt.Errorf("ensure task %s: %w", t.id, err) } } return nil } // ListHeroTasks returns current daily and weekly tasks with progress for a hero. // Only returns tasks for the current period (daily = today, weekly = this week). func (s *DailyTaskStore) ListHeroTasks(ctx context.Context, heroID int64) ([]model.HeroDailyTask, error) { now := time.Now().UTC() dailyStart := periodStart(now, "daily") weeklyStart := periodStart(now, "weekly") rows, err := s.pool.Query(ctx, ` SELECT hdt.hero_id, hdt.task_id, hdt.progress, hdt.completed, hdt.claimed, hdt.period_start, dt.id, dt.title, dt.description, dt.objective_type, dt.objective_count, dt.reward_type, dt.reward_amount, dt.period FROM hero_daily_tasks hdt JOIN daily_tasks dt ON hdt.task_id = dt.id WHERE hdt.hero_id = $1 AND ( (dt.period = 'daily' AND hdt.period_start = $2) OR (dt.period = 'weekly' AND hdt.period_start = $3) ) ORDER BY dt.period ASC, dt.id ASC `, heroID, dailyStart, weeklyStart) if err != nil { return nil, fmt.Errorf("list hero tasks: %w", err) } defer rows.Close() var tasks []model.HeroDailyTask for rows.Next() { var ht model.HeroDailyTask var dt model.DailyTask if err := rows.Scan( &ht.HeroID, &ht.TaskID, &ht.Progress, &ht.Completed, &ht.Claimed, &ht.PeriodStart, &dt.ID, &dt.Title, &dt.Description, &dt.ObjectiveType, &dt.ObjectiveCount, &dt.RewardType, &dt.RewardAmount, &dt.Period, ); err != nil { return nil, fmt.Errorf("scan hero task: %w", err) } ht.Task = &dt tasks = append(tasks, ht) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("hero tasks rows: %w", err) } if tasks == nil { tasks = []model.HeroDailyTask{} } return tasks, nil } // IncrementTaskProgress increments progress for all matching uncompleted tasks // in the current period. Automatically marks tasks as completed when the objective // count is reached. func (s *DailyTaskStore) IncrementTaskProgress(ctx context.Context, heroID int64, objectiveType string, delta int) error { if delta <= 0 { return nil } now := time.Now().UTC() dailyStart := periodStart(now, "daily") weeklyStart := periodStart(now, "weekly") _, err := s.pool.Exec(ctx, ` UPDATE hero_daily_tasks hdt SET progress = LEAST(hdt.progress + $3, dt.objective_count), completed = CASE WHEN hdt.progress + $3 >= dt.objective_count THEN true ELSE hdt.completed END FROM daily_tasks dt WHERE hdt.task_id = dt.id AND hdt.hero_id = $1 AND hdt.completed = false AND dt.objective_type = $2 AND ( (dt.period = 'daily' AND hdt.period_start = $4) OR (dt.period = 'weekly' AND hdt.period_start = $5) ) `, heroID, objectiveType, delta, dailyStart, weeklyStart) if err != nil { return fmt.Errorf("increment task progress: %w", err) } return nil } // ClaimTask marks a completed task as claimed and returns the reward. // Returns an error if the task is not completed or already claimed. func (s *DailyTaskStore) ClaimTask(ctx context.Context, heroID int64, taskID string) (*model.DailyTaskReward, error) { now := time.Now().UTC() dailyStart := periodStart(now, "daily") weeklyStart := periodStart(now, "weekly") var rewardType string var rewardAmount int err := s.pool.QueryRow(ctx, ` UPDATE hero_daily_tasks hdt SET claimed = true FROM daily_tasks dt WHERE hdt.task_id = dt.id AND hdt.hero_id = $1 AND hdt.task_id = $2 AND hdt.completed = true AND hdt.claimed = false AND ( (dt.period = 'daily' AND hdt.period_start = $3) OR (dt.period = 'weekly' AND hdt.period_start = $4) ) RETURNING dt.reward_type, dt.reward_amount `, heroID, taskID, dailyStart, weeklyStart).Scan(&rewardType, &rewardAmount) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, fmt.Errorf("task not found, not completed, or already claimed") } return nil, fmt.Errorf("claim task: %w", err) } return &model.DailyTaskReward{ RewardType: rewardType, RewardAmount: rewardAmount, }, nil }