Update backend and frontend components
This commit is contained in:
@@ -192,7 +192,7 @@
|
|||||||
<span class="status" id="dailyReportStatus" style="display: none;"></span>
|
<span class="status" id="dailyReportStatus" style="display: none;"></span>
|
||||||
</h2>
|
</h2>
|
||||||
<p style="margin-bottom: 15px; color: #666;">
|
<p style="margin-bottom: 15px; color: #666;">
|
||||||
Нажмите кнопку для отправки ежедневного отчёта по Score и Целям в Telegram (обычно отправляется автоматически в 11:59).
|
Нажмите кнопку для отправки ежедневного отчёта по Score и Целям в Telegram (обычно отправляется автоматически в 23:59).
|
||||||
</p>
|
</p>
|
||||||
<button onclick="triggerDailyReport()">Отправить отчёт</button>
|
<button onclick="triggerDailyReport()">Отправить отчёт</button>
|
||||||
<div id="dailyReportResult"></div>
|
<div id="dailyReportResult"></div>
|
||||||
|
|||||||
@@ -1431,20 +1431,20 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
COALESCE(wr.total_score, 0.0000) AS total_score,
|
COALESCE(wr.total_score, 0.0000) AS total_score,
|
||||||
wg.min_goal_score,
|
wg.min_goal_score,
|
||||||
wg.max_goal_score,
|
wg.max_goal_score,
|
||||||
wg.priority
|
COALESCE(wg.priority, p.priority) AS priority
|
||||||
FROM
|
FROM
|
||||||
weekly_goals wg
|
projects p
|
||||||
JOIN
|
LEFT JOIN
|
||||||
projects p ON wg.project_id = p.id
|
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
|
LEFT JOIN
|
||||||
weekly_report_mv wr
|
weekly_report_mv wr
|
||||||
ON wg.project_id = wr.project_id
|
ON p.id = wr.project_id
|
||||||
AND wg.goal_year = wr.report_year
|
AND EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER = wr.report_year
|
||||||
AND wg.goal_week = wr.report_week
|
AND EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER = wr.report_week
|
||||||
WHERE
|
WHERE
|
||||||
-- Фильтруем ТОЛЬКО по целям текущего года и недели
|
p.deleted = FALSE
|
||||||
wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
|
||||||
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
|
|
||||||
ORDER BY
|
ORDER BY
|
||||||
total_score DESC
|
total_score DESC
|
||||||
`
|
`
|
||||||
@@ -1463,13 +1463,14 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var project WeeklyProjectStats
|
var project WeeklyProjectStats
|
||||||
|
var minGoalScore sql.NullFloat64
|
||||||
var maxGoalScore sql.NullFloat64
|
var maxGoalScore sql.NullFloat64
|
||||||
var priority sql.NullInt64
|
var priority sql.NullInt64
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&project.ProjectName,
|
&project.ProjectName,
|
||||||
&project.TotalScore,
|
&project.TotalScore,
|
||||||
&project.MinGoalScore,
|
&minGoalScore,
|
||||||
&maxGoalScore,
|
&maxGoalScore,
|
||||||
&priority,
|
&priority,
|
||||||
)
|
)
|
||||||
@@ -1479,6 +1480,12 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if minGoalScore.Valid {
|
||||||
|
project.MinGoalScore = minGoalScore.Float64
|
||||||
|
} else {
|
||||||
|
project.MinGoalScore = 0
|
||||||
|
}
|
||||||
|
|
||||||
if maxGoalScore.Valid {
|
if maxGoalScore.Valid {
|
||||||
maxGoalVal := maxGoalScore.Float64
|
maxGoalVal := maxGoalScore.Float64
|
||||||
project.MaxGoalScore = &maxGoalVal
|
project.MaxGoalScore = &maxGoalVal
|
||||||
@@ -1492,7 +1499,7 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Расчет calculated_score по формуле из n8n
|
// Расчет calculated_score по формуле из n8n
|
||||||
totalScore := project.TotalScore
|
totalScore := project.TotalScore
|
||||||
minGoalScore := project.MinGoalScore
|
minGoalScoreVal := project.MinGoalScore
|
||||||
var maxGoalScoreVal float64
|
var maxGoalScoreVal float64
|
||||||
if project.MaxGoalScore != nil {
|
if project.MaxGoalScore != nil {
|
||||||
maxGoalScoreVal = *project.MaxGoalScore
|
maxGoalScoreVal = *project.MaxGoalScore
|
||||||
@@ -1508,15 +1515,15 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Расчет базового прогресса
|
// Расчет базового прогресса
|
||||||
var baseProgress float64
|
var baseProgress float64
|
||||||
if minGoalScore > 0 {
|
if minGoalScoreVal > 0 {
|
||||||
baseProgress = (min(totalScore, minGoalScore) / minGoalScore) * 100.0
|
baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Расчет экстра прогресса
|
// Расчет экстра прогресса
|
||||||
var extraProgress float64
|
var extraProgress float64
|
||||||
denominator := maxGoalScoreVal - minGoalScore
|
denominator := maxGoalScoreVal - minGoalScoreVal
|
||||||
if denominator > 0 && totalScore > minGoalScore {
|
if denominator > 0 && totalScore > minGoalScoreVal {
|
||||||
excess := min(totalScore, maxGoalScoreVal) - minGoalScore
|
excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal
|
||||||
extraProgress = (excess / denominator) * extraBonusLimit
|
extraProgress = (excess / denominator) * extraBonusLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1524,23 +1531,45 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
project.CalculatedScore = roundToTwoDecimals(resultScore)
|
project.CalculatedScore = roundToTwoDecimals(resultScore)
|
||||||
|
|
||||||
// Группировка для итогового расчета
|
// Группировка для итогового расчета
|
||||||
if _, exists := groups[priorityVal]; !exists {
|
// Проекты с minGoal = 0 или null не учитываются в общем проценте выполнения
|
||||||
groups[priorityVal] = make([]float64, 0)
|
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)
|
projects = append(projects, project)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Находим среднее внутри каждой группы
|
// Находим среднее внутри каждой группы
|
||||||
groupAverages := make([]float64, 0)
|
groupAverages := make([]float64, 0)
|
||||||
for _, scores := range groups {
|
for priorityVal, scores := range groups {
|
||||||
if len(scores) > 0 {
|
if len(scores) > 0 {
|
||||||
sum := 0.0
|
var avg float64
|
||||||
for _, score := range scores {
|
|
||||||
sum += score
|
// Для приоритета 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)
|
groupAverages = append(groupAverages, avg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1926,23 +1955,32 @@ func (a *App) initPlayLifeDB() error {
|
|||||||
func (a *App) startWeeklyGoalsScheduler() {
|
func (a *App) startWeeklyGoalsScheduler() {
|
||||||
// Получаем часовой пояс из переменной окружения (по умолчанию UTC)
|
// Получаем часовой пояс из переменной окружения (по умолчанию UTC)
|
||||||
timezoneStr := getEnv("TIMEZONE", "UTC")
|
timezoneStr := getEnv("TIMEZONE", "UTC")
|
||||||
|
log.Printf("Loading timezone for weekly goals scheduler: '%s'", timezoneStr)
|
||||||
|
|
||||||
// Загружаем часовой пояс
|
// Загружаем часовой пояс
|
||||||
loc, err := time.LoadLocation(timezoneStr)
|
loc, err := time.LoadLocation(timezoneStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err)
|
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
|
loc = time.UTC
|
||||||
|
timezoneStr = "UTC"
|
||||||
} else {
|
} 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))
|
c := cron.New(cron.WithLocation(loc))
|
||||||
|
|
||||||
// Добавляем задачу: каждый понедельник в 6:00 утра
|
// Добавляем задачу: каждый понедельник в 6:00 утра
|
||||||
// Cron выражение: "0 6 * * 1" означает: минута=0, час=6, любой день месяца, любой месяц, понедельник (1)
|
// Cron выражение: "0 6 * * 1" означает: минута=0, час=6, любой день месяца, любой месяц, понедельник (1)
|
||||||
_, err = c.AddFunc("0 6 * * 1", func() {
|
_, 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 {
|
if err := a.setupWeeklyGoals(); err != nil {
|
||||||
log.Printf("Error in scheduled weekly goals setup: %v", err)
|
log.Printf("Error in scheduled weekly goals setup: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1955,7 +1993,7 @@ func (a *App) startWeeklyGoalsScheduler() {
|
|||||||
|
|
||||||
// Запускаем планировщик
|
// Запускаем планировщик
|
||||||
c.Start()
|
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,
|
COALESCE(wr.total_score, 0.0000) AS total_score,
|
||||||
wg.min_goal_score,
|
wg.min_goal_score,
|
||||||
wg.max_goal_score,
|
wg.max_goal_score,
|
||||||
wg.priority
|
COALESCE(wg.priority, p.priority) AS priority
|
||||||
FROM
|
FROM
|
||||||
weekly_goals wg
|
projects p
|
||||||
JOIN
|
LEFT JOIN
|
||||||
projects p ON wg.project_id = p.id
|
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
|
LEFT JOIN
|
||||||
weekly_report_mv wr
|
weekly_report_mv wr
|
||||||
ON wg.project_id = wr.project_id
|
ON p.id = wr.project_id
|
||||||
AND wg.goal_year = wr.report_year
|
AND EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER = wr.report_year
|
||||||
AND wg.goal_week = wr.report_week
|
AND EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER = wr.report_week
|
||||||
WHERE
|
WHERE
|
||||||
-- Фильтруем ТОЛЬКО по целям текущего года и недели
|
p.deleted = FALSE
|
||||||
wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
|
||||||
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
|
|
||||||
AND p.deleted = FALSE
|
|
||||||
ORDER BY
|
ORDER BY
|
||||||
total_score DESC
|
total_score DESC
|
||||||
`
|
`
|
||||||
@@ -2008,13 +2045,14 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
|
|||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var project WeeklyProjectStats
|
var project WeeklyProjectStats
|
||||||
|
var minGoalScore sql.NullFloat64
|
||||||
var maxGoalScore sql.NullFloat64
|
var maxGoalScore sql.NullFloat64
|
||||||
var priority sql.NullInt64
|
var priority sql.NullInt64
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&project.ProjectName,
|
&project.ProjectName,
|
||||||
&project.TotalScore,
|
&project.TotalScore,
|
||||||
&project.MinGoalScore,
|
&minGoalScore,
|
||||||
&maxGoalScore,
|
&maxGoalScore,
|
||||||
&priority,
|
&priority,
|
||||||
)
|
)
|
||||||
@@ -2023,6 +2061,12 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
|
|||||||
return nil, fmt.Errorf("error scanning weekly stats row: %w", err)
|
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 {
|
if maxGoalScore.Valid {
|
||||||
maxGoalVal := maxGoalScore.Float64
|
maxGoalVal := maxGoalScore.Float64
|
||||||
project.MaxGoalScore = &maxGoalVal
|
project.MaxGoalScore = &maxGoalVal
|
||||||
@@ -2036,7 +2080,7 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
|
|||||||
|
|
||||||
// Расчет calculated_score по формуле из n8n
|
// Расчет calculated_score по формуле из n8n
|
||||||
totalScore := project.TotalScore
|
totalScore := project.TotalScore
|
||||||
minGoalScore := project.MinGoalScore
|
minGoalScoreVal := project.MinGoalScore
|
||||||
var maxGoalScoreVal float64
|
var maxGoalScoreVal float64
|
||||||
if project.MaxGoalScore != nil {
|
if project.MaxGoalScore != nil {
|
||||||
maxGoalScoreVal = *project.MaxGoalScore
|
maxGoalScoreVal = *project.MaxGoalScore
|
||||||
@@ -2052,15 +2096,15 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
|
|||||||
|
|
||||||
// Расчет базового прогресса
|
// Расчет базового прогресса
|
||||||
var baseProgress float64
|
var baseProgress float64
|
||||||
if minGoalScore > 0 {
|
if minGoalScoreVal > 0 {
|
||||||
baseProgress = (min(totalScore, minGoalScore) / minGoalScore) * 100.0
|
baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Расчет экстра прогресса
|
// Расчет экстра прогресса
|
||||||
var extraProgress float64
|
var extraProgress float64
|
||||||
denominator := maxGoalScoreVal - minGoalScore
|
denominator := maxGoalScoreVal - minGoalScoreVal
|
||||||
if denominator > 0 && totalScore > minGoalScore {
|
if denominator > 0 && totalScore > minGoalScoreVal {
|
||||||
excess := min(totalScore, maxGoalScoreVal) - minGoalScore
|
excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal
|
||||||
extraProgress = (excess / denominator) * extraBonusLimit
|
extraProgress = (excess / denominator) * extraBonusLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2068,23 +2112,45 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
|
|||||||
project.CalculatedScore = roundToTwoDecimals(resultScore)
|
project.CalculatedScore = roundToTwoDecimals(resultScore)
|
||||||
|
|
||||||
// Группировка для итогового расчета
|
// Группировка для итогового расчета
|
||||||
if _, exists := groups[priorityVal]; !exists {
|
// Проекты с minGoal = 0 или null не учитываются в общем проценте выполнения
|
||||||
groups[priorityVal] = make([]float64, 0)
|
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)
|
projects = append(projects, project)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Находим среднее внутри каждой группы
|
// Находим среднее внутри каждой группы
|
||||||
groupAverages := make([]float64, 0)
|
groupAverages := make([]float64, 0)
|
||||||
for _, scores := range groups {
|
for priorityVal, scores := range groups {
|
||||||
if len(scores) > 0 {
|
if len(scores) > 0 {
|
||||||
sum := 0.0
|
var avg float64
|
||||||
for _, score := range scores {
|
|
||||||
sum += score
|
// Для приоритета 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)
|
groupAverages = append(groupAverages, avg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2191,27 +2257,36 @@ func (a *App) sendDailyReport() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// startDailyReportScheduler запускает планировщик для ежедневного отчета
|
// startDailyReportScheduler запускает планировщик для ежедневного отчета
|
||||||
// каждый день в 11:59 в указанном часовом поясе
|
// каждый день в 23:59 в указанном часовом поясе
|
||||||
func (a *App) startDailyReportScheduler() {
|
func (a *App) startDailyReportScheduler() {
|
||||||
// Получаем часовой пояс из переменной окружения (по умолчанию UTC)
|
// Получаем часовой пояс из переменной окружения (по умолчанию UTC)
|
||||||
timezoneStr := getEnv("TIMEZONE", "UTC")
|
timezoneStr := getEnv("TIMEZONE", "UTC")
|
||||||
|
log.Printf("Loading timezone for daily report scheduler: '%s'", timezoneStr)
|
||||||
|
|
||||||
// Загружаем часовой пояс
|
// Загружаем часовой пояс
|
||||||
loc, err := time.LoadLocation(timezoneStr)
|
loc, err := time.LoadLocation(timezoneStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err)
|
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
|
loc = time.UTC
|
||||||
|
timezoneStr = "UTC"
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Daily report scheduler timezone set to: %s", timezoneStr)
|
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))
|
c := cron.New(cron.WithLocation(loc))
|
||||||
|
|
||||||
// Добавляем задачу: каждый день в 11:59
|
// Добавляем задачу: каждый день в 23:59
|
||||||
// Cron выражение: "59 11 * * *" означает: минута=59, час=11, любой день месяца, любой месяц, любой день недели
|
// Cron выражение: "59 23 * * *" означает: минута=59, час=23, любой день месяца, любой месяц, любой день недели
|
||||||
_, err = c.AddFunc("59 11 * * *", func() {
|
_, err = c.AddFunc("59 23 * * *", func() {
|
||||||
log.Printf("Scheduled task: Sending daily report (timezone: %s)", timezoneStr)
|
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 {
|
if err := a.sendDailyReport(); err != nil {
|
||||||
log.Printf("Error in scheduled daily report: %v", err)
|
log.Printf("Error in scheduled daily report: %v", err)
|
||||||
}
|
}
|
||||||
@@ -2224,7 +2299,7 @@ func (a *App) startDailyReportScheduler() {
|
|||||||
|
|
||||||
// Запускаем планировщик
|
// Запускаем планировщик
|
||||||
c.Start()
|
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()
|
app.startWeeklyGoalsScheduler()
|
||||||
|
|
||||||
// Запускаем планировщик для ежедневного отчета в 11:59
|
// Запускаем планировщик для ежедневного отчета в 23:59
|
||||||
app.startDailyReportScheduler()
|
app.startDailyReportScheduler()
|
||||||
|
|
||||||
r := mux.NewRouter()
|
r := mux.NewRouter()
|
||||||
@@ -2876,7 +2951,7 @@ func (a *App) setupWeeklyGoals() error {
|
|||||||
EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER AS c_week
|
EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER AS c_week
|
||||||
),
|
),
|
||||||
goal_metrics AS (
|
goal_metrics AS (
|
||||||
-- Считаем медиану на основе последних 12 записей из вьюхи
|
-- Считаем медиану на основе данных за 3 месяца (12 недель), исключая текущую неделю
|
||||||
SELECT
|
SELECT
|
||||||
project_id,
|
project_id,
|
||||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total_score) AS median_score
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total_score) AS median_score
|
||||||
@@ -2884,11 +2959,19 @@ func (a *App) setupWeeklyGoals() error {
|
|||||||
SELECT
|
SELECT
|
||||||
project_id,
|
project_id,
|
||||||
total_score,
|
total_score,
|
||||||
|
report_year,
|
||||||
|
report_week,
|
||||||
-- Нумеруем недели от новых к старым
|
-- Нумеруем недели от новых к старым
|
||||||
ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn
|
ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn
|
||||||
FROM weekly_report_mv
|
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
|
) sub
|
||||||
WHERE rn <= 12 -- Берем историю за последние 12 недель активности
|
WHERE rn <= 12 -- Берем историю за последние 12 недель (3 месяца), исключая текущую неделю
|
||||||
GROUP BY project_id
|
GROUP BY project_id
|
||||||
)
|
)
|
||||||
INSERT INTO weekly_goals (
|
INSERT INTO weekly_goals (
|
||||||
@@ -2903,13 +2986,15 @@ func (a *App) setupWeeklyGoals() error {
|
|||||||
p.id,
|
p.id,
|
||||||
ci.c_year,
|
ci.c_year,
|
||||||
ci.c_week,
|
ci.c_week,
|
||||||
|
-- Если нет данных (gm.median_score IS NULL), используем 0 (значение по умолчанию)
|
||||||
COALESCE(gm.median_score, 0) AS min_goal_score,
|
COALESCE(gm.median_score, 0) AS min_goal_score,
|
||||||
-- Логика max_score в зависимости от приоритета
|
-- Логика max_score в зависимости от приоритета (только если есть данные)
|
||||||
CASE
|
CASE
|
||||||
WHEN p.priority = 1 THEN COALESCE(gm.median_score, 0) * 1.5
|
WHEN gm.median_score IS NULL THEN NULL
|
||||||
WHEN p.priority = 2 THEN COALESCE(gm.median_score, 0) * 1.3
|
WHEN p.priority = 1 THEN gm.median_score * 1.5
|
||||||
ELSE COALESCE(gm.median_score, 0) * 1.2
|
WHEN p.priority = 2 THEN gm.median_score * 1.3
|
||||||
END + (CASE WHEN COALESCE(gm.median_score, 0) = 0 THEN 10 ELSE 0 END) AS max_goal_score,
|
ELSE gm.median_score * 1.2
|
||||||
|
END AS max_goal_score,
|
||||||
p.priority
|
p.priority
|
||||||
FROM projects p
|
FROM projects p
|
||||||
CROSS JOIN current_info ci
|
CROSS JOIN current_info ci
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
onClick={() => onNavigate?.('words', { isNewDictionary: true })}
|
onClick={() => onNavigate?.('words', { dictionaryId: null, isNewDictionary: true })}
|
||||||
className="add-dictionary-button"
|
className="add-dictionary-button"
|
||||||
>
|
>
|
||||||
<div className="add-config-icon">+</div>
|
<div className="add-config-icon">+</div>
|
||||||
|
|||||||
@@ -11,36 +11,45 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
|||||||
const [dictionaryName, setDictionaryName] = useState('')
|
const [dictionaryName, setDictionaryName] = useState('')
|
||||||
const [originalDictionaryName, setOriginalDictionaryName] = useState('')
|
const [originalDictionaryName, setOriginalDictionaryName] = useState('')
|
||||||
const [isSavingName, setIsSavingName] = useState(false)
|
const [isSavingName, setIsSavingName] = useState(false)
|
||||||
const [currentDictionaryId, setCurrentDictionaryId] = useState(dictionaryId)
|
// Normalize undefined to null for clarity: new dictionary if dictionaryId is null or undefined
|
||||||
const [isNewDict, setIsNewDict] = useState(isNewDictionary)
|
const [currentDictionaryId, setCurrentDictionaryId] = useState(dictionaryId ?? null)
|
||||||
|
|
||||||
|
// isNewDict is computed from currentDictionaryId: new dictionary if currentDictionaryId == null
|
||||||
|
const isNewDict = currentDictionaryId == null
|
||||||
|
|
||||||
|
// Helper function to check if dictionary exists and is not new
|
||||||
|
const hasValidDictionary = (dictId) => {
|
||||||
|
return dictId !== undefined && dictId !== null
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentDictionaryId(dictionaryId)
|
// Normalize undefined to null: if dictionaryId is undefined, treat it as null (new dictionary)
|
||||||
setIsNewDict(isNewDictionary)
|
const normalizedDictionaryId = dictionaryId ?? null
|
||||||
|
setCurrentDictionaryId(normalizedDictionaryId)
|
||||||
|
|
||||||
if (isNewDictionary) {
|
if (normalizedDictionaryId == null) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setDictionary(null)
|
setDictionary(null)
|
||||||
setDictionaryName('')
|
setDictionaryName('')
|
||||||
setOriginalDictionaryName('')
|
setOriginalDictionaryName('')
|
||||||
setWords([])
|
setWords([])
|
||||||
} else if (dictionaryId !== undefined && dictionaryId !== null) {
|
} else if (hasValidDictionary(normalizedDictionaryId)) {
|
||||||
fetchDictionary()
|
fetchDictionary(normalizedDictionaryId)
|
||||||
fetchWords()
|
fetchWords(normalizedDictionaryId)
|
||||||
} else {
|
} else {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setWords([])
|
setWords([])
|
||||||
}
|
}
|
||||||
}, [dictionaryId, isNewDictionary, refreshTrigger])
|
}, [dictionaryId, refreshTrigger])
|
||||||
|
|
||||||
const fetchDictionary = async () => {
|
const fetchDictionary = async (dictId) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/dictionaries`)
|
const response = await fetch(`${API_URL}/dictionaries`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка при загрузке словарей')
|
throw new Error('Ошибка при загрузке словарей')
|
||||||
}
|
}
|
||||||
const dictionaries = await response.json()
|
const dictionaries = await response.json()
|
||||||
const dict = dictionaries.find(d => d.id === dictionaryId)
|
const dict = dictionaries.find(d => d.id === dictId)
|
||||||
if (dict) {
|
if (dict) {
|
||||||
setDictionary(dict)
|
setDictionary(dict)
|
||||||
setDictionaryName(dict.name)
|
setDictionaryName(dict.name)
|
||||||
@@ -51,14 +60,14 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchWords = async () => {
|
const fetchWords = async (dictId) => {
|
||||||
if (isNewDictionary || dictionaryId === undefined || dictionaryId === null) {
|
if (!hasValidDictionary(dictId)) {
|
||||||
setWords([])
|
setWords([])
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetchWordsForDictionary(dictionaryId)
|
await fetchWordsForDictionary(dictId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchWordsForDictionary = async (dictId) => {
|
const fetchWordsForDictionary = async (dictId) => {
|
||||||
@@ -91,7 +100,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
|||||||
|
|
||||||
setIsSavingName(true)
|
setIsSavingName(true)
|
||||||
try {
|
try {
|
||||||
if (isNewDictionary) {
|
if (!hasValidDictionary(currentDictionaryId)) {
|
||||||
// Create new dictionary
|
// Create new dictionary
|
||||||
const response = await fetch(`${API_URL}/dictionaries`, {
|
const response = await fetch(`${API_URL}/dictionaries`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -108,21 +117,21 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
|||||||
const newDict = await response.json()
|
const newDict = await response.json()
|
||||||
const newDictionaryId = newDict.id
|
const newDictionaryId = newDict.id
|
||||||
|
|
||||||
// Update local state immediately
|
// Update local state
|
||||||
setOriginalDictionaryName(newDict.name)
|
setOriginalDictionaryName(newDict.name)
|
||||||
setDictionaryName(newDict.name)
|
setDictionaryName(newDict.name)
|
||||||
setDictionary(newDict)
|
setDictionary(newDict)
|
||||||
setCurrentDictionaryId(newDictionaryId)
|
setCurrentDictionaryId(newDictionaryId)
|
||||||
setIsNewDict(false)
|
|
||||||
|
|
||||||
// Fetch words for the new dictionary
|
// Reinitialize screen: fetch dictionary info and words for the new dictionary
|
||||||
|
await fetchDictionary(newDictionaryId)
|
||||||
await fetchWordsForDictionary(newDictionaryId)
|
await fetchWordsForDictionary(newDictionaryId)
|
||||||
|
|
||||||
// Update navigation to use the new dictionary ID and remove isNewDictionary flag
|
// Update navigation to use the new dictionary ID
|
||||||
onNavigate?.('words', { dictionaryId: newDictionaryId, isNewDictionary: false })
|
onNavigate?.('words', { dictionaryId: newDictionaryId })
|
||||||
} else if (dictionaryId !== undefined && dictionaryId !== null) {
|
} else if (hasValidDictionary(currentDictionaryId)) {
|
||||||
// Update existing dictionary
|
// Update existing dictionary (rename)
|
||||||
const response = await fetch(`${API_URL}/dictionaries/${dictionaryId}`, {
|
const response = await fetch(`${API_URL}/dictionaries/${currentDictionaryId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -146,6 +155,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show save button only if name is not empty and has changed
|
||||||
const showSaveButton = dictionaryName.trim() !== '' && dictionaryName.trim() !== originalDictionaryName
|
const showSaveButton = dictionaryName.trim() !== '' && dictionaryName.trim() !== originalDictionaryName
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -195,10 +205,8 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Show add button and words list:
|
{/* Show add button and words list only if dictionaryId exists and is not new */}
|
||||||
- If dictionary exists (has dictionaryId), show regardless of name
|
{hasValidDictionary(currentDictionaryId) && (
|
||||||
- If new dictionary (no dictionaryId), show only if name is set */}
|
|
||||||
{((currentDictionaryId !== undefined && currentDictionaryId !== null && !isNewDict) || (isNewDict && dictionaryName.trim())) && (
|
|
||||||
<>
|
<>
|
||||||
{(!words || words.length === 0) ? (
|
{(!words || words.length === 0) ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Reference in New Issue
Block a user