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 {
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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