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 {
@@ -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" {
_, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv")
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)
// Продолжаем выполнение даже если обновление не удалось sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
} 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 {
@@ -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 {
@@ -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 {
@@ -5126,11 +5231,8 @@ 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{}{
@@ -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{}{
@@ -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)
} }
@@ -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)
} }
@@ -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",