4.1.0: Оптимизация получения данных текущей недели
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.';
|
||||||
@@ -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.';
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user