diff --git a/play-life-backend/admin.html b/play-life-backend/admin.html index 11c1ec5..0ad9e10 100644 --- a/play-life-backend/admin.html +++ b/play-life-backend/admin.html @@ -192,7 +192,7 @@

- Нажмите кнопку для отправки ежедневного отчёта по Score и Целям в Telegram (обычно отправляется автоматически в 11:59). + Нажмите кнопку для отправки ежедневного отчёта по Score и Целям в Telegram (обычно отправляется автоматически в 23:59).

diff --git a/play-life-backend/main.go b/play-life-backend/main.go index c3c05b7..a8e3872 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -1431,20 +1431,20 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { COALESCE(wr.total_score, 0.0000) AS total_score, wg.min_goal_score, wg.max_goal_score, - wg.priority + COALESCE(wg.priority, p.priority) AS priority FROM - weekly_goals wg - JOIN - projects p ON wg.project_id = p.id + projects p + LEFT JOIN + weekly_goals wg ON wg.project_id = p.id + AND wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER + AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER LEFT JOIN weekly_report_mv wr - ON wg.project_id = wr.project_id - AND wg.goal_year = wr.report_year - AND wg.goal_week = wr.report_week + ON p.id = wr.project_id + AND EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER = wr.report_year + AND EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER = wr.report_week WHERE - -- Фильтруем ТОЛЬКО по целям текущего года и недели - wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER - AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER + p.deleted = FALSE ORDER BY total_score DESC ` @@ -1463,13 +1463,14 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { for rows.Next() { var project WeeklyProjectStats + var minGoalScore sql.NullFloat64 var maxGoalScore sql.NullFloat64 var priority sql.NullInt64 err := rows.Scan( &project.ProjectName, &project.TotalScore, - &project.MinGoalScore, + &minGoalScore, &maxGoalScore, &priority, ) @@ -1479,6 +1480,12 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { return } + if minGoalScore.Valid { + project.MinGoalScore = minGoalScore.Float64 + } else { + project.MinGoalScore = 0 + } + if maxGoalScore.Valid { maxGoalVal := maxGoalScore.Float64 project.MaxGoalScore = &maxGoalVal @@ -1492,7 +1499,7 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { // Расчет calculated_score по формуле из n8n totalScore := project.TotalScore - minGoalScore := project.MinGoalScore + minGoalScoreVal := project.MinGoalScore var maxGoalScoreVal float64 if project.MaxGoalScore != nil { maxGoalScoreVal = *project.MaxGoalScore @@ -1508,15 +1515,15 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { // Расчет базового прогресса var baseProgress float64 - if minGoalScore > 0 { - baseProgress = (min(totalScore, minGoalScore) / minGoalScore) * 100.0 + if minGoalScoreVal > 0 { + baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0 } // Расчет экстра прогресса var extraProgress float64 - denominator := maxGoalScoreVal - minGoalScore - if denominator > 0 && totalScore > minGoalScore { - excess := min(totalScore, maxGoalScoreVal) - minGoalScore + denominator := maxGoalScoreVal - minGoalScoreVal + if denominator > 0 && totalScore > minGoalScoreVal { + excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal extraProgress = (excess / denominator) * extraBonusLimit } @@ -1524,23 +1531,45 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { project.CalculatedScore = roundToTwoDecimals(resultScore) // Группировка для итогового расчета - if _, exists := groups[priorityVal]; !exists { - groups[priorityVal] = make([]float64, 0) + // Проекты с minGoal = 0 или null не учитываются в общем проценте выполнения + if minGoalScoreVal > 0 { + if _, exists := groups[priorityVal]; !exists { + groups[priorityVal] = make([]float64, 0) + } + groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore) } - groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore) projects = append(projects, project) } // Находим среднее внутри каждой группы groupAverages := make([]float64, 0) - for _, scores := range groups { + for priorityVal, scores := range groups { if len(scores) > 0 { - sum := 0.0 - for _, score := range scores { - sum += score + var avg float64 + + // Для приоритета 1 и 2 - обычное среднее (как было) + if priorityVal == 1 || priorityVal == 2 { + sum := 0.0 + for _, score := range scores { + sum += score + } + avg = sum / float64(len(scores)) + } else { + // Для проектов без приоритета (priorityVal == 0) - новая формула + projectCount := float64(len(scores)) + multiplier := 100.0 / math.Floor(projectCount * 0.8) + + sum := 0.0 + for _, score := range scores { + // score уже в процентах (например, 80.0), переводим в долю (0.8) + scoreAsDecimal := score / 100.0 + sum += scoreAsDecimal * multiplier + } + + avg = math.Min(120.0, sum) } - avg := sum / float64(len(scores)) + groupAverages = append(groupAverages, avg) } } @@ -1926,23 +1955,32 @@ func (a *App) initPlayLifeDB() error { func (a *App) startWeeklyGoalsScheduler() { // Получаем часовой пояс из переменной окружения (по умолчанию UTC) timezoneStr := getEnv("TIMEZONE", "UTC") + log.Printf("Loading timezone for weekly goals scheduler: '%s'", timezoneStr) // Загружаем часовой пояс loc, err := time.LoadLocation(timezoneStr) if err != nil { log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err) + log.Printf("Note: Timezone must be in IANA format (e.g., 'Europe/Moscow', 'America/New_York'), not 'UTC+3'") loc = time.UTC + timezoneStr = "UTC" } else { - log.Printf("Scheduler timezone set to: %s", timezoneStr) + log.Printf("Weekly goals scheduler timezone set to: %s", timezoneStr) } + // Логируем текущее время в указанном часовом поясе для проверки + now := time.Now().In(loc) + log.Printf("Current time in scheduler timezone (%s): %s", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) + log.Printf("Next weekly goals setup will be on Monday at: 06:00 %s (cron: '0 6 * * 1')", timezoneStr) + // Создаем планировщик с указанным часовым поясом c := cron.New(cron.WithLocation(loc)) // Добавляем задачу: каждый понедельник в 6:00 утра // Cron выражение: "0 6 * * 1" означает: минута=0, час=6, любой день месяца, любой месяц, понедельник (1) _, err = c.AddFunc("0 6 * * 1", func() { - log.Printf("Scheduled task: Setting up weekly goals (timezone: %s)", timezoneStr) + now := time.Now().In(loc) + log.Printf("Scheduled task: Setting up weekly goals (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) if err := a.setupWeeklyGoals(); err != nil { log.Printf("Error in scheduled weekly goals setup: %v", err) } @@ -1955,7 +1993,7 @@ func (a *App) startWeeklyGoalsScheduler() { // Запускаем планировщик c.Start() - log.Println("Weekly goals scheduler started: every Monday at 6:00 AM") + log.Printf("Weekly goals scheduler started: every Monday at 6:00 AM %s", timezoneStr) // Планировщик будет работать в фоновом режиме } @@ -1976,21 +2014,20 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) { COALESCE(wr.total_score, 0.0000) AS total_score, wg.min_goal_score, wg.max_goal_score, - wg.priority + COALESCE(wg.priority, p.priority) AS priority FROM - weekly_goals wg - JOIN - projects p ON wg.project_id = p.id + projects p + LEFT JOIN + weekly_goals wg ON wg.project_id = p.id + AND wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER + AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER LEFT JOIN weekly_report_mv wr - ON wg.project_id = wr.project_id - AND wg.goal_year = wr.report_year - AND wg.goal_week = wr.report_week + ON p.id = wr.project_id + AND EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER = wr.report_year + AND EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER = wr.report_week WHERE - -- Фильтруем ТОЛЬКО по целям текущего года и недели - wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER - AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER - AND p.deleted = FALSE + p.deleted = FALSE ORDER BY total_score DESC ` @@ -2008,13 +2045,14 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) { for rows.Next() { var project WeeklyProjectStats + var minGoalScore sql.NullFloat64 var maxGoalScore sql.NullFloat64 var priority sql.NullInt64 err := rows.Scan( &project.ProjectName, &project.TotalScore, - &project.MinGoalScore, + &minGoalScore, &maxGoalScore, &priority, ) @@ -2023,6 +2061,12 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) { return nil, fmt.Errorf("error scanning weekly stats row: %w", err) } + if minGoalScore.Valid { + project.MinGoalScore = minGoalScore.Float64 + } else { + project.MinGoalScore = 0 + } + if maxGoalScore.Valid { maxGoalVal := maxGoalScore.Float64 project.MaxGoalScore = &maxGoalVal @@ -2036,7 +2080,7 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) { // Расчет calculated_score по формуле из n8n totalScore := project.TotalScore - minGoalScore := project.MinGoalScore + minGoalScoreVal := project.MinGoalScore var maxGoalScoreVal float64 if project.MaxGoalScore != nil { maxGoalScoreVal = *project.MaxGoalScore @@ -2052,15 +2096,15 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) { // Расчет базового прогресса var baseProgress float64 - if minGoalScore > 0 { - baseProgress = (min(totalScore, minGoalScore) / minGoalScore) * 100.0 + if minGoalScoreVal > 0 { + baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0 } // Расчет экстра прогресса var extraProgress float64 - denominator := maxGoalScoreVal - minGoalScore - if denominator > 0 && totalScore > minGoalScore { - excess := min(totalScore, maxGoalScoreVal) - minGoalScore + denominator := maxGoalScoreVal - minGoalScoreVal + if denominator > 0 && totalScore > minGoalScoreVal { + excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal extraProgress = (excess / denominator) * extraBonusLimit } @@ -2068,23 +2112,45 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) { project.CalculatedScore = roundToTwoDecimals(resultScore) // Группировка для итогового расчета - if _, exists := groups[priorityVal]; !exists { - groups[priorityVal] = make([]float64, 0) + // Проекты с minGoal = 0 или null не учитываются в общем проценте выполнения + if minGoalScoreVal > 0 { + if _, exists := groups[priorityVal]; !exists { + groups[priorityVal] = make([]float64, 0) + } + groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore) } - groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore) projects = append(projects, project) } // Находим среднее внутри каждой группы groupAverages := make([]float64, 0) - for _, scores := range groups { + for priorityVal, scores := range groups { if len(scores) > 0 { - sum := 0.0 - for _, score := range scores { - sum += score + var avg float64 + + // Для приоритета 1 и 2 - обычное среднее (как было) + if priorityVal == 1 || priorityVal == 2 { + sum := 0.0 + for _, score := range scores { + sum += score + } + avg = sum / float64(len(scores)) + } else { + // Для проектов без приоритета (priorityVal == 0) - новая формула + projectCount := float64(len(scores)) + multiplier := 100.0 / math.Floor(projectCount * 0.8) + + sum := 0.0 + for _, score := range scores { + // score уже в процентах (например, 80.0), переводим в долю (0.8) + scoreAsDecimal := score / 100.0 + sum += scoreAsDecimal * multiplier + } + + avg = math.Min(120.0, sum) } - avg := sum / float64(len(scores)) + groupAverages = append(groupAverages, avg) } } @@ -2191,27 +2257,36 @@ func (a *App) sendDailyReport() error { } // startDailyReportScheduler запускает планировщик для ежедневного отчета -// каждый день в 11:59 в указанном часовом поясе +// каждый день в 23:59 в указанном часовом поясе func (a *App) startDailyReportScheduler() { // Получаем часовой пояс из переменной окружения (по умолчанию UTC) timezoneStr := getEnv("TIMEZONE", "UTC") + log.Printf("Loading timezone for daily report scheduler: '%s'", timezoneStr) // Загружаем часовой пояс loc, err := time.LoadLocation(timezoneStr) if err != nil { log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err) + log.Printf("Note: Timezone must be in IANA format (e.g., 'Europe/Moscow', 'America/New_York'), not 'UTC+3'") loc = time.UTC + timezoneStr = "UTC" } else { log.Printf("Daily report scheduler timezone set to: %s", timezoneStr) } + // Логируем текущее время в указанном часовом поясе для проверки + now := time.Now().In(loc) + log.Printf("Current time in scheduler timezone (%s): %s", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) + log.Printf("Next daily report will be sent at: 23:59 %s (cron: '59 23 * * *')", timezoneStr) + // Создаем планировщик с указанным часовым поясом c := cron.New(cron.WithLocation(loc)) - // Добавляем задачу: каждый день в 11:59 - // Cron выражение: "59 11 * * *" означает: минута=59, час=11, любой день месяца, любой месяц, любой день недели - _, err = c.AddFunc("59 11 * * *", func() { - log.Printf("Scheduled task: Sending daily report (timezone: %s)", timezoneStr) + // Добавляем задачу: каждый день в 23:59 + // Cron выражение: "59 23 * * *" означает: минута=59, час=23, любой день месяца, любой месяц, любой день недели + _, err = c.AddFunc("59 23 * * *", func() { + now := time.Now().In(loc) + log.Printf("Scheduled task: Sending daily report (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) if err := a.sendDailyReport(); err != nil { log.Printf("Error in scheduled daily report: %v", err) } @@ -2224,7 +2299,7 @@ func (a *App) startDailyReportScheduler() { // Запускаем планировщик c.Start() - log.Println("Daily report scheduler started: every day at 11:59 AM") + log.Printf("Daily report scheduler started: every day at 23:59 %s", timezoneStr) // Планировщик будет работать в фоновом режиме } @@ -2331,7 +2406,7 @@ func main() { // Запускаем планировщик для автоматической фиксации целей на неделю app.startWeeklyGoalsScheduler() - // Запускаем планировщик для ежедневного отчета в 11:59 + // Запускаем планировщик для ежедневного отчета в 23:59 app.startDailyReportScheduler() r := mux.NewRouter() @@ -2876,7 +2951,7 @@ func (a *App) setupWeeklyGoals() error { EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER AS c_week ), goal_metrics AS ( - -- Считаем медиану на основе последних 12 записей из вьюхи + -- Считаем медиану на основе данных за 3 месяца (12 недель), исключая текущую неделю SELECT project_id, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total_score) AS median_score @@ -2884,11 +2959,19 @@ func (a *App) setupWeeklyGoals() error { SELECT project_id, total_score, + report_year, + report_week, -- Нумеруем недели от новых к старым ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn FROM weekly_report_mv + WHERE + -- Исключаем текущую неделю и все будущие недели + -- Используем сравнение (year, week) < (current_year, current_week) для корректного исключения + (report_year < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER) + OR (report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER + AND report_week < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER) ) sub - WHERE rn <= 12 -- Берем историю за последние 12 недель активности + WHERE rn <= 12 -- Берем историю за последние 12 недель (3 месяца), исключая текущую неделю GROUP BY project_id ) INSERT INTO weekly_goals ( @@ -2903,13 +2986,15 @@ func (a *App) setupWeeklyGoals() error { p.id, ci.c_year, ci.c_week, + -- Если нет данных (gm.median_score IS NULL), используем 0 (значение по умолчанию) COALESCE(gm.median_score, 0) AS min_goal_score, - -- Логика max_score в зависимости от приоритета + -- Логика max_score в зависимости от приоритета (только если есть данные) CASE - WHEN p.priority = 1 THEN COALESCE(gm.median_score, 0) * 1.5 - WHEN p.priority = 2 THEN COALESCE(gm.median_score, 0) * 1.3 - ELSE COALESCE(gm.median_score, 0) * 1.2 - END + (CASE WHEN COALESCE(gm.median_score, 0) = 0 THEN 10 ELSE 0 END) AS max_goal_score, + WHEN gm.median_score IS NULL THEN NULL + WHEN p.priority = 1 THEN gm.median_score * 1.5 + WHEN p.priority = 2 THEN gm.median_score * 1.3 + ELSE gm.median_score * 1.2 + END AS max_goal_score, p.priority FROM projects p CROSS JOIN current_info ci diff --git a/play-life-web/src/components/TestConfigSelection.jsx b/play-life-web/src/components/TestConfigSelection.jsx index 71d5554..6b0512e 100644 --- a/play-life-web/src/components/TestConfigSelection.jsx +++ b/play-life-web/src/components/TestConfigSelection.jsx @@ -228,7 +228,7 @@ function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) { ))}