4.1.0: Оптимизация получения данных текущей недели
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s

This commit is contained in:
poignatov
2026-01-26 18:45:58 +03:00
parent 904b00f3f5
commit a611f05959
5 changed files with 671 additions and 326 deletions

View File

@@ -1 +1 @@
4.0.6 4.1.0

View File

@@ -26,20 +26,21 @@ import (
"time" "time"
"unicode/utf16" "unicode/utf16"
"image/jpeg"
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
"github.com/disintegration/imaging" "github.com/disintegration/imaging"
"github.com/go-telegram-bot-api/telegram-bot-api/v5" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres" _ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file" _ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/joho/godotenv" "github.com/joho/godotenv"
_ "github.com/lib/pq"
"github.com/lib/pq" "github.com/lib/pq"
_ "github.com/lib/pq"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"image/jpeg"
) )
type Word struct { type Word struct {
@@ -73,8 +74,8 @@ type TestProgressUpdate struct {
} }
type TestProgressRequest struct { type TestProgressRequest struct {
Words []TestProgressUpdate `json:"words"` Words []TestProgressUpdate `json:"words"`
ConfigID *int `json:"config_id,omitempty"` ConfigID *int `json:"config_id,omitempty"`
} }
type Config struct { type Config struct {
@@ -90,9 +91,9 @@ type ConfigRequest struct {
} }
type Dictionary struct { type Dictionary struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
WordsCount int `json:"wordsCount"` WordsCount int `json:"wordsCount"`
} }
type DictionaryRequest struct { type DictionaryRequest struct {
@@ -100,7 +101,7 @@ type DictionaryRequest struct {
} }
type TestConfigsAndDictionariesResponse struct { type TestConfigsAndDictionariesResponse struct {
Configs []Config `json:"configs"` Configs []Config `json:"configs"`
Dictionaries []Dictionary `json:"dictionaries"` Dictionaries []Dictionary `json:"dictionaries"`
} }
@@ -153,14 +154,14 @@ type WeeklyGoalSetup struct {
} }
type Project struct { type Project struct {
ProjectID int `json:"project_id"` ProjectID int `json:"project_id"`
ProjectName string `json:"project_name"` ProjectName string `json:"project_name"`
Priority *int `json:"priority,omitempty"` Priority *int `json:"priority,omitempty"`
} }
type ProjectPriorityUpdate struct { type ProjectPriorityUpdate struct {
ID int `json:"id"` ID int `json:"id"`
Priority *int `json:"priority"` Priority *int `json:"priority"`
} }
type ProjectPriorityRequest struct { type ProjectPriorityRequest struct {
@@ -215,34 +216,34 @@ type TelegramUpdate struct {
// Task structures // Task structures
type Task struct { type Task struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Completed int `json:"completed"` Completed int `json:"completed"`
LastCompletedAt *string `json:"last_completed_at,omitempty"` LastCompletedAt *string `json:"last_completed_at,omitempty"`
NextShowAt *string `json:"next_show_at,omitempty"` NextShowAt *string `json:"next_show_at,omitempty"`
RewardMessage *string `json:"reward_message,omitempty"` RewardMessage *string `json:"reward_message,omitempty"`
ProgressionBase *float64 `json:"progression_base,omitempty"` ProgressionBase *float64 `json:"progression_base,omitempty"`
RepetitionPeriod *string `json:"repetition_period,omitempty"` RepetitionPeriod *string `json:"repetition_period,omitempty"`
RepetitionDate *string `json:"repetition_date,omitempty"` RepetitionDate *string `json:"repetition_date,omitempty"`
WishlistID *int `json:"wishlist_id,omitempty"` WishlistID *int `json:"wishlist_id,omitempty"`
ConfigID *int `json:"config_id,omitempty"` ConfigID *int `json:"config_id,omitempty"`
RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями
// Дополнительные поля для списка задач (без omitempty чтобы всегда передавались) // Дополнительные поля для списка задач (без omitempty чтобы всегда передавались)
ProjectNames []string `json:"project_names"` ProjectNames []string `json:"project_names"`
SubtasksCount int `json:"subtasks_count"` SubtasksCount int `json:"subtasks_count"`
HasProgression bool `json:"has_progression"` HasProgression bool `json:"has_progression"`
} }
type Reward struct { type Reward struct {
ID int `json:"id"` ID int `json:"id"`
Position int `json:"position"` Position int `json:"position"`
ProjectName string `json:"project_name"` ProjectName string `json:"project_name"`
Value float64 `json:"value"` Value float64 `json:"value"`
UseProgression bool `json:"use_progression"` UseProgression bool `json:"use_progression"`
} }
type Subtask struct { type Subtask struct {
Task Task `json:"task"` Task Task `json:"task"`
Rewards []Reward `json:"rewards"` Rewards []Reward `json:"rewards"`
} }
@@ -253,10 +254,10 @@ type WishlistInfo struct {
} }
type TaskDetail struct { type TaskDetail struct {
Task Task `json:"task"` Task Task `json:"task"`
Rewards []Reward `json:"rewards"` Rewards []Reward `json:"rewards"`
Subtasks []Subtask `json:"subtasks"` Subtasks []Subtask `json:"subtasks"`
WishlistInfo *WishlistInfo `json:"wishlist_info,omitempty"` WishlistInfo *WishlistInfo `json:"wishlist_info,omitempty"`
// Test-specific fields (only present if task has config_id) // Test-specific fields (only present if task has config_id)
WordsCount *int `json:"words_count,omitempty"` WordsCount *int `json:"words_count,omitempty"`
MaxCards *int `json:"max_cards,omitempty"` MaxCards *int `json:"max_cards,omitempty"`
@@ -278,15 +279,15 @@ type SubtaskRequest struct {
} }
type TaskRequest struct { type TaskRequest struct {
Name string `json:"name"` Name string `json:"name"`
ProgressionBase *float64 `json:"progression_base,omitempty"` ProgressionBase *float64 `json:"progression_base,omitempty"`
RewardMessage *string `json:"reward_message,omitempty"` RewardMessage *string `json:"reward_message,omitempty"`
RepetitionPeriod *string `json:"repetition_period,omitempty"` RepetitionPeriod *string `json:"repetition_period,omitempty"`
RepetitionDate *string `json:"repetition_date,omitempty"` RepetitionDate *string `json:"repetition_date,omitempty"`
WishlistID *int `json:"wishlist_id,omitempty"` WishlistID *int `json:"wishlist_id,omitempty"`
RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями
Rewards []RewardRequest `json:"rewards,omitempty"` Rewards []RewardRequest `json:"rewards,omitempty"`
Subtasks []SubtaskRequest `json:"subtasks,omitempty"` Subtasks []SubtaskRequest `json:"subtasks,omitempty"`
// Test-specific fields // Test-specific fields
IsTest bool `json:"is_test,omitempty"` IsTest bool `json:"is_test,omitempty"`
WordsCount *int `json:"words_count,omitempty"` WordsCount *int `json:"words_count,omitempty"`
@@ -316,35 +317,35 @@ type LinkedTask struct {
} }
type WishlistItem struct { type WishlistItem struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Price *float64 `json:"price,omitempty"` Price *float64 `json:"price,omitempty"`
ImageURL *string `json:"image_url,omitempty"` ImageURL *string `json:"image_url,omitempty"`
Link *string `json:"link,omitempty"` Link *string `json:"link,omitempty"`
Unlocked bool `json:"unlocked"` Unlocked bool `json:"unlocked"`
Completed bool `json:"completed"` Completed bool `json:"completed"`
FirstLockedCondition *UnlockConditionDisplay `json:"first_locked_condition,omitempty"` FirstLockedCondition *UnlockConditionDisplay `json:"first_locked_condition,omitempty"`
MoreLockedConditions int `json:"more_locked_conditions,omitempty"` MoreLockedConditions int `json:"more_locked_conditions,omitempty"`
UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"` UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"`
LinkedTask *LinkedTask `json:"linked_task,omitempty"` LinkedTask *LinkedTask `json:"linked_task,omitempty"`
} }
type UnlockConditionDisplay struct { type UnlockConditionDisplay struct {
ID int `json:"id"` ID int `json:"id"`
Type string `json:"type"` Type string `json:"type"`
TaskID *int `json:"task_id,omitempty"` // ID задачи (для task_completion) TaskID *int `json:"task_id,omitempty"` // ID задачи (для task_completion)
TaskName *string `json:"task_name,omitempty"` TaskName *string `json:"task_name,omitempty"`
ProjectID *int `json:"project_id,omitempty"` // ID проекта (для project_points) ProjectID *int `json:"project_id,omitempty"` // ID проекта (для project_points)
ProjectName *string `json:"project_name,omitempty"` ProjectName *string `json:"project_name,omitempty"`
RequiredPoints *float64 `json:"required_points,omitempty"` RequiredPoints *float64 `json:"required_points,omitempty"`
StartDate *string `json:"start_date,omitempty"` // Дата начала подсчёта (YYYY-MM-DD), NULL = за всё время StartDate *string `json:"start_date,omitempty"` // Дата начала подсчёта (YYYY-MM-DD), NULL = за всё время
DisplayOrder int `json:"display_order"` DisplayOrder int `json:"display_order"`
// Прогресс выполнения // Прогресс выполнения
CurrentPoints *float64 `json:"current_points,omitempty"` // Текущее количество баллов (для project_points) CurrentPoints *float64 `json:"current_points,omitempty"` // Текущее количество баллов (для project_points)
TaskCompleted *bool `json:"task_completed,omitempty"` // Выполнена ли задача (для task_completion) TaskCompleted *bool `json:"task_completed,omitempty"` // Выполнена ли задача (для task_completion)
// Персональные цели // Персональные цели
UserID *int `json:"user_id,omitempty"` // ID пользователя для персональных целей UserID *int `json:"user_id,omitempty"` // ID пользователя для персональных целей
UserName *string `json:"user_name,omitempty"` // Имя пользователя для персональных целей UserName *string `json:"user_name,omitempty"` // Имя пользователя для персональных целей
} }
type WishlistRequest struct { type WishlistRequest struct {
@@ -355,7 +356,7 @@ type WishlistRequest struct {
} }
type UnlockConditionRequest struct { type UnlockConditionRequest struct {
ID *int `json:"id,omitempty"` // ID существующего условия (для сохранения чужих условий) ID *int `json:"id,omitempty"` // ID существующего условия (для сохранения чужих условий)
Type string `json:"type"` Type string `json:"type"`
TaskID *int `json:"task_id,omitempty"` TaskID *int `json:"task_id,omitempty"`
ProjectID *int `json:"project_id,omitempty"` ProjectID *int `json:"project_id,omitempty"`
@@ -365,10 +366,10 @@ type UnlockConditionRequest struct {
} }
type WishlistResponse struct { type WishlistResponse struct {
Unlocked []WishlistItem `json:"unlocked"` Unlocked []WishlistItem `json:"unlocked"`
Locked []WishlistItem `json:"locked"` Locked []WishlistItem `json:"locked"`
Completed []WishlistItem `json:"completed,omitempty"` Completed []WishlistItem `json:"completed,omitempty"`
CompletedCount int `json:"completed_count"` // Количество завершённых желаний CompletedCount int `json:"completed_count"` // Количество завершённых желаний
} }
// ============================================ // ============================================
@@ -1432,8 +1433,8 @@ func (a *App) getTestWordsHandler(w http.ResponseWriter, r *http.Request) {
} }
// Calculate group sizes (use ceiling to ensure we don't lose words due to rounding) // Calculate group sizes (use ceiling to ensure we don't lose words due to rounding)
group1Count := int(float64(wordsCount) * 0.3) // 30% group1Count := int(float64(wordsCount) * 0.3) // 30%
group2Count := int(float64(wordsCount) * 0.4) // 40% group2Count := int(float64(wordsCount) * 0.4) // 40%
// group3Count is calculated dynamically based on actual words collected from groups 1 and 2 // group3Count is calculated dynamically based on actual words collected from groups 1 and 2
// Base query parts // Base query parts
@@ -2272,7 +2273,7 @@ func (a *App) addConfigHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Origin", "*")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Config created successfully", "message": "Config created successfully",
"id": id, "id": id,
}) })
} }
@@ -2437,18 +2438,17 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("getWeeklyStatsHandler called from %s, path: %s, user: %d", r.RemoteAddr, r.URL.Path, userID) log.Printf("getWeeklyStatsHandler called from %s, path: %s, user: %d", r.RemoteAddr, r.URL.Path, userID)
// Опционально обновляем materialized view перед запросом // Получаем данные текущей недели напрямую из nodes
// Это можно сделать через query parameter ?refresh=true currentWeekScores, err := a.getCurrentWeekScores(userID)
if r.URL.Query().Get("refresh") == "true" { if err != nil {
_, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") log.Printf("Error getting current week scores: %v", err)
if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
log.Printf("Warning: Failed to refresh materialized view: %v", err) return
// Продолжаем выполнение даже если обновление не удалось
}
} }
query := ` query := `
SELECT SELECT
p.id AS project_id,
p.name AS project_name, p.name AS project_name,
-- Используем COALESCE для установки total_score в 0.0000, если нет данных в weekly_report_mv -- Используем COALESCE для установки total_score в 0.0000, если нет данных в weekly_report_mv
COALESCE(wr.total_score, 0.0000) AS total_score, COALESCE(wr.total_score, 0.0000) AS total_score,
@@ -2486,11 +2486,13 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
for rows.Next() { for rows.Next() {
var project WeeklyProjectStats var project WeeklyProjectStats
var projectID int
var minGoalScore sql.NullFloat64 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(
&projectID,
&project.ProjectName, &project.ProjectName,
&project.TotalScore, &project.TotalScore,
&minGoalScore, &minGoalScore,
@@ -2503,6 +2505,11 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Объединяем данные: если есть данные текущей недели, используем их вместо MV
if currentWeekScore, exists := currentWeekScores[projectID]; exists {
project.TotalScore = currentWeekScore
}
if minGoalScore.Valid { if minGoalScore.Valid {
project.MinGoalScore = minGoalScore.Float64 project.MinGoalScore = minGoalScore.Float64
} else { } else {
@@ -2770,7 +2777,17 @@ func (a *App) startWeeklyGoalsScheduler() {
// 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() {
now := time.Now().In(loc) 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")) log.Printf("Scheduled task: Refreshing weekly report MV and setting up weekly goals (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST"))
// Сначала обновляем MV (чтобы в ней были данные прошлой недели)
_, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv")
if err != nil {
log.Printf("Error refreshing materialized view: %v", err)
} else {
log.Printf("Materialized view refreshed successfully")
}
// Затем настраиваем цели на новую неделю
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)
} }
@@ -2785,17 +2802,94 @@ func (a *App) startWeeklyGoalsScheduler() {
log.Println("Weekly goals scheduler started") log.Println("Weekly goals scheduler started")
} }
// getCurrentWeekScores получает данные текущей недели напрямую из таблицы nodes для конкретного пользователя
// Возвращает map[project_id]total_score для текущей недели
func (a *App) getCurrentWeekScores(userID int) (map[int]float64, error) {
query := `
SELECT
n.project_id,
COALESCE(SUM(n.score), 0) AS total_score
FROM nodes n
JOIN projects p ON n.project_id = p.id
WHERE
p.deleted = FALSE
AND p.user_id = $1
AND n.user_id = $1
AND EXTRACT(ISOYEAR FROM n.created_date)::INTEGER = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND EXTRACT(WEEK FROM n.created_date)::INTEGER = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
GROUP BY n.project_id
`
rows, err := a.DB.Query(query, userID)
if err != nil {
log.Printf("Error querying current week scores: %v", err)
return nil, fmt.Errorf("error querying current week scores: %w", err)
}
defer rows.Close()
scores := make(map[int]float64)
for rows.Next() {
var projectID int
var totalScore float64
if err := rows.Scan(&projectID, &totalScore); err != nil {
log.Printf("Error scanning current week scores row: %v", err)
return nil, fmt.Errorf("error scanning current week scores row: %w", err)
}
scores[projectID] = totalScore
}
return scores, nil
}
// getCurrentWeekScoresAllUsers получает данные текущей недели для всех пользователей
// Возвращает map[project_id]total_score для текущей недели
func (a *App) getCurrentWeekScoresAllUsers() (map[int]float64, error) {
query := `
SELECT
n.project_id,
COALESCE(SUM(n.score), 0) AS total_score
FROM nodes n
JOIN projects p ON n.project_id = p.id
WHERE
p.deleted = FALSE
AND EXTRACT(ISOYEAR FROM n.created_date)::INTEGER = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND EXTRACT(WEEK FROM n.created_date)::INTEGER = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
GROUP BY n.project_id
`
rows, err := a.DB.Query(query)
if err != nil {
log.Printf("Error querying current week scores for all users: %v", err)
return nil, fmt.Errorf("error querying current week scores for all users: %w", err)
}
defer rows.Close()
scores := make(map[int]float64)
for rows.Next() {
var projectID int
var totalScore float64
if err := rows.Scan(&projectID, &totalScore); err != nil {
log.Printf("Error scanning current week scores row: %v", err)
return nil, fmt.Errorf("error scanning current week scores row: %w", err)
}
scores[projectID] = totalScore
}
return scores, nil
}
// getWeeklyStatsData получает данные о проектах и их целях (без HTTP обработки) // getWeeklyStatsData получает данные о проектах и их целях (без HTTP обработки)
func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) { func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
// Обновляем materialized view перед запросом // Получаем данные текущей недели для всех пользователей
_, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") currentWeekScores, err := a.getCurrentWeekScoresAllUsers()
if err != nil { if err != nil {
log.Printf("Warning: Failed to refresh materialized view: %v", err) log.Printf("Error getting current week scores: %v", err)
// Продолжаем выполнение даже если обновление не удалось return nil, fmt.Errorf("error getting current week scores: %w", err)
} }
query := ` query := `
SELECT SELECT
p.id AS project_id,
p.name AS project_name, p.name AS project_name,
-- Используем COALESCE для установки total_score в 0.0000, если нет данных в weekly_report_mv -- Используем COALESCE для установки total_score в 0.0000, если нет данных в weekly_report_mv
COALESCE(wr.total_score, 0.0000) AS total_score, COALESCE(wr.total_score, 0.0000) AS total_score,
@@ -2832,11 +2926,13 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
for rows.Next() { for rows.Next() {
var project WeeklyProjectStats var project WeeklyProjectStats
var projectID int
var minGoalScore sql.NullFloat64 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(
&projectID,
&project.ProjectName, &project.ProjectName,
&project.TotalScore, &project.TotalScore,
&minGoalScore, &minGoalScore,
@@ -2848,6 +2944,11 @@ 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)
} }
// Объединяем данные: если есть данные текущей недели, используем их вместо MV
if currentWeekScore, exists := currentWeekScores[projectID]; exists {
project.TotalScore = currentWeekScore
}
if minGoalScore.Valid { if minGoalScore.Valid {
project.MinGoalScore = minGoalScore.Float64 project.MinGoalScore = minGoalScore.Float64
} else { } else {
@@ -2929,14 +3030,16 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
// getWeeklyStatsDataForUser получает данные о проектах для конкретного пользователя // getWeeklyStatsDataForUser получает данные о проектах для конкретного пользователя
func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error) { func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error) {
// Обновляем materialized view перед запросом // Получаем данные текущей недели напрямую из nodes
_, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") currentWeekScores, err := a.getCurrentWeekScores(userID)
if err != nil { if err != nil {
log.Printf("Warning: Failed to refresh materialized view: %v", err) log.Printf("Error getting current week scores: %v", err)
return nil, fmt.Errorf("error getting current week scores: %w", err)
} }
query := ` query := `
SELECT SELECT
p.id AS project_id,
p.name AS project_name, p.name AS project_name,
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,
@@ -2970,11 +3073,13 @@ func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error
for rows.Next() { for rows.Next() {
var project WeeklyProjectStats var project WeeklyProjectStats
var projectID int
var minGoalScore sql.NullFloat64 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(
&projectID,
&project.ProjectName, &project.ProjectName,
&project.TotalScore, &project.TotalScore,
&minGoalScore, &minGoalScore,
@@ -2985,6 +3090,11 @@ func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error
return nil, fmt.Errorf("error scanning weekly stats row: %w", err) return nil, fmt.Errorf("error scanning weekly stats row: %w", err)
} }
// Объединяем данные: если есть данные текущей недели, используем их вместо MV
if currentWeekScore, exists := currentWeekScores[projectID]; exists {
project.TotalScore = currentWeekScore
}
if minGoalScore.Valid { if minGoalScore.Valid {
project.MinGoalScore = minGoalScore.Float64 project.MinGoalScore = minGoalScore.Float64
} else { } else {
@@ -3209,10 +3319,10 @@ func (a *App) startDailyReportScheduler() {
func readVersion() string { func readVersion() string {
// Пробуем разные пути к файлу VERSION // Пробуем разные пути к файлу VERSION
paths := []string{ paths := []string{
"/app/VERSION", // В Docker контейнере "/app/VERSION", // В Docker контейнере
"../VERSION", // При запуске из play-life-backend/ "../VERSION", // При запуске из play-life-backend/
"../../VERSION", // Альтернативный путь "../../VERSION", // Альтернативный путь
"VERSION", // Текущая директория "VERSION", // Текущая директория
} }
for _, path := range paths { for _, path := range paths {
@@ -3759,7 +3869,6 @@ func (a *App) getTelegramIntegrationForUser(userID int) (*TelegramIntegration, e
return &integration, nil return &integration, nil
} }
// sendTelegramMessageToChat - отправляет сообщение в конкретный чат по chat_id // sendTelegramMessageToChat - отправляет сообщение в конкретный чат по chat_id
func (a *App) sendTelegramMessageToChat(chatID int64, text string) error { func (a *App) sendTelegramMessageToChat(chatID int64, text string) error {
if a.telegramBot == nil { if a.telegramBot == nil {
@@ -3774,8 +3883,8 @@ func (a *App) sendTelegramMessageToChat(chatID int64, text string) error {
if err != nil { if err != nil {
// Проверяем, не заблокирован ли бот // Проверяем, не заблокирован ли бот
if strings.Contains(err.Error(), "blocked") || if strings.Contains(err.Error(), "blocked") ||
strings.Contains(err.Error(), "chat not found") || strings.Contains(err.Error(), "chat not found") ||
strings.Contains(err.Error(), "bot was blocked") { strings.Contains(err.Error(), "bot was blocked") {
// Пользователь заблокировал бота - очищаем данные // Пользователь заблокировал бота - очищаем данные
chatIDStr := strconv.FormatInt(chatID, 10) chatIDStr := strconv.FormatInt(chatID, 10)
a.DB.Exec(` a.DB.Exec(`
@@ -4272,29 +4381,25 @@ func (a *App) insertMessageData(entryText string, createdDate string, nodes []Pr
return fmt.Errorf("failed to find project %s: %w", node.Project, err) return fmt.Errorf("failed to find project %s: %w", node.Project, err)
} }
// Вставляем node с user_id // Вставляем node с user_id и created_date (денормализация)
if userID != nil { if userID != nil {
_, err = tx.Exec(` _, err = tx.Exec(`
INSERT INTO nodes (project_id, entry_id, score, user_id) INSERT INTO nodes (project_id, entry_id, score, user_id, created_date)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4, $5)
`, projectID, entryID, node.Score, *userID) `, projectID, entryID, node.Score, *userID, createdDate)
} else { } else {
_, err = tx.Exec(` _, err = tx.Exec(`
INSERT INTO nodes (project_id, entry_id, score) INSERT INTO nodes (project_id, entry_id, score, created_date)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4)
`, projectID, entryID, node.Score) `, projectID, entryID, node.Score, createdDate)
} }
if err != nil { if err != nil {
return fmt.Errorf("failed to insert node for project %s: %w", node.Project, err) return fmt.Errorf("failed to insert node for project %s: %w", node.Project, err)
} }
} }
// Обновляем materialized view после вставки данных // MV обновляется только по крону в понедельник в 6:00 утра
_, err = tx.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") // Данные текущей недели берутся напрямую из nodes
if err != nil {
log.Printf("Warning: Failed to refresh materialized view: %v", err)
// Не возвращаем ошибку, так как это не критично
}
// Коммитим транзакцию // Коммитим транзакцию
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
@@ -5047,7 +5152,7 @@ func (a *App) moveProjectHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Project renamed successfully", "message": "Project renamed successfully",
"project_id": req.ID, "project_id": req.ID,
}) })
return return
@@ -5126,15 +5231,12 @@ func (a *App) moveProjectHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Обновляем materialized view // MV обновляется только по крону в понедельник в 6:00 утра
_, err = a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") // Данные текущей недели берутся напрямую из nodes
if err != nil {
log.Printf("Warning: Failed to refresh materialized view: %v", err)
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Project moved successfully", "message": "Project moved successfully",
"project_id": finalProjectID, "project_id": finalProjectID,
}) })
} }
@@ -5207,11 +5309,8 @@ func (a *App) deleteProjectHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Обновляем materialized view // MV обновляется только по крону в понедельник в 6:00 утра
_, err = a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") // Данные текущей недели берутся напрямую из nodes
if err != nil {
log.Printf("Warning: Failed to refresh materialized view: %v", err)
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
@@ -5278,8 +5377,8 @@ func (a *App) createProjectHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Project created successfully", "message": "Project created successfully",
"project_id": projectID, "project_id": projectID,
"project_name": req.Name, "project_name": req.Name,
}) })
} }
@@ -5703,6 +5802,19 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Получаем данные текущей недели
currentWeekScores, err := a.getCurrentWeekScores(userID)
if err != nil {
log.Printf("Error getting current week scores: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error getting current week scores: %v", err), http.StatusInternalServerError)
return
}
// Получаем ISO год и неделю для текущей даты
now := time.Now()
_, currentWeekInt := now.ISOWeek()
currentYearInt := now.Year()
query := ` query := `
SELECT SELECT
p.name AS project_name, p.name AS project_name,
@@ -5717,7 +5829,8 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) {
COALESCE(wg.min_goal_score, 0.0000) AS min_goal_score, COALESCE(wg.min_goal_score, 0.0000) AS min_goal_score,
-- Максимальная цель: COALESCE(NULL, 0.0000) -- Максимальная цель: COALESCE(NULL, 0.0000)
COALESCE(wg.max_goal_score, 0.0000) AS max_goal_score COALESCE(wg.max_goal_score, 0.0000) AS max_goal_score,
p.id AS project_id
FROM FROM
weekly_report_mv wr weekly_report_mv wr
FULL OUTER JOIN FULL OUTER JOIN
@@ -5748,8 +5861,10 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) {
defer rows.Close() defer rows.Close()
statistics := make([]FullStatisticsItem, 0) statistics := make([]FullStatisticsItem, 0)
for rows.Next() { for rows.Next() {
var item FullStatisticsItem var item FullStatisticsItem
var projectID int
err := rows.Scan( err := rows.Scan(
&item.ProjectName, &item.ProjectName,
@@ -5758,6 +5873,7 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) {
&item.TotalScore, &item.TotalScore,
&item.MinGoalScore, &item.MinGoalScore,
&item.MaxGoalScore, &item.MaxGoalScore,
&projectID,
) )
if err != nil { if err != nil {
log.Printf("Error scanning full statistics row: %v", err) log.Printf("Error scanning full statistics row: %v", err)
@@ -5765,9 +5881,78 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Если это текущая неделя, заменяем данные из MV на данные из nodes
if item.ReportYear == currentYearInt && item.ReportWeek == currentWeekInt {
if score, exists := currentWeekScores[projectID]; exists {
item.TotalScore = score
}
}
statistics = append(statistics, item) statistics = append(statistics, item)
} }
// Добавляем проекты текущей недели, которых нет в MV (новые проекты без исторических данных)
// Получаем goals для текущей недели
currentWeekGoalsQuery := `
SELECT
p.id AS project_id,
p.name AS project_name,
COALESCE(wg.min_goal_score, 0.0000) AS min_goal_score,
COALESCE(wg.max_goal_score, 0.0000) AS max_goal_score
FROM 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
WHERE p.deleted = FALSE AND p.user_id = $1
AND NOT EXISTS (
SELECT 1 FROM weekly_report_mv wr
WHERE wr.project_id = p.id
AND wr.report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND wr.report_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
)
`
goalsRows, err := a.DB.Query(currentWeekGoalsQuery, userID)
if err == nil {
defer goalsRows.Close()
existingProjects := make(map[int]bool)
for _, stat := range statistics {
if stat.ReportYear == currentYearInt && stat.ReportWeek == currentWeekInt {
// Найдем project_id по имени проекта (не идеально, но работает)
var pid int
if err := a.DB.QueryRow("SELECT id FROM projects WHERE name = $1 AND user_id = $2", stat.ProjectName, userID).Scan(&pid); err == nil {
existingProjects[pid] = true
}
}
}
for goalsRows.Next() {
var projectID int
var projectName string
var minGoalScore, maxGoalScore float64
if err := goalsRows.Scan(&projectID, &projectName, &minGoalScore, &maxGoalScore); err == nil {
// Добавляем только если проекта еще нет в статистике
if !existingProjects[projectID] {
totalScore := 0.0
if score, exists := currentWeekScores[projectID]; exists {
totalScore = score
}
_, weekISO := time.Now().ISOWeek()
item := FullStatisticsItem{
ProjectName: projectName,
ReportYear: time.Now().Year(),
ReportWeek: weekISO,
TotalScore: totalScore,
MinGoalScore: minGoalScore,
MaxGoalScore: maxGoalScore,
}
statistics = append(statistics, item)
}
}
}
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(statistics) json.NewEncoder(w).Encode(statistics)
} }
@@ -6168,7 +6353,7 @@ func (a *App) getTodoistStatusHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"connected": true, "connected": true,
"todoist_email": todoistEmail.String, "todoist_email": todoistEmail.String,
}) })
} }
@@ -8581,7 +8766,7 @@ func (a *App) todoistDisconnectHandler(w http.ResponseWriter, r *http.Request) {
// ============================================ // ============================================
// calculateProjectPointsFromDate считает баллы проекта с указанной даты до текущего момента // calculateProjectPointsFromDate считает баллы проекта с указанной даты до текущего момента
// Считает напрямую из таблицы nodes, фильтруя по дате entries // Считает напрямую из таблицы nodes, используя денормализованное поле created_date
func (a *App) calculateProjectPointsFromDate( func (a *App) calculateProjectPointsFromDate(
projectID int, projectID int,
startDate sql.NullTime, startDate sql.NullTime,
@@ -8600,17 +8785,17 @@ func (a *App) calculateProjectPointsFromDate(
`, projectID, userID).Scan(&totalScore) `, projectID, userID).Scan(&totalScore)
} else { } else {
// С указанной даты до текущего момента // С указанной даты до текущего момента
// Считаем все nodes этого пользователя, где дата entry >= startDate // Считаем все nodes этого пользователя, где дата created_date >= startDate
// Используем DATE() для сравнения только по дате (без времени) // Используем DATE() для сравнения только по дате (без времени)
// Теперь используем nodes.created_date напрямую (без JOIN с entries)
err = a.DB.QueryRow(` err = a.DB.QueryRow(`
SELECT COALESCE(SUM(n.score), 0) SELECT COALESCE(SUM(n.score), 0)
FROM nodes n FROM nodes n
JOIN entries e ON n.entry_id = e.id
JOIN projects p ON n.project_id = p.id JOIN projects p ON n.project_id = p.id
WHERE n.project_id = $1 WHERE n.project_id = $1
AND n.user_id = $2 AND n.user_id = $2
AND p.user_id = $2 AND p.user_id = $2
AND DATE(e.created_date) >= DATE($3) AND DATE(n.created_date) >= DATE($3)
`, projectID, userID, startDate.Time).Scan(&totalScore) `, projectID, userID, startDate.Time).Scan(&totalScore)
} }
@@ -12106,7 +12291,7 @@ func extractMetadataViaHTTP(targetURL string) (*LinkMetadataResponse, error) {
} }
client := &http.Client{ client := &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
Transport: transport, Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 { if len(via) >= 10 {
@@ -12632,4 +12817,3 @@ func decodeHTMLEntities(s string) string {
} }
return s return s
} }

View File

@@ -0,0 +1,67 @@
-- Migration: Revert optimization of weekly_report_mv
-- Date: 2026-01-26
--
-- This migration reverts:
-- 1. Removes created_date column from nodes table
-- 2. Drops indexes
-- 3. Restores MV to original structure (include current week, use entries.created_date)
-- ============================================
-- Step 1: Recreate MV with original structure
-- ============================================
DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv;
CREATE MATERIALIZED VIEW weekly_report_mv AS
SELECT
p.id AS project_id,
agg.report_year,
agg.report_week,
COALESCE(agg.total_score, 0.0000) AS total_score,
CASE
WHEN wg.max_score IS NULL THEN COALESCE(agg.total_score, 0.0000)
ELSE LEAST(COALESCE(agg.total_score, 0.0000), wg.max_score)
END AS normalized_total_score
FROM
projects p
LEFT JOIN
(
SELECT
n.project_id,
EXTRACT(ISOYEAR FROM e.created_date)::INTEGER AS report_year,
EXTRACT(WEEK FROM e.created_date)::INTEGER AS report_week,
SUM(n.score) AS total_score
FROM
nodes n
JOIN
entries e ON n.entry_id = e.id
GROUP BY
1, 2, 3
) agg
ON p.id = agg.project_id
LEFT JOIN
weekly_goals wg
ON wg.project_id = p.id
AND wg.goal_year = agg.report_year
AND wg.goal_week = agg.report_week
WHERE
p.deleted = FALSE
ORDER BY
p.id, agg.report_year, agg.report_week
WITH DATA;
CREATE INDEX idx_weekly_report_mv_project_year_week
ON weekly_report_mv(project_id, report_year, report_week);
-- ============================================
-- Step 2: Drop indexes
-- ============================================
DROP INDEX IF EXISTS idx_nodes_project_user_created_date;
DROP INDEX IF EXISTS idx_nodes_created_date_user;
-- ============================================
-- Step 3: Remove created_date column from nodes
-- ============================================
ALTER TABLE nodes
DROP COLUMN IF EXISTS created_date;
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations at year boundaries. Includes all projects via LEFT JOIN. Adds normalized_total_score using weekly_goals.max_score snapshot.';

View File

@@ -0,0 +1,94 @@
-- Migration: Optimize weekly_report_mv by denormalizing created_date into nodes and excluding current week from MV
-- Date: 2026-01-26
--
-- This migration:
-- 1. Adds created_date column to nodes table (denormalization to avoid JOIN with entries)
-- 2. Populates existing data from entries
-- 3. Creates indexes for optimized queries
-- 4. Updates MV to exclude current week and use nodes.created_date instead of entries.created_date
-- ============================================
-- Step 1: Add created_date column to nodes
-- ============================================
ALTER TABLE nodes
ADD COLUMN created_date TIMESTAMP WITH TIME ZONE;
-- ============================================
-- Step 2: Populate existing data from entries
-- ============================================
UPDATE nodes n
SET created_date = e.created_date
FROM entries e
WHERE n.entry_id = e.id;
-- ============================================
-- Step 3: Set NOT NULL constraint
-- ============================================
ALTER TABLE nodes
ALTER COLUMN created_date SET NOT NULL;
-- ============================================
-- Step 4: Create indexes for optimized queries
-- ============================================
-- Index for filtering by date and user (for current week queries)
CREATE INDEX IF NOT EXISTS idx_nodes_created_date_user
ON nodes(created_date, user_id);
-- Index for queries with grouping by project (for current week queries)
CREATE INDEX IF NOT EXISTS idx_nodes_project_user_created_date
ON nodes(project_id, user_id, created_date);
COMMENT ON INDEX idx_nodes_created_date_user IS 'Index for filtering nodes by created_date and user_id - optimized for current week queries';
COMMENT ON INDEX idx_nodes_project_user_created_date IS 'Index for grouping nodes by project, user and created_date - optimized for current week aggregation queries';
-- ============================================
-- Step 5: Recreate MV to exclude current week and use nodes.created_date
-- ============================================
DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv;
CREATE MATERIALIZED VIEW weekly_report_mv AS
SELECT
p.id AS project_id,
agg.report_year,
agg.report_week,
COALESCE(agg.total_score, 0.0000) AS total_score,
CASE
WHEN wg.max_score IS NULL THEN COALESCE(agg.total_score, 0.0000)
ELSE LEAST(COALESCE(agg.total_score, 0.0000), wg.max_score)
END AS normalized_total_score
FROM
projects p
LEFT JOIN
(
SELECT
n.project_id,
EXTRACT(ISOYEAR FROM n.created_date)::INTEGER AS report_year,
EXTRACT(WEEK FROM n.created_date)::INTEGER AS report_week,
SUM(n.score) AS total_score
FROM
nodes n
WHERE
-- Exclude current week: only include data from previous weeks
(EXTRACT(ISOYEAR FROM n.created_date)::INTEGER < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
OR (EXTRACT(ISOYEAR FROM n.created_date)::INTEGER = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND EXTRACT(WEEK FROM n.created_date)::INTEGER < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
GROUP BY
1, 2, 3
) agg
ON p.id = agg.project_id
LEFT JOIN
weekly_goals wg
ON wg.project_id = p.id
AND wg.goal_year = agg.report_year
AND wg.goal_week = agg.report_week
WHERE
p.deleted = FALSE
ORDER BY
p.id, agg.report_year, agg.report_week
WITH DATA;
-- Recreate index on MV
CREATE INDEX idx_weekly_report_mv_project_year_week
ON weekly_report_mv(project_id, report_year, report_week);
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations at year boundaries. Includes all projects via LEFT JOIN. Adds normalized_total_score using weekly_goals.max_score snapshot. Contains only historical data (excludes current week). Uses nodes.created_date (denormalized) instead of entries.created_date.';

View File

@@ -1,6 +1,6 @@
{ {
"name": "play-life-web", "name": "play-life-web",
"version": "4.0.6", "version": "4.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",